☰
MySQL与Redis数据一致性:根因分析、同步策略与兜底方案
2026/10/14 5:33:02 网站建设 项目流程

MySQL 与 Redis 的数据一致性问题,我最早是在一个凌晨的线上排查里被真实毒打过的。某社区产品上线了一套用户资料缓存逻辑,用户改了昵称之后,App 端刷新接口返回的却是旧昵称;数据库里明明已经是新值了,缓存层却一直给出旧值。当时第一反应是 Redis 的 key 过期时间太长,查了一圈发现过期时间确实很长,但这只是表象。真正的问题在于:我们设计缓存更新策略的时候,压根没把"缓存更新失败"这件事当成一个必须认真对待的异常路径。更让我警觉的是,群里另一位同事反馈了另一个更严重的现象——一张优惠券在 Redis 里的状态还是"未使用",同一张券被两次订单流程读到,生成了两张待支付订单。从那以后,我再也不敢小看这个老生常谈的话题,MySQL 与 Redis 的数据一致性,是每个做后端的人迟早要还的债。

这篇文章我想把我踩过的坑、复盘过的时序、看过的方案都摊开来讲。适合正在做缓存设计、被数据不一致问题困扰过的后端开发者,也适合刚接触分布式系统、想搞懂"为什么不一致"和"怎么尽量一致"的初学者。内容不会有太多数学公式,也不追求教科书式的严谨,而是一个经历过凌晨排查的人,把这件事说人话、说透。

1. 为什么 Redis 缓存会跟 MySQL 对着干

要解决一致性问题,先得搞明白"不一致"是怎么产生的。这里没什么玄妙的道理,核心就一句话:Redis 是 MySQL 的副本,副本和主数据源之间永远存在一个同步窗口。

1.1 双写系统的本质矛盾

我们引入 Redis 做缓存,通常是为了扛住读多写少的流量。读请求打到缓存上,命中就直接返回,不查数据库,数据库的负载就降下来了。这个模型在数据不变的时候非常好用,但一旦数据发生变化,问题就来了:你更新了 MySQL,Redis 里的那份数据不会自己跟着变。必须有人写代码去同步它,而这个同步动作,和用户的读请求之间,天然存在时间差。

你可以把这个场景类比成:给家里书柜拍了一张照片发给朋友,朋友看到的永远是拍照那一刻的书柜状态。你把书换了一批,照片不会自己更新;要更新照片,你得重新拍一张发过去。这个"重新拍照发送"的动作什么时候做、做成功了没有、朋友是不是刚好在动作完成之前看了一眼旧照片,全都影响最终看到的内容。

在技术层面,MySQL 的 update 是一套事务,Redis 的 set 或 del 是另一套操作,两个存储之间没有任何 ACID 保证。你不可能用一个数据库事务把 MySQL 的写入和 Redis 的写入包在一起提交。这就是"双写一致性"问题的本质:两个系统各有一份数据,它们之间的同步动作分散在业务代码里,任何一个环节出问题,两边就分叉了。

1.2 并不是所有读场景都需要一致性

这里有个容易被忽略的点:一致性需求不是一刀切的。你产品里可能有几十个缓存 key,有的数据每秒钟被读几千次但几乎不写,有的数据每次写都对实时性极为敏感。如果我们对所有 key 都使用同一套"强一致策略",你会发现要么性能撑不住,要么代码复杂度爆炸。正确做法是先分清数据的容忍度,再谈方案。

我在很多项目里看到团队直接把所有 Redis 缓存都设置成 24 小时过期,以为这样最省事。结果是任何一次数据库更新,都可能让用户在一整天内看到脏数据。后来我把所有缓存 key 按业务重新梳理了一遍,把高频读写的 key 的过期时间压到分钟级,把几乎不变的数据保留较长过期,一致性问题立刻少了一大半。这是后话,但说明了一个道理:在谈"怎么同步"之前,先想清楚"这个 key 到底需要多新"。

2. 先理清三条经典的读写同步路线

关于缓存和数据库同步,业界其实已经沉淀出了几条成熟的路线。虽然它们各有变种,但底层思路就那几种。这里我分别展开讲,包括各自适合什么场景、坑在哪。

