缓存与数据库一致性实战:延迟双删与 binlog 订阅

缓存与数据库一致性,是每一个引入 Redis 做加速的系统迟早要面对的难题。在读多写少的业务里,我们用缓存挡在数据库前面扛流量;但只要发生写操作,缓存与数据库双写之间就会出现时间差,旧值就可能被后续的读请求回填,造成用户看到过期数据。本文从最常见的不一致场景讲起,对比「先更新库再删缓存」「延迟双删」「订阅 binlog 异步更新」三种方案,并复盘生产环境真正踩过的坑与选型建议。

一、不一致是怎么发生的

设想一个经典链路:读请求先查缓存,未命中再查数据库并回写缓存;写请求更新数据库。如果写请求「先删缓存、再更新库」,那么在删缓存之后、库更新完成之前,恰好进来一个读请求,它会从库里读到旧值并写回缓存——于是缓存里长期留着脏数据。反过来,如果「先更新库、再删缓存」,在库更新后、缓存删除前的极短窗口里,读请求也可能拿到旧缓存。这两种顺序都不是银弹,差别只在于脏数据的存活时间。

// 典型读流程(伪代码)
String get(String key) {
    String v = redis.get(key);
    if (v != null) return v;          // 命中缓存
    v = db.query(key);                 // 回源数据库
    redis.setex(key, 300, v);          // 回填缓存
    return v;
}

真正制造不一致的不是「双写」这个动作,而是「两个存储系统的更新不是原子的」。只要中间有并发读或网络抖动,就可能出现时序错乱。理解了这一点,后面所有方案都是在用不同的代价去压缩这个不一致窗口

二、方案一:Cache-Aside(先更新数据库,再删缓存)

这是最常用、也是工程上最推荐的基础方案:写请求先改数据库,成功后再删除缓存(而不是更新缓存)。下次读请求发现缓存缺失,自然会回源拿到最新值并重新回填。之所以「删」而不是「更新」缓存,是为了避免并发写导致的缓存覆盖错乱,也让缓存的写入天然收敛到读路径。

// Cache-Aside 写路径(Java 示例)
@Transactional
public void updateOrder(long id, Order o) {
    orderMapper.updateById(o);        // 1. 先更新数据库
    redis.del("order:" + id);         // 2. 再删除缓存
}

public Order getOrder(long id) {
    String key = "order:" + id;
    String json = redis.get(key);
    if (json != null) return JSON.parse(json);
    Order o = orderMapper.selectById(id);  // 缓存未命中回源
    redis.setex(key, 300, JSON.toStr(o));
    return o;
}

它的优点是简单、对读多写少场景足够。但它不是强一致:在「更新库成功」到「删除缓存成功」之间有一个微小窗口,且如果删缓存这一步失败了,脏数据会一直留着。关于缓存放慢查询、过期策略怎么设,可参考 Redis 缓存设计:穿透、击穿、雪崩与最佳实践Redis 缓存三大问题:穿透击穿雪崩防护实战

三、方案二:延迟双删

延迟双删是对 Cache-Aside 的加固:在更新数据库前后各删一次缓存,第二次删除「延迟」一小段时间(通常 500ms~1s,取业务读耗时上限)。它的意图是覆盖「删缓存后、写库完成前」那个窗口里可能回填的旧值——等读请求把旧值写回后,我们再补一刀删掉它。

// 延迟双删(Java,配合异步线程避免阻塞主流程)
public void updateWithDelayDelete(long id, Order o) {
    redis.del("order:" + id);             // 第一次删:写库前
    orderMapper.updateById(o);            // 更新数据库
    // 第二次删:延迟执行,覆盖回填的旧值
    scheduler.schedule(() -> redis.del("order:" + id), 800, TimeUnit.MILLISECONDS);
}

延迟时间怎么定?经验值是「读接口 99 分位耗时 + 网络抖动余量」。太短覆盖不住,太长则不一致窗口被人为拉长。它仍不保证强一致,只是把脏数据存活时间压到延迟窗口内,属于「最终一致」的折中。

