
缓存与数据库一致性:延迟双删与订阅 binlog 的取舍
关键词标签 :缓存一致性 延迟双删 Canal binlog Spring Cache
先抛一个容易被忽略的结论:"延迟双删"本质上不是一个方案,而是一种"用时间窗口换一致性"的妥协。它没有真正解决并发下的读写竞态,只是把竞态窗口从"必然踩中"压缩成"概率踩中"。而订阅 binlog 也不是银弹------它把一致性问题的战场从"应用层代码"搬到了"数据变更的可靠投递"上。理解这两者的取舍,比记住"先删缓存还是先删库"重要得多。
本系列前面聊过 Spring Boot 自动配置、线程池源码、AQS 这些偏"机制"的话题,这篇换个角度:从并发时序 和故障模型两个维度,拆解缓存一致性这道经典题。
一、先把"不一致"这件事定义清楚
很多人讨论缓存一致性,一上来就争"先删缓存还是先删数据库",但没定义清楚到底要防什么。缓存与数据库的不一致,归根到底是两个写操作(写库、写/删缓存)之间被其他请求插入导致的。
假设 order-service(仅为讲解示例,非真实系统)有一个典型的读路径:
public Order getOrder(Long id) {
String key = "order:" + id;
String cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, Order.class);
}
Order order = orderMapper.selectById(id);
if (order != null) {
redis.set(key, JSON.toJSONString(order), 60, TimeUnit.SECONDS);
}
return order;
}
问题出在并发读写的交错。考虑"先更新数据库,再删除缓存"这个常被推荐的顺序:
| 时刻 | 线程 A(写) | 线程 B(读) |
|---|---|---|
| T1 | update DB = v2 | |
| T2 | 读缓存 miss | |
| T3 | 读 DB = v2 | |
| T4 | 写缓存 = v2 | |
| T5 | delete cache |
这个时序下其实一致。真正会出问题的是"先删缓存,再更新数据库":
| 时刻 | 线程 A(写) | 线程 B(读) |
|---|---|---|
| T1 | delete cache | |
| T2 | 读缓存 miss | |
| T3 | 读 DB = v1(旧值) | |
| T4 | 写缓存 = v1(脏) | |
| T5 | update DB = v2 |
结果缓存里长期是 v1,直到过期。这就是"先删缓存"被诟病的根因------删除动作和更新动作之间的空窗,被读请求利用。
所以判断"先删缓存还是先更新数据库",本质是在比较两种顺序下,脏数据残留的概率和修复成本。先更新数据库、后删缓存,出现脏窗口的概率更低,代价是极端情况下仍可能残留(比如删除失败),需要靠"过期时间"兜底。
二、延迟双删:它到底延迟了什么
延迟双删的典型流程是:删缓存 → 更新数据库 → 延迟 N 毫秒 → 再删一次缓存。
public void updateOrder(Order order) {
String key = "order:" + order.getId();
// 第一次删除
redis.del(key);
// 更新数据库
orderMapper.updateById(order);
// 延迟再删一次,异步执行,避免阻塞主流程
delayDeleteExecutor.schedule(() -> {
redis.del(key);
}, 500, TimeUnit.MILLISECONDS);
}
这里几个关键点必须说清:
1. 延迟的第二次删除,删的是什么
它删的是"在第一次删除之后、数据库更新提交之前,被某个读请求重新写入的旧值"。上面那张时序表里,线程 B 在 T4 写入的 v1 脏值,会在延迟到期后被第二次删除清掉。所以延迟双删的真正价值是给"读请求回填脏缓存"留出一个可被清理的窗口。
2. 延迟时间怎么定,是个伪命题
很多文章说"延迟时间要大于一次读请求耗时",但一次读请求耗时受 GC、网络、慢 SQL 影响,波动极大。你无法给出一个既覆盖所有慢请求、又不让脏数据暴露太久的固定值。这就是延迟双删最大的软肋:它依赖一个你根本估不准的参数。
3. 第二次删除失败怎么办
如果延迟删除因为 Redis 抖动失败,脏数据依然残留。此时只能靠缓存的过期时间兜底。所以延迟双删的正确心态是:它是"缩短脏数据存活时间"的手段,不是"保证强一致"的手段。
4. 异步线程池的坑
用 ScheduledExecutorService 做延迟删除时,注意线程池的拒绝策略和队列容量。如果队列满了、任务被丢弃,第二次删除就静默消失了。这跟本系列前面讲线程池参数演算时提到的"拒绝策略决定丢任务的后果"是同一类问题。
三、订阅 binlog:把一致性交给数据变更流
另一条路是:应用只负责更新数据库,缓存失效由数据库变更事件驱动。典型实现是 Canal 伪装成 MySQL 的从库,订阅 binlog,解析出数据变更后投递到 MQ,再由消费者删除/更新缓存。
这个方案的核心优势是解耦:业务代码里不再有"删缓存"的逻辑,缓存失效变成数据变更的副作用。
// 消费者侧:监听 binlog 解析后的变更消息
@RocketMQMessageListener(topic = "order-binlog", consumerGroup = "cache-invalidator")
public class OrderBinlogConsumer implements RocketMQListener<OrderChangeEvent> {
@Override
public void onMessage(OrderChangeEvent event) {
// event 由 Canal 解析 binlog 后投递,包含表名、主键、变更类型
if ("order".equals(event.getTableName())) {
String key = "order:" + event.getOrderId();
redis.del(key);
}
}
}
1. 它为什么比延迟双删"更接近一致"
因为缓存的失效动作,严格发生在数据库事务提交、binlog 落盘之后。读请求即使在这之前回填了旧值,也会在 binlog 事件到达后被删除。理论上脏窗口只等于"binlog 投递 + MQ 消费"的延迟。
2. 但它引入了新的故障面
- binlog 投递延迟:Canal 到 MQ、MQ 到消费者,任何一环积压,脏数据存活时间就被拉长。
- 消息重复:MQ 通常保证 at-least-once,消费者需要幂等。删缓存本身是幂等的,这点还好。
- 消息乱序:同一行的两次变更,如果投递乱序,可能先删后写,需要按主键分区保证顺序。
- Canal 自身的可用性:Canal Server 挂了,整个失效链路就断了。这时缓存会一直用旧值,直到过期。
所以订阅 binlog 不是"消灭了不一致",而是把不一致的触发条件从"并发时序"转移到了"消息链路可靠性"。前者难以预测,后者至少可以监控、可以重试。
四、两者的取舍:一张对比表
| 维度 | 延迟双删 | 订阅 binlog |
|---|---|---|
| 实现成本 | 低,几行代码 | 高,需部署 Canal/MQ |
| 一致性强度 | 弱,依赖猜测的延迟时间 | 较强,依赖消息链路 |
| 脏数据窗口 | 由延迟时间决定,不可控 | 由消息延迟决定,可监控 |
| 故障恢复 | 删除失败静默丢失 | 可重试、可补偿 |
| 对业务代码侵入 | 有,每个写路径都要写 | 无,集中处理 |
| 额外组件 | 无 | Canal + MQ |
| 适用规模 | 中小规模、写少读多 | 大规模、多写入方 |
一个务实的判断:如果你的系统只有一个写入入口、写 QPS 不高,延迟双删够用;如果写入方多(多个服务、甚至 DBA 直接改库)、对一致性敏感,订阅 binlog 更合适。 因为 binlog 能捕获"所有"变更源,而延迟双删只能覆盖你写了代码的那些路径。
五、什么时候别用 / 别踩的坑
反直觉清单,逐条对照:
- 别把延迟双删当强一致方案。它只是缩短脏窗口,任何声称"延迟双删保证一致性"的说法都不成立。真正的强一致只能靠"读时加锁"或"读写都走同一份存储"。
- 别用固定延迟时间糊弄 。
500ms、1s这种拍脑袋的值,在慢请求面前形同虚设。如果非要用,至少让它可配置,并配合缓存过期时间兜底。
- 别忽略缓存过期时间的作用 。无论哪种方案,过期时间都是最后一道防线。没有过期时间的缓存 + 失效逻辑 bug = 永久脏数据。
- 别在订阅 binlog 时假设消息一定到达。Canal 挂了、MQ 积压、消费者重启,都会导致失效消息丢失或延迟。要有监控和补偿机制。
- 别忽略"删除失败"这个静默杀手 。Redis 超时、网络抖动导致
del失败,如果代码里try-catch后吞掉异常,脏数据就没人管了。至少打日志、上报监控。
- 别在写路径里同步调用重试逻辑。删缓存失败就重试,可能拖垮写接口的 RT。重试应该异步化,或者交给 binlog 链路。
缓存一致性没有"最优解",只有"在你的一致性要求、系统规模、团队运维能力下,最合适的妥协"。延迟双删是用代码换时间 ,订阅 binlog 是用基础设施换可靠性。选哪个,取决于你愿意在哪一层承担复杂度。
这个系列后面还会继续聊分布式场景下的其他经典取舍,比如分布式锁的选型、幂等设计的落地方式。如果这篇对你有帮助,欢迎关注,我们下篇见。