2.1 旁路缓存:最简单也最容易出错的路线

旁路缓存,也就是大家熟知的 Cache Aside 模式,是互联网后端最普及的一种做法。它的读写逻辑是:

读的时候,先查缓存;命中就直接返回;没命中就回源查数据库,再把结果回填到缓存里。写的时候,先更新数据库,然后删除缓存里的对应 key。下一次读的时候缓存 miss,由读请求把新值回填。

这套逻辑的巧妙之处在于:写入时不是去更新缓存,而是删除缓存。为什么删除而不是更新?因为更新缓存需要处理序列化格式、并发覆盖等一堆问题,而删除缓存始终是幂等操作,删了不存在也无所谓。我当初第一次看到这个设计时觉得太浪费——每次写完数据库都要把缓存删掉,下次读还要重新回源。后来才明白,这种"浪费"恰恰是它稳定的原因:缓存本质上只是一个带过期时间的副本,让读请求自己回填,可以把并发竞争降到最低。

但 Cache Aside 不是没有代价。它把一致性压力全部放在了"读未命中"这个窗口期上。如果写请求删除缓存之后、读请求回填之前,又发生了另一次写操作,就可能会出现旧值回填的经典问题。这个我在下一节细说。

2.2 读写穿透:把缓存当成数据库的门面

读写穿透模式,核心思路是把缓存层提到数据访问的最前端,业务代码不需要直接访问数据库,而是统一访问一个缓存代理组件。读请求命中缓存就返回,没命中由代理组件去加载数据库并回填;写请求由代理组件负责写数据库,同时更新缓存。

这个模式对业务代码是最透明的:你只管调接口,不用关心缓存和数据库之间的同步细节,对开发效率有很大提升。但它的代价是,你需要一个足够可靠的缓存中间层,而且这个中间层本身仍然要处理"先写库还是先写缓存"的问题。如果代理组件只是简单地把写操作同时发给 MySQL 和 Redis,那么任何一边失败,数据照样分叉。

我个人的看法是:如果你的团队有能力维护这样的中间层,且多个应用共享同一套缓存访问规范,读写穿透确实能减少重复代码。但如果你只是单机应用、业务量也不大,为了引入穿透模式而专门造一套封装,性价比很低。很多时候直接使用旁路缓存,把同步逻辑收敛到服务层里的一个统一方法里,比造轮子更可靠。

2.3 写后回写:高吞吐下的钢丝绳

写后回写模式(Write Behind)的思路是:业务请求只写缓存,由后台异步任务把缓存中的数据批量刷回数据库。这个模式的好处是写入吞吐极高,因为绕过了数据库的即时写入;但坏处也很明显:一旦缓存节点在数据刷回之前宕机,缓存里尚未落库的数据就丢了。

这个风险在大多数业务场景里是无法接受的。我见过一些团队为了追求排行榜、计数器这类高吞吐写入而使用 Write Behind,但这些人要么允许丢失少量数据,要么有非常完善的数据恢复机制。如果你做的是订单、支付、库存这类核心链路,千万不要碰 Write Behind。它就像一根钢丝绳,走得快是快,但摔下去也是真的疼。

三种模式抽出来对比,大致可以这样看:

同步策略核心思想强一致程度实现成本典型适用场景
Cache Aside读回填、写删缓存最终一致,窗口期可优化低大部分普通业务缓存
Read/Write Through缓存代理访问数据库取决于代理层实现中多应用共享缓存规范
Write Behind异步批量回写弱一致、有丢数据风险中高允许丢失的分析类数据

3. 先删缓存还是先更库,这个老话题我得说透

这是网上讨论最多、也是我最想吐槽的部分。很多人一谈到缓存一致性,脑子里就跳出一堆口诀:"先更新数据库,再删除缓存""要延迟双删"。但如果你不真正理解每一步背后在防什么,光背口诀是应付不了线上情况的。

3.1 四种执行顺序逐个过一遍

假设我们有一个 key,数据库里存着它的真值,目标是让缓存最终跟得上。那么理论上你有四种顺序:

