ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis与MySQL数据一致性:方案原理、坑与选型全解析

Redis与MySQL数据一致性:方案原理、坑与选型全解析

先说结论:Redis 和 MySQL 之间不存在绝对强一致,工程上我们追求的永远是“可控的最终一致”。但“可控”这个词,背后藏着大量细节。很多团队最初用缓存只是为了挡流量,真正出问题往往是在一次大促、一次发版、一次半夜的告警之后——缓存里是旧值、数据库里是新值,用户看到的价格、库存、状态全对不上,这时候才意识到,当初随手扣的缓存方案,坑比想象中深得多。

今天这篇就围绕“Redis 与 MySQL 数据一致性”这个老话题,把方案、原理、坑、选型一次性讲透。不管你是刚把 Redis 引入项目、正在排查缓存不一致 bug,还是准备设计一套新的缓存架构,这篇都值得花十分钟认真读一遍。

1. 先把问题定义清楚:不一致到底是怎么发生的

1.1 一个请求的完整旅程:缓存写在哪个环节

要理解一致性,先得画清楚一条读请求和一条写请求分别在做什么。绝大多数业务用的是 Cache Aside 模式,也就是旁路缓存。

读请求:先查 Redis,命中直接返回;没命中就去 MySQL 查,查到后回填 Redis,再返回。这是最常见的读路径。

写请求:先更新 MySQL,成功后删除对应的 Redis key。注意,这里是删除而不是更新缓存。原因很朴素——更新的代价可能很高,而且频繁的写操作会让缓存里的值反复变来变去,删除则简单得多,下次读的时候再重建缓存就行了。

这个模式本身没有大问题,问题出在“写”这个环节:MySQL 更新成功,Redis 删除失败,缓存和数据库就不一致了;或者删除成功了,但并发读请求刚好在删除前把旧值读走并回填,缓存里又变成了旧值。这两种情况是所有缓存一致性问题的根源。

1.2 不一致的三种典型现场

我把实际生产中遇到的不一致场景归纳成三类,很多同学排查问题时容易眉毛胡子一把抓,先明确是哪一种,再对症下药会快很多。

第一种:删除缓存失败。这是最直白的场景。代码执行到delete redisKey这一步,Redis 超时了、网络抖动了、序列化异常了,删除操作没生效。数据库里的数据已经是最新,缓存里还是旧值。这种问题靠代码 review 很难发现,因为它不是必然发生的,属于低概率但后果严重的故障。

第二种:并发读写在时间窗口内竞争。假设线程 A 更新了数据库,正准备删除缓存;线程 B 在缓存还没被删除之前发起读请求,正好命中旧缓存,直接返回了旧数据。这个窗口时间极短,往往是毫秒级甚至微秒级,但高并发下会被放大。更麻烦的是线程 B 不仅读到旧值,如果它执行的是“读后回写”逻辑,还会把旧值重新塞回 Redis,把后续的修复也搅黄了。

第三种:数据库主从延迟导致的回写污染。项目里读操作基本都走从库,主库写完同步给从库需要时间。假设线程 A 写完主库、删除缓存,线程 B 读缓存未命中,转向从库查询,但此时从库还没同步到最新数据,线程 B 拿到的是旧值,再回写缓存。主从延迟的窗口比删除缓存失败更隐蔽,因为代码看起来一切正常,但值就是不对。

1.3 一致性需求的三个层次:别一上来就要最强的

技术讨论最怕目标不统一。你会发现问“Redis 和 MySQL 怎么做到一致”的人,脑海里对“一致”的定义可能完全不同。我习惯把需求分成三个层次。

强一致性:读操作永远返回最新写入的值,任何一个时刻都完全一致。这在分布式系统里通常要借助分布式事务、锁或者串行化读写才能做到,代价极高,绝大多数业务用不起。

最终一致性:允许系统在一段时间内不一致,但在某个时间点之后会达到一致。中间这个“一段时间”就是一致性窗口,业务允许窗口多短,决定了技术方案的下限和成本。

