☰
Redis与MySQL缓存一致性实战:从Cache Aside到延迟双删与最终一致
2026/10/19 11:29:11 网站建设 项目流程

缓存一致性这个话题,我前后做了好几年的后端和中间件相关工作,每次系统流量一上来,Redis和MySQL数据对不上这个问题就会冒出来。数据不一致不像宕机那样直接报警,但用户会看到"刚才改的东西刷新一下又变回去了"、"明明下单成功却显示未支付",这类问题特别磨人。这篇文章我从实际工程角度,把缓存和数据库保持一致的常用方案、底层逻辑、落地细节一次性梳理清楚。

先说清楚这篇文章能解决什么问题:你在设计订单系统、库存系统、配置中心这类强一致业务时,可以拿它做方案选型参考。内容适合后端开发、架构设计者,以及正在做中间件封装的同学。就算你只是刚接触Redis的初级开发者,我也会把核心原理掰开来讲,不会上来就甩代码。

1. 缓存一致性问题的本质

1.1 为什么Redis和MySQL会“不听话”

很多人一开始觉得,缓存和数据库不一致,无非就是更新操作没同步过去。但深入去想,问题远不止这么简单。

先从两个存储的特性说起。MySQL是持久化存储,数据落盘,支持事务,强一致是它的核心价值;Redis是内存存储,读写速度极快,但默认情况下不保证严格的持久化,同时它也没有像MySQL那样的事务隔离机制。我们在业务里把Redis放在MySQL前面,本质上是用空间换时间、用一致性换性能。

但问题就出在这次“交换”上。业务系统每次写请求可能只更新了MySQL,也可能只更新了Redis,更常见的是一套流程里先后更新了两边,但只要在并发场景下,先后顺序被打乱,不一致就产生了。

举个例子。一个典型的更新操作流程是先更新MySQL,再更新Redis缓存。假设两个请求A和B同时进来,A先更新了MySQL(新值v2),B后更新了MySQL(新值v3),但B的缓存更新操作先完成了,A的缓存更新操作后完成,最终缓存里存的是老值v2,数据库里是v3。两边数据就分叉了。

这个问题的本质,是缓存和数据库两个系统做不到原子性操作。任何方案最终要解决的,就是如何在做不到原子操作的前提下,尽可能让两边数据最终一致,甚至在某些场景下做到强一致。

1.2 不一致发生的典型场景速查

我把工作中真实遇到的不一致场景归类整理了一下,方便你对照自查:

场景产生原因典型后果
更新顺序错乱先更新DB后更新Cache,两个操作非原子,并发下执行顺序被打乱缓存中老值覆盖新值
删除缓存失败更新DB成功,但删除缓存时Redis异常或网络抖动缓存长期陈旧
缓存过期时间过长TTL设置太久,DB更新后缓存未失效数据延迟可见
多副本/多实例并发微服务多实例同时更新同一key,没有控制并发顺序后写覆盖先写
读操作回填旧值请求在更新期间读DB拿到旧值,回填到缓存缓存被旧数据污染
主从延迟更新主库成功后读从库拿到旧值回填缓存短暂不一致

个人经验是,第三类和第五类问题最隐蔽。你排查半天,最后发现不是更新逻辑错了,而是某个读请求在中间状态把旧值灌回了缓存。这类问题光靠加缓存Key前缀解决不了,必须从流程设计上堵住。

2. 经典方案:Cache Aside模式拆解

2.1 Cache Aside的核心流程

作为理论基础,Cache Aside模式(也叫旁路缓存模式)是绕不开的。很多方案都是在此基础上演进的。它的流程可以描述为:

  • 读请求:先查Redis,命中则直接返回;未命中则查MySQL,把结果回填到Redis,再返回。
  • 写请求:先更新MySQL,更新成功后再删除Redis中对应的缓存Key。

这里的关键点在于:更新操作不做“更新缓存”,而是做“删除缓存”。为什么要这么做?因为更新缓存的值是一个昂贵操作,而且容易被并发问题放大。

我见过不少团队在初版代码里写了更新MySQL后直接set缓存新值的逻辑。看起来逻辑没问题:新值写进去了,缓存也同步了。但你要考虑几个问题。首先,更新操作可能涉及计算逻辑,比如库存扣减、余额变更,这个新值并不是简单从入参里拿到的,很可能需要先查一次数据库或者做一次复杂计算,这就白白增加了一次读操作。其次,如果这个缓存Key被多个请求同时写,你没办法保证最后一次set的数据是不是真正的最新值。删除缓存则不同,它只需要一次O(1)的操作,下次读请求自然会把新值拉回来。

提示:Cache Aside模式是所有缓存一致性方案的基底。后面讨论的延迟双删、消息队列异步补偿、binlog订阅等,本质上都是在这个流程上加了“补偿”或“约束”机制。

