MySQL × Redis 一致性

从「为什么会不一致」到「延迟双删,再到更好的方案」—— 一次讲透

1为什么会不一致?先看清架构与根因

缓存与数据库是两个独立的存储。一次「写」需要同时操作两者,而这两步不是原子的,并发读写就会交错出不一致。

Client 应用层 读写逻辑 Redis 缓存 MySQL 主库(真源) 读/删缓存 读/写DB 两步非原子 = 不一致窗口

三大根因

① 双写非原子

更新 DB 与更新/删除缓存是两个独立操作,中间可能被其它请求插入,造成「DB 是新值、缓存是旧值」或反之。

② 并发读写交错

「读」在缓存失效时会回源 DB 并写回缓存;若此刻有「写」并发进行,就可能把旧值写回缓存,长期脏读。

③ 删除/更新失败

即便顺序正确,第二步(删缓存)可能因网络抖动失败,留下脏缓存;或缓存与 DB 间存在主从延迟(见前文主从专题)。

核心认知:「强一致」在「DB + 独立缓存」架构下代价极高(需分布式锁串行化,牺牲并发)。业界普遍追求最终一致性——允许短暂不一致,但要保证「最终一致、且不一致窗口尽可能短、可收敛」。

2三种写入策略,哪个会翻车?

写操作时「先动谁」决定了不一致的概率与形态。逐一点评,并用翻车时序说明。

策略 C:先更新 DB,再更新缓存 (最不推荐)

两个写操作。并发写时顺序可能交错成 写A-DB → 写B-DB → 写B-缓存 → 写A-缓存,结果缓存是 A 的旧值、DB 是 B 的新值,且长期脏读(缓存没 TTL 就永不自愈)。

策略 A:先删除缓存,再更新 DB (有经典翻车)

危险场景:写线程删完缓存后、更新 DB 前,一个读线程进来——缓存 miss → 读 DB 拿到旧值 → 写回缓存。随后写线程更新 DB 为新值。结果:缓存旧、DB 新,不一致

策略 B:先更新 DB,再删除缓存(Cache-Aside 标准写法)(推荐基线)

Facebook《Scaling Memcache》等主流实践。读时回填、写时「删」而非「更新」缓存(删除更不易出错)。它的残留竞态窗口极小(见下方交互),通常配合「删除失败重试 / binlog 兜底」即可。

为什么「删」比「更新」好? 更新缓存要把计算结果算出来再写,成本高且易因并发写顺序而出错;删除让下次读自动回填最新值,逻辑简单、天然收敛。

交互:看一次「先删缓存→再更新DB」的翻车

写线程 W
读线程 R
点击上方按钮播放时序动画。每个节点代表一个原子动作,粉色游标表示「当前时间」,动画会逐步揭示交错过程与最终状态。

3方案全景:按「解决层级」分四层

不是只有「延迟双删」。从「改写入顺序」到「彻底解耦」,越往上越优雅、越可靠。

L1 · 写入策略层(最简单,治标) Cache-Aside 标准写法 / 先更新DB再删缓存 / 给缓存设 TTL 兜底 L2 · 删除兜底层(经验补偿) 延迟双删 / 删除失败重试 / 延时消息队列再删一次 L3 · 数据订阅层(业界主流·解耦·最终一致) Canal/Debezium 订阅 binlog + MQ 异步淘汰/更新 Redis L4 · 强一致层(牺牲并发换准确) 分布式读写锁串行化 / 读时强制回源DB / Redisson 锁

4延迟双删:原理、坑、与交互演示

延迟双删是 L2 层最经典的经验方案:在「先删缓存→更新DB」或标准写法基础上,延迟一段时间后再删一次缓存,用来清掉并发读回填的旧值。

流程

// 写操作
deleteCache(key);          // ① 先删缓存
updateDB(key, newVal);     // ② 更新数据库
sleep(N);                 // ③ 休眠 N 毫秒(关键)
deleteCache(key);          // ④ 再删一次,清掉期间被回填的旧值
延迟 N 取多少? 经验值通常 500ms~1s,原则是 N > 一次「读 miss → 回源DB → 写回缓存」的耗时。但要命中正好覆盖窗口很难——N 太小清不掉,N 太大则期间读到的仍是(可能)旧缓存且线程被阻塞。

交互:调节延迟,看能否盖住「读回填窗口」

写线程 W 读线程 R 删缓存 更新DB 再删 读DB(旧值) 写回旧值 DB更新完成
写线程:删缓存(50ms) → 更新DB(90~200ms) → 睡 N ms → 再删缓存。读线程:缓存 miss 后从 100ms 起回源DB(耗时=读回填耗时),完成后把旧值写回缓存。只要「第二次删除」晚于「读线程写回旧值」的时刻,旧值就会被清掉。拖动两个滑块,看判定如何变化。
延迟双删的硬伤:
sleep 阻塞线程:在 Web 请求里 sleep 占用连接/线程,高并发下是灾难 → 应改用「延时消息」异步执行第二次删除。
延迟靠拍脑袋:N 是经验值,无法精准匹配真实读耗时,极端情况下仍漏。
只解决单一竞态:对「删除失败」「主从延迟」无能为力。
④ 第二次删除也可能失败,需要重试机制。
所以——它有效,但不算优雅的解法。更好的在下一节。

5更好的方案:订阅 binlog 异步淘汰(业界主流)

把「维护缓存」这件事从业务写入链路里彻底剥离。业务只管写 MySQL,由独立消费者监听 binlog 来同步 Redis。这是阿里、美团等大厂的标准做法。

业务写请求 只写 MySQL MySQL 主库 产生 binlog Canal 订阅 binlog Kafka 消费服务 删/更 Redis Redis binlog 解耦 同步

