缓存与数据库一致性:延迟双删与订阅 binlog 的取舍

缓存与数据库一致性:延迟双删与订阅 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 能捕获"所有"变更源,而延迟双删只能覆盖你写了代码的那些路径。


五、什么时候别用 / 别踩的坑

反直觉清单,逐条对照:

  1. 别把延迟双删当强一致方案。它只是缩短脏窗口,任何声称"延迟双删保证一致性"的说法都不成立。真正的强一致只能靠"读时加锁"或"读写都走同一份存储"。
  1. 别用固定延迟时间糊弄 。500ms、1s 这种拍脑袋的值,在慢请求面前形同虚设。如果非要用,至少让它可配置,并配合缓存过期时间兜底。
  1. 别忽略缓存过期时间的作用 。无论哪种方案,过期时间都是最后一道防线。没有过期时间的缓存 + 失效逻辑 bug = 永久脏数据。
  1. 别在订阅 binlog 时假设消息一定到达。Canal 挂了、MQ 积压、消费者重启,都会导致失效消息丢失或延迟。要有监控和补偿机制。
  1. 别忽略"删除失败"这个静默杀手 。Redis 超时、网络抖动导致 del 失败,如果代码里 try-catch 后吞掉异常,脏数据就没人管了。至少打日志、上报监控。
  1. 别在写路径里同步调用重试逻辑。删缓存失败就重试,可能拖垮写接口的 RT。重试应该异步化,或者交给 binlog 链路。

缓存一致性没有"最优解",只有"在你的一致性要求、系统规模、团队运维能力下,最合适的妥协"。延迟双删是用代码换时间 ,订阅 binlog 是用基础设施换可靠性。选哪个,取决于你愿意在哪一层承担复杂度。

这个系列后面还会继续聊分布式场景下的其他经典取舍,比如分布式锁的选型、幂等设计的落地方式。如果这篇对你有帮助,欢迎关注,我们下篇见。

相关推荐
眼不痛请看我1 小时前
Ubuntu_22.04_LTS虚拟机安装指南
linux·数据库·ubuntu
Omics Pro2 小时前
强生:以机理为中心!虚拟细胞世界模型
数据库·人工智能·python·深度学习·算法·机器学习·自然语言处理
java资料站2 小时前
四、Spring AIAlibaba · Tools(工具调用)
数据库·sql·spring
步行cgn2 小时前
Spring 事务传播行为详解
java·数据库·spring
anxiao_m2 小时前
跨地域大文件怎么传?2026主流传输软件实测对比
大数据·数据库·文件传输
Su米苏2 小时前
基于 Token 预算的上下文压缩控制器(Context Compaction Controller)
前端·数据库·人工智能
暖核2 小时前
Redis 从基础到集群实战:数据类型、客户端、高可用架构完整梳理
数据库·redis·架构
神一样的老师3 小时前
WS63 访问 HTTPS 握手失败(-0x7780)根治
数据库·网络协议·https
Patrick在香港3 小时前
时间戳凭空早了 8 小时:datetime.utcnow() 弃用实测与漂移复盘
数据库·python·标准库·datetime·时区·弃用