2.2 为什么“先更新数据库再删缓存”也有坑

即便遵循了标准的Cache Aside模式,依然存在问题。

核心风险出现在一个极小的时间窗口:更新MySQL和删除缓存这两个操作之间。假设一个读请求正好卡在这个窗口里,它在缓存中读不到Key(可能缓存刚好过期或被其他线程删了),于是去MySQL读数据。此时如果MySQL已经更新完成,它读到的是新值,没问题;但如果MySQL还没提交事务,或者读到的是旧快照,它就会把旧值回填到缓存。这个旧值随后可能长期留在Redis中,直到下一次过期或下一次更新。

这就是我说的“读请求回填旧值”污染缓存的问题。标准Cache Aside模式对这个问题是无解的,只能通过后续方案来缓解。

另一个问题是删除缓存失败。Redis执行DEL命令,可能因为网络超时、Redis进程OOM、连接池耗尽等原因失败。一旦失败,缓存里的数据就成了“永久债务”,你更新一百次数据库,它还是那个旧值。最简单的应对方式是给缓存设置合理的过期时间兜底,比如业务允许最多5分钟内不一致,那TTL就设成5分钟。但注意,这不是解决一致性,只是把不一致的时间收敛到可控范围。

所以在进入更复杂方案之前,先把基础保障做扎实:写请求统一走Cache Aside,缓存Key必须设置TTL,删除缓存必须使用带重试的封装。这三件事做不好,后面再花哨的方案都白搭。

3. 从双删到延迟双删

3.1 简单双删为什么不够

既然读请求回填旧值是这个流程的核心痛点,一个直接的想法就是:更新MySQL之后,删两次缓存。

“双删”策略是这么运作的:先更新MySQL,第一次删除缓存;隔一小段时间(根据业务耗时估算)再删除一次缓存。第二次删除的目的,就是为了清掉可能在那段时间窗口内被某个读请求回填的旧值。

但如果你真去生产环境做,会发现问题挺多的。第一次删除和第二次删除之间到底间隔多久?这个时间不好定。如果间隔太短,读请求还没完成从MySQL读旧值并回填缓存的过程,第二次删除就白删了;如果间隔太长,那这段时间内所有读请求都打到MySQL上,缓存形同虚设。

简单双删的另一个问题是,它把延迟逻辑放在业务线程里,通常用Thread.sleep实现。在高并发下,线程阻塞会拖垮Tomcat线程池,而且sleep时间不可控,对依赖超时控制的场景也不友好。所以简单双删只是个过渡思路,真正落地还得做延迟双删。

3.2 延迟双删的工程化落地

延迟双删在双删的基础上,把“第二次删除”从同步操作改成异步延迟操作。

整体流程是这样的:

  1. 更新MySQL。
  2. 立即删除缓存Key。
  3. 发送一条延迟消息(比如延迟500毫秒执行),消息内容包含这个缓存Key。
  4. 延迟任务收到消息后,再次删除该Key,删除前可以比对版本号,只删除“旧版本”的值。

这里有两个细节很关键。第一点是延迟时长的选择。我一般用请求耗时的P99值乘以2作为参考。比如读请求从缓存未命中到回填完成,P99耗时是200毫秒,那延迟时长设为400到500毫秒比较稳妥。太短没有意义,太长会影响缓存命中率。

第二点是第二次删除要带版本校验,否则可能误删新值。具体做法是:缓存Value里塞一个版本号字段,比如每次更新MySQL时同时生成一个新的版本号(可以用MySQL的binlog时间戳或者业务递增字段),第一次删除后,有读请求回填旧值时会把旧版本号一起写进Redis。延迟删除时,先取Redis里的版本号,如果与当前MySQL的最新版本号不相等,说明是旧值,直接删掉;如果已经相等,说明缓存里的值已经是新的,就不需要再删。这个校验能避免误删刚被其他请求写好的新缓存。

我在实现延迟消息时,如果公司有RocketMQ或RabbitMQ,直接用它们的延迟消息队列就好,比如RocketMQ的延迟等级。如果没有消息队列,也可以用Redis本身的ZSet做延迟队列,score存执行时间戳,由消费端轮询。注意,Redis自研延迟队列要注意消费端的幂等性,同一个Key的删除操作重复执行本身是无害的,但最好还是用版本号跳过不必要的删除。

4. 消息队列驱动异步补偿

4.1 为什么把删除操作丢进MQ里更可靠

延迟双删虽然能解决大部分问题,却有一个绕不开的痛点:如果第二次删缓存也失败了怎么办?或者是,业务系统中间件在更新MySQL之后、还没执行删除缓存就宕机了怎么办?