先更新数据库,再更新缓存。这个方案最直接,但也是最容易出问题的。一方面,并发写多线程时,两个线程可能按不同顺序更新数据库、更新缓存,导致缓存里的值和数据库不一致;另一方面,缓存更新失败没有任何兜底,数据库更新成功后缓存还是旧值,脏数据可能一直存活到 key 过期。

先更新缓存,再更新数据库。这个方案几乎没人用,因为缓存是易失的,缓存更新成功了数据库更新失败,缓存就成了"无源之水",整个系统瞬间出现大面积不一致,而且很难回滚。

先删除缓存,再更新数据库。这个方案有经典的并发漏洞。假设线程 A 删除了缓存,然后准备更新数据库;在线程 A 更新数据库完成之前,线程 B 发起了一次读请求,缓存 miss,B 回源数据库读到了旧值,并把旧值回填到了缓存。等到 A 更新完数据库,缓存里存的还是 B 回填的旧值,数据库和缓存彻底分叉。这个场景被我复现过很多次,不是理论问题,是真实会发生的。

先更新数据库,再删除缓存。这个方案是目前的主流选择。它的风险窗口比第三种小很多,但依然存在:线程 A 更新数据库,线程 B 恰好在这之前读了一次缓存并 miss,接着从数据库里读到旧值,在 A 提交后回填旧值;如果 A 的删除动作发生在 B 回填动作之前,缓存里就会残留旧值。不过这个窗口远比第三种小,因为回填动作和删除动作的先后时序难以精确控制,工程上更可控。

3.2 并发读写的交错窗口到底在哪里

我用一个更容易想象的方式描述一下"先更库再删缓存"的问题窗口。假设数据库的更新是瞬时的,但读请求回填缓存需要一次网络往返。顺序可能是:读请求 L 先查缓存 miss,再从库里读数据;写请求 W 同时完成了数据库更新并发出了删除命令。如果 L 从库里读到的是更新前的旧值,并且 L 的回填发生在 W 的删除之前,那么缓存就会残留旧值。

但这个场景要真正发生,需要满足几个苛刻条件:读请求在写请求提交前完成数据库读取,回填动作又恰好发生在删除动作之前。实际工程里,这个窗口期通常只有几十毫秒。更关键的是,如果业务把 key 的过期时间设得短一点,即使这个窗口发生了,也会在很短时间内被过期机制纠正。

那"延迟双删"解决的是什么呢?它的思路是:先删缓存,再更新数据库,然后在短暂延迟之后再删一次缓存。第二次删除就是为了清掉并发读请求可能回填的旧值。代码类似这样:

def update_with_delay_double_delete(key, new_value): delete_cache(key) update_db(new_value) time.sleep(0.5) delete_cache(key)

延迟时间怎么定?理论上应该大于"业务中读请求从 miss 到回填完毕的平均耗时",我通常是取线上最长耗时再加一点余量,比如 500ms 到 1s。这里要提醒一句:延迟双删并不是严格正确的方案。sleep 会阻塞业务线程,你可以改成把第二次删除丢到异步任务里做,但异步任务本身又引入了"异步任务在什么时候执行"的不确定性问题,实际效果需要压测验证。

还有一点容易被忽视:延迟双删解决的是"读回填旧值"导致的问题,但它不能解决"缓存更新失败"导致的问题。如果删除操作本身就失败了,你延迟双删再多次也是白搭。所以延迟双删只是优化了时序窗口,真正的兜底必须靠下一节的链路。

4. 缓存更新失败之后怎么办

我见过太多团队在方案设计里假设"缓存删除一定会成功"。实际上 Redis 操作也会失败:网络抖动、连接池耗尽、Redis 实例重启、服务进程被 kill 掉,任何一步异常都会让删除动作落空。更隐性的失败是:删除指令成功发给了 Redis,但因为某种原因(比如执行超时),客户端以为自己失败了,其实是成功的;或者客户端以为自己成功了,但服务端根本没有执行。

4.1 缓存更新失败为什么比想象中更隐蔽

说一个最容易踩的坑:很多人会把"删除缓存"和业务主流程混在一个方法里,删除操作抛出的异常要么被吞掉,要么直接导致接口报错。不管哪种处理,结果都很尴尬。