方案一致性强度实现复杂度适用场景
Cache-Aside弱(极小窗口)绝大多数读多写少业务
延迟双删弱→中(压缩窗口)低~中对脏读敏感、写频率可控
订阅 binlog最终一致(解耦)中~高多服务共享数据、异构存储

四、方案三:订阅 binlog 异步更新(Canal / Debezium)

当数据变更的来源变多(多个服务都写同一张表),靠业务代码里「顺手删缓存」会越来越不可靠。更彻底的做法是把缓存更新从写路径里剥离出来:用 Canal 或 Debezium 订阅 MySQL 的 binlog,把变更事件投递到消息队列,由独立的消费者负责失效或刷新缓存。这样业务代码只管写库,缓存一致性由「数据库变更」这一个事实源驱动。

# Canal 监听 MySQL binlog,将 ROW 变更发到 Kafka
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal_pwd
canal.mq.topic=db_order_change
canal.mq.partitionHash=db_order_change:order:id
// 消费者:收到变更即删除对应缓存(最终一致)
@KafkaListener(topics = "db_order_change")
public void onRowChange(ChangeEvent e) {
    if ("order".equals(e.getTable())) {
        redis.del("order:" + e.getAfter().get("id"));
    }
}

这套架构的缓存更新与写请求完全解耦,也不会因为业务忘了删缓存而脏读。代价是要引入 Canal、Kafka 等组件,且消息有消费延迟——它提供的是最终一致性,不是实时一致。如果你们的服务是容器化部署,可参考 Docker Compose 一键编排:MySQL + Redis + 后端服务 把这套组件快速拉起。

五、生产踩坑实录

1. 删除缓存失败,旧值永久留存

Cache-Aside 最脆弱的一环是「删缓存」。如果 Redis 当时超时或网络抖动,删除没成功,而数据库已是新值,缓存就永远停留在旧值,直到自然过期。解决办法是给删除动作加重试(带退避),或把「删除失败」作为事件丢进延迟队列异步补偿;更稳妥的是走向方案三——由 binlog 保证最终一定会删。

2. 并发读写把旧值回填

经典时序:线程 A 更新库(新值),还没删缓存;线程 B 读缓存未命中,从库读到「旧值」并写回缓存。即使 A 随后删了缓存,B 刚写的旧值已经在里面了。延迟双删正是为此存在,但若延迟时间设得比读耗时还短仍会漏。本质是:只要「读回源」和「删缓存」存在竞争,就需要用延迟或 binlog 兜底。

3. 大 key / 热 key 放大不一致窗口

一个被高频读的大对象,每次回源都慢,意味着「删缓存→读请求回填旧值」的窗口被拉长,脏数据存活更久。对热 key 应做本地缓存(如 Caffeine)或拆分,减少回源压力;对大 key 控制体积,避免一次回源拖垮一致性。

4. 主从延迟造成的「读从库拿到旧值」

很多系统读请求走从库做读写分离(见 MySQL 主从复制与读写分离:高可用架构实战)。如果写完主库立刻删缓存,但读请求落在「还没同步到新值」的从库,就会把旧值回填。缓解方式是写后强制读主库一小段时间,或依赖 binlog 订阅( Canal 读的正是主库已提交的数据),从根源规避主从延迟。

六、选型建议

绝大多数业务,Cache-Aside + 删除重试就够用,简单且抖动小。当脏读后果严重(如余额、库存),再上延迟双删把窗口压到亚秒级。当多个服务共享同一份数据、或缓存异构存储繁多,直接用 binlog 订阅做最终一致,把一致性交给数据库事实源。记住一条铁律:能删缓存就别更新缓存,让缓存的写入收敛到读路径,可以消除大部分并发覆盖问题。

七、小结

缓存与数据库一致性没有银弹,只有「用多大代价换多小窗口」的权衡。Cache-Aside 是默认答案,延迟双删是加固,订阅 binlog 是解耦终态。真正决定线上是否翻车的,往往不是选了哪个方案,而是有没有为「删缓存失败」「主从延迟」「并发回填」这些边界留好补偿。把缓存失效当成异步事件去设计,系统才扛得住真实流量。

上一篇 Elasticsearch 全文检索实战:索引原理与调优
下一篇 MoE 混合专家模型:原理、路由与落地实战