单调读一致性:比最终一致性弱一点,但比无约束强一点。它要求如果一个请求读到了某个值,后续请求不会读到更旧的值。这个级别在用户维度上保证了体验,但对全局数据不强制一致。

看清这三个层次之后你就会明白,一致性不是一道“做不做”的判断题,而是一道“做到什么程度”的选型题。后面聊的所有方案,本质都是在不同窗口大小和不同代价之间做权衡。

2. 常规手段盘点:从最常用到相对稳妥

2.1 先更新库再删缓存:为什么它是最流行的基础方案

如果你去读各大厂的技术博客,会发现“先更新数据库,再删除缓存”几乎是默认选项。它背后有一个很关键的理由:这个方案的不一致窗口是所有方案里最小的。

对比“先删缓存再更新数据库”,后者有一个明显的问题:线程 A 删除缓存后、更新数据库前,线程 B 发起读请求,缓存未命中,线程 B 去数据库读到旧值,然后把旧值回填缓存。线程 A 随后才更新数据库,这时候缓存里的值已经是旧值了,而且没有任何机制去纠正它,除非等到缓存过期。这是灾难性的。

而“先更新数据库再删缓存”只存在一个极短的时间窗口——线程 A 更新完数据库但还没删除缓存,这个窗口内读请求可能命中旧缓存。好消息是这个窗口通常只有几毫秒,而且缓存一旦删除,后续读请求会重建新值,不需要额外等待过期。所以业务上如果对一致性窗口容忍度在百毫秒级别,这个方案基本够用。

但它有一个致命短板:删除缓存这步可能失败。一旦失败,没有补偿机制,缓存和数据库再也不会一致,直到缓存自然过期。所以用这个方案,一个必须做的前置动作是:给每一个缓存 key 设置合理的过期时间。过期时间是最后的兜底,散弹枪打不中,但你把靶子移走,总能重新上靶。

2.2 延迟双删:为什么删除要删两次

既然删除失败是痛点,聪明人就想到,能不能多删几次?于是有了延迟双删。

延迟双删的执行流程是:先更新数据库,接着删除缓存,然后等待一小段时间,再次删除缓存。第二次删除的目标,是清掉在第一次删除之后、等待期间内可能被并发读请求写回的旧缓存值。

这套方案能处理一部分并发竞争问题。举个例子:线程 A 更新数据库,删除缓存;线程 B 在缓存被删除之后读到数据库旧值(可能因为主从延迟),把旧值回写缓存;此时线程 A 的第二次删除到来,把旧值清掉,缓存和数据库重新一致。整个过程对业务无感知,实现也不算复杂。

细节都在等待时间上。等待太短,旧值可能还没被回写;等待太长,接口响应时间被拉长,用户体感变差。工业界的经验值一般是几百毫秒到一两秒之间。我见过有人直接睡 500 毫秒,也有人根据业务读写耗时动态调整,没有绝对标准,但要明确:这个等待时间必须大于“从写库完成到最晚可能回写旧缓存”的时间窗口,否则第二次删除等于白删。

另一个容易忽略的点是第二次删除本身也可能失败。所以延迟双删的完整形态应该是:第二次删除异步执行,失败进入重试队列。只做同步删除而不带补偿,等于把问题从一个小概率事件变成另一个小概率事件,并没有根治。

2.3 队列补偿与重试机制:把“删除失败”关进笼子里

不管用哪种删除策略,删除失败都是绕不开的。成熟的工程方案不是寄希望于“删除永远成功”,而是建立一个失败补偿机制。

做法不复杂:把删除缓存这个操作包装成一个可以重试的任务。代码里捕获删除失败的异常,把失败的 key 投递到一个消息队列或者写入一张本地的重试表,由后台定时的任务或队列消费者去重试删除。重试要设置最大次数和退避策略,比如第一次失败后等 1 秒重试、第二次等 5 秒、第三次等 30 秒,超过最大次数后进入人工告警。

