“为什么要休眠 1 秒?这 1 秒是怎么算出来的?如果数据库主从同步延迟了 1.5 秒,你这第 1 秒删了个寂寞?难道你要休眠 2 秒?那接口响应时间(RT)还要不要了?” 对方直接哑火。
说实话,“延时双删”在低并发的小系统里能用,但在大厂高并发场景下,它就是个“缝合怪”方案:既拖慢了性能(阻塞 Sleep),又无法保证 100% 的一致性(不可靠的 Sleep 时间)。面了十几个“五年经验”的 Java 开发,一问到“如何保证 Redis 和 MySQL 的数据一致性”,90% 的人都像背书一样回答: “用延时双删策略!先删缓存,再更数据库,休眠 1 秒,再删缓存。”
今天咱们就撕开这层遮羞布,看看大厂真正的“高可靠最终一致性”是怎么做的。
一、 为什么 “延时双删” 在高并发场景下不适用?
为了解决“更新数据库期间,旧数据被回填到缓存”的问题,有人发明了“延时双删”:先删缓存 -> Update DB -> Sleep(N) -> 再删缓存
这个方案在生产环境有两个致命硬伤:
硬伤 1:Sleep 多少秒是个玄学
你是 Sleep 500ms 还是 1s?这个时间必须大于“主从同步延迟 + 业务查询耗时”。 但在大促流量洪峰下,MySQL 主从延迟可能瞬间飙升到 3 秒甚至更久。你怎么保证你的 Sleep 时间一定够?只要 Sleep 结束时从库数据还没同步过来,别的线程读到的依然是旧数据,并再次回填到缓存。
硬伤 2:吞吐量自杀
你可是高并发系统啊!为了数据一致性,强行让业务线程Thread.sleep几百毫秒? 这意味着你的接口响应时间(RT)直接增加了几百毫秒,Tomcat 线程池瞬间被占满,吞吐量(QPS)腰斩。在秒杀场景下,这种代码就是给 CPU 递刀子。
二、 生产级的标准答案:Cache Aside + Binlog 异步兜底
在大厂的核心链路,我们追求的是“低延迟的最终一致性”。 既然“第二次删除”是为了防止脏数据回填,那为什么非要在业务线程里傻等?把这个“等待”和“删除”的动作剥离出来,交给异步组件去做,才是正解
真正的架构方案:
- App 侧(保障实时性):先删除缓存,再更新数据库。
- 注意:保留“先删缓存”,是为了让后续的读请求直接打到 DB 拿最新数据,防止用户看到旧值。
- 防坑指南:对于热点 Key,删除缓存后可能会引发“缓存击穿”导致 DB 压力骤增。建议配合分布式锁或逻辑过期策略来保护 DB,而不是在业务代码里搞复杂的“回写旧值”。
- MySQL 侧:数据变更产生 Binlog。
- 中间件侧(保障最终一致性):Canal 监听 Binlog -> 投递到 MQ -> 消费者收到消息。
- Consumer 侧(兜底清理):消费者收到消息后,执行第二次删除 Redis。
这套方案是如何降维打击“双删”的?
- 解耦(彻底去掉了 Sleep)业务线程执行完
Update DB直接返回成功,不需要留下来陪跑。接口性能拉满。 - 可靠性(MQ 重试机制)如果 Redis 挂了,或者删除失败,MQ 的 ACK 机制会保证消息不断重试,直到删除成功为止。而“双删”方案里,如果第二次删除失败了,那缓存里永远是脏数据。
- 解决主从延迟(自适应)MQ 的消息传输本身就有天然的“延时”。如果担心主从延迟极高,可以在 Consumer 端配置“消费延迟”(比如 RocketMQ 的 Level 3),或者让 Consumer 查不到数据再删。这比在业务代码里硬写
Sleep(1000)优雅一万倍。 - 补充兜底策略针对 Canal 宕机、MQ 消息堆积等异常场景,务必给缓存加上过期时间(TTL)。即使异步删除彻底失败,脏数据也会在 TTL 到期后自动失效,这是最后一道防线。
三、 如果面试官问“Binlog 方案太重了怎么办?”
有些面试官会杠:“引入 Canal 和 MQ,系统复杂度太高了,中小公司玩不起怎么办?”
这时候你要甩出“轻量级线程池延迟删除”方案:如果不上一整套 Binlog 中间件,可以在 Update DB 成功后,往当前服务的“延迟消息队列”(可以是基于内存的 DelayQueue,或者 RocketMQ 的延迟消息)扔一个任务。 任务内容:{key: "item_100", delay: 1000ms, retry: 3}。 由后台线程池去执行这个“第二次删除”。
注意事项:延迟队列虽然可以基于内存(DelayQueue),但在生产环境强烈建议使用 RocketMQ/Kafka 延迟消息或Redisson 延迟队列。 因为基于内存的队列一旦服务重启,任务就丢了,会导致缓存永远删不掉。