Redis 缓存与数据库双写一致性:延迟双删为什么不是答案
旁路缓存的常见基线是:读时未命中再查库,写时先提交数据库再删除缓存。 它仍有两类问题:删除失败,以及旧查询在删除之后才回填。异步重试、Outbox、CDC 和 TTL 各自减少风险,不能单凭“经过消息队列”就保证一致。Microsoft Cache-Aside
必须强一致的业务,选择具有对应一致性承诺的读写路径,或直接绕过旁路缓存。缓存层增加锁或版本检查也需要完整协议,不能只加一把锁就宣称强一致。
先看一次脏回填
Section titled “先看一次脏回填”假设订单值原为 V=1,两个请求同时处理:
| 顺序 | A:读取 | B:修改 | 缓存 |
|---|---|---|---|
| 1 | 未命中,查库得到 V=1 | 空 | |
| 2 | 查询结果仍在返回途中 | 数据库提交 V=2 | 空 |
| 3 | 删除缓存 | 空 | |
| 4 | 把旧值 V=1 回填 | 旧值重新出现 |
删除成功也不代表没有脏回填。另一个失败窗口是数据库已提交、应用在删除或发送删除消息前退出。
三种方案按条件选
Section titled “三种方案按条件选”| 方案 | 解决什么 | 主要条件与代价 |
|---|---|---|
| Cache Aside + 固定 TTL | 基础读写和缓存过期 | 能容忍旧值;删除失败仍可能持续到过期 |
| 同事务 Outbox + Relay/MQ | 删除意图不会因应用在提交后退出而丢失 | 业务写入和 Outbox 同库同事务;Relay 需重试、积压与死信处理 |
| CDC + 缓存同步消费者 | 从数据库变更生成删除事件 | 运维日志采集与消息链路,保留位点、日志与恢复能力 |
TTL 限制的是一次回填后缓存还能存活多久,不是数据库变更后的严格不一致上限。旧查询很晚才回填、读取旧副本或刷新 TTL,都可能延长窗口。
删除失败怎样补救
Section titled “删除失败怎样补救”Outbox 方案在更新业务数据的同一个本地事务中记录待删 Key。提交后 Relay 投递 MQ,消费者执行 del;失败则有限重试并告警,持续失败进入死信或由对账修复。
不能先提交数据库,等 MQ 发送失败再写 Outbox:进程可能在两者之间退出,这个窗口还在。完整事务和 Relay 示例见Outbox 与消费者幂等。
重复删除通常可安全处理,但 ACK、重试和对账仍需定义。记录待办能恢复任务,不等于删除必然即时成功。
CDC 怎样工作,不能保证什么
Section titled “CDC 怎样工作,不能保证什么”业务代码只更新数据库,由变更采集链路处理缓存:
public void updateOrder(Order order) { orderRepository.update(order); // 只更新 MySQL,缓存同步交给 CDC 管道}Canal / Debezium 读取 MySQL Binlog → 变更事件进入消息系统 → 缓存消费者删除相关 Key。采集位点和日志保留期限要匹配故障恢复时间,并监控端到端延迟、积压、重试与死信。Debezium 官方文档
CDC 不直接控制另一个请求的回填:如果旧查询比删除事件更晚返回,旧值仍会写回。按 Key 分区可以保住删除事件自身的顺序,却不能自动排序消息链路外的缓存回填。
延迟双删的边界
Section titled “延迟双删的边界”“先删 → 更新数据库 → Thread.sleep(500) → 再删”依赖读请求在等待窗口内结束。慢查询、GC 或网络抖动超过窗口后,第二次删除仍可能太早;第二次删除也可能失败。
| 问题 | 影响 |
|---|---|
| 同步 sleep | 示例中 50ms 请求加等 500ms,变成约 550ms;占用处理线程 |
| 固定等待时长 | 不是对并发时序的保证 |
| 删除失败 | 仍需可靠重试与对账 |
需要进一步收紧旧值窗口时,设计版本校验、过久回源结果不回填等规则,并用并发时序验证。高并发本身不是选 CDC 的唯一理由,先明确可接受的不一致时间和失效后的恢复方式。