如果吞掉异常,数据库更新成功了,缓存没删掉,脏数据安安静静地躺在 Redis 里,直到过期或被 LRU 淘汰。如果直接抛异常回滚请求,数据库的更新也会回滚,虽然不会出现不一致,但用户的一次合法操作可能因为缓存删除失败而失败,体验很差,这种设计也根本扛不住高峰期的抖动。

所以正确的思路是:删除缓存不能作为业务主事务的一部分去强依赖。你要有一个独立的、可靠的异步链路,确保数据库变更发生后,不管业务代码里删除缓存这一步行不行,最终都会执行删除动作。

4.2 binlog 订阅加消息队列的标准兜底

说到可靠的异步链路,业界最常用的做法是订阅数据库的 binlog。MySQL 的 binlog 是数据库的原始变更日志,只要数据库写入成功,binlog 里就有对应事件。我们可以用一个开源的增量订阅组件去监听 binlog,解析出发生变化的数据主键,然后把"旧 key"投递到消息队列,由消费端统一执行缓存删除。

这个方案的妙处在于解耦。业务代码只需要关心"更新数据库"这一件事,删缓存的动作完全从业务主流程里剥离出去了。即使你业务代码里忘写了删除缓存逻辑,只要 binlog 订阅链路在,数据最终也会被纠正。这也是很多中大型系统在缓存一致性上的标准答案。

链路大致是这样的:MySQL 产生 binlog,增量订阅组件实时解析变更事件,把变更的表名和主键信息封装成消息,投递到 MQ。消费端拿到消息后,根据约定规则把主键拼成 Redis key,然后执行删除。如果删除失败,消息消费失败,消息进入重试队列,按照 1 秒、5 秒、30 秒的间隔退避重试;重试超过 N 次进入死信队列,由监控告警通知人工介入。

我个人的建议是:如果你们的团队已经引入了消息队列,这个链路并不难搭。最大的工作量在于把"数据库表的主键"映射到"Redis key"的规则沉淀下来,这需要业务方把缓存 key 的设计规范统一。否则订阅组件解析出了主键,却不知道该拼出什么 key,链路就断了。

4.3 几个不贵但很实用的补充手段

不是所有团队都有条件马上搭 binlog 订阅链路。在那之前,有几个成本很低但非常有效的补充手段可以先用起来。

第一是版本号方案。给数据库表加一个 version 字段,每次更新数据库时同时递增 version;Redis 缓存里也保存这个 version。更新缓存或回填缓存时,用 Lua 脚本校验版本号,只有新版本才能覆盖旧版本。这个方案能解决并发场景下"旧值覆盖新值"的竞争问题,代价是每次读缓存和回填都要多一次版本判断,对热 key 会有一点额外开销。

第二是合理的过期时间兜底。很多人把 Redis key 的过期时间设得很长,甚至不设置,以为这样命中率高就是好事。但从一致性角度看,过期时间是你最后一道保险丝。给高频变更的 key 设置 5 分钟或 10 分钟过期,即使同步链路出了任何问题,数据也会在过期后自动纠正。这个手段不能解决所有问题,但它能把"不一致的持续时间"控制在一个可接受的范围。

第三是写后即刻强制过期。如果你做了写操作,并且这个用户在短时间内大概率会读这个数据,可以直接在写入业务结束时再把缓存删一次,而不是只依赖消息队列的延迟消费。虽然和主流程仍有耦合,但双保险总比单保险稳。

下面是三种兜底手段的对比:

兜底手段解决的问题实现成本注意点
短过期时间脏数据长期存活极低过期时长太短会放大缓存穿透
版本号校验并发覆盖、旧值覆盖新值中Redis 脚本支持需统一约定
binlog 订阅删除动作遗漏、失败无重试中高需要消息队列和消费端幂等

5. 别指望绝对一致,按业务划一致性的容忍底线

如果你看到这里,希望已经接受一个事实:任何用缓存扛读流量的系统,在"同一瞬间完全一致"这个标准下,都不可能做到绝对一致。你在做的是工程,不是数学证明。工程讲究的是把不一致的窗口压到业务可容忍的范围内。

