跳转到内容

Redis 缓存与数据库双写一致性:延迟双删为什么不是答案

旁路缓存的常见基线是:读时未命中再查库,写时先提交数据库再删除缓存。 它仍有两类问题:删除失败,以及旧查询在删除之后才回填。异步重试、Outbox、CDC 和 TTL 各自减少风险,不能单凭“经过消息队列”就保证一致。Microsoft Cache-Aside

必须强一致的业务,选择具有对应一致性承诺的读写路径,或直接绕过旁路缓存。缓存层增加锁或版本检查也需要完整协议,不能只加一把锁就宣称强一致。

假设订单值原为 V=1,两个请求同时处理:

顺序A:读取B:修改缓存
1未命中,查库得到 V=1空
2查询结果仍在返回途中数据库提交 V=2空
3删除缓存空
4把旧值 V=1 回填旧值重新出现

删除成功也不代表没有脏回填。另一个失败窗口是数据库已提交、应用在删除或发送删除消息前退出。

方案解决什么主要条件与代价
Cache Aside + 固定 TTL基础读写和缓存过期能容忍旧值;删除失败仍可能持续到过期
同事务 Outbox + Relay/MQ删除意图不会因应用在提交后退出而丢失业务写入和 Outbox 同库同事务;Relay 需重试、积压与死信处理
CDC + 缓存同步消费者从数据库变更生成删除事件运维日志采集与消息链路,保留位点、日志与恢复能力

TTL 限制的是一次回填后缓存还能存活多久,不是数据库变更后的严格不一致上限。旧查询很晚才回填、读取旧副本或刷新 TTL,都可能延长窗口。

Outbox 方案在更新业务数据的同一个本地事务中记录待删 Key。提交后 Relay 投递 MQ,消费者执行 del;失败则有限重试并告警,持续失败进入死信或由对账修复。

不能先提交数据库,等 MQ 发送失败再写 Outbox:进程可能在两者之间退出,这个窗口还在。完整事务和 Relay 示例见Outbox 与消费者幂等。

重复删除通常可安全处理,但 ACK、重试和对账仍需定义。记录待办能恢复任务,不等于删除必然即时成功。

业务代码只更新数据库,由变更采集链路处理缓存:

public void updateOrder(Order order) {
orderRepository.update(order); // 只更新 MySQL,缓存同步交给 CDC 管道
}

Canal / Debezium 读取 MySQL Binlog → 变更事件进入消息系统 → 缓存消费者删除相关 Key。采集位点和日志保留期限要匹配故障恢复时间,并监控端到端延迟、积压、重试与死信。Debezium 官方文档

CDC 不直接控制另一个请求的回填:如果旧查询比删除事件更晚返回,旧值仍会写回。按 Key 分区可以保住删除事件自身的顺序,却不能自动排序消息链路外的缓存回填。

“先删 → 更新数据库 → Thread.sleep(500) → 再删”依赖读请求在等待窗口内结束。慢查询、GC 或网络抖动超过窗口后,第二次删除仍可能太早;第二次删除也可能失败。

问题影响
同步 sleep示例中 50ms 请求加等 500ms,变成约 550ms;占用处理线程
固定等待时长不是对并发时序的保证
删除失败仍需可靠重试与对账

需要进一步收紧旧值窗口时,设计版本校验、过久回源结果不回填等规则,并用并发时序验证。高并发本身不是选 CDC 的唯一理由,先明确可接受的不一致时间和失效后的恢复方式。