这套机制的要点在于幂等。重复删除一个不存在的缓存 key,Redis 不会报错,所以删除操作天然幂等,不需要担心重复执行产生副作用。真正要注意的是别把重试做成“风暴”——一次缓存删除失败引发几十条重试消息,每个消费者又触发一次数据库查询或者缓存重建,机器压力会瞬间增高。所以重试队列最好加上去重,同一个 key 在短时间窗口内只允许一个重试任务。

3. 进阶方案:用 Binlog 订阅把同步从业务代码里剥离

3.1 原理与整体架构:数据库变更变成一条消息

前面聊的方案都需要业务代码在写流程里显式地删除缓存,这就有一个隐患:团队大了、模块多了,总有某个团队忘了删、删错或者删的时候 key 规则不对。能不能把这些琐事从业务代码里拿掉?

可以。基于 MySQL Binlog 的订阅同步方案,就是让数据库变更自己开口说话。主从复制里,从库就是通过解析 Binlog 来复制数据的,我们也可以做同样的事:用一套工具伪装成从库,连接到主库获取 Binlog,解析出每一行数据的变更事件,再投递到消息队列里,最后由消费者针对变更的 key 执行缓存删除。

方案的核心意义在于,业务代码只需要关心写数据库,缓存侧由独立的数据管道去同步。删除缓存成为一个自动化的动作,不会因为某次发版遗漏或者某个新人没遵守规范而失效。

3.2 落地关键点:Binlog 格式、延迟和事件幂等

真正上手这套方案,有几个细节必须处理。

Binlog 格式要设置为 ROW 模式。Statement 模式下 Binlog 只记录 SQL 语句,解析出来的变更信息不够结构化,很难精确到某行数据的 key;ROW 模式记录了每一行的完整变化,能够拿到表名、主键 ID 以及 before/after 的值,这样才能拼出需要删除的缓存 key。

数据库主从切换的问题要提前想清楚。订阅工具和主库的连接断了、位点丢失,或者主库切换后 Binlog 文件名和偏移量对不上,都会导致事件漏读。所以方案里要有位点记录和校准机制,必要时做一次全量补偿同步,不能默认“断了就断了”。

消费端的幂等也是老生常谈。同一个变更事件可能被消息队列投递多次,消费方删缓存的操作要能接受重复执行。删除缓存天然幂等,这点倒不用担心,但如果你在消费端还做了其他操作,比如更新本地缓存或者写统计,就必须考虑幂等或者接受轻微的不精确。

3.3 什么时候值得上 Binlog 方案

Binlog 方案不是银弹。它引入了额外的组件、消息管道、消费端,运维复杂度和故障排查成本显著上升。我见过有团队为了同步一个几万 QPS 的缓存,搭了一整套 Canal + Kafka,最后维护管道比维护业务还累,得不偿失。

它比较适合的场景有几个特征:第一,业务模块多、团队多,很难通过规范和 review 保证每个人都正确删除缓存;第二,对不一致窗口不敏感,因为同步链路有延迟,从 Binlog 解析到消息投递到消费端删除,可能需要几百毫秒甚至几秒;第三,已经有成熟的消息队列基础设施,团队具备运维能力。

如果只是一个小团队维护一个核心服务,完全没必要上这套。用延迟双删加补偿队列,完全可以把不一致窗口控制在可控范围内。

4. 一致性级别怎么选:别用强一致的标准要求所有缓存

4.1 强一致方案的代价:锁、串行化和分布式事务

有一部分业务的缓存数据确实是不能接受任何不一致的,典型如库存扣减、订单状态流转、金额操作。如果这类业务也走缓存,那需要思考的是:能不能接受为了强一致而付出的巨大代价。

要让 Redis 和 MySQL 达到强一致,常规思路是串行化读写。对同一个 key 的读操作和写操作都加锁,让它们排队执行;或者把读写都路由到同一个节点,从源头避免并发竞争。理论上可行,但现实中很少看到有人直接这么干,原因很直接:性能打回原形。Redis 最大的优势是快,你让它去等锁、等数据库事务提交、等确认,它跟直接查 MySQL 相比已经没有明显优势了,那还要缓存干什么。