把删除操作交给消息队列,本质上是为了让“删除缓存”这个动作变得可重试、可审计、可补偿。MySQL更新成功后,业务方发送一条消息到MQ,消息内容是缓存Key;一个专门的处理者消费这条消息,执行删除操作,成功则ACK;失败则消息重新投递,直到删除成功,或者达到重试上限后进入死信队列告警处理。

这个方案的优势很明显:它把缓存删除从业务调用路径中解耦了。业务线程只需要关心MySQL事务提交成功和消息发送成功,不需要阻塞等待Redis的响应。Redis抖动、网络超时这些异常都被消息队列的重试机制兜住了。

相比延迟双删,它的可靠性更强,但实时性更差一些。因为消息从发送到被消费有一定延迟,在极端高并发场景下,这个短暂的不一致窗口还是会存在。所以它更适合对最终一致性能接受的业务,比如用户昵称更新、商品详情编辑这类场景。

4.2 保证消息不丢的落地细节

既然选择了MQ方案,就要把消息可靠性做扎实。否则发送消息成功了但消费端没收到,问题比不用MQ更隐蔽。

我建议关注这几个点:

  1. 事务消息。如果用的是支持事务消息的MQ,如RocketMQ,那就用事务消息:本地事务(更新MySQL)提交成功后,半消息才会被确认投递;本地事务回滚,半消息被丢弃。这样能保证“MySQL更新成功”和“消息成功投递”至少在语义上具备原子性。

  2. 消息幂等。同一个缓存Key的删除操作,天然是幂等的——删除一个不存在的Key不会报错。但为了做审计,消息体里尽量带上业务版本号,消费端可以判断是否要执行删除。

  3. 死信队列监控。删除操作如果连续重试多次失败,说明Redis大概率有问题,或者Key已损坏,这时候要让消息进入死信队列,并发送监控报警。

注意:事务消息不是所有MQ都有。如果只有Kafka或RabbitMQ,就无法直接用半消息机制,通常的替代方案是本地消息表:在同一个本地事务里写入业务数据和待发送消息,然后通过定时任务轮询消息表把消息投递给MQ。这个方案代码量多一点,但同样能保证发送的一致性。

4.3 基于Binlog订阅的Cache同步

如果要彻底把业务代码和缓存同步逻辑解耦,那就得换一种思路:不去业务里写删除缓存的代码,而是直接从MySQL的binlog里感知数据变化。

典型实现是使用Canal这类中间件把自己伪装成MySQL的从库,接收binlog事件,解析出具体是哪个表、哪一行数据发生了变化,然后把变更消息推给消费端。消费端根据表名和主键ID,拼出对应的缓存Key,执行删除操作,或者把新数据直接写入新Key。

这个方案带来的好处非常明显。第一,业务代码里完全不需要写缓存删除逻辑,更新MySQL的代码保持纯净,降低了业务侵入性;第二,不会漏掉任何数据变更,因为binlog是MySQL层面的完整记录,只要解析和投递链路不中断,每次变更都能被感知;第三,天然具备重试基础,binlog解析失败或者推送失败,可以回放。

实际落地时要留意几点。binlog必须以ROW模式开启,因为只有ROW模式才能拿到完整行数据;STATEMENT模式只记录SQL语句,对于解析来说基本不可用。消费端处理数据变更时,要考虑多条binlog投递的乱序问题,比如同一行的两条更新日志事件可能乱序到达,这时候可以通过比较事件里的时间戳或自增位点来丢弃旧事件。

Canal方案在数据量大的场景下效果很好,但它引入了额外的基础设施。运维Canal本身需要一些精力,所以在小项目里会显得笨重。但对于中大型中台系统,这个方案几乎是标配,因为不同业务线都要消费同一份数据变更,抽成公共组件后边际成本很低。

5. 进阶场景:强一致的边界与取舍

5.1 能不能做到完全强一致

说了这么多最终一致方案,你可能最关心的是:有没有方案能让缓存和MySQL在任何时刻都强一致?

如果严格意义上要求强一致,标准答案是:不要用Redis做业务数据的缓存层。把缓存放在强一致链路里,必然要付出性能代价或复杂度代价。像分布式锁、分布式事务中间件这套基础设施,它们存的数据在Redis里,但对一致性要求极高,它们会用RedLock或者类似机制加上特殊处理,而不是简单读缓存。

如果你只是想让“读”在绝大多数时候都能拿到最新值,那可以考虑读写锁方案:在更新MySQL时,先获取一把分布式写锁,然后更新MySQL、删除缓存或者更新缓存,最后释放写锁。读请求尝试获取读锁,如果写锁存在就等一会儿再去读缓存。这个方案把小概率的不一致窗口控制到了锁粒度以内,但它本质上是在用锁换一致性,并发能力会有所下降。

