缓存与数据库一致性:旁路缓存、延迟双删与订阅 binlog
更新时间:2026-09-18。只要用缓存,就绕不开「缓存和数据库两份数据何时一致」。核心结论先行:绝大多数业务用 Cache Aside + 删除缓存(而非更新缓存)+ TTL 兜底,追求的是最终一致而非强一致。本篇把每种策略的并发时序、脏数据窗口和补救手段讲透。异常模式见 缓存穿透/击穿/雪崩,binlog 订阅工程实现见 CDC 数据同步:Canal/Debezium。
本文要回答的问题
- 更新数据时,为什么推荐「更新 DB + 删缓存」而不是「同时更新缓存」?
- 先删缓存还是先更 DB?两种顺序各自的并发问题?
- 延迟双删解决什么、为什么仍不是银弹?
- 订阅 binlog 异步删缓存适合什么场景?
- 怎么界定强一致与最终一致的工程边界?TTL 为什么是最后防线?
一、四种缓存策略先定位

| 策略 | 读路径 | 写路径 | 一致性强度 | 典型用途 |
|---|---|---|---|---|
| Cache Aside | 先缓存,miss 查库回填 | 更新库 + 删缓存 | 最终一致(短窗口) | 绝大多数业务 |
| Read Through | 缓存层代查库 | —— | 取决于写策略 | 透明缓存层 |
| Write Through | —— | 缓存层同步写库 | 较强但慢 | 写少、要即时持久 |
| Write Behind | 读缓存 | 只写缓存后异步刷库 | 弱(可能丢) | 计数、日志、可重放 |
二、为什么写时「删除缓存」而不是「更新缓存」
- 缓存可能是派生数据:缓存 value 常是聚合结果(多表 join、计算字段),多个写都去更新缓存,要重复计算且容易互相覆盖;删除后等下次读时按最新数据重建,逻辑唯一。
- 写多读少会做无用功:字段被改 100 次但没人读,更新 100 次缓存全浪费;删除是惰性的,只在真正被读时重建。
- 并发更新互相覆盖更危险:两个写线程更新缓存顺序与更新库顺序不一致时,缓存可能留下更旧的值;删除让缓存回到「未知」,由读重建,避免缓存里固化错误结果。
所以标准 Cache Aside 写路径是:先更新数据库,再删除缓存。
三、「先删缓存」vs「先更库」的并发窗口
方案 A:先更新 DB,再删缓存(推荐)
正常时序:更新 DB 成功 -> DEL cache
残留问题:若 DEL 失败(网络抖动/Redis 故障),缓存保留旧值直到 TTL。
极小概率脏读时序(需读极慢且恰好卡在中间):
读 miss -> 读到 DB 旧值(还没写) -> 写更新 DB -> 写删缓存
-> 读才把旧值回填缓存
这要求“读 DB + 回填”比“写全程”还慢,工程上罕见。- 优点:常见并发下安全;缓存里默认是「失效后重建」,不会长期固化旧值。
- 风险:删缓存这一步失败 → 旧值存活到 TTL。补救靠重试/异步删除/binlog 订阅 + TTL。
方案 B:先删缓存,再更新 DB(不推荐单独用)
写:DEL cache -> 更新 DB(耗时)...
在 DB 更新完成前,另一个读请求 miss -> 读到 DB 旧值 -> 回填缓存
随后写完成 DB -> 但缓存里已是旧值,且不会再被删 -> 脏数据长期存在这个窗口在写库耗时(事务、锁等待)内持续存在,比方案 A 更容易中招。为了补它,才有了延迟双删:
1. 删除缓存
2. 更新数据库
3. sleep(一段大于“读 miss + 回填”耗时的时间)
4. 再删一次缓存(把期间被旧值回填的缓存清掉)redis.del(key);
db.update(entity);
try { Thread.sleep(delayMs); } catch (InterruptedException ignored) {}
redis.del(key); // 第二次删除,清掉并发读回填的旧值坑一:延迟双删不是强一致保证。 sleep 时长难以精确覆盖所有慢查询;第二次删除也可能失败;主从延迟下读到从库旧值的回填还会污染。它只是降低概率,仍需删除重试 + TTL 兜底。延迟时间通常按「一次读业务的平均/较坏 RT」估算(几百 ms~1s),可异步化(投递延迟消息再删)避免阻塞写请求。
四、删缓存失败怎么办:重试与订阅 binlog
DEL 失败是方案 A 唯一的主要脏数据来源。两类工程做法:
1)消息队列重试 / 订阅重试
更新 DB 成功 -> 尝试 DEL;失败 -> 投递“删除 key”消息 -> 消费者指数退避重试直到成功要点:删除操作要幂等(DEL 天然幂等)、消息要可靠投递(本地消息表/事务消息),防止「DB 更新了但删除消息丢了」。
2)订阅 binlog 异步失效(数据驱动、与业务解耦)
用 Canal/Debezium 订阅 MySQL binlog,监听到行变更就生成对应缓存 key 的删除事件:
MySQL binlog -> Canal/Debezium -> MQ -> 缓存失效消费者 -> DEL key- 优点:不依赖每个业务点都记得删缓存;统一治理、补偿漏删;多业务/多语言共享。
- 缺点:链路更长(binlog→MQ→消费有秒级延迟),是最终一致;要保证消费幂等、处理积压。
- 工程细节见 CDC 数据同步:Binlog 监听 Canal/Debezium。
五、读路径的并发与一致性细节
读:GET cache
命中 -> 返回
miss -> (可选分布式/单机锁,防击穿)-> 查 DB -> 回填(带随机 TTL)-> 返回- 缓存击穿重建:热点 key 失效瞬间只放一个请求回源,用互斥锁(见 穿透/击穿/雪崩)。
- 回填 TTL 加随机抖动:避免一批缓存同时过期。
- 主从延迟污染:读走从库时,若主从有延迟,miss 回填的可能是从库旧值;强一致读可在写后短窗口内强制读主,或延迟双删结合 GTID 等待(wait until 从库追上)。
- 缓存里不放强时效敏感字段:余额、库存这类宁可实时查库/加锁,也不要只信缓存。
六、工程边界:什么时候必须上更强方案
| 要求 | 方案 |
|---|---|
| 可容忍秒级内旧数据(绝大多数读多写少) | Cache Aside + 删缓存 + TTL |
| 删除可能失败要补救 | 消息重试 / binlog 订阅失效 |
| 热点防击穿 | 互斥重建 / 逻辑过期 |
| 读写都要求不读到旧值 | 旁路缓存改为读主/加版本号,或关键路径不缓存 |
| 强一致(资金、库存扣减) | 以**数据库事务/行锁/版本号(CAS)**为权威,缓存只做只读副本,写后失效 |
一种实用的「版本号/时间戳」加固:value 内嵌 version,回填时 SET ... IF 新版本 >= 旧版本(Lua 原子比较),防止乱序回填把旧值盖回新值;缓存与库的权威始终是数据库。
七、一套推荐组合(生产可参照)

