Skip to content

Redis 缓存与数据库双写一致性:别再无脑延迟双删了

Redis 缓存与数据库双写一致性:别再无脑延迟双删了

Section titled “Redis 缓存与数据库双写一致性:别再无脑延迟双删了”

“先更新数据库,还是先删除缓存?” 这几乎是每一场 Java 后端面试必问的八股文。很多候选人会熟练地背诵出标准答案:“使用延迟双删策略:先删缓存,再更新数据库,最后休眠 500 毫秒再删一次缓存。”

然而,在 2026 年的真实高并发生产环境中,如果你敢把“延迟双删(休眠 500 毫秒)”写进核心交易链路的同步代码里,你的系统一定会雪崩。

今天,我们就来扒一扒各种缓存同步策略的底裤,并给出企业级的标准解法。

1. 为什么“先更新数据库,再删除缓存”是标准基线? (Cache Aside)

Section titled “1. 为什么“先更新数据库,再删除缓存”是标准基线? (Cache Aside)”

我们先看看大名鼎鼎的 Cache Aside Pattern (旁路缓存模式)

  • 读请求:先读缓存,命中直接返回;未命中则读数据库,然后把数据写入缓存,返回。
  • 写请求:先更新数据库,然后再删除缓存。

注意:是删除缓存,而不是更新缓存。因为更新缓存不仅容易引发并发覆盖写(脏数据),而且很多缓存数据是经过复杂计算的,如果更新后没人读,白白浪费 CPU。

假设有两个并发请求,A 在读,B 在写,且此时缓存刚好过期了

  1. A 读缓存未命中,去查数据库,拿到了旧值 V=1
  2. B 更新数据库,将值改为 V=2
  3. B 删除了缓存(此时缓存本来就是空的)。
  4. 致命一击:A 将刚才拿到的旧值 V=1 写回了缓存。

这就是典型的并发读写脏数据。 但为什么业界依然把它作为标准基线?因为这种情况发生的概率极低。它要求读请求(A)在写数据库操作(B)发生之前开始,并在 B 删完缓存之后才结束。通常写数据库(B)的速度远慢于查数据库并写缓存(A)的速度,所以 A 很难在 B 之后才写缓存。

2. 批判“延迟双删”:为了极小概率的漏洞,牺牲了可用性

Section titled “2. 批判“延迟双删”:为了极小概率的漏洞,牺牲了可用性”

为了解决上面那个极小概率的漏洞,有人发明了“延迟双删”:

  1. 线程 A 先删除缓存。
  2. 线程 A 更新数据库。
  3. Thread.sleep(500);
  4. 线程 A 再删除一次缓存。

为什么这是毒瘤架构?

  • 吞吐量暴跌:如果你的 API 原本耗时 50ms,你强行 sleep(500),耗时直接变成了 550ms。在 Tomcat 的线程池模型下,如果有 200 个并发写请求,所有的工作线程瞬间全部被 sleep 卡死,整个服务直接假死。
  • 500 毫秒是个玄学:你到底该 sleep 多少?如果是网络抖动,读请求卡了 600 毫秒才写缓存,你的双删依然拦不住脏数据。
  • 第二步删除失败了怎么办? 如果最后一次删除报错了,你的缓存将永远是脏的。

3. 2026 年的架构解法:异步与最终一致性

Section titled “3. 2026 年的架构解法:异步与最终一致性”

在微服务架构中,如果你引入了缓存,你就必须接受“最终一致性”,而不是“强一致性”。 如果你非要强一致,那就别用 Redis,直接去读 MySQL。

既然底线是最终一致性,我们的解法就变得非常优雅了:

方案 A:可靠的异步重试删除 (MQ 兜底)

Section titled “方案 A:可靠的异步重试删除 (MQ 兜底)”

还是使用标准的 Cache Aside(先更 DB,后删 Cache)。但是,把“删除缓存”这个动作放到消息队列(RabbitMQ / RocketMQ)里去执行。

  1. 更新数据库。
  2. 将需要删除的 Key 发送到 MQ(如果发送失败,利用本地消息表重试)。
  3. 消费者监听 MQ,执行删除 Redis。如果删除失败,依赖 MQ 的自动重试机制(Retry机制)不断尝试,直到删除成功。

优点:代码不阻塞,容错率极高。 缺点:在业务代码里强行侵入了发 MQ 的逻辑,代码不够纯粹。

方案 B:终极解法:监听 MySQL Binlog (Canal 方案)

Section titled “方案 B:终极解法:监听 MySQL Binlog (Canal 方案)”

这是目前大厂的绝对主流方案,它实现了业务代码与缓存同步逻辑的彻底解耦

业务代码:

// 业务代码只有一行,清清爽爽
public void updateOrder(Order order) {
orderRepository.update(order); // 直接更新 MySQL,什么都不管了
}

后台基建:

  1. 部署一个阿里开源的 Canal(或 Debezium)服务,伪装成 MySQL 的从节点(Slave)。
  2. MySQL 发生更新时,会自动生成 Binlog,Canal 实时将 Binlog 抓取下来。
  3. Canal 将数据变更的事件推送到 Kafka。
  4. 有一个专门的 Cache-Sync-Service 消费 Kafka,看到哪行数据变了,就去 Redis 里发起对应的 del 操作。

优点

  • 业务代码零侵入。
  • Binlog 是绝对可靠的,只要数据落盘,必然会触发删除,解决了删除失败导致的永久脏数据问题。
  • 由于中间经过了 Kafka,天然起到了“延迟删除”的效果,顺带解决了第一节提到的极小概率并发脏数据问题!

做架构设计,最怕的就是生搬硬套八股文。

  • 小项目/容忍极短不一致:直接使用标准的 Cache Aside,设置一个合理的过期时间(TTL 兜底)即可。
  • 企业级高并发/绝对不容忍缓存脏数据:拥抱 Canal + Binlog + MQ 的异步终极一致性方案。

永远不要在你的生产代码里写 Thread.sleep 去做这种玄学的时序控制!