ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL与Redis缓存一致性实战:更新策略与延迟双删方案

MySQL与Redis缓存一致性实战:更新策略与延迟双删方案 1. 先聊聊这个问题的本质为什么缓存和数据库天生就容易打架最近刚处理完一个因为缓存不一致导致的线上事故趁热打铁把这个老生常谈但又极其容易翻车的话题捋一捋。先交代一下背景公司有个订单查询接口QPS 峰值大概三千多MySQL 扛不住读压力所以在前面加了一层 Redis 做缓存。上线初期一切正常但运行了两周之后开始出现用户看到的订单状态和实际库里的状态对不上的问题——明明已经取消了页面还显示待支付明明改了地址用户下单页还是旧地址。排查到最后问题全部集中在缓存和数据库的数据一致性问题。先说结论**在绝大多数互联网场景下MySQL 和 Redis 之间不存在绝对的强一致性我们追求的是最终一致性。**为什么这么说因为缓存和数据库是两套独立的存储系统它们之间没有分布式事务的强绑定关系。你更新数据库和更新缓存这两件事无论如何编排顺序中间都隔着网络IO和代码执行的时延这个窗口期内必然存在不一致。所以问题的关键不是如何消除不一致而是如何把不一致的窗口压缩到业务可接受的范围并且保证最终能收敛到一致状态。这里先科普一个基本概念方便后面展开。所谓缓存一致性指的是缓存里的数据和数据库里的数据在逻辑上保持等价。如果缓存里有 key它的 value 必须和数据库对应记录一致如果数据库里记录被删了缓存里那个 key 也应该不存在。当发生读写并发时这个约束被打破就叫不一致。我见过很多刚入行的同学一上来就问有没有办法让 Redis 和 MySQL 永远一模一样这是个伪命题。你得先想明白业务场景用户修改了自己的收货地址他下一次刷新页面时必须看到新地址这个要求合理但运营后台批量改了 10 万条商品的库存要求所有用户在一毫秒内全部感知到新库存这不现实也没必要。所以设计缓存策略的第一步是明确你的业务能够容忍多大的不一致窗口。2. 缓存更新策略的三种经典姿势以及它们各自的坑既然一致性无法做到绝对那么我们讨论的核心就是**在更新数据时以什么样的顺序去操作缓存和数据库能把不一致的概率和影响降到最低。**目前业界主流的策略就三种我一个个拆开讲包括每种策略在真实业务里的表现和翻车案例。2.1 先更新数据库再删除缓存Cache-Aside 的写侧这是目前应用最广泛的方案也是我默认首选的方案。逻辑很简单写请求来了先 UPDATE 数据库然后 DEL 缓存里的 key。下次读请求到来时缓存没命中回源数据库查最新值再回填缓存。这个方案好在哪里最核心的一点是**它把缓存数据过期作为常态来接受而不是试图去维护缓存和数据库的同步更新。**删除缓存这个操作是幂等的即使删除失败了后果也只是一个缓存 miss下次读取会重新加载系统整体还是能收敛到一致状态不会出现缓存里躺着脏数据一直不更新的情况。但这里有个经典的并发竞态问题几乎每篇讲一致性的文章都会提线程 A 执行更新数据库的过程中线程 B 执行读缓存发现 miss于是回源数据库——注意此时数据库可能还没提交成功B 读到的是旧数据然后 B 把旧数据回填到缓存。接着 A 完成数据库更新再删除缓存。但问题是A 删除缓存的动作发生在了 B 回填缓存之前于是旧数据被 B 写回了缓存这个脏 key 就一直留在那里了。这个问题我在实际项目里遇到过两次一次是商品详情页的库存展示一次是用户资料的修改。解决思路有两个层面把更新数据库和删除缓存放在同一个事务里显然做不到跨存储系统但可以把删除缓存的动作延后用延迟双删的变体下面第三节细讲。另一个更实用的办法是给缓存 value 里写入数据版本号或者最后更新时间戳读侧在回填缓存之前先校验这个时间戳是否比库里旧如果旧就不回填。这个方案在 Java 里配合 MyBatis Plus 的乐观锁插件或自定义注解很容易实现。2.2 先删缓存再更新数据库这个策略现在被很多技术文章列为绝对不要用但我持保留态度——它本身不是不能用而是要看业务抗不抗得住那一下阵痛。它的流程是写请求先把 Redis 里对应的 key 删掉然后去更新 MySQL。这会导致一个直接后果在删缓存和更新数据库之间的时间窗口内任何一个读请求都会 miss 缓存然后打到数据库上压力瞬间被放大。如果你的数据库连接池只有 50 个连接而缓存 miss 流量有 2000 QPS那数据库大概率直接被打挂。更微妙的风险在于并发时序线程 A 删缓存此时线程 B 读请求 miss 后回源数据库读到旧值并回填缓存线程 A 随后更新数据库为新值。最终结果是数据库是新值缓存是旧值。**这个不一致一旦形成只能等待缓存过期才能修复或者人工清理。**相比 Cache-Aside 方案这个方案的不一致窗口更大而且修复手段更被动。我唯一一次建议使用这个策略的场景是**缓存里存的数据是冷热切换性质的比如定时任务跑出来的报表数据更新频率极低读频率极高。**在这种场景下你可以在任务开始前主动删除缓存让更新期间的新请求打库计算完成后再由读请求慢慢回填。因为写频率极低这个删缓存到更新完成的窗口非常短几乎可以忽略。但如果你的业务是高频写的千万别用这个方案。2.3 先更新数据库再更新缓存双写模式这个方案直观上最省事但也是我在生产环境里最反对的方案。为什么因为它把一致性问题的复杂度从缓存 miss 的处理转移到了并发写顺序的处理上。假设线程 A 和线程 B 同时执行更新操作A 先把数据库里的值改成了 100B 再把数据库里的值改成了 200。如果没有严格的顺序控制可能出现这样一种情况A 更新数据库 → B 更新数据库 → B 更新缓存此时缓存为 200→ A 更新缓存此时缓存为 100。最终数据库值是 200缓存值却是 100就永远不一致了。有人可能会说那我用分布式锁把 A、B 的更新顺序锁住不就行了理论上可以但为了维护缓存一致性去引入分布式锁等于把一个简单的读写问题复杂化成一个分布式并发控制问题。你不仅要处理锁的获取和释放还要处理锁超时、锁续期、锁误删等一系列边界问题。这笔账算下来非常不划算。双写模式还有一个隐患是**有些缓存 value 根本不是简单赋值而是累加操作。**比如库存扣减你在缓存里做的是DECR在数据库里做的是UPDATE stock stock - 1 WHERE id ?。这两个操作如果执行顺序错乱库存数据就会发生超卖或者少卖。Redis 的DECR是原子的MySQL 的UPDATE也是原子的但两个原子操作组合在一起不是原子的。这也是我在订单系统里始终坚持更新数据库后删缓存的原因。2.4 三种策略的适用范围对比策略不一致窗口风险点适用场景先更新 DB 再删缓存极小删除缓存前有极短窗口删除缓存失败或并发回填旧值大多数读写混合业务默认首选先删缓存再更新 DB较大删除与更新之间所有读请求打库缓存回填旧值、数据库压力陡增低频写、高频读且可容忍短暂 miss先更新 DB 再更新缓存大并发写时缓存值可能被覆盖并发写顺序导致永久不一致串行化写请求的场景基本不推荐综合下来如果你没有特别特殊的场景我强烈建议你默认采用Cache-Aside 延迟双删的组合这是我在多个线上系统验证过的性价比最高的方案。3. 实战缓存不一致问题的完整排查链路与根因定位光讲理论没有用我拿一次真实的线上问题排查过程来做演示。这套排查思路比任何策略表格都有价值因为它是可复现的。3.1 事故现象与初步定位某天下午客服反馈有一批用户的下单接口返回的运费金额和订单详情页展示的运费金额对不上。订单详情页读的是 Redis 缓存接口写库后返回的是实时的。初步判断就是缓存和数据库不一致。第一步我先在 Redis 里查了这个可疑订单的缓存 key发现 value 确实是旧的运费金额。第二步去数据库里查这条订单记录发现运费字段已经是新的。确认了缓存里有脏数据。这时候先别急着清缓存要搞清楚脏数据是怎么写进去的。如果只是偶发清掉就完事如果是某种固定的时序导致的不清根因还会复发。3.2 排查过程中的关键证据链我打开了该订单号对应的应用日志重点找几个时间点写请求的入口时间、更新数据库的 SQL 执行时间、删除缓存的操作时间。结果发现了两个异常点第一删除缓存操作没有对应日志。也就是说写链路执行了数据库更新但删除 Redis 的代码没有执行。查代码后发现删除缓存的方法被放在了一个try-catch里Redis 连接池在这段时间因为之前在修复一个连接泄漏问题时被误改小了导致某些删除操作在获取连接时抛了异常异常被吞掉只打了 warn 日志。于是部分 key 就成了永远不被删除的脏缓存。第二有一条读请求的回填时间恰好落在写请求的更新和删除之间。这意味着即使删除缓存成功了那条读请求也可能会把旧值回填进缓存。这是并发时序问题不是某一个环节的错误而是多个环节叠加导致的。3.3 修复方案与验证过程针对上面两条根因我做了两处修复第一处删除缓存的操作改成确保执行模式使用单独的 Redis 连接不参与业务连接池竞争、增加重试机制最多重试 3 次间隔 100ms、删除失败后发送告警到钉钉群。同时把try-catch里的吞异常逻辑改成记录完整堆栈 告警确保下次再出问题能第一时间看到。第二处采用延迟双删的变体在执行更新数据库 → 删除缓存的流程之后再启动一个延迟任务在 500ms 后再次删除同样的 key。为什么是 500ms因为这个值要比一个读请求从回源到回填缓存的典型耗时更长。我观察了线上读请求的 P99 耗时为 80ms 左右所以 500ms 是安全的既不会太长导致窗口残留也不会太短起不到作用。修复上线后我连续观察了一周统计缓存不同步的告警数量从修复前的日均 30 降到了 0。这件事给我最大的教训是一致性问题的根子往往不在设计层面而在执行层面——Redis 连接池配置不当、异常被吞、重试机制缺失任何一个看似无关的环节都可能成为脏数据的源头。4. 为什么说延迟双删不是银弹关于一致性的几个反直觉真相很多同学学了一致性方案之后容易陷入一种我知道了标准答案的错觉。这里我讲几个反直觉的真相都是我在实战中被教育过的地方。4.1 延迟双删的时间窗口需要动态评估延迟双删的核心是在第一次删除后等一段时间再删一次目的是覆盖读请求回填旧值的时间窗口。但问题在于这个窗口的时间不是固定的。**它取决于读请求回源数据库的执行耗时而数据库耗时是动态变化的。**高峰期数据库负载重一条查询可能要跑 200ms低峰期可能 20ms 就返回了。如果你把延迟时间设成固定的 500ms高峰期可能不够低峰期又显得多余。所以更稳的做法是**延迟时间设定为读请求回源耗时的 P99 乘以 2这一量级并且定期根据监控数据调整。**我在订单系统里就是这么干的最初设 500ms后来发现数据库大查询导致 P99 上涨到 300ms就调到了 800ms直到系统稳定后又调回 500ms。4.2 主从延迟会让读缓存回填旧值的概率上升先更新主库再删缓存这个流程本身没问题。但如果在主从复制架构下读请求走了从库问题就来了读请求 miss 缓存后回源从库此时主库还没把更新同步到从库从库返回旧值读请求把旧值回填到缓存。这就出现了一个新的不一致来源——不是缓存策略错了而是你回填数据的数据源本身是滞后的。针对这个问题我在实际项目中用的办法是**对强一致要求的数据在缓存回填时强制走主库查询。**比如订单支付状态、用户余额这种数据回源时在代码里加上强制主库路由的注解。对一致性要求不高的数据比如商品评分、浏览量走从库没问题因为最终会收敛。4.3 Redis 的过期时间是你最好的朋友很多人设计缓存 key 时为了省事把 TTL 设成 30 分钟甚至更长。从一致性的角度讲这是有风险的即使你的更新策略完全正确依然会有极端情况——比如消息队列消费重复、定时任务重跑、代码 Bug 导致漏删——让缓存里的脏数据存活很长时间。TTL 是最后一道屏障它保证不管前面出了什么幺蛾子数据最多不一致 TTL 那么久。我在实际项目中对读缓存回填的 key 通常设置 5~15 分钟的 TTL对写链路直接操作的 key 设置 30~60 分钟 TTL。这背后的逻辑是**缓存 miss 越多数据库压力越大所以 TTL 不能太短但 TTL 越长脏数据存活时间越长所以 TTL 不能太长。**具体取值要根据你业务对数据时效性的敏感度来权衡。不过记住一点TTL 一定要加哪怕你觉得你的更新策略完美无缺。4.4 缓存雪崩与一致性的关系这里补一个容易被忽略的点当大量缓存同时过期或者缓存集群发生故障时请求全部打到数据库数据库可能瞬间被压垮。数据库一旦进入不可用或半不可用状态所有依赖数据库的更新操作都会失败或超时这又会导致缓存删不掉、数据回填失败。缓存雪崩不仅会让系统性能崩盘还会放大数据不一致的概率。所以我强烈建议在 Redis 客户端封装一层熔断 降级能力当 Redis 读超时持续超过阈值时直接短路缓存让请求走数据库当 Redis 写操作失败时先记录失败 key 到本地队列由后台线程异步重试删除。这套机制我是在一次大促压测之后补上的之后再也没有出现过因为缓存集群抖动导致的缓存里全是陈年旧数据的问题。5. 从工具到工程化一致性保障的落地清单聊完了原理和策略最后给一份可以直接照着做的落地清单。这些不是理论推演是我在多个生产环境里验证过的标准操作。5.1 代码层面的固定范式我把写请求处理缓存的方式固定成下面这套流程所有业务代码统一走这个模板避免每个开发自己发挥开启数据库事务执行 UPDATE/INSERT/DELETE。事务提交成功后立即执行 Redis DEL此时应使用独立的 Redis 连接池避免和读链路争抢连接资源。异步提交一个延迟双删任务500ms~1000ms 后再执行一次 DEL。如果 DEL 失败记录失败 key 到重试表或者 Redis List后台消费者每 5 秒重试一次直到成功或者 key 过期。所有缓存 key 设置合理的 TTLTTL 不只用于最终兜底清除脏数据也用于避免缓存无限制膨胀。这套流程的关键点在于**删除缓存不是尽力而为而是必须有回执的操作。**我遇到过最常见的问题就是开发同学觉得删缓存失败一次没关系下次读请求会回填但你没想过如果那个 key 再也没有读请求了它就永远不被回填也永远不会被删除然后某一天你去做数据统计时发现它是个脏数据。5.2 监控与告警体系一致性问题是典型的不出事则已出事就是大事故的问题所以必须提前埋好监控。我常用的指标有四个缓存删除失败率每 1 分钟统计一次 Redis DEL 返回非 1 的比例。缓存回填延迟通过 AOP 拦截回源数据库的方法记录耗时超过 P99 的请求打点告警。缓存与数据库定期对账写一个定时任务每天深夜扫描缓存里所有的 key抽样对比数据库发现不一致就告警。这招很土但非常有效我靠它抓到过好几个藏在角落里的问题。连接池与线程池饱和度Redis 连接池和数据库连接池的使用率超过 70% 就要预警这两个指标是缓存链路健康度的晴雨表。5.3 常见误区自查表看似正确实则危险的做法实际风险我的建议更新数据库后直接更新缓存并发写导致缓存值被旧覆盖改为删缓存删缓存用业务公用连接池连接池争抢导致删除失败或超时单独建一个小连接池缓存 key 不设置 TTL脏缓存永远无法自愈设置 5~30 分钟的 TTL消息队列异步删缓存消费乱序导致删了又写入旧值消费时校验版本号或时间戳只要删缓存失败就记录日志日志被淹没问题无人跟进重试 告警 对账兜底5.4 关于分布式锁与一致性的一点点延伸搜索热词里出现了redis分布式锁这里顺带提一句。分布式锁一般用于多实例部署下的互斥操作比如防止定时任务重复执行、防止并发扣减同一个库存。它和缓存一致性的关联在于如果你用分布式锁来保护更新数据库 更新缓存的原子性那锁本身就引入了新的复杂度——锁过期怎么办、锁误删怎么办、锁重入怎么办我的经验是**优先通过设计规避并发而不是通过锁来解决并发。**比如对库存在 Redis 里用 Lua 脚本保证扣减的原子性数据库里用乐观锁 CAS 更新最终以数据库为准缓存失效后用回源的最新值重建。整个过程完全不需要分布式锁却能达到同样的一致性目标。只有在确实需要跨服务互斥的场景比如多个服务同时写同一份数据才考虑引入 Redisson 这类成熟的分布式锁框架而且务必配置好看门狗自动续期避免锁过期导致逻辑重复执行。6. 最后分享一点我踩坑摸索出来的小习惯每次聊到 MySQL 和 Redis 的一致性总会有人追问到底有没有最优解。我的回答是**没有绝对的最优解只有基于业务场景的平衡点。**你需要想清楚三个问题你的读多还是写多你能容忍的数据不一致窗口是毫秒级、秒级还是分钟级缓存故障时你更倾向于返回旧数据还是直接报错把这三个问题想明白了一致性方案基本就出来了。在我自己维护的系统中最常用也最推荐的一套组合是**更新数据库后删缓存配合延迟双删和 TTL 兜底再加上离线对账任务巡检。**这套方案不 fancy但它在我的生产环境里经受住了多次大促流量和大规模促销活动的考验没有出现过一次因为缓存不一致导致的资损事故。最后送大家一个实用建议在开发环境里故意制造缓存不一致的测试场景——让一个线程每 100ms 更新一次数据库另一个线程每 50ms 读一次缓存看看你的系统多久能收敛到一致。这种故障注入测试做上几次你对缓存一致性的理解会远超看十篇理论文章。一致性不是一个可以一次搞定的功能它更像一个需要持续维护的系统质量属性数据结构的每次调整、缓存策略的每次改动都应该重新审视一遍这条更新链路。
返回列表