另一种思路是用分布式事务,把缓存更新和数据库更新放在同一个全局事务里保证原子性。听着很美好,实操中需要引入分布式事务协调器,性能和复杂性双爆炸。大多数业务不值得这么干。

所以我在选型时有一条铁律:如果一个数据点的读写要求强一致,那就别让它进缓存。直接读数据库,或者只在保证短时间一致性要求低时才用缓存做热点保护。强一致不值得用缓存。

4.2 最终一致性才是大多数场景的答案

回过头来看,绝大多数业务场景对一致性的容忍度远高于技术人员的想象。用户改完昵称,一分钟内没看到变更很正常;商品价格改了,几秒内全端生效已经很快了。这类业务用最终一致性、控制好一致性窗口,完全没有问题。

那就要回答一个关键问题:这个窗口到底能控多大?如果你用的是“先更新库再删缓存 + 补偿重试”,窗口主要是更新数据库到删除缓存完成的耗时,通常几十毫秒到几百毫秒。这个级别对绝大多数业务足够了。如果不够,说明业务完全不该用缓存。如果用的是 Binlog 订阅,窗口取决于同步链路延迟,几百毫秒到几秒也很常见。能不能接受,你要用业务指标去衡量,而不是拍脑袋。

另外,给缓存 key 设置合理的过期时间,本身就是最终一致性的兜底策略。哪怕上面的所有机制全部失效,缓存最终会因为过期而重建,系统会恢复到一致状态。过期时间等于“最大的不一致时长”,设得越长,兜底效果越差。我建议所有能容忍缓存的场景,过期时间都不要超过 10 分钟,既保证读命中率,又保证兜底足够快。

4.3 不同业务场景的推荐组合

做方案选型时我喜欢把业务场景分成三类,分别给出推荐组合。

读写比特别高的热点数据、可重算的展示类数据(比如用户信息、商品详情),推荐“先更新库再删缓存 + 合理过期时间 + 删除失败补偿队列”。这套组合便宜、好理解、容易落地,窗口控制在百毫秒级,绝大多数展示类业务够用。

更新频繁、对一致性要求中等、且有现成 MQ 基础设施的数据(比如库存数量、关注列表),可以考虑“延迟双删 + 队列补偿”,或者干脆“Binlog 订阅 + 消息管道”。这里要注意延迟双删只对第一次删除后的旧值回写窗口有效,如果数据频繁更新,多线程并发的场景会变得非常复杂,必要时直接走 Binlog 方案更稳妥。

对一致性极其敏感、写操作是核心链路(比如订单支付状态、账号余额),直接放弃缓存,读写全走数据库,最多在数据库前面加一层只读副本做查询扩展,但业务逻辑上不要依赖 Redis 作为数据来源。

5. 实战踩坑记录与选型对照

5.1 踩过的坑:删除失败只是冰山一角

第一个坑是删除失败被静默吞掉。实际项目里,很多代码在try/catch里捕获了 Redis 异常后打了条日志就不管了。删除缓存失败率高不高?平时确实不高,但一旦 Redis 抖动,失败率会成片上涨,而你的监控里根本没有这个指标,等业务反馈问题时,缓存里已经是积累了很久的脏数据。所以上线前必须给删除缓存失败加上一个计数器,失败率超过阈值要告警。

第二个坑是延迟双删里的重试风暴。我见过一个项目,延迟双删的第二次删除执行失败后,进入一个本地内存队列,后面有一个线程扫描队列重试。高峰期 Redis 抖动,一瞬间几千个 key 涌入重试队列,扫描线程拼命删缓存,Redis CPU 被打满,正常请求反而超时。后来改成熟:重试队列限制长度、按 key 去重、失败超过三次直接丢弃并告警。

