我做架构踩坑这些年,MySQL 和 Redis 的数据一致性问题,几乎在每个缓存系统里都会遇到。今天想把这个问题彻底聊透,包括它为什么难解决、常用的策略各自有什么坑、以及工程上最终我是怎么把一致性做到基本可用的。我尽量用实际项目里的场景来讲,不会只堆理论。
先给出一个最简单的背景,方便刚接触缓存的读者对齐认知。我们通常把 MySQL 当作最终数据的唯一来源,也就是“主存储”;Redis 则放在前面做“挡箭牌”,把热数据暂存起来,让查询直接走内存而不是每次都去查关系型数据库。一个标准读多写少的系统,用户请求先看 Redis,缓存里有就直接返回;没有就回源到 MySQL,回源后再把结果写回 Redis,方便下次命中。这套流程本身没问题,问题出在“写”的时候。一旦数据库里的数据更新了,Redis 里还保留着旧值,那用户就会一直读到过期数据。这个“旧值与新值不同步”的状态,就是数据一致性问题。
这个问题的麻烦之处在于:它不像接口报错那样立刻可见,而是以一种“时好时坏”的方式存在。可能大多数请求都正常,只有极小概率读到旧数据;也可能某段时间缓存大面积失效,所有请求都压到数据库。所以它调试起来很头疼,开发环境几乎复现不了,只有到了线上高并发的时候才突然暴露。我最早接手的一个电商订单服务,就是这样被“缓存脏数据”坑过整整一周,后来才系统性地把整套策略重建了一遍。这篇文章就是那次重建后沉淀下来的完整思路。
1. 一致性问题到底从哪里来:读写流程里的三处逻辑漏洞
想理解怎么解决,得先看清楚问题是怎么产生的。一个看似简单的“先读缓存、读不到再读库、读库后回填缓存”流程,在并发场景下藏着三处容易被忽视的逻辑缺口。
1.1 第一处:更新数据库之后,缓存没跟上
最直观的情况:业务代码执行了 UPDATE 语句,MySQL 里已经是新值了,但 Redis 里的旧值还活得好好的。这里有两种可能:
- 代码里压根没写更新 Redis 的逻辑,只更新了数据库。常见于多人协作时,写接口的开发不知道读接口依赖缓存。
- 代码写了更新 Redis 的逻辑,但用的是更新语句
SET。如果同一时刻有两个线程按不同顺序更新,Redis 里最终留下的值可能是旧那个,而不是最新值。
举个例子,用户修改昵称,先后发起了两次请求:请求 A 把昵称改为“张三”,请求 B 把昵称改为“李四”。假设 A 先改完 MySQL(此时库里是张三),B 后改完 MySQL(库里是李四)。但 Redis 的写入顺序可能因为网络调度等原因反过来,B 的 SET 先执行,A 的 SET 后执行,最终 Redis 里存的是“张三”,和数据库中的“李四”不一致。
也许有人会问,更新请求不都是串行到达的吗?实际分布式系统里,请求从网关到应用层再到缓存层,链路中没有任何一个环节能保证两个线程到达 Redis 的执行顺序和它们到达 MySQL 的顺序一致。尤其是微服务架构下,同一个用户的不同请求可能被负载均衡转发到不同实例,线程调度完全不同。
1.2 第二处:删缓存之后,数据库更新失败
很多人为了解决上面那个问题,把“更新缓存”换成“删除缓存”。思路是这样的:既然改 Redis 可能因为并发顺序出问题,那不如干脆删除缓存,让下一次读请求回源数据库,把数据库里的最新值重新 load 出来。这个思路本身是对的,删除操作天然具有“幂等性”——不管删几次效果一样,也不会出现 A 值覆盖 B 值的问题。
但它引入了一个新的风险:如果先删缓存、再更新数据库,而更新数据库这一条 SQL 因为锁等待、连接超时、事务回滚等原因失败了,那 Redis 里已经没有缓存了,下一次读请求只能回源 MySQL。虽然这时候数据库里还是旧值(因为更新失败),但至少 Redis 里没有与新值冲突。所以这种场景还不算严重,最多是缓存击穿(大量请求同时回源)。
真正的麻烦在后面:如果先更新数据库、再删除缓存,而删除缓存这步失败了,比如 Redis 超时、网络抖动、序列化异常,那 Redis 里会一直是旧值,而且这个旧值可能直到过期才被清理。在高并发下,这个窗口内所有读请求都会拿到旧数据。
所以你看,两种顺序各有各的失败模式。难点就变成了:如何在部分失败的情况下,尽量不让 Redis 留下与 MySQL 不一致的数据。
1.3 第三处:并发读写的竞态窗口
这是最隐蔽的一处。即便你选了“先更新数据库,再删除缓存”,并且删除缓存也成功了,依然可能在极端并发下出现不一致。
时间线是这样的:
- 线程 A 执行更新操作,把 MySQL 中某条数据从 old 改为 new。
- 线程 B 发起一次读请求,此时缓存已被 A 删掉(如果 A 先删缓存的话),B 回源 MySQL,读到了 old 值。
- 线程 B 把 old 值写回 Redis。
- 线程 A 执行删除缓存操作。
注意这个顺序:A 的删除操作在 B 的写回之后执行,所以 B 写回的 old 值被 A 删掉了,最终一致没问题。但如果是这样:
- 线程 A 更新 MySQL,把数据从 old 改为 new。
- 线程 B 发起读请求,缓存未命中,回源 MySQL 读到 old(因为 A 的事务可能还没提交,或者 A 先更新了还没删缓存)。
- 线程 B 把 old 写回 Redis。
- 线程 A 删除缓存。
这里 A 的删除操作发生在 B 的写回之前,那么 B 写回的 old 就会残留在 Redis 里,直到过期。这个窗口非常短,但确实存在。
还有一种更常见的竞态:
- 线程 B 读缓存未命中。
- 线程 A 更新数据库为 new。
- 线程 A 删除缓存。
- 线程 B 回源 MySQL 读到 old(A 的更新可能还没提交,或 B 在 A 更新前已经读取了旧快照)。
- 线程 B 把 old 写回 Redis。
这种场景下,Redis 里的 old 和 MySQL 里的 new 之间会存在一段不可控的不一致期。如果请求量很大,第一个读请求的写回行为会直接影响后续所有请求的命中结果。
正是这三个漏洞,构成了缓存一致性问题的主要来源。代码里写更新缓存还是删除缓存、先删还是先更新、删除失败怎么办、并发窗口怎么防护,这些本质上都是在和这三处漏洞博弈。
2. 主流缓存读写策略逐一拆解,以及它们各自的取舍
要聊数据一致性,不能绕开缓存读写策略。经典的策略有 Cache Aside、Read Through、Write Through、Write Behind Caching 四种。每一种策略对一致性的保证力度不同,适用的场景也不同。
2.1 Cache Aside:应用最广,但它是“最终一致”而不是“强一致”
Cache Aside 的逻辑可以概括为两句话:读的时候先读缓存,缓存未命中就读数据库,然后回填缓存;写的时候先更新数据库,然后删除缓存。
这个策略最大的优点是足够简单,实现成本低,绝大多数团队都会采用。但它的本质是“最终一致”,也就是经过一段不确定的时间后,数据库和缓存一定会同步(因为缓存被删了,下次读取会重新加载),但在这段时间之前,可能出现短暂的不一致。
工程上怎么把“这段不确定的时间”压缩到最小?答案是严格控制“更新数据库”和“删除缓存”之间的间隔。既然两个操作做不到原子,那就要保证它们之间的窗口尽可能短。还有一点很重要:删除缓存的动作必须判断结果,如果删除失败要重试。
2.2 Read Through:把辅助逻辑收进缓存层
Read Through 与 Cache Aside 的差异在于“谁来写缓存”。Cache Aside 是业务代码手动管理缓存的写入,Read Through 则把写缓存的动作下放到缓存中间件层,业务侧只查缓存,缓存没命中时由缓存层自己加载数据库并回填。
实现 Read Through 时,通常会给缓存组件配置一个 Loader,也就是“当 key 不存在时,从数据库加载数据并回填”的处理器。这样业务代码里就不需要写回填逻辑,一致性控制点更集中。
它的优缺点和 Cache Aside 差不多,本质上缓存与数据库之间仍然不是一个原子操作。好处是降低了业务代码的侵入性,坏处是牺牲了灵活性——遇到特殊场景(比如缓存空值防止穿透)时,Loader 里的逻辑需要设计得很严谨。
2.3 Write Through:牺牲写入性能换读出强一致
Write Through 的意思是:写请求进来时,先写缓存,由缓存组件负责把数据同步写入数据库,全部成功后才算写成功。
这种策略下,缓存始终有一个“最近版本”,因为每次写操作都会同步更新两个存储。它的最大优点是从缓存读取的数据基本就是数据库的最新数据——如果缓存中间件写数据库成功,那这两者就是一致的。
但 Write Through 的代价非常明显:每次写操作都要等两个存储都写完才返回,写入延迟明显升高。在写多读多的场景下,这个延迟往往不可接受。这也是为什么真正的生产系统中,直接采用 Write Through 作为唯一策略的情况并不多见,更多是把它的思想用在局部热点数据上。
2.4 Write Behind:性能最好,但一致性代价极大
Write Behind 是“先写缓存,缓存异步批量写回数据库”。它把 Redis 当作写缓存,写请求只要写进 Redis 就返回,后台任务再定期把 Redis 中的数据合并写回 MySQL。
它的性能是所有策略里最好的,因为写操作完全不经过数据库,Redis 扛下所有写入压力。但一致性也最差:一旦 Redis 宕机且数据还没来得及刷回 MySQL,那部分更新就永久丢失了。而且即使不宕机,Redis 与 MySQL 之间也有一段滞后期,这个滞后期内用户可能读到不一致的数据。
所以 Write Behind 几乎只在数据允许丢失、且对写入性能要求极高的日志、埋点、计数累加等场景使用。真正承载核心交易数据时,我不建议采用。
下面用一张表快速对比这四种策略的一致性与性能特征:
| 策略 | 一致性程度 | 写性能 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|
| Cache Aside | 最终一致,窗口短 | 较好 | 低 | 大多数业务系统 |
| Read Through | 最终一致,窗口短 | 较好 | 中 | 缓存组件自带加载逻辑 |
| Write Through | 强一致(仅在同步成功时) | 较差 | 中 | 对读一致性要求高的局部数据 |
| Write Behind | 弱一致,可能丢数据 | 最高 | 高 | 日志、计数、异步批量写 |
我在实际项目里选型,核心业务数据用的是 Cache Aside 加各种补偿机制;写频率高、读一致性要求不高的指标类数据,才考虑 Write Behind。
3. 先更新数据库再删缓存,还是先删缓存再更新数据库:两种顺序的竞态对比
上一节说的四种策略,实际上大多数团队最终落地的都是 Cache Aside。那么 Cache Aside 内部还有一个争议了多年的问题:写操作的两个顺序到底应该怎么选?
3.1 先删缓存,再更新数据库:窗口期的旧值问题
这种思路很直观:既然缓存是旧数据,那就先把它干掉,让后续读请求全部走数据库,数据库更新后自然读到新值。
但问题在于,删除缓存和更新数据库之间的时间窗口内,所有读请求都会穿透到 MySQL。如果这是一个热点 key,短时间内的读请求就会直接打满数据库,造成缓存击穿。更麻烦的是,如果在这个窗口内有一个读请求先回源数据库读到了旧值,又把这个旧值写回缓存,那这个旧值会一直存在,直到下一次删除或者过期。
这还没算上更新失败的情况:如果先删了缓存,然后数据库更新失败,那缓存就白白被删了,后续的读请求全部回源,数据库压力陡增。虽然不是数据错误,但系统可用性会受影响。
3.2 先更新数据库,再删除缓存:唯一的风险是删除失败
先更新数据库、再删除缓存的思路,是阿里《Java 开发手册》和很多一线团队的默认推荐。理由很简单:数据库是唯一权威数据源,既然数据已经更新成功了,接下来只需要保证缓存中的旧值被删除。
这个顺序最致命的风险就是删除缓存失败。删除失败意味着旧值继续留在 Redis 中,用户会一直读到旧数据,直到缓存过期。这个风险可以通过重试机制来降低,但重试本身也有延迟。
它有一个很好的特性:如果更新数据库失败,那根本不会执行删除缓存,缓存里即使还是旧数据,也不会与数据库冲突。换句话说,失败模式下它比“先删后更新”少一个“缓存白白删除”的问题。
3.3 两个并发时序的详细对比
我把两种顺序放到同一个并发时间轴里比较一下。
先删缓存再更新数据库:
| 时间 | 线程 A(写) | 线程 B(读) | 结果 |
|---|---|---|---|
| T1 | 删除缓存 | 缓存无值 | |
| T2 | 读缓存未命中 | 回源数据库 | |
| T3 | 读到旧值 old | B 读到旧值 | |
| T4 | 更新数据库为 new | ||
| T5 | 将 old 写回缓存 | 缓存残留旧值 |
先更新数据库再删除缓存:
| 时间 | 线程 A(写) | 线程 B(读) | 结果 |
|---|---|---|---|
| T1 | 更新数据库为 new | ||
| T2 | 读缓存命中,返回 old | B 读到旧值 | |
| T3 | 删除缓存 | 后续读请求走数据库 | |
| T4 | 读缓存未命中,回源数据库 | 读到 new |
对比一目了然:先删后更新,只要读请求在更新之前回源并写回缓存,旧值就会永远卡在缓存里;先更新后删除,最坏情况是删除之前的读请求命中旧值,窗口非常短,而且删除一旦成功,后续都是新数据。
实际工程中,删除动作的耗时通常只有几毫秒,而数据库更新的耗时可能涉及锁、事务、commit,是几十毫秒甚至更长的量级。先删后更新把“读请求回源读旧值”的窗口拉长到了整个数据库更新过程;先更新后删除则把“读到旧值”的窗口压缩到了一两次缓存命中之间。所以选哪种,答案已经很清楚了。
4. 删除缓存失败的兜底:从简单重试到延迟双删
先更新数据库、再删除缓存这个顺序基本确定了,但删除缓存失败怎么办?以及上面提到的读请求写回旧值怎么办?这就轮到两个经典的补充方案登场:删除重试与延迟双删。
4.1 删除失败的最基础兜底:消息队列重试
最直接的办法是:在删除缓存失败时,不要默默吞掉异常,而是把这个“待删除的 key”丢进消息队列,由一个独立的消费者去重试删除。
伪代码思路很清晰:
- 业务代码更新 MySQL 成功后,执行 Redis DEL。
- 如果 DEL 返回异常或者返回 0(key 不存在也算成功),把 key 封装成一条消息发给 MQ。
- 消费者收到消息后,再执行一次 Redis DEL。
- 如果仍然失败,设置重试次数和退避策略,比如每隔 1 秒重试一次,最多重试 5 次,仍失败则告警人工介入。
这个方案之所以可靠,是因为消息队列本身具有持久化和重复消费能力,删除动作是幂等的,重复删不会产生副作用。但要注意,消息积压时间不能太长,否则在“数据库已更新”和“缓存最终删除成功”之间会有较长的不一致窗口。
4.2 延迟双删解决了什么问题
延迟双删,通俗理解就是“删两次,中间隔一个时间窗口”。第一次删除发生在更新数据库之前或之后,第二次删除发生在更新数据库之后的一小段时间后。它的目标很明确:干掉那些在第一次删除窗口内被并发读请求写回缓存的旧值。
最常用的时序如下:
- 先删除缓存。
- 更新数据库。
- 休眠一小段时间(例如 500 毫秒)。
- 再次删除缓存。
为什么要休眠?核心是等那些“已经回源数据库、但还没写回缓存”的读请求完成。如果读到旧值的读请求在第二次删除之前就已经把旧值写回缓存,那么第二次删除会把它清理掉。
但这里有个无法回避的问题:休眠时间是拍脑袋定的。如果业务逻辑里从 MySQL 读取到写回 Redis 的耗时超过休眠时间,旧值会在第二次删除之后才写回,等于白删。所以延迟双删方案有一个天然上限,它只能降低不一致概率,不能消除不一致。
4.3 延迟双删的硬伤:休眠时长怎么定
我见过不少团队把延迟双删的休眠时间写成固定 500 毫秒,觉得很“安全”。但在实际压测里,一次正常的读请求从回源 MySQL 到写回 Redis,在极端情况下可能超过 1 秒。比如数据库连接池满了要排队、Redis 网络出现抖动,这期间原本 50 毫秒的写回操作被拖到 1 秒以上,固定休眠 500 毫秒的第二次删除就永远赶不上写回动作。
更麻烦的是,写回旧值的线程可能在第二次删除之后,因为 GC STW(垃圾回收导致的停顿)被暂停很久,恢复后才把旧值写进去。这种场景下,延迟双删也无能为力。
所以我的建议很明确:延迟双删适合作为“降低不一致概率”的辅助手段,不应该作为唯一防线。真正要解决一致性问题,还是要靠下面的过期时间兜底和异步补偿机制。
5. 工程上的可落地组合方案:用兜底和异步补偿把一致性做到基本可靠
聊了这么多理论,真正让我在项目中睡得着觉的,不是某一招,而是一套组合拳。下面这套方案,我按照工程落地顺序依次展开,读者可以对照自己的系统做取舍。
5.1 过期时间兜底:所有缓存都必须设置 TTL
第一原则:任何 key 都必须有过期时间,哪怕这个时间是 10 分钟或者 1 小时。TTL 是最后的保险丝,即便缓存出现了预期外的脏数据,只要过了 TTL,缓存就会失效,下一次读请求会重新加载数据库最新值。
这里有个容易被忽略的细节:TTL 不要设置得完全一致,最好在基础值上增加一个随机偏移。比如基础过期时间 600 秒,实际设置的过期时间是 600 到 900 秒之间的随机值。这样做的原因是避免大量 key 在同一时刻集中过期,导致缓存雪崩,所有请求同时打到数据库。
我在实际项目里见过因为 TTL 统一设置为 1 小时,结果每到整点数据库就飙高一次的情况。加了随机偏移后,这个问题直接消失。
5.2 异步重试机制:把删除缓存变成可靠事件
前面提到过,删除缓存失败要丢进 MQ 重试。但这里要补充一个设计细节:消息体里不要只放一个 key 名称,最好把“数据版本号”或“最后更新时间”也带上。这样消费者在删除缓存之前,可以先查询一下缓存里的 version,如果缓存里的版本已经比消息里的版本新,说明在消息积压期间已经有更新的数据写入了,这次删除就没必要了,可以直接跳过,避免“旧删除把新值误删”的情况。
我用过两种实现方式:
- 如果 Redis 里存的是 JSON 字符串,可以在 JSON 中带一个 version 字段,删除前先
GET一次,比较版本号后再DEL。 - 如果不想多一次 GET,也可以直接用 Redis 的
Lua脚本,在脚本里只删除符合版本条件的 key。
实际上更省心的做法是用 Redis 的Lua脚本保证“版本过期才删除”的原子性。核心脚本逻辑是:local v = redis.call('GET', KEYS[1]) if v == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end。这样即使多个删除请求乱序到达,也不会误删更新的数据。
5.3 Binlog 监听补偿:利用 Canal 把删除操作做成异步事件
如果你不想在业务代码里埋太多“删除缓存”的逻辑,另一个思路是让数据库的变化自己说话。
MySQL 的 Binlog(二进制日志)会记录所有数据变更。使用 Canal(阿里开源)这类工具订阅 Binlog,然后把变更事件转成一条消息发到 MQ 或直接调用缓存删除接口。这样业务代码只需要关心 MySQL 操作,缓存删除完全由 Binlog 监听器异步完成。
这个方案最大的优点是解耦。业务开发写 INSERT 或 UPDATE 时,不需要记得去删缓存,只要数据库变更了,Binlog 一定会捕获到,并触发一次缓存清理。
但也有代价:
- 需要额外部署 Canal 服务,并确保 Canal 的高可用。
- 如果 Binlog 监听有延迟,删除缓存的动作就会延后,期间读到旧值的概率变大。
- Binlog 不是实时触发,严格来说是近实时。对于一致性要求极高的场景,不能完全依赖它。
我通常把 Binlog 监听放在最后一道兜底,而不是主链路。业务代码该删缓存还是删,Binlog 监听处理那些“业务代码删漏了”的异常场景。
5.4 分布式锁与版本号:在并发写多的情况下保驾护航
当同一个 key 的并发写操作非常多时,缓存删除和数据库更新的时序会变得极度混乱。此时可以考虑给写操作加一把分布式锁,让同一个 key 的写请求串行执行。Redis 的 SETNX 或者 Redisson 的分布式锁都可以实现。
锁的粒度要尽量小,比如按userId:orderId维度加锁,避免锁住整个表或者整个缓存 namespace。同时要设置锁的自动过期时间,防止某个线程持锁后异常退出,其他线程一直阻塞。
加了分布式锁之后,同一时间只有一个线程可以更新这个 key 的数据库与缓存,竞态窗口消失。注意,这只适合“同一 key 并发写”很猛烈的场景,如果系统里绝大多数 key 的并发写并不高,引入锁反而会增加不必要的延迟。
另外还可以在缓存中存入版本号,业务读的时候带入版本号,写回的时候如果版本号低于当前值,就丢弃不写。这相当于在 Redis 层做乐观锁,能有效避免旧值覆盖新值。
6. 常见业务场景下的一致性应对思路
不同的业务场景,对一致性的容忍度不同。脱离业务去追求绝对的一致,很容易把系统做复杂。我总结了三个典型的场景和对应的处理思路,供读者参考。
6.1 订单支付状态:强一致诉求明显
订单支付状态如果出现不一致,用户付款成功但页面显示未支付,会立刻引发客诉。这种场景不能只依赖 TTL 兜底,因为几秒钟的不一致都可能产生问题。
我的处理方式是:支付回调里更新 MySQL 状态后,同步执行缓存删除;如果删除失败,立即进入重试流程,同时通过 Binlog 监听再补一道。此外,订单详情页读缓存时会带上一个很短的 TTL(比如 60 秒),确保任何遗漏都能在 1 分钟内自愈。关键接口还可以做“强制读主库”的开关,遇到可疑状态时直接越过缓存。
6.2 商品库存:允许短时间超卖,但不允许长期不一致
商品库存是典型的“写并发高、读量大”场景,Redis 里的库存本身就是热数据。大部分团队不会把库存完全放在 MySQL 里由缓存来控制,而是把 Redis 当作库存的“临时计数器”,利用 Redis 的 DECR 原子性扣减,再异步同步到 MySQL。
这个模式下,Redis 才是实际扣减的权威,MySQL 是异步落库。两者之间必然存在短暂差异,但只要保证最终 MySQL 扣减的量与 Redis 扣减的量一致即可。这里需要设计对账任务,定期比对 Redis 和 MySQL 的库存差异,发现异常及时修正。绝对的一致性在这个场景是不现实的,但最终一致完全可以做到。
6.3 用户资料类:不追求实时,过期重查就行
用户头像、昵称、偏好设置这类数据,允许几分钟甚至更长时间的不一致。这种情况下不需要复杂的删除重试,只要设置一个合理的 TTL(比如 10 分钟),用户下次请求时缓存自然过期,重新加载最新数据即可。
这类数据的写入频率低,读频率高,非常适合 Cache Aside 加 TTL 的简单模式。我甚至会在更新接口里故意不删除缓存,让它自然过期,以换取更高的写入吞吐。这算是一种有意识的“以大量过期容忍换取更高频率的新数据加载”的取舍,适合非关键数据才能这么玩。
7. 一次真实事故复盘:删除缓存失败引发的问题,以及我是如何修复的
理论说再多,也不如一次真实事故来得直观。我想分享一个之前遇到的案例,这个案例非常典型,正好覆盖了前面所有提到的问题点。
当时我们有一个商品中心服务,缓存策略就是 Cache Aside,也是先更新数据库再删除缓存。某次上线后,运营反馈商品价格修改之后,前台页面还是旧价格,持续了大概十分钟才恢复,并且不是偶发,是几乎每次改价都会出现。
排查过程从代码入手,确认更新数据库和删除缓存都在同一个方法里,顺序也是正确:先 UPDATE,再 DEL。那为什么还会出现旧值残留?继续看日志发现,删除缓存时 Redis 客户端抛出过超时异常,但代码没有处理,异常被吞掉了,于是 DEL 操作实际上没有生效。Redis 里的旧值一直存在,要等 TTL 过后才会重新加载。当时商品的 TTL 设置了 10 分钟,所以用户看到的旧价格持续了最多 10 分钟。
修复方案分三个阶段:
第一阶段,立刻在代码里把删除缓存加上 try-catch,失败时写入 MQ,由消费者重试删除。这一步上线后,改价后 1 秒内缓存就能被删除,事故消失。
第二阶段,给 Redis 操作加了超时阈值和熔断逻辑。如果 Redis 频繁超时,不继续重试,而是直接降级处理:读请求回源数据库,写请求确保 MySQL 更新成功后丢弃缓存删除动作,等待 TTL 兜底。
第三阶段,引入 Binlog 监听作为最终保险。即使业务代码的删除逻辑因为各种原因漏掉了,Canal 也会在数据库变更后自动触发一次删除,把不一致的窗口压缩到秒级以内。
这次事故让我意识到,数据一致性从来不只是“选对策略”问题,更是“失败链路怎么兜底”的问题。代码里的一行 catch 不处理,线上就可能是十分钟的脏数据事故。任何一步删除操作都要考虑:如果它失败了,接下来会发生什么,有没有自动修复机制。
8. 后续还能怎么演进:一致性监控与灰度发布
如果你所在的团队已经做到了上面这些,那数据一致性问题的基本盘已经稳了。但如果还想让系统更稳健,可以在监控和上线流程上做更多功课。
一致性监控方面,可以定期扫描 Redis 中的 key,与 MySQL 的对应数据做抽样比对。具体来说,用一个定时任务对核心缓存的 key 做一次“穿透查询”:直接读 MySQL,对比 Redis 中的值是否一致。不一致时记录日志并告警。不必全量比对,抽样比例可以控制在 1% 左右,覆盖核心数据即可。
灰度发布方面,涉及缓存策略变更或 Redis 集群迁移时,建议先灰度一部分流量,观察一致性告警和数据库压力。比如把 10% 的读流量指向新缓存集群,对比新旧两个集群的命中率和数据差异,确认没问题再逐步放量。
我个人在实际项目里的体会是,MySQL 与 Redis 的数据一致性问题是“系统工程”问题,它的答案不是某个单一的“正确方案”,而是由策略选型、失败补偿、兜底机制、监控告警组合成的一套体系。想追求绝对强一致,最简单的办法是把 Redis 拿掉,所有请求直接读 MySQL——但这又回到了性能瓶颈。既然选择了缓存,接受了性能红利,就得同时接受一致性治理的成本。把这套组合拳做好,业务在 99% 以上的场景下都不会感受到数据不一致,剩下的 1%,就交给监控告警和快速修复能力去兜住。