MySQL与Redis缓存一致性实战:延迟双删与binlog订阅
缓存和数据库的一致性问题几乎是每个使用 Redis 的团队都会撞上的一堵墙。我接手过的一个新项目里业务上只是把商品库存冗余了一份到 Redis结果活动期间优惠券发超了——原因就是 MySQL 扣减成功而 Redis 里的库存数据没有同步更新前端展示的库存量一直虚高。排查到最后问题落在“先更新数据库再更新缓存”这个看起来毫无毛病的顺序上。这类问题不会因为你业务规模小就不出现它只跟并发窗口和更新时序有关跟团队大小、框架选型都没太大关系。这篇文章我按一线实践的顺序把 MySQL 与 Redis 数据一致性问题的本质、缓存策略选择、可落地的解决方案、对账兜底手段以及我踩过的坑一次讲清楚适合正在维护缓存类业务的开发同学也适合刚接触分布式系统、想知道“为什么 Redis 和 MySQL 总是对不上”的读者。1. 数据一致性问题的本质先搞清楚矛盾在哪里1.1 缓存和数据库“不同步”是一种必然MySQL 和 Redis 是两种性质完全不同的存储。MySQL 把数据持久化到磁盘以事务为边界提供强一致性的读写能力数据在这里是“真相源”。Redis 则是一种基于内存的键值存储读写极快但默认不承担持久化职责即使开启 AOF 或 RDB它的核心定位依然是加速访问的缓存层。在绝大多数业务架构里MySQL 是底账Redis 是前台展示两者各司其职。既然存在双层结构一个基本事实就绕不开写请求最终要落库但不能每次更新都同步刷新缓存读请求优先访问 Redis但缓存里的数据必然是 MySQL 某一次写入后的结果。这两个操作之间一定存在时间窗口网络往返、磁盘刷盘、序列化开销都会拉大这个窗口。窗口一长并发一高竞态就来了——线程 A 刚把新值写进数据库还没来得及告诉缓存线程 B 已经读到了旧值甚至把旧值重新写回了缓存一致性就此破裂。我见过不少刚接触缓存项目的同学觉得一致性就是同一个 key 在两边数值相等。这个理解框架太窄了。缓存里没有这个 key、缓存里的 key 是旧版本、缓存里的 value 是错误的数据类型这些都属于一致性被破坏的形态只是有些容易发现有些要等到线上出故障才会暴露。先把问题范围划清楚后面选方案才不会做偏。1.2 三种典型的不一致形态第一类强一致读失效。用户更新了自己的昵称第二次打开应用时页面上还是旧昵称。原因是更新接口把新昵称写进了 MySQL但 Redis 里的缓存既没删除也没更新TTL 又没到用户一直在读旧值。这类问题最常见根源是“先更新数据库再更新缓存”的双写操作没有保证原子性更新缓存那一步可能失败也可能被并发请求覆盖。第二类缓存回填错乱。读请求先查缓存未命中线程 A 去数据库里读到旧值线程 B 抢先完成了数据库写入并更新了缓存线程 A 随后返回并把自己的旧值覆盖到缓存上。常规的缓存旁路模式一样存在这个窗口。多个线程并发操作同一个 key 时谁后写谁说了算但最后写进缓存的那个请求不一定代表数据库的最新状态。第三类删缓存之后写入失败。按“先删缓存再写库”的顺序执行删除缓存成功数据库写入超时或者事务回滚缓存就空了。此时所有读流量直接打到 MySQL扛不住就是雪崩。如果为了补偿再删一次缓存却发现删完又被别的线程回填了旧数据补刀反而扩大问题。这三种形态背后其实共享同一个根源MySQL 和 Redis 之间的数据同步不是事务性的没有两阶段提交也做不到原子生效。想明白这一点你就会发现所有一致性方案本质上都是在回答同一个问题——如何在“先写库”和“先清缓存”之间找到一个并发安全、可容忍失败、能自愈的执行顺序。1.3 一致性强弱与业务容忍度在选方案之前先要搞清楚业务到底需要什么程度的一致性。有些场景比如用户余额、订单状态、优惠券库存用户对数据高度敏感旧数据会造成资损或客诉这类业务要尽量做到强一致或者至少把不一致窗口压缩到百毫秒内。有些场景比如用户头像、文章阅读数、商品详情页允许缓存里短暂存在旧数据哪怕五分钟之后才刷新也没有致命影响这类业务追求最终一致就够了。更极端一些的场景比如排行榜、热搜词数据本身实时性要求不高Redis 里放一个快照定期刷新也能接受。很多团队不做区分一律用最重的方案去保护所有 key结果系统复杂度上去了性能优势反而没了。正确的做法是先按“写入频率 × 读取频率 × 业务敏感度”给缓存 key 分个类高敏感且高频写的走强一致方案低敏感低频写的用最普通的旁路缓存就够。分类做完后面每一步选择都会变得很清晰。2. 缓存读写策略的底层逻辑为什么“删缓存”通常优于“更新缓存”2.1 四种主流策略的原理对比缓存与数据库之间的读写策略业界总结下来主要有四种Cache Aside、Read Through、Write Through、Write Behind。网上资料很多我直接用业务视角把它们串一遍。Cache Aside 也叫旁路缓存这是国内应用最广泛的模式。读的时候先查 Redis命中直接返回未命中就查 MySQL把结果写回 Redis 再返回。写的时候有两种做法一种是先更新数据库再删除缓存另一种是先删除缓存再更新数据库。旁路模式最核心的特征是缓存里的数据不是应用主动维护的而是读请求“顺便”写进去的。Read Through 是让缓存组件自己承担回源职责。应用只跟缓存交互缓存未命中时由缓存层去查数据库并填充。这个模式对上层应用封装得干净但一般需要引入专门的缓存代理或框架支持自研成本偏高小团队用得少。Write Through 是写请求先写缓存由缓存组件同步写数据库两个动作对应用来说是原子的。好处是应用侧代码简单坏处是每次写都要等数据库落盘性能优势基本被削没了而且一旦缓存组件出问题写链路直接断掉。Write Behind 是写请求只写缓存立即返回由后台任务异步批量刷入数据库。性能极好但风险也最大——缓存一旦宕机还没落库的数据直接丢失。电商秒杀场景里用得多但需要非常完善的数据补偿机制。绝大部分业务系统落地的都是 Cache Aside 变体核心原因是它实现成本最低、对已有代码侵入性最小而且能利用 MySQL 的事务能力做补偿。Read Through 和 Write Through 在中小团队里反而容易引入框架依赖出问题后排查链路更长不建议作为默认选择。2.2 并发交错是缓存失序的根源很多人以为“先更新数据库再更新缓存”是最自然的写法数据库成功了缓存改一下就行怎么还会出问题我给出一个非常具体的并发交错场景。线程 A 和线程 B 同时对同一个 key 发起写操作。线程 A 先把数据库里的值从 1 改成 2线程 B 随后把数据库里的值从 2 改成 3。如果按“先更库再更缓存”的顺序理想情况下缓存最终也是 3。可现实是线程 A 更新完数据库后网络卡了一下线程 B 反而先把缓存更新成了 3然后线程 A 才缓过来又把缓存写成了 2。数据库已经是 3缓存却是 2两边永远对不上。假设把顺序改成“先更新缓存再更新数据库”问题同样存在只是竞态窗口发生了位移。所以结论是只要两个操作之间没有同步机制最终结果就取决于调度顺序而不是业务逻辑顺序这种失序本质上无法通过调整执行顺序来解决。这也是为什么主流的做法是“更新数据库后删除缓存而不是更新缓存”。删除一个不存在的 key 是幂等的哪怕删失败、删重复无非是多一次穿透请求下次读请求会把数据库里的新值回填到缓存。而“更新缓存”是覆盖写一旦并发乱序旧值覆盖新值的概率非常高脏数据会长时间驻留。所以“删”比“改”安全这是无数实践验证过的基本判断。2.3 策略选型的判断标准在实际项目里我一般按三条标准来决策。第一条写多读少还是读多写少。如果写频繁但读很少比如内部系统每天更新几千条配置数据但访问量极低那缓存要不要都无所谓直接用“更新数据库后删除缓存”最省事读请求回填成本可控。如果读多写少比如商品详情、用户资料那缓存命中率是优化重点删除缓存会导致频繁回源的流量放大这时可以考虑结合后续讲的延迟双删或异步订阅方案既保证一致性又不牺牲命中率。第二条缓存是“唯一副本”还是“冗余副本”。如果缓存只是数据库的冗余展示层那删除缓存重建的成本很低如果缓存里还存了聚合数据、计数数据、甚至是多张表 join 后的结果那删除后重建的代价很大可能需要专门的刷新任务这类 key 就要慎重用“删缓存”策略。第三条能不能容忍缓存偶发的过期穿透。删缓存必然带来一次缓存穿透如果被穿透的读流量打到了数据库数据库抗不扛得住这个评估必须在方案落地前做而不是出事以后才做。扛不住就得引入分布式锁限流或热点 key 永不过期等手段把这些成本计入方案。3. 两个真正可落地的解决方案3.1 延迟双删把竞态窗口压缩到可控范围删除缓存比更新缓存安全但“先更新数据库再删除缓存”本身并不是无懈可击的。考虑这样一个时间线线程 A 更新数据库成功执行删除缓存动作但在删除之前线程 B 正好发起了读请求缓存未命中线程 B 查数据库拿到了旧值然后线程 A 删除了缓存线程 B 紧接着把旧值写回缓存。数据库是新值缓存是旧值一次普通的“删缓存”就这样被并发读请求破坏了。延迟双删就是专门针对这个场景设计的。执行顺序是先删除缓存再更新数据库休眠几百毫秒再次删除缓存。为什么这样能解决问题第一次删除缓存是为了让后续读请求有机会拿到新值休眠期间给并发读请求留出“从数据库回填旧值”的窗口第二次删除则把回填的旧值清理掉让后续读请求重新回源拿到新值。两次删除之间形成一道屏障把旧值驻留时间压缩成毫秒级。延迟双删里最关键的两个点是休眠时长的确定和第二次删除失败后的补偿。休眠时长理论上必须大于“读请求从数据库回填缓存”的耗时实际操作中我一般取业务高峰期的 P99 耗时再乘上 1.5 到 2 的安全系数。如果回填平均耗时 200 毫秒P99 是 400 毫秒休眠就设在 800 毫秒左右不能拍脑袋定个 1 秒。第二次删除失败是逃不掉的现实。常规做法是把删除操作投递到消息队列由消费者异步重试删除。相当于把同步链路上的“删缓存”动作异步化失败后依赖 MQ 的重试机制兜底直到删除成功或 key 过期自然淘汰。这套组合在实践中能覆盖百分之九十五以上的不一致场景。延迟双删也有明显的软肋。它对写请求增加了两次 Redis 网络往返成本写入链路延迟会上升。休眠期间读请求会打到 MySQL如果是热点 key回流量会比平时大不少。我在评估是否用延迟双删时会先算一下写入 QPS 和回源流量再决定要不要加分布式锁或数据预热措施。3.2 订阅数据库变更日志最终一致性的工程化方案如果说延迟双删是“手工堵漏”那基于 binlog 订阅的异步方案就是“结构性改造”。思路是应用只负责写 MySQL等数据库的 binlog 产生变更事件由独立的订阅组件解析这些事件把“某个 key 应该被删除或更新”的消息投递到消息队列消费端再对 Redis 执行对应操作。应用层不需要关心缓存同不同步一致性由这条异步链路保证。这个方案的优势非常明显。首先应用代码不用在业务事务里拼接缓存操作业务逻辑和缓存逻辑彻底分离缓存更新不会回滚业务事务事务回滚也不会影响后续的缓存补偿。其次基于 binlog 的变更事件是严格的“顺序数据流”天然解决了并发乱序问题——数据库里哪个修改先生效binlog 里就先生成对应事件消费端按序处理覆盖顺序就有了依据。最后就算 Redis 操作失败消息可以重试而且 binlog 可以回溯理论上不会丢事件。落地时要注意几个关键环节。第一MySQL 必须开启 binlog且格式建议设置为 ROW 模式因为 ROW 模式下的 binlog 记录的是每一行变更前后的完整值解析出来的信息足够重建缓存STATEMENT 模式下只有 SQL 语句重建难度很大。第二订阅组件要维护消费位点否则组件重启后会从当前位置继续消费漏掉的变更就再也补不回来。通常会把位点持久化到本地磁盘或元数据库每次启动时读取。第三也是最容易踩坑的DDL 语句。建表、加字段、删表这类操作也会产生 binlog 事件如果订阅组件没有针对 DDL 做特殊处理可能导致整个消费链路报错进而阻塞后续所有变更事件。我处理过的最极端情况是某次上线执行了一条加索引命令订阅组件解析到未知事件类型直接抛出异常消费线程挂掉缓存更新停了半个多小时业务上表现为所有数据都变“旧”了。所以订阅端的异常隔离非常重要——单个事件解析失败应当进入独立的死信队列而不是中断整个消费线程。3.3 两种方案的取舍建议延迟双删和 binlog 订阅不是互斥的很多项目里两个都在用。我一般这样分团队人少、业务复杂度低、缓存 key 数量不超过几十个的项目优先用延迟双删实现成本低出现问题也好排查。团队有一定规模、缓存 key 多、对一致性要求高、还要做缓存预热或缓存重建的项目优先上 binlog 订阅方案虽然初期改造量大但收益是体系性的。从数据流角度来看延迟双删本质是针对单 key 的“局部一致性手段”binlog 订阅则是覆盖全量 key 的“全局一致性通道”。前者适合数据库写入量不大、回填成本低的场景后者适合写入量中等偏上、需要统一管理缓存生命周期的场景。在数据库写入量极大比如每秒几万条更新时binlog 事件量也会放大消费端容易堆积此时还得配合批量聚合、按 key 合并等手段来控制处理延迟。从运维角度来看延迟双删几乎不引入新组件出了问题直接查应用日志binlog 订阅则引入订阅端和消费端两个新组件链路更长任何一个环节抖动都会导致缓存滞后。上线前一定要对订阅组件做压测确认它的消费吞吐量能扛住业务峰值写入量否则高峰期缓存会大面积滞后比不一致更尴尬。4. 兜底与度量不能让一致性全靠运气4.1 怎么设计一个靠谱的对账任务无论用延迟双删还是 binlog 订阅线上总会有极端情况让缓存和数据库对不上网络分区、Redis 主从切换、消息丢失、代码 bug。一致性方案要做的是把不一致概率降到很低但做不到零。所以对账机制是每一条缓存链路的必备项不是可选优化。对账任务的核心思路是“抽样比对”。全量比对一个 step 的成本太高实时性也撑不住实际操作中我按业务重要级分成两类。第一类是核心类 key比如库存、余额、价格每天在业务低峰期做一次全量比对扫描 MySQL 和 Redis找出不一致的 key按差值排序输出告警。第二类是普通类 key比如商品标题、封面图按 key 空间哈希抽千分之一到万分之一的比例比对发现不一致再扩大抽样范围。比对方式也不一定逐字段相等。有些缓存里存的是聚合数据比如“某个商家的总销量”它是由 MySQL 多行数据聚合出来的直接比对值与值没意义。这类 key 的比对应改为“倒推一致性”校验上游源数据的状态是否与缓存快照相匹配比如源数据最近更新时间是否早于缓存写入时间。如果源数据已经更新而缓存写入时间还停在更早的节点基本可以判定不一致。对账数据不一致之后修复动作要自动触发不要等人工介入。自动修复可以把相关 key 直接删除让它走一次回源流程即可更复杂的情况则需要根据业务规则重建缓存。这里要提醒一句自动修复面对的核心问题不是“要不要修”而是“修的时机”——业务正在高频写入时强行修可能刚修完又被写坏甚至触发新的覆盖事故。低峰期执行、对账与修复链路分离、每次修复留审计日志这三条是底线。4.2 缓存过期时间与流量控制很多人忽视 TTL 在一致性方案里的战略价值。一个合理的过期时间就是一道最终兜底哪怕上面所有方案都失效TTL 一到缓存自动淘汰读请求回源数据库数据最终还是会一致。所以我给缓存设置 TTL 时永远不会设成“永久”。即便是热点 key 用永不过期策略扛流量也会配一个后台定时刷新线程保证长期来看数据不会被永久锁死在旧状态。TTL 的具体值要根据业务敏感度来定。用户余额一类的高敏感数据TTL 只做极端兜底设 30 秒到 5 分钟日常一致性靠更新链路本身保证商品详情这类低敏感数据TTL 可以放到 30 分钟到几小时既降低回源压力也让缓存能够自然周期。设置 TTL 时还要考虑 Redis 过期机制的特性Redis 的过期清理是惰性加定期抽样的组合过期 key 不一定会被立刻删除在并发高峰期尤其明显。所以对账任务不能完全依赖 TTL 来修复不一致TTL 只是最后一道防线不是常规的修复手段。流量控制方面删除缓存必然带来短暂的回源放大我习惯在缓存层做一层“回源限流”。具体做法是回源数据库前先尝试获取一个分布式锁抢到锁的线程回源并写缓存没抢到的线程短暂等待后重试读缓存。这样同一时刻一个 key 最多只有一个线程打数据库能有效防止“删除缓存后并发请求全部回源”导致的数据库压力尖峰。4.3 降级与应急预案一致性方案再完善也要为最坏情况准备预案。最常见的极端情况是写库瞬时抖动或 Redis 局部故障导致删除动作大面积失败缓存里堆了成片的旧数据。这时候最忌讳的是无限重试重试只会放大压力把局部故障拖成全局故障。我的处理惯例是给缓存更新链路配置两个阈值一个是删除失败的连续次数阈值一个是失败 key 的数量阈值。超过之后自动触发“缓存降级开关”强制让后续读请求跳过 Redis 直接走数据库同时把失败的 key 清单推到告警平台等数据库恢复稳定后再逐步开放缓存。降级期间性能会损失但数据一致性保住了这比“高性能但不正确”重要得多。预案要定期演练。我见过有团队写了很完善的降级文档结果真出故障时发现降级开关的代码路径几个月没跑过打开就是报错。演练不需要搞多复杂每季度选一个低峰期手动触发一次缓存降级观察回源流量增长、耗时变化和恢复过程确认链路是通的。这个过程能帮你发现很多平时意识不到的问题。5. 一线踩坑记录与避坑原则5.1 删除缓存失败的连锁反应我处理过的一个案子场景是用户修改收货地址系统采用“先更新数据库再删除缓存”的方式。某天 Redis 主节点内存碎片率高删除命令超时频繁删除动作失败后没有重试机制导致大量用户的地址缓存停留在旧值。最要命的是地址缓存 TTL 设置了 7 天用户看到旧地址下单包裹发到了错误的位置客诉直接升级。复盘时整理出三个教训。第一个删除缓存必须有失败补偿不能删除失败就当无事发生补偿要么用消息队列重试要么至少记录到本地失败表由定时任务扫表重删。第二个给缓存设置“业务可容忍窗口”级别的 TTL不要因为“麻烦”就把 TTL 拉长到天级看似省了回源流量实际上把风险敞口也拉大了。第三个Redis 的可用性要和业务系统的关键链路挂钩像地址、余额这类高敏感缓存Redis 故障时宁可直接降级走数据库也不要让它带着半死不活的性能继续服务。这类问题的共同点是“静默失败”——Redis 操作超时不报错业务代码吞掉异常继续往下走直到数据长期不一致才暴露。排查手段也很直接给所有缓存操作加上耗时监控和失败告警任何超过阈值或失败的删除操作都要有记录不能拿“缓存偶尔失败没关系”当借口。5.2 订阅消费的滞后与位点管理binlog 订阅方案上线初期我遇到过消费端一直追不上生产端的局面。业务高峰每秒产生上千条商品更新事件消费端每处理一条还要做一次 Redis 删除操作RTT 成了瓶颈消费速度始终差一截缓存里的商品数据滞后越来越严重最后对账任务一跑拉出一大片不一致告警。定位到最后核心问题是“消费端对全量变更事件一视同仁”每一条都独立处理效率太低。优化方向是批量聚合在消费端维护一个短时间窗口的 key 变更表500 毫秒窗口内的多个变更事件合并成一次 Redis 操作再批量执行。商品更新这种 key 维度天然具备合并空间合完之后消费吞吐量直接翻了几倍滞后问题解决。位点管理也是个大坑。某次订阅组件所在机器宕机重启后消费位点回退重复消费了之前已经处理过的事件。操作类事件重复处理还好幂等性保证了最终结果一致但如果事件本身有副作用比如“删除缓存后通知外部系统”重复消费就会引发重复通知。所以我在设计事件结构时强制要求每条消息携带业务幂等键消费方必须做去重校验。5.3 三条我觉得最值钱的实战经验第一条任何缓存方案都要先回答“不一致窗口最长能容忍多久”。这个窗口决定了你用轻量方案还是重型方案也决定了 TTL 怎么定、对账频率如何设。把窗口时长想清楚很多纠结的选择会自己变清晰。第二条不要把缓存一致性做成“全有或全无”。分层对待不同 key 的敏感度核心数据上重型保障边缘数据让位给性能这个梯度设计比一套方案打天下要可靠得多。缓存领域有一个基本矛盾就是一致性和可用性之间的权衡要顺着业务特性来决定矛盾往哪边倾斜。第三条缓存故障时要优先保住数据库这条生命线。缓存击穿、缓存雪崩、缓存不一致本质上都是数据库承压。无论是延迟双删的休眠回源还是 binlog 订阅的消息堆积出发点和最终目的都是不让异常缓存状态击穿数据库。我在每次排障时都会问自己一个问题如果 Redis 彻底不可用系统能不能平滑降级这个问题的答案决定了系统抗风险的底牌有多厚。缓存一致性的问题不会有银弹但也不是无解。把策略选型、TTL 设计、对账任务和故障预案这四件事做成常态机制你手里的缓存系统就从一个“定时炸弹”变成了一个看得见、控得住、坏了能修的组件。这几年下来我的体会是别追求“永远一致”而是追求“有界的不一致”——把窗口控制住把修复路径铺好这比任何漂亮的理论都实用。