网站建设报告书,WordPress国内开发主题,网站做外链推广的常用方法,国外网站设计模板如何保证缓存和数据库一致性#xff0c;这是一个老生常谈的话题了。
但很多人对这个问题#xff0c;依旧有很多疑惑#xff1a;
到底是更新缓存还是删缓存#xff1f;到底选择先更新数据库#xff0c;再删除缓存#xff0c;还是先删除缓存#xff0c;再更新数据库这是一个老生常谈的话题了。
但很多人对这个问题依旧有很多疑惑
到底是更新缓存还是删缓存到底选择先更新数据库再删除缓存还是先删除缓存再更新数据库为什么要引入消息队列保证一致性延迟双删会有什么问题到底要不要用...
这篇文章我们就来把这些问题讲清楚。
这篇文章干货很多希望你可以耐心读完。 引入缓存提高性能 我们从最简单的场景开始讲起。 如果你的业务处于起步阶段流量非常小那无论是读请求还是写请求直接操作数据库即可这时你的架构模型是这样的 但随着业务量的增长你的项目请求量越来越大这时如果每次都从数据库中读数据那肯定会有性能问题。 这个阶段通常的做法是引入「缓存」来提高读性能架构模型就变成了这样 当下优秀的缓存中间件当属 Redis 莫属它不仅性能非常高还提供了很多友好的数据类型可以很好地满足我们的业务需求。 但引入缓存之后你就会面临一个问题之前数据只存在数据库中现在要放到缓存中读取具体要怎么存呢
最简单直接的方案是「全量数据刷到缓存中」
数据库的数据全量刷入缓存不设置失效时间写请求只更新数据库不更新缓存启动一个定时任务定时把数据库的数据更新到缓存中 这个方案的优点是所有读请求都可以直接「命中」缓存不需要再查数据库性能非常高。
但缺点也很明显有 2 个问题
缓存利用率低 不经常访问的数据还一直留在缓存中数据不一致 因为是「定时」刷新缓存缓存和数据库存在不一致取决于定时任务的执行频率
所以这种方案一般更适合业务「体量小」且对数据一致性要求不高的业务场景。
那如果我们的业务体量很大怎么解决这 2 个问题呢
推荐下自己做的 Spring Boot 的实战项目
https://github.com/YunaiV/ruoyi-vue-pro
缓存利用率和一致性问题 先来看第一个问题如何提高缓存利用率 想要缓存利用率「最大化」我们很容易想到的方案是缓存中只保留最近访问的「热数据」。但具体要怎么做呢
我们可以这样优化
写请求依旧只写数据库读请求先读缓存如果缓存不存在则从数据库读取并重建缓存同时写入缓存中的数据都设置失效时间 这样一来缓存中不经常访问的数据随着时间的推移都会逐渐「过期」淘汰掉最终缓存中保留的都是经常被访问的「热数据」缓存利用率得以最大化。
再来看数据一致性问题。 要想保证缓存和数据库「实时」一致那就不能再用定时任务刷新缓存了。 所以当数据发生更新时我们不仅要操作数据库还要一并操作缓存。具体操作就是修改一条数据时不仅要更新数据库也要连带缓存一起更新。
但数据库和缓存都更新又存在先后问题那对应的方案就有 2 个
先更新缓存后更新数据库先更新数据库后更新缓存
哪个方案更好呢 先不考虑并发问题正常情况下无论谁先谁后都可以让两者保持一致但现在我们需要重点考虑「异常」情况。 因为操作分为两步那么就很有可能存在「第一步成功、第二步失败」的情况发生。
这 2 种方案我们一个个来分析。
1) 先更新缓存后更新数据库 如果缓存更新成功了但数据库更新失败那么此时缓存中是最新值但数据库中是「旧值」。 虽然此时读请求可以命中缓存拿到正确的值但是一旦缓存「失效」就会从数据库中读取到「旧值」重建缓存也是这个旧值。 这时用户会发现自己之前修改的数据又「变回去」了对业务造成影响。
2) 先更新数据库后更新缓存 如果数据库更新成功了但缓存更新失败那么此时数据库中是最新值缓存中是「旧值」。 之后的读请求读到的都是旧数据只有当缓存「失效」后才能从数据库中得到正确的值。 这时用户会发现自己刚刚修改了数据但却看不到变更一段时间过后数据才变更过来对业务也会有影响。
可见无论谁先谁后但凡后者发生异常就会对业务造成影响。那怎么解决这个问题呢
别急后面我会详细给出对应的解决方案。
我们继续分析除了操作失败问题还有什么场景会影响数据一致性
这里我们还需要重点关注并发问题 。
推荐下自己做的 Spring Cloud 的实战项目
https://github.com/YunaiV/onemall
并发引发的一致性问题
假设我们采用「先更新数据库再更新缓存」的方案并且两步都可以「成功执行」的前提下如果存在并发情况会是怎样的呢
有线程 A 和线程 B 两个线程需要更新「同一条」数据会发生这样的场景
线程 A 更新数据库X 1线程 B 更新数据库X 2线程 B 更新缓存X 2线程 A 更新缓存X 1
最终 X 的值在缓存中是 1在数据库中是 2发生不一致。
也就是说A 虽然先于 B 发生但 B 操作数据库和缓存的时间却要比 A 的时间短执行时序发生「错乱」最终这条数据结果是不符合预期的。
同样地采用「先更新缓存再更新数据库」的方案也会有类似问题这里不再详述。
除此之外我们从「缓存利用率」的角度来评估这个方案也是不太推荐的。
这是因为每次数据发生变更都「无脑」更新缓存但是缓存中的数据不一定会被「马上读取」这就会导致缓存中可能存放了很多不常访问的数据浪费缓存资源。
而且很多情况下写到缓存中的值并不是与数据库中的值一一对应的很有可能是先查询数据库再经过一系列「计算」得出一个值才把这个值才写到缓存中。
由此可见这种「更新数据库 更新缓存」的方案不仅缓存利用率不高还会造成机器性能的浪费。
所以此时我们需要考虑另外一种方案删除缓存 。
删除缓存可以保证一致性吗
删除缓存对应的方案也有 2 种
先删除缓存后更新数据库先更新数据库后删除缓存
经过前面的分析我们已经得知但凡「第二步」操作失败都会导致数据不一致。
这里我不再详述具体场景你可以按照前面的思路推演一下就可以看到依旧存在数据不一致的情况。
这里我们重点来看「并发」问题。
1) 先删除缓存后更新数据库
如果有 2 个线程要并发「读写」数据可能会发生以下场景
线程 A 要更新 X 2原值 X 1线程 A 先删除缓存线程 B 读缓存发现不存在从数据库中读取到旧值X 1线程 A 将新值写入数据库X 2线程 B 将旧值写入缓存X 1
最终 X 的值在缓存中是 1旧值在数据库中是 2新值发生不一致。
可见先删除缓存后更新数据库当发生「读写」并发时还是存在数据不一致的情况。
2) 先更新数据库后删除缓存
依旧是 2 个线程并发「读写」数据
缓存中 X 不存在数据库 X 1线程 A 读取数据库得到旧值X 1线程 B 更新数据库X 2)线程 B 删除缓存线程 A 将旧值写入缓存X 1
最终 X 的值在缓存中是 1旧值在数据库中是 2新值也发生不一致。
这种情况「理论」来说是可能发生的但实际真的有可能发生吗
其实概率「很低」这是因为它必须满足 3 个条件
缓存刚好已失效读请求 写请求并发更新数据库 删除缓存的时间步骤 3-4要比读数据库 写缓存时间短步骤 2 和 5
仔细想一下条件 3 发生的概率其实是非常低的。
因为写数据库一般会先「加锁」所以写数据库通常是要比读数据库的时间更长的。
这么来看「先更新数据库 再删除缓存」的方案是可以保证数据一致性的。
所以我们应该采用这种方案来操作数据库和缓存。
好解决了并发问题我们继续来看前面遗留的第二步执行「失败」导致数据不一致的问题 。
如何保证两步都执行成功
前面我们分析到无论是更新缓存还是删除缓存只要第二步发生失败那么就会导致数据库和缓存不一致。
保证第二步成功执行就是解决问题的关键。
想一下程序在执行过程中发生异常最简单的解决办法是什么
答案是重试 。
是的其实这里我们也可以这样做。
无论是先操作缓存还是先操作数据库但凡后者执行失败了我们就可以发起重试尽可能地去做「补偿」。
那这是不是意味着只要执行失败我们「无脑重试」就可以了呢
答案是否定的。现实情况往往没有想的这么简单失败后立即重试的问题在于
立即重试很大概率「还会失败」「重试次数」设置多少才合理重试会一直「占用」这个线程资源无法服务其它客户端请求
看到了么虽然我们想通过重试的方式解决问题但这种「同步」重试的方案依旧不严谨。
那更好的方案应该怎么做
答案是异步重试 。什么是异步重试
其实就是把重试请求写到「消息队列」中然后由专门的消费者来重试直到成功。
或者更直接的做法为了避免第二步执行失败我们可以把操作缓存这一步直接放到消息队列中由消费者来操作缓存。
到这里你可能会问写消息队列也有可能会失败啊而且引入消息队列这又增加了更多的维护成本这样做值得吗
这个问题很好但我们思考这样一个问题如果在执行失败的线程中一直重试还没等执行成功此时如果项目「重启」了那这次重试请求也就「丢失」了那这条数据就一直不一致了。
所以这里我们必须把重试或第二步操作放到另一个「服务」中这个服务用「消息队列」最为合适。这是因为消息队列的特性正好符合我们的需求
消息队列保证可靠性 写到队列中的消息成功消费之前不会丢失重启项目也不担心消息队列保证消息成功投递 下游从队列拉取消息成功消费后才会删除消息否则还会继续投递消息给消费者符合我们重试的场景
至于写队列失败和消息队列的维护成本问题
写队列失败 操作缓存和写消息队列「同时失败」的概率其实是很小的维护成本 我们项目中一般都会用到消息队列维护成本并没有新增很多
所以引入消息队列来解决这个问题是比较合适的。这时架构模型就变成了这样 那如果你确实不想在应用中去写消息队列是否有更简单的方案同时又可以保证一致性呢
方案还是有的这就是近几年比较流行的解决方案订阅数据库变更日志再操作缓存 。
具体来讲就是我们的业务应用在修改数据时「只需」修改数据库无需操作缓存。
那什么时候操作缓存呢这就和数据库的「变更日志」有关了。
拿 MySQL 举例当一条数据发生修改时MySQL 就会产生一条变更日志Binlog我们可以订阅这个日志拿到具体操作的数据然后再根据这条数据去删除对应的缓存。 订阅变更日志目前也有了比较成熟的开源中间件例如阿里的 canal使用这种方案的优点在于
无需考虑写消息队列失败情况 只要写 MySQL 成功Binlog 肯定会有自动投递到下游队列 canal 自动把数据库变更日志「投递」给下游的消息队列
当然与此同时我们需要投入精力去维护 canal 的高可用和稳定性。
如果你有留意观察很多数据库的特性就会发现其实很多数据库都逐渐开始提供「订阅变更日志」的功能了相信不远的将来我们就不用通过中间件来拉取日志自己写程序就可以订阅变更日志了这样可以进一步简化流程。
至此我们可以得出结论想要保证数据库和缓存一致性推荐采用「先更新数据库再删除缓存」方案并配合「消息队列」或「订阅变更日志」的方式来做 。
主从库延迟和延迟双删问题
到这里还有 2 个问题是我们没有重点分析过的。
第一个问题 还记得前面讲到的「先删除缓存再更新数据库」方案导致不一致的场景么
这里我再把例子拿过来让你复习一下
2 个线程要并发「读写」数据可能会发生以下场景
线程 A 要更新 X 2原值 X 1线程 A 先删除缓存线程 B 读缓存发现不存在从数据库中读取到旧值X 1线程 A 将新值写入数据库X 2线程 B 将旧值写入缓存X 1
最终 X 的值在缓存中是 1旧值在数据库中是 2新值发生不一致。
第二个问题 是关于「读写分离 主从复制延迟」情况下缓存和数据库一致性的问题。
在「先更新数据库再删除缓存」方案下「读写分离 主从库延迟」其实也会导致不一致
线程 A 更新主库 X 2原值 X 1线程 A 删除缓存线程 B 查询缓存没有命中查询「从库」得到旧值从库 X 1从库「同步」完成主从库 X 2线程 B 将「旧值」写入缓存X 1
最终 X 的值在缓存中是 1旧值在主从库中是 2新值也发生不一致。
看到了么这 2 个问题的核心在于缓存都被回种了「旧值」 。
那怎么解决这类问题呢
最有效的办法就是把缓存删掉 。
但是不能立即删而是需要「延迟删」这就是业界给出的方案缓存延迟双删策略 。
按照延时双删策略这 2 个问题的解决方案是这样的
解决第一个问题 在线程 A 删除缓存、更新完数据库之后先「休眠一会」再「删除」一次缓存。
解决第二个问题 线程 A 可以生成一条「延时消息」写到消息队列中消费者延时「删除」缓存。
这两个方案的目的都是为了把缓存清掉这样一来下次就可以从数据库读取到最新值写入缓存。
但问题来了这个「延迟删除」缓存延迟时间到底设置要多久呢
问题1延迟时间要大于「主从复制」的延迟时间问题2延迟时间要大于线程 B 读取数据库 写入缓存的时间
但是这个时间在分布式和高并发场景下其实是很难评估的 。
很多时候我们都是凭借经验大致估算这个延迟时间例如延迟 1-5s只能尽可能地降低不一致的概率。
所以你看采用这种方案也只是尽可能保证一致性而已极端情况下还是有可能发生不一致。
所以实际使用中我还是建议你采用「先更新数据库再删除缓存」的方案同时要尽可能地保证「主从复制」不要有太大延迟降低出问题的概率。
可以做到强一致吗
看到这里你可能会想这些方案还是不够完美我就想让缓存和数据库「强一致」到底能不能做到呢
其实很难。
要想做到强一致最常见的方案是 2PC、3PC、Paxos、Raft 这类一致性协议但它们的性能往往比较差而且这些方案也比较复杂还要考虑各种容错问题。
相反这时我们换个角度思考一下我们引入缓存的目的是什么
没错性能 。
一旦我们决定使用缓存那必然要面临一致性问题。性能和一致性就像天平的两端无法做到都满足要求。
而且就拿我们前面讲到的方案来说当操作数据库和缓存完成之前只要有其它请求可以进来都有可能查到「中间状态」的数据。
所以如果非要追求强一致那必须要求所有更新操作完成之前期间不能有「任何请求」进来。
虽然我们可以通过加「分布锁」的方式来实现但我们要付出的代价很可能会超过引入缓存带来的性能提升。
所以既然决定使用缓存就必须容忍「一致性」问题我们只能尽可能地去降低问题出现的概率。
同时我们也要知道缓存都是有「失效时间」的就算在这期间存在短期不一致我们依旧有失效时间来兜底这样也能达到最终一致。缓存中的最终一致性通过失效时间来保证
总结
好了总结一下这篇文章的重点。
1、想要提高应用的性能可以引入「缓存」来解决
2、引入缓存后需要考虑缓存和数据库一致性问题可选的方案有「更新数据库 更新缓存」、「更新数据库 删除缓存」
3、更新数据库 更新缓存方案在「并发」场景下无法保证缓存和数据一致性且存在「缓存资源浪费」和「机器性能浪费」的情况发生
4、在更新数据库 删除缓存的方案中「先删除缓存再更新数据库」在「并发」场景下依旧有数据不一致问题解决方案是「延迟双删」但这个延迟时间很难评估所以推荐用「先更新数据库再删除缓存」的方案
5、在「先更新数据库再删除缓存」方案下为了保证两步都成功执行需配合「消息队列」或「订阅变更日志」的方案来做本质是通过「重试」的方式保证数据一致性
6、在「先更新数据库再删除缓存」方案下「读写分离 主从库延迟」也会导致缓存和数据库不一致缓解此问题的方案是「延迟双删」凭借经验发送「延迟消息」到队列中延迟删除缓存同时也要控制主从库延迟尽可能降低不一致发生的概率
后记
本以为这个老生常谈的话题写起来很好写没想到在写的过程中还是挖到了很多之前没有深度思考过的细节。
在这里我也分享 4 点心得给你
1、性能和一致性不能同时满足为了性能考虑通常会采用「最终一致性」的方案
2、掌握缓存和数据库一致性问题核心问题有 3 点缓存利用率、并发、缓存 数据库一起成功问题
3、失败场景下要保证一致性常见手段就是「重试」同步重试会影响吞吐量所以通常会采用异步重试的方案
4、订阅变更日志的思想本质是把权威数据源例如 MySQL当做 leader 副本让其它异质系统例如 Redis / Elasticsearch成为它的 follower 副本通过同步变更日志的方式保证 leader 和 follower 之间保持一致
很多一致性问题都会采用这些方案来解决希望我的这些心得对你有所启发。
- END -