资讯详情

Redis与MySQL缓存一致性实战:从Cache Aside到延迟双删与最终一致

📅 2026/10/19 11:29:15 | 华诺云谱 👁 阅读
Redis与MySQL缓存一致性实战:从Cache Aside到延迟双删与最终一致
缓存一致性这个话题我前后做了好几年的后端和中间件相关工作每次系统流量一上来Redis和MySQL数据对不上这个问题就会冒出来。数据不一致不像宕机那样直接报警但用户会看到刚才改的东西刷新一下又变回去了、明明下单成功却显示未支付这类问题特别磨人。这篇文章我从实际工程角度把缓存和数据库保持一致的常用方案、底层逻辑、落地细节一次性梳理清楚。先说清楚这篇文章能解决什么问题你在设计订单系统、库存系统、配置中心这类强一致业务时可以拿它做方案选型参考。内容适合后端开发、架构设计者以及正在做中间件封装的同学。就算你只是刚接触Redis的初级开发者我也会把核心原理掰开来讲不会上来就甩代码。1. 缓存一致性问题的本质1.1 为什么Redis和MySQL会“不听话”很多人一开始觉得缓存和数据库不一致无非就是更新操作没同步过去。但深入去想问题远不止这么简单。先从两个存储的特性说起。MySQL是持久化存储数据落盘支持事务强一致是它的核心价值Redis是内存存储读写速度极快但默认情况下不保证严格的持久化同时它也没有像MySQL那样的事务隔离机制。我们在业务里把Redis放在MySQL前面本质上是用空间换时间、用一致性换性能。但问题就出在这次“交换”上。业务系统每次写请求可能只更新了MySQL也可能只更新了Redis更常见的是一套流程里先后更新了两边但只要在并发场景下先后顺序被打乱不一致就产生了。举个例子。一个典型的更新操作流程是先更新MySQL再更新Redis缓存。假设两个请求A和B同时进来A先更新了MySQL新值v2B后更新了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 延迟双删的工程化落地延迟双删在双删的基础上把“第二次删除”从同步操作改成异步延迟操作。整体流程是这样的更新MySQL。立即删除缓存Key。发送一条延迟消息比如延迟500毫秒执行消息内容包含这个缓存Key。延迟任务收到消息后再次删除该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更隐蔽。我建议关注这几个点事务消息。如果用的是支持事务消息的MQ如RocketMQ那就用事务消息本地事务更新MySQL提交成功后半消息才会被确认投递本地事务回滚半消息被丢弃。这样能保证“MySQL更新成功”和“消息成功投递”至少在语义上具备原子性。消息幂等。同一个缓存Key的删除操作天然是幂等的——删除一个不存在的Key不会报错。但为了做审计消息体里尽量带上业务版本号消费端可以判断是否要执行删除。死信队列监控。删除操作如果连续重试多次失败说明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被并发读写检查缓存里的值是否符合版本号顺序。我见过太多方案在设计文档里画得头头是道一压测就暴露问题的情况。并发压测不过关的一致性方案宁可不用也别带病上线。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