Redis 缓存与数据库双写一致性:别再无脑延迟双删了
Redis 缓存与数据库双写一致性:别再无脑延迟双删了
Section titled “Redis 缓存与数据库双写一致性:别再无脑延迟双删了”“先更新数据库,还是先删除缓存?” 这几乎是每一场 Java 后端面试必问的八股文。很多候选人会熟练地背诵出标准答案:“使用延迟双删策略:先删缓存,再更新数据库,最后休眠 500 毫秒再删一次缓存。”
然而,在 2026 年的真实高并发生产环境中,如果你敢把“延迟双删(休眠 500 毫秒)”写进核心交易链路的同步代码里,你的系统一定会雪崩。
今天,我们就来扒一扒各种缓存同步策略的底裤,并给出企业级的标准解法。
1. 为什么“先更新数据库,再删除缓存”是标准基线? (Cache Aside)
Section titled “1. 为什么“先更新数据库,再删除缓存”是标准基线? (Cache Aside)”我们先看看大名鼎鼎的 Cache Aside Pattern (旁路缓存模式):
- 读请求:先读缓存,命中直接返回;未命中则读数据库,然后把数据写入缓存,返回。
- 写请求:先更新数据库,然后再删除缓存。
注意:是删除缓存,而不是更新缓存。因为更新缓存不仅容易引发并发覆盖写(脏数据),而且很多缓存数据是经过复杂计算的,如果更新后没人读,白白浪费 CPU。
它有什么漏洞?
Section titled “它有什么漏洞?”假设有两个并发请求,A 在读,B 在写,且此时缓存刚好过期了:
- A 读缓存未命中,去查数据库,拿到了旧值
V=1。 - B 更新数据库,将值改为
V=2。 - B 删除了缓存(此时缓存本来就是空的)。
- 致命一击:A 将刚才拿到的旧值
V=1写回了缓存。
这就是典型的并发读写脏数据。 但为什么业界依然把它作为标准基线?因为这种情况发生的概率极低。它要求读请求(A)在写数据库操作(B)发生之前开始,并在 B 删完缓存之后才结束。通常写数据库(B)的速度远慢于查数据库并写缓存(A)的速度,所以 A 很难在 B 之后才写缓存。
2. 批判“延迟双删”:为了极小概率的漏洞,牺牲了可用性
Section titled “2. 批判“延迟双删”:为了极小概率的漏洞,牺牲了可用性”为了解决上面那个极小概率的漏洞,有人发明了“延迟双删”:
- 线程 A 先删除缓存。
- 线程 A 更新数据库。
Thread.sleep(500);- 线程 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)里去执行。
- 更新数据库。
- 将需要删除的 Key 发送到 MQ(如果发送失败,利用本地消息表重试)。
- 消费者监听 MQ,执行删除 Redis。如果删除失败,依赖 MQ 的自动重试机制(Retry机制)不断尝试,直到删除成功。
优点:代码不阻塞,容错率极高。 缺点:在业务代码里强行侵入了发 MQ 的逻辑,代码不够纯粹。
方案 B:终极解法:监听 MySQL Binlog (Canal 方案)
Section titled “方案 B:终极解法:监听 MySQL Binlog (Canal 方案)”这是目前大厂的绝对主流方案,它实现了业务代码与缓存同步逻辑的彻底解耦。
业务代码:
// 业务代码只有一行,清清爽爽public void updateOrder(Order order) { orderRepository.update(order); // 直接更新 MySQL,什么都不管了}后台基建:
- 部署一个阿里开源的 Canal(或 Debezium)服务,伪装成 MySQL 的从节点(Slave)。
- MySQL 发生更新时,会自动生成 Binlog,Canal 实时将 Binlog 抓取下来。
- Canal 将数据变更的事件推送到 Kafka。
- 有一个专门的
Cache-Sync-Service消费 Kafka,看到哪行数据变了,就去 Redis 里发起对应的del操作。
优点:
- 业务代码零侵入。
- Binlog 是绝对可靠的,只要数据落盘,必然会触发删除,解决了删除失败导致的永久脏数据问题。
- 由于中间经过了 Kafka,天然起到了“延迟删除”的效果,顺带解决了第一节提到的极小概率并发脏数据问题!
做架构设计,最怕的就是生搬硬套八股文。
- 小项目/容忍极短不一致:直接使用标准的 Cache Aside,设置一个合理的过期时间(TTL 兜底)即可。
- 企业级高并发/绝对不容忍缓存脏数据:拥抱 Canal + Binlog + MQ 的异步终极一致性方案。
永远不要在你的生产代码里写 Thread.sleep 去做这种玄学的时序控制!