为什么它「更好」

✅ 业务零侵入

业务代码只写 DB,不用在每次写入里手动删缓存、不用 sleep、不用担心删失败。缓存逻辑集中在一个消费者里。

✅ 可靠、可重试

binlog 是 MySQL 已提交日志,只要写进 DB 就一定有;MQ 保证至少消费一次,失败可重试,不会像「应用内删缓存失败」那样静默丢。

✅ 顺序天然正确

binlog 顺序即事务提交顺序,消费者按序处理,避免并发双写导致的乱序脏数据。

✅ 可精准更新

不只是「删」,还能基于 binlog 的 row 数据做增量更新缓存,减少读 miss 回源,性能更优。

落地要点:
· 用 Canal(阿里,伪装 MySQL 从库拉 binlog)或 Debezium(基于 Kafka Connect);
· MQ 选 Kafka/RocketMQ,消费者幂等(同一主键多次消费结果一致);
· 仍建议 Redis 数据带 TTL 兜底,防止消费者挂掉期间无限期脏;
· 延迟是「秒级」的最终一致,对绝大多数业务足够。
结论:延迟双删是「在业务写链路里打补丁」;binlog 订阅是「把缓存一致性当成一条独立的数据管道来治理」。后者在可靠性、可维护性、可扩展性上全面优于前者,是生产环境首选。延迟双删可作为没有基建时的过渡方案。

6其他进阶方案(按场景选用)

版本号 / CAS
延时消息队列
分布式读写锁
读时补偿 / 读穿校验
写时强制回源

版本号 / CAS 机制

缓存值里带上 versionupdated_at 时间戳。写入时比对版本,只允许新版本覆盖旧版本,防止并发下「旧值后到」覆盖新值。

// 缓存结构
{ "data": ..., "version": 1001 }

// 写回缓存前比对
if (cached.version < newVersion) {
    redis.set(key, newValueWithVersion);  // 仅在更新时
} else { // 丢弃迟到的旧值,避免回退 }

适合「先更新DB再删缓存」仍有极小竞态、或需要防乱序的场景。注意 version 要来自 DB 的事务版本或自增时间戳。

延时消息队列(延迟双删的优雅版)

把「第二次删除」从同步 sleep 改成发一条延时消息(RocketMQ 延时等级 / Kafka 延时插件 / Redis ZSet 定时),到点再由独立消费者删缓存。请求线程不再被阻塞。

// 写操作
updateDB(key, newVal);
deleteCache(key);                 // 立即删一次
mq.sendDelay("deleteCache:"+key, delay=500ms);  // 异步再删

解决了延迟双删「阻塞线程」的硬伤,是延迟双删的工程化升级。但延迟仍是经验值,未根治「拍脑袋」问题。

分布式读写锁(强一致,牺牲并发)

对同一条数据,写时加「写锁」、读时加「读锁」(Redisson ReadWriteLock 或基于 Redis/ZK 的锁),让读写串行化,从根上消除竞态。

RLock writeLock = redisson.getWriteLock(key);
writeLock.lock();
try { updateDB(key, v); deleteCache(key); }
finally { writeLock.unlock(); }

一致性最强(接近线性一致),但并发度骤降,只适合一致性要求极高、且热点不集中的关键数据(如账户余额)。绝大多数业务不值得。

读时补偿 / 读穿校验

读请求命中缓存后,额外校验「缓存版本/时间戳」是否落后于 DB(或是否带过期标记);发现可疑旧值则异步回源刷新,或返回 DB 真值。

if (cached != null) {
    if (cached.isStaleOrSuspicious()) {
        asyncRefreshFromDB(key);  // 后台修正
    }
    return cached.data;
}

作为「最终一致」的额外收敛手段,与 binlog 方案互补,缩短用户可见的不一致窗口。

写时强制回源(绕过缓存)

对一致性要求极高的写后读(如支付后查余额),写完后让后续读强制走 DB(参考前文「主从延迟」专题的「写后读走主库」思路),不碰可能脏的缓存,直至确认同步完成。

本质是「用一致性换性能」的局部策略,只针对少数关键路径,避免全局锁带来的性能崩塌。

7方案对比总表

方案一致性强度业务侵入复杂度性能影响适用场景
Cache-Aside(先更DB再删缓存)最终·弱窗绝大多数读写缓存场景(基线)
延迟双删最终·改善sleep阻塞无 binlog 基建时的过渡
延时消息再删最终·改善延迟双删的升级版
binlog 订阅(Canal+MQ)最终·可靠中高极小生产首选·大型系统
版本号 / CAS防乱序防并发旧值覆盖
分布式读写锁强一致大(降并发)极关键数据(余额等)
读时补偿 / 强制回源收敛窗口关键路径局部兜底
共同底线:无论选哪种,都给缓存加合理的 TTL 过期作为最后兜底——即使所有同步机制失效,脏数据也会在过期后自愈。

8实战选型决策树 & 一句话总结

用缓存吗?一致性要求多高? 大型系统 / 高一致诉求 一般业务最终一致 强一致(余额等) → binlog 订阅 (Canal+MQ) 首选 → Cache-Aside + TTL + 删除重试/延时再删 → 读写锁 / 强制回源 关键路径局部用

一句话总结:

① 写缓存永远选 「先更新 DB,再删除缓存」(Cache-Aside),别双写、别先删再更(除非配延迟双删)。

② 想要真正省心 → binlog 订阅(Canal + MQ),业务零侵入、可靠、可重试,是延迟双删的全面升级。

③ 任何方案都加 TTL 兜底;关键路径可用读写锁 / 写后强制回源补强。

④ 延迟双删有效但「打补丁式」——理解它,然后尽量用更优方案替代。