实际业务里,我更推荐做“级别划分”:对一致性要求极其苛刻的数据(比如钱包余额、库存扣减),直接走MySQL,哪怕性能低一点;对一致性要求中等的数据(比如商品名称、描述),用Cache Aside + 可靠消息补偿;对一致性要求宽松的数据(比如排行榜、热门列表),直接用TTL让缓存自然过期重建。这样既保证了业务的核心正确性,又不至于把所有数据都拖入高成本的一致性保障中。

5.2 一致性方案选型参考

我给一个自己常用的选型参考表,结合数据实时性要求和基础设施情况来判断:

数据特征推荐方案原因
强一致核心资产(库存/余额)不缓存或读写锁一致性优于性能,宁可MySQL抗压
高并发读取+改动频繁延迟双删 + MQ补偿在性能与一致性间取平衡
改动频率低但读取量大Cache Aside + 合理TTL缓存命中率高,删除缓存足够兜底
多业务线复用数据变更binlog订阅推送解耦且数据完整,一次解析多处消费
敏感配置类版本号比对+定时刷新配置量小、变更可审计,可用轮询解决

要注意的是,没有一劳永逸的方案。即便是同一个系统,不同模块的数据也应该选择不同的策略。

6. 常见问题与排查技巧实录

这个部分我直接分享这些年踩坑总结出来的排查工具包,遇到缓存不一致问题时,按顺序排查基本能定位大部分问题。

6.1 同一Key大量更新,缓存抖动严重

现象:某个商品Key被频繁修改,表现为Redis未命中率上升、MySQL压力变大。

排查:先看是不是缓存Key被集中失效。如果每次更新都删Key,那读请求就会全部打到MySQL,自然会把MySQL压垮。解决方案是不要删除整个Key,而是使用Hash结构只更新其中某个Field,或者对Key做“逻辑删除”:在Value里标记一个版本字段,读请求读到旧版本时主动去MySQL拉新值再覆盖。

6.2 缓存里总出现“老值复活”

现象:明明删了缓存,过了一段时间老值又出现在Redis里。

排查:这就是典型的读请求回填旧值。除了用延迟双删,还要检查读路径上是否用了多级缓存(比如本地缓存),本地缓存里的老值也可能回填到Redis。这时候优先级排序要一致,所有层级的过期时间必须对齐。

6.3 MySQL更新成功但缓存删除一直失败

现象:Redis网络不稳定,删除重试多次仍然失败,数据长期不一致。

排查:把删除操作从同步改为异步。同步删除失败影响主流程,异步删除失败还能靠重试兜底。同时检查Redis客户端连接池配置,我之前遇到过连接池maxTotal设置过小,高并发下连接排队导致大量DEL超时。调大连接池后问题立刻缓解。

6.4 多实例并发导致缓存被旧值覆盖

现象:同一个缓存Key被多个服务实例并发更新,最终还是旧值。

排查:这类问题要靠版本号解决。在缓存Value中存储自增版本号或时间戳,更新缓存前比对版本号,新值版本号必须大于旧值版本号才允许写入。我用Redisson封装了一个CompareAndSet操作,底层用Lua脚本原子比对版本号再写缓存,效果很好。

6.5 binlog消费延迟导致短暂不一致

现象:商品详情修改后,前端一段时间内还是老样式,过一会才正常。

排查:binlog消费链路延迟可能来自Canal解析慢、MQ积压或消费端处理慢。先检查MQ的堆积数,再看消费端是否有慢查询。如果只是允许秒级延迟的业务,消费端可以加缓存预加载逻辑——消费到更新事件后,不用等到下次读请求才刷新缓存,而是主动把MySQL里的新值取出写入Redis。

7. 总结不是结束,而是选型前的提醒

回到最初的问题,Redis缓存和MySQL一致性的保持,核心不是选一个“最完美”的方案,而是为每类数据匹配合适的一致性级别。这套思路在中间件设计和业务开发中同样适用。

我个人在实际操作中的体会是:最初总会倾向于用最复杂的方案解决问题,觉得binlog订阅、消息补偿、版本控制全上才是对系统负责任的表现。后来踩过几次坑才明白,一致性架构的成本是持续性的,每增加一种机制,就多一层运维负担和一个故障点。与其追求方案的“绝对完备”,不如先把Cache Aside模式的细节做好——缓存Key粒度设计、TTL和淘汰策略搭配、删除操作失败的兜底,这几件事做到位,已经能解决80%的问题。剩下的20%,再根据业务对实时性的容忍度,逐步引入延迟队列或binlog订阅。

最后再分享一个小技巧:不管用了哪种一致性方案,上线前一定要做并发回放测试。模拟核心Key被并发读写,检查缓存里的值是否符合版本号顺序。我见过太多方案在设计文档里画得头头是道,一压测就暴露问题的情况。并发压测不过关的一致性方案,宁可不用也别带病上线。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询