第三个坑是主从延迟引发的回写污染,这也是最难排查的。表面上代码没问题,读缓存未命中就查库,可查的库是从库,主从延迟 200 毫秒,读到的就是旧值。旧值写回缓存后,你以为缓存是新的,实际它是“最新的旧数据”。这个坑你用“先更新库再删缓存”的视角去看会非常困惑,因为问题不在缓存更新逻辑,而在数据来源读取逻辑。解决方案是:业务上能接受的话,每次写库后短暂时间内读主库;或者把“查询数据库”这一步的延迟监控起来,及时发现从库同步异常。

5.2 方案选型对照速查表

方案一致性窗口性能影响实现复杂度适用场景
先更新库再删缓存数十毫秒到数百毫秒极小低大多数读多写少场景
延迟双删与等待时间相关,可控制在秒级中(需睡眠/异步等待)中并发竞争较明显、无 MQ 基础设施场景
删除失败补偿队列取决于重试间隔,最终一致中(依赖 MQ)中对删除缓存可靠性要求高的场景
Binlog 订阅同步秒级低(异步管道)高多团队协作、无法保证业务代码规范场景
强一致(锁/分布式事务)无窗口严重极高基本不建议在缓存层实现
不用缓存(直接查库)无窗口取决于数据库抗压无强一致敏感、写核心链路场景

表格说得很清楚,越靠上越轻量,越靠下成本越高。我的经验是,先从最轻量级的方案开始,加上过期时间兜底和删除失败告警,运行一段时间后根据实际告警和业务反馈决定要不要升级到更复杂的方案。不要一开始就在缓存层设计一套完美方案,往往维护成本比收益高。

5.3 给监控和告警的最小集

一致性实施得怎么样,要靠监控数据说话。我建议最小监控集至少要覆盖三个指标。

第一个是缓存删除失败率。统计所有删除 Redis key 操作中失败次数的比例,超过阈值就告警。这是整个一致性的第一道防线。

第二个是缓存重建的延迟分布。缓存未命中后查数据库重建缓存,这个过程的耗时如果持续升高,说明缓存命中率在下降或者数据库在变慢,需要及时处理。

第三个是主从复制延迟。如果查数据库的路径走了从库,必须监控从库和主库之间的延迟秒数。很多缓存不一致的根因是读到从库旧值,而不是缓存逻辑本身。延迟超过 1 秒就应该引起重视。

这套监控统计起来成本不高,价值很大。很多团队直到线上出了事故才去查日志,如果早一点从指标上看到异常趋势,很多事故是可以提前拦住的。

6. 我的个人实践经验:不同选择背后的取舍逻辑

最后聊点这些年实际项目里沉淀下来的经验。水平有限,但都是踩过坑换来的。

同一套方案在不同的流量规模下,结论可能完全不同。我在一个日活百万级别的业务上,用最基础的“先更新库再删缓存 + 删除失败补偿”,运行大半年没出过一致性问题。同样的方案搬到另一个订单量很大、写并发极高的业务上,一次 Redis 抖动就暴露出大量脏数据。不是因为方案本身有问题,而是业务的写并发量已经超过了这个方案所能覆盖的“一致性窗口可控”的边界。方案只有合不合适,没有绝对的好和坏。

如果你问我在一致性这件事上最重要的心得是什么,我的答案是:先定义清楚你能接受的多长时间不一致,再去选方案。很多团队的问题恰恰是没定义清楚,上来就讨论技术方案,最后方案选了一个重的、贵的,业务却根本不需要。

还有一点想特别提一下:一致性方案不是“加一个删除缓存操作”就结束了,它是一个闭环,包含了操作、失败处理、补偿、监控和告警。我观察下来,很多团队的问题出在只做了“操作”那一环,后面全部没有。设计缓存一致性时,可以用这个清单自检:写库后有没有删缓存?删除失败有没有补偿?兜底过期时间设置了吗?监控指标有吗?不符合这个闭环的方案,尽早补上。

写到这里,关于 Redis 与 MySQL 数据一致性的核心问题应该都覆盖了。总结成一句话就是:我们很难做到理论上的绝对一致,但完全可以做到业务层面的可控一致——选对方案、设好兜底、建好监控,这比追求一个“完美一致”的乌托邦实际得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进