5.1 先分清数据的一致性是秒级、分钟级还是零容忍

我把线上常见的缓存数据分成了三类。第一类是完全容忍最终一致的数据,比如用户昵称、头像、文章阅读量、排行榜,这类数据延迟几秒甚至几分钟才更新,业务上完全无感。对于这类数据,你甚至可以放任缓存自然过期,不必做太多复杂的同步工作。

第二类是"读自己的写"需要即时一致的数据,比如用户刚刚修改了自己的配置,刷新页面时要立刻看到新值。这类场景其实不需要全局强一致,只需要保证"写后立即读"这个路径能发现更新。一个常见做法是:写操作完成时主动删除缓存,用户下一次读会 miss 回源到数据库;如果你的数据库存在主从延迟,可以在写后很短时间窗口内强制读主库。

第三类是零容忍的数据,主要是支付金额、订单状态、账户余额、优惠券已用状态这一类。我的建议是这些数据根本不应该走"缓存副本 + 异步刷新"的路线。你可以在数据库层面设计高性能的扣减方案,但不要试图用 Redis 先扣再回刷,否则任何一次故障或并发覆盖,都会造成资金级别的资损事故。

5.2 我处理过的两个真实案例

优惠券重复使用的问题,我前面提过。当时我们用的是"先更新数据库,再更新缓存"的方案,更新缓存时序列化工具抛了异常,但异常被上层吞掉了。于是数据库里优惠券已经是"已使用",Redis 里还是"未使用",两个订单流程同时读到同一个可用券,业务各创建了一笔订单。后来我们改成了"先更新数据库,再删除缓存",同时加了 binlog 订阅兜底,最后给这类关键 key 设置了 5 分钟过期。从那以后,再也没有出现过同类问题。

另一个案例是用户配置修改后的"读自己的写"。当时的问题是改了配置后,App 端下拉刷新偶尔还是能看到旧配置,定位是因为走了 Redis 缓存,而缓存删除动作还在消息队列里排队,延迟了 2 秒才消费。对于这种用户立刻感知的场景,2 秒已经非常明显了。最后的改法是:写请求处理完数据库更新后,在业务线程里同步执行一次 Redis 删除,同时也保留消息队列兜底。双删一次,感知问题直接解决。

这两个案例结合起来看会发现一个规律:真正制造线上事故的,往往不是并发窗口理论上存在的几毫秒,而是"更新动作失败后没有兜底"这个异常路径。所以你与其花大量精力优化并发时序,不如先把失败后的自动恢复链路补上,再谈缩小窗口。

5.3 最后几个实操建议

根据我踩过的坑,我想给出几条可以直接抄作业的建议。

能用短过期解决,就不要上分布式锁。很多人一听到一致性问题就想到加锁,但分布式锁会显著降低吞吐量,而且锁的粒度、时效、容错都很难设计。先试试把过期时间设短一点,很多业务根本不需要锁。

能用"删缓存"解决,就不要"更新缓存"。写缓存要做序列化,要考虑并发覆盖,要考虑失败回滚,而删缓存永远幂等。这是 Cache Aside 模式最核心的智慧。

能靠 binlog 兜底,就不要在业务代码里硬等。业务线程里做延迟双删,终究会阻塞主链路。把删除动作交给异步消费者,虽然有一点点延迟,但可靠性和吞吐量都会更好。

最后,不要给所有 key 套用同一个策略。把缓存 key 分级,核心资金类不走缓存,高实时性数据走主动删缓存加短过期,普通读多写少数据走自然过期。很多一致性问题的根源,不是技术选型不对,而是把所有数据都混在同一个篮子里。

我的真实体会是,每次处理完一起缓存不一致的事故,我都会把"同步链路是否具备故障自愈能力"重新问一遍。与其追求一个理论上的完美时序,不如接受"最终一致"这个现实,然后用重试、过期、订阅机制把不一致的持续时间不断压缩。这套组合拳打下来,线上能安静很久,你在深夜被叫醒的概率也会小很多。

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

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

立即咨询