要点:写库与发删除消息的原子性可用本地消息表(同事务落库、异步投递)或事务消息;删除全部幂等;TTL 是最终防线,必须设置。
八、常见误区
- 更新缓存而不是删缓存:并发写互相覆盖、固化旧值、做无用计算。
- 以为先删缓存再更库就安全:写库期间并发读会把旧值回填,需要双删且仍非强一致。
- 延迟双删当银弹:sleep 不可精确覆盖、二次删除可能失败、主从延迟另有窗口。
- 没有 TTL 兜底:删除链路全挂时脏数据永久驻留。
- 资金/库存只信缓存:强一致字段必须以数据库事务/锁/CAS 为权威。
- binlog 订阅当同步:它有秒级延迟,是最终一致补偿,不是两阶段提交。
九、相关与延伸
缓存异常模式见 缓存穿透/击穿/雪崩;互斥重建与热点见 BigKey/HotKey 诊断;binlog 链路实现见 CDC 数据同步:Canal/Debezium;持久化见 RDB 与 AOF;数据结构见 Redis 数据结构。
总结
缓存与数据库一致性:读多写少用 Cache Aside——读 miss 查库回填、写时先更新数据库再删缓存(删除而非更新,避免并发写覆盖与无效计算);先删缓存再更库会在写库期间被并发读回填旧值,延迟双删可降低概率但 sleep 不精确、二次删可能失败,并非强一致;删缓存失败用幂等消息重试和订阅 binlog(Canal/Debezium)补偿,但 binlog 有秒级延迟属最终一致;读路径互斥锁防击穿、回填带 version 经 Lua 比较防旧盖新、TTL 加随机抖动并作最终兜底;资金库存等强一致必须以数据库事务/行锁/CAS 为权威,缓存只作只读副本。