从「为什么会不一致」到「延迟双删,再到更好的方案」—— 一次讲透
缓存与数据库是两个独立的存储。一次「写」需要同时操作两者,而这两步不是原子的,并发读写就会交错出不一致。
更新 DB 与更新/删除缓存是两个独立操作,中间可能被其它请求插入,造成「DB 是新值、缓存是旧值」或反之。
「读」在缓存失效时会回源 DB 并写回缓存;若此刻有「写」并发进行,就可能把旧值写回缓存,长期脏读。
即便顺序正确,第二步(删缓存)可能因网络抖动失败,留下脏缓存;或缓存与 DB 间存在主从延迟(见前文主从专题)。
写操作时「先动谁」决定了不一致的概率与形态。逐一点评,并用翻车时序说明。
两个写操作。并发写时顺序可能交错成 写A-DB → 写B-DB → 写B-缓存 → 写A-缓存,结果缓存是 A 的旧值、DB 是 B 的新值,且长期脏读(缓存没 TTL 就永不自愈)。
危险场景:写线程删完缓存后、更新 DB 前,一个读线程进来——缓存 miss → 读 DB 拿到旧值 → 写回缓存。随后写线程更新 DB 为新值。结果:缓存旧、DB 新,不一致。
Facebook《Scaling Memcache》等主流实践。读时回填、写时「删」而非「更新」缓存(删除更不易出错)。它的残留竞态窗口极小(见下方交互),通常配合「删除失败重试 / binlog 兜底」即可。
不是只有「延迟双删」。从「改写入顺序」到「彻底解耦」,越往上越优雅、越可靠。
延迟双删是 L2 层最经典的经验方案:在「先删缓存→更新DB」或标准写法基础上,延迟一段时间后再删一次缓存,用来清掉并发读回填的旧值。
// 写操作 deleteCache(key); // ① 先删缓存 updateDB(key, newVal); // ② 更新数据库 sleep(N); // ③ 休眠 N 毫秒(关键) deleteCache(key); // ④ 再删一次,清掉期间被回填的旧值
N > 一次「读 miss → 回源DB → 写回缓存」的耗时。但要命中正好覆盖窗口很难——N 太小清不掉,N 太大则期间读到的仍是(可能)旧缓存且线程被阻塞。sleep 占用连接/线程,高并发下是灾难 → 应改用「延时消息」异步执行第二次删除。
把「维护缓存」这件事从业务写入链路里彻底剥离。业务只管写 MySQL,由独立消费者监听 binlog 来同步 Redis。这是阿里、美团等大厂的标准做法。
业务代码只写 DB,不用在每次写入里手动删缓存、不用 sleep、不用担心删失败。缓存逻辑集中在一个消费者里。
binlog 是 MySQL 已提交日志,只要写进 DB 就一定有;MQ 保证至少消费一次,失败可重试,不会像「应用内删缓存失败」那样静默丢。
binlog 顺序即事务提交顺序,消费者按序处理,避免并发双写导致的乱序脏数据。
不只是「删」,还能基于 binlog 的 row 数据做增量更新缓存,减少读 miss 回源,性能更优。
缓存值里带上 version 或 updated_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(参考前文「主从延迟」专题的「写后读走主库」思路),不碰可能脏的缓存,直至确认同步完成。
本质是「用一致性换性能」的局部策略,只针对少数关键路径,避免全局锁带来的性能崩塌。
| 方案 | 一致性强度 | 业务侵入 | 复杂度 | 性能影响 | 适用场景 |
|---|---|---|---|---|---|
| Cache-Aside(先更DB再删缓存) | 最终·弱窗 | 低 | 低 | 小 | 绝大多数读写缓存场景(基线) |
| 延迟双删 | 最终·改善 | 中 | 中 | sleep阻塞 | 无 binlog 基建时的过渡 |
| 延时消息再删 | 最终·改善 | 中 | 中 | 小 | 延迟双删的升级版 |
| binlog 订阅(Canal+MQ) | 最终·可靠 | 零 | 中高 | 极小 | 生产首选·大型系统 |
| 版本号 / CAS | 防乱序 | 中 | 中 | 小 | 防并发旧值覆盖 |
| 分布式读写锁 | 强一致 | 中 | 高 | 大(降并发) | 极关键数据(余额等) |
| 读时补偿 / 强制回源 | 收敛窗口 | 中 | 中 | 中 | 关键路径局部兜底 |
一句话总结:
① 写缓存永远选 「先更新 DB,再删除缓存」(Cache-Aside),别双写、别先删再更(除非配延迟双删)。
② 想要真正省心 → binlog 订阅(Canal + MQ),业务零侵入、可靠、可重试,是延迟双删的全面升级。
③ 任何方案都加 TTL 兜底;关键路径可用读写锁 / 写后强制回源补强。
④ 延迟双删有效但「打补丁式」——理解它,然后尽量用更优方案替代。