十万个why:都说延迟双删能保证缓存一致性,为什么高并发下它根本不靠谱?
大家好,我是小富~
《十万个why》系列持续更新中
如何保证 Redis 和 MySQL 的数据一致性?标准八股文的答案:先删缓存,再更新数据库,然后延迟一段时间再删一次缓存。
听起来逻辑挺对的,步骤也不复杂。但在高并发的生产环境里,这个方案简直就是坑。
延迟双删流程

为什么要删两次?因为在步骤 1 删完缓存和步骤 2 更新完数据库之间,有一个时间窗口。在这个窗口内,另一个读请求可能查到了数据库的旧值,然后把旧值又写回了缓存。第二次删除就是为了把这个被回填的脏缓存给清掉。
逻辑上好像没毛病,但问题出在细节里。
第一个坑:这个延迟时间到底该定多少?
休眠 N 毫秒,这个 N 又该定多少呢?
如果设得太短,还没等读请求把旧值写回缓存,第二次删除就执行了,白忙活
如果设得太长,在你休眠的这段时间里,所有读请求都读到脏缓存,脏缓存永不刷新
通常建议 N 设成读请求的耗时 + 几百毫秒,但读请求的耗时在高并发下波动很大,平时 50ms 高峰期可能 500ms。设个固定值,要么平时浪费,要么高峰期覆盖不到。
更要命的是,这个延迟时间一旦定了,就是硬编码在代码里的。数据库换了、网络抖了、机器扩容了,全得重新评估。
第二个坑:线程 sleep 阻塞业务
最直接简单的实现就是在业务线程里 Thread.sleep(500)。
在高并发场景下,业务线程是非常宝贵的资源,一个写请求进来,处理完数据库更新,然后线程就在那傻等 500ms。如果每秒有 1000 个写请求,就有 1000 个线程同时在 sleep,线程池很快就被占满,后面的请求全部排队甚至超时。
要不我们用异步?用 MQ 发个延迟消息来做第二次删除?
可以是可以,但这又引入了 MQ 的复杂度,MQ 消费失败了怎么办?消费延迟了怎么办?为了一个缓存一致性,引入了一整套消息中间件的运维成本。
第三个坑:第二次删除也可能失败
网络又不是 100% 可靠的,第二次删除 Redis 的操作可能因为网络抖动、Redis 短暂不可用、超时等原因失败,失败了怎么办?加重试?重试几次?重试间隔多久?
每一层兜底方案都会引入新的复杂度,最后你会发现为了保证一个删除操作的可靠性,你写了一大坨重试 + 补偿逻辑。
第四个坑:并发写,直接翻车
延迟双删只考虑了"一个写请求 + 一个读请求"的并发场景,两个写请求并发到来时会发生什么
假设有两个写请求,A 和 B,按照时间顺序:

这个场景下没什么问题。但如果时序稍微变一下:

但请求 B 是后发起的,业务语义上最终值应该是 Y,而数据库里存的却是 X。
这已经不是缓存一致性的问题了,是数据库本身的并发写顺序问题,延迟双删根本管不了这个。
推荐的方案
旁路缓存策略,核心就两句话:先读缓存,缓存没有就读数据库,读完写入缓存;先更新数据库,再删除缓存。
注意:是先更新库再删缓存,不是先删缓存再更新库。
为什么这个顺序更好?因为数据库更新和缓存删除之间的时间窗口通常极短(微秒级),在这个窗口内恰好有读请求命中旧缓存的概率很低。而先删缓存再更新库的时间窗口(数据库写入耗时)要大得多。
如果你还想要更强的一致性保证,可以参考下面的方案:
- Cache Aside(先更新库再删缓存),适用于大多数业务场景
- Cache Aside+TTL 兜底,适用于允许短暂不一致的场景
- Canal 监听 binlog + MQ 异步更新,适用于高一致性要求的场景
- 写请求加分布式锁串行化,适用于并发写冲突严重的场景
延迟双删不是说完全不能用,在低并发、对一致性要求不高的场景下,它确实简单好理解。但一旦并发上来了,它的每一个假设都可能被打破。
如果对一致性的要求高到需要延迟双删,那说明你应该用更靠谱的方案了,如果你对一致性的要求没那么高,那单纯的先更新库再删缓存就够了,延迟双删刚好卡在一个尴尬的位置上。
