☰
延迟双删策略:解决缓存与数据库一致性问题的工程实践
2026/9/28 7:54:38 网站建设 项目流程

做后端开发的一定被缓存和数据库不一致的问题折磨过。MySQL里数据明明改了,Redis里拿到的还是旧值,用户一刷新看到的还是老数据,客服投诉接二连三。延迟双删策略是我在实际项目里用的最多、也是性价比最高的一套缓存一致性兜底方案。从名字上看它只是“多删一次缓存”,但背后隐含的时序设计、并发窗口补偿、失败重试机制,才是真正值钱的东西。这篇文章我会尽量把延迟双删的原理、参数计算、工程实现、坑点和备选方案都讲透,适合正在被缓存与数据库一致性困扰,或者打算给系统做最终一致性设计的朋友参考。

延迟双删本身不是高深算法,但很多人在落地时会掉进同一个坑:以为两个delete中间加个sleep就完事了,结果线上照样出脏数据。下面我从问题的源头开始,把整个策略拆开聊。

1. 缓存一致性问题,到底是怎么发生的

1.1 缓存和数据库是两套独立的存储系统

先看最基本的背景。Redis是高速缓存层,MySQL/PostgreSQL是持久化存储层。数据写入时先落到数据库,读取时先查缓存,缓存不命中再回源数据库并写回缓存。这是最经典的Cache Aside模式。

问题在于,这两套存储之间没有跨系统事务。你不可能像操作单库那样,在一个事务里同时提交数据库写入和缓存删除。哪怕用了Lua脚本、用了分布式事务框架,也无法保证两个存储系统的原子提交。既然做不到原子,那就只能通过“顺序约定 + 补偿机制”来逼近一致性,这也是延迟双删存在的前提。

我举个例子:一个商品库存接口,前端库存初始为100,管理员后台把库存改为50。数据库执行UPDATE后,Redis里的库存还是100。读请求进来,缓存命中直接返回100,用户看到过期库存,超卖风险跟着就来了。要想避免这种问题,必须保证数据库更新后,缓存中对应的旧值能被及时清掉。

1.2 两条经典更新顺序,各自都有致命窗口

处理缓存和数据库更新时,大多数人首先会想到两种顺序。

第一种:先更新数据库,再删除缓存。优势是逻辑简单,命中率也高,但有一个最典型的失败场景——删除缓存这一步如果失败了,或者删除操作因为网络超时没执行到位,数据库已经是新值,缓存里还是旧值。只要缓存key不过期,后续所有读请求都会一直命中旧数据。这个场景在慢查询、Redis连接池耗尽时特别容易触发。

第二种:先删除缓存,再更新数据库。这种顺序表面看避免了删除失败问题,但它引入了一个更大的并发窗口。假设请求A执行“删除缓存 -> 更新数据库”,删除缓存动作完成后、数据库还没提交事务的间隙,请求B进来读缓存,发现缓存不存在,回源数据库拿到了旧值,然后把旧值写回缓存。等请求A更新完数据库,缓存里已经躺着请求B写入的旧值了。这个旧值比第一种方案的“删除失败”更隐蔽,因为它不是删除动作出错,而是读操作把旧值“灌”了回去。

延迟双删的核心,就是针对第二种顺序里的回写窗口做补偿,同时保留先更新数据库再删缓存的简单性。

1.3 一致性不是在所有场景里都要求强一致

聊延迟双删前,必须先明确一件事:业务到底需要什么级别的一致性。

强一致意味着任何时刻读到的都是最新值,这对大多数互联网业务来说代价极高,通常需要引入分布式锁、事务消息、或者让读写都路由到单机。最终一致是绝大多数缓存场景都能接受的:系统允许短期出现不一致,但在一段时间后(通常几百毫秒到几秒)必须收敛到最新值。

延迟双删就是典型的最终一致性方案。它不追求数据库更新的瞬间缓存立刻失效,而是通过延迟补偿,把不一致窗口压缩到一个非常小的范围,同时极大降低删除失败带来的持续脏数据风险。如果你做的业务对一致性极其敏感,比如余额扣减、订单状态流转,那延迟双删只能作为辅助,不能作为唯一手段。先定位清楚自己需要的一致性等级,再决定要不要用这套策略,能省下很多无用功。

2. 延迟双删的核心设计,为什么是“延迟”+“双删”

2.1 标准执行流程拆解

延迟双删的标准流程一句话就能概括:更新数据库,删除缓存,等待一段时间,再次删除缓存。拆开看是这样的:

  1. 先执行数据库更新,并且确保事务提交成功。
  2. 事务提交后,立刻删除一次缓存。
  3. 线程或者异步任务休眠一段预设的时间。
  4. 休眠结束后,再一次删除缓存。

有人会问,第一次删除缓存不就已经把旧值删掉了吗?第二次删除到底在防谁?防的是第一次删除到第二次删除之间,可能出现的“旧值回写缓存”。

我们把时间线拉直:数据库更新完成,假设在T1时刻删了缓存。此时读请求进来,缓存没有数据,去数据库读。如果这个读请求在数据库拿到的是更新后的新值,那写回缓存自然没问题。但如果数据库部署了主从架构,读请求走的是从库,而从库同步主库的更新存在延迟,读请求在T2时刻读到了旧值,然后把这个旧值写进缓存。等延迟结束,T3时刻第二次删除缓存,把这份回写的旧值清掉,下一次读请求再回源时读到新值,缓存最终变新。这就是整个策略的核心逻辑。

2.2 为什么必须“延迟”,而不是连续删两次

有人可能会想:既然第二次删除是为了清掉旧值回写,那我连续删除两次不也一样?不行,关键在于两次删除之间必须留出足够的时间窗口。

假设连续执行delete A、delete B、delete C,前后间隔微秒级。读请求回源数据库、拿到数据、网络传输、写回缓存,这一整套动作通常需要几毫秒到几十毫秒。你前脚刚删完,后脚旧值就被写回去了,第二次删除也因为已经执行完而失效。延迟的作用就是把这个补偿动作往后挪,确保所有“在删除动作触发前就已经开始回源”的读请求,都有足够时间把旧值写回缓存,然后再被第二次删除清掉。

2.3 延迟时间到底应该设多少

延迟时间设短了,覆盖不住回写窗口;设长了,缓存可能长期没有数据,请求全部打到数据库,造成缓存穿透压力。这个参数是整个策略里最需要结合业务评估的。

最标准的估算方法是:延迟时间要大于“一次读请求从回源数据库到写回缓存的耗时”,同时要覆盖主从复制延迟的最大值。实际项目中,我一般先把延迟设为500ms到1s,再用压测数据修正。比如业务QPS不高、数据库响应稳定在10ms以内、主从延迟在200ms以内,那500ms已经留了充足余量。如果主从延迟经常到400ms,建议直接设1s。

但延迟不能无限大。如果设成3秒、5秒,虽然一致性更稳了,但缓存被清空的时间变长,热点key瞬间的穿透流量可能直接把数据库打挂。所以延迟时长本质上是在“一致性与可用性”之间做取舍。

2.4 延迟双删必须放在事务提交之后

一个我在代码评审里经常看到的错误:把第一次删除缓存放在数据库事务内部执行。假设事务还没有提交,删除动作先发出去了,这时候读请求回源数据库,因为事务隔离级别的关系,可能根本读不到未提交的新值,只能读到旧值,并写回缓存。等事务提交后,第一次删除的“坑”已经被旧值填上了,第二次删除还要等一个延迟周期才能兜底,不一致窗口被人为拉大。

正确的做法是先确保数据库事务提交成功,再进行缓存删除。事务提交成功后,再触发一次异步延迟删除,时序才是最合理的。

3. 延迟双删的工程实现,从伪代码到可上线方案

3.1 最简单的同步阻塞实现

先看最直白的实现,所有逻辑都写在一个方法里面,适合理解原理,但不建议直接上线。下面是Java风格的示意代码:

public void updateProductStock(String productId, int newStock) { // 1. 更新数据库(事务内部) productDao.updateStock(productId, newStock); // 2. 事务提交后,第一次删除缓存 String cacheKey = "product:stock:" + productId; redisTemplate.delete(cacheKey); // 3. 模拟延迟,等待并发读回写旧值 try { Thread.sleep(600); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 4. 第二次删除缓存 redisTemplate.delete(cacheKey); }

这段代码在原理演示上没问题,但工程上至少有三个硬伤。

硬伤一:Thread.sleep阻塞了业务线程。假设一次更新接口耗时600ms,在线程池模型下,每秒只能处理不到两个更新请求,这是不能接受的。

硬伤二:如果第二次删除失败,这个策略就降级成了普通的“先更新DB再删缓存”,缓存可能长时间保留旧值。

硬伤三:如果服务在sleep期间被重启或者被kill,第二次删除直接丢失,没有任何补偿机制。

所以这个写法只能是教学示例,线上必须引入异步化和失败重试。

3.2 把延迟双删改成异步任务执行

既然不能阻塞接口线程,那就把延迟操作丢到异步线程池。Spring下可以用@Async,也可以用自带的ScheduledExecutorService或者引入消息队列。先看线程池版本:

@Async("delayDeleteCacheExecutor") public void asyncDelayDelete(String cacheKey, long delayMillis) { try { Thread.sleep(delayMillis); redisTemplate.delete(cacheKey); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }

主流程变成:事务提交后,先同步删除一次缓存,然后把“延迟再删”的任务交给异步线程池。这样接口响应时间不再被sleep拖住。

不过这个方案还是有一个隐患:异步线程池中的任务在服务重启后会丢失。如果你对一致性要求不高,可以接受这种概率性丢失;但如果要求高一些,最好把延迟任务落到消息队列里,用MQ的重试机制来保证任务最终被执行。

3.3 基于消息队列的延迟删除,是我常用的方案

我之前在业务中经常用这种方式:更新数据库成功后,先同步删除一次缓存,同时发送一条延迟消息到MQ,延迟时间设为600ms或1s,消费者收到消息后再删除一次缓存。这里给一个简化的流程描述:

  1. 更新数据库,事务提交。
  2. 同步删除Redis缓存。
  3. 发送一条带延迟的MQ消息,payload里带上cacheKey和业务标识。
  4. MQ延迟队列在指定时间后将消息投递给消费者。
  5. 消费者收到消息后,执行第二次缓存删除。
  6. 如果删除失败,根据失败次数决定重试或者记录告警,由补偿任务兜底。

使用MQ的好处很多:任务状态持久化在Broker中,服务重启不会丢消息;天然支持重试;延迟时间可以灵活调整,不需要在代码里sleep。坏处是引入了额外组件,如果你的系统本身没有MQ,专门为了延迟双删引入一套消息中间件确实有点重。

如果不引入MQ,可以选择Redis本身的延迟队列实现,用ZSet存任务,再用一个定时任务扫描到期任务。这种方式比MQ轻,但需要自行处理任务幂等和持久化,工作量也不小。

3.4 第二次删除失败后的兜底:删除重试

无论是同步删除还是MQ删除,都可能遇到删除失败。Redis delete失败大概就几种原因:连接超时、网络闪断、key不存在、Redis执行出错(比如误用了错误的库)。前两种是暂时的,重试一般能解决。

给删除操作加重试机制时要注意幂等。Redis的delete本身是幂等的,key不存在时删除结果返回0,对业务无影响。所以你可以放心地重试,不用考虑重复删除副作用。

实际工程中,我一般会用一个简单的补偿表或者日志表。删除失败时记录一条失败日志,再由一个定时任务扫描,每隔一段时间重试一次,直到成功或者达到最大重试次数。超过最大次数的,告警通知人工介入。别小看这个兜底,只要删除失败率高,没有补偿机制,延迟双删就只剩“延迟”没有“双删”了。

3.5 避免旧值回写,还能再叠加一层“写缓存锁”

延迟双删防的是旧值回写,但它并不阻止并发写。极端情况下,一个读请求在第一次删除前已经开始回源,旧值写回发生在第二次删除完成后,那延迟双删也救不回来。

为了进一步缩小这个窗口,可以在回源写缓存的地方加一个条件:写缓存之前,先确认缓存key是否仍然不存在。如果不存在,再写入。如果存在,可能是其他线程刚写入的新值,就放弃覆盖。这个判断可以直接用SETNX或者Lua脚本完成。虽然不能完全消除并发问题,但可以把旧值覆盖新值的概率降到很低。

这个方案是否要加,取决于你的业务对并发风险的容忍度。我一般在更新频繁、读取量大的热点key上会加上这个保护,普通低频key就不浪费这个开销了。

4. 延迟双删的局限与排查实录

4.1 延迟双删不能解决的三类问题

第一个解决不了的是数据库主从复制延迟。如果你的读写分离架构中,读请求走从库,从库同步延迟大于设定的延迟时间,那么第二次删除执行后,依然有读请求从从库读到旧值并写回缓存,最终重新污染缓存。这种情况下,光调大延迟时间是治标不治本,更好的做法是让更新操作和后续读操作走主库,或者强制缓存过期等待下一次回源。

第二个解决不了的是强一致性要求。延迟双删本质是最终一致性,窗口期内出现旧值是设计允许的。如果你业务要求用户点击后立刻看到最新结果,那这个策略不合适,需要引入读写锁或者数据库层面的状态判断。

第三个解决不了的是缓存自身高并发穿透问题。第二次删除执行后,到下一次数据被读入缓存之间,大量请求会击穿Redis打到数据库。如果热点key正好经历一波集中访问,延迟双删可能会放大缓存击穿的风险。这种情况下必须配合缓存重建锁(比如Redisson的分布式锁)来控制回源流量。

4.2 常见问题速查表

现象可能原因排查思路解决建议
更新后长时间读到旧值第二次删除失败或未执行查Redis日志、删除失败补偿日志增加删除失败重试与告警
更新后偶发读到旧值延迟时间小于旧值回写窗口压测回源耗时,观察主从延迟调大延迟时间,或用MQ延迟队列
缓存被删除后数据库压力剧增延迟时间设置过长观察Redis命中率和DB QPS缩短延迟时间,加缓存重建锁
删除缓存任务在服务重启后丢失使用了线程池sleep方案检查异步任务执行历史改用MQ中间件或持久化补偿表
主从架构下旧值反复出现从库同步延迟大于延迟时间查询从库延迟指标修正路由策略,或扩展延迟时间

4.3 我在实际项目里踩过的坑

这里挑三个印象最深的。

第一个坑是延迟时间拍脑袋定了300ms,压测时看不出问题,上线后偶尔有几条脏数据。后来查日志发现,请求高峰时段Redis连接池出现排队现象,一次回源写缓存耗时接近400ms,300ms根本覆盖不住。之后我把时间改成800ms,并且把回源耗时加到了监控里,再没出现过这个类型的脏数据。

第二个坑是第一次删除缓存放在了事务里。有一次在代码评审时发现,同事为了“减少一次删除时间差”,把redisTemplate.delete放到了事务提交之前。结果更新接口在事务提交前有大约150ms的间隙,读请求在这个间隙把旧值写回缓存。后来调整为先提交事务再删缓存,问题就消失了。

第三个坑是异步线程池没有监控。某个版本上线后,我配置的延迟双删线程池因为核心线程数设置太小,任务大量排队,第二次删除迟迟执行不了。观察缓存后才发现问题。教训是:所有异步任务都要加队列积压的监控,否则线程池满了,任务不失败也不执行,系统表现会非常诡异。

5. 延迟双删不是唯一答案,主流方案对比与选型

5.1 Cache Aside、Write Through 与延迟双删

缓存更新的经典思路是Cache Aside(旁路缓存),它的核心规则就是“更新数据库时删除缓存,读不到缓存时回源数据库并写回缓存”。延迟双删本质上是Cache Aside的一种增强,增加了延迟补偿删一次。单纯Cache Aside的缺点是删除失败没有兜底,延迟双删解决了“删除动作虽成功但被旧值回写”的问题,两者配合起来更稳。

Write Through(写穿透)则是把缓存和数据库封装成“一个整体”,写入时先写缓存,再由缓存组件同步写数据库。这个模式缓存是主存储,数据库是同步目标,适合对缓存一致性要求极高的场景,但对缓存组件的可靠性和写入性能要求很高,一般业务不会轻易用它。

对比下来,延迟双删最大的优势是它不需要改造存储层,不需要引入复杂组件,用很轻量的方式把最终一致性做到比较可靠。适合绝大多数互联网后端场景。

5.2 基于Binlog订阅的最终一致性方案

延迟双删的替代方案之一是通过订阅MySQL Binlog来同步更新缓存。典型实现是Canal监听数据库的增量变更,解析binlog后把变更内容推送给MQ,再由消费者更新缓存。

这个方案的好处是:数据库是数据源,所有更新动作都会被binlog记录,删除缓存的操作不会被业务代码遗忘;即使业务进程崩溃,binlog也不会丢,消息队列可以保证事件最终被消费。缺点是链路变长,引入Canal和MQ,运维成本更高,而且从数据库变更到缓存删除之间有一定延迟,同样属于最终一致性。

如果系统已经有Canal或MQ,这个方案值得考虑。但如果你只是解决几个热点接口的缓存一致性问题,延迟双删明显更轻量。

5.3 怎么选,我给几个实际判断标准

选型没什么统一的银弹,我一般按下面几条来定:

第一,团队有没有成熟的MQ基础设施。有MQ,优先用MQ延迟消息做删缓存,可靠性高很多。没有MQ,又不想为了一个缓存问题引入新组件,那就用线程池+补偿表。

第二,数据更新的并发频率高不高。如果每秒更新几百次,延迟双删的第二次删除会让缓存频繁失效,数据库压力很大。这种情况更适合binlog订阅后按最终结果刷新缓存,而不是每个更新都删两次缓存。

第三,业务是否允许短暂不一致。允许的话,延迟双删和binlog方案都可以;不允许,只能上强一致方案,比如读写锁或者将业务逻辑收敛到单点串行处理。

我个人的经验是,先想清楚业务的一致性目标,再决定技术方案。很多项目其实只是需要“别让旧值长时间赖在缓存里”,那延迟双删就足够,没必要把架构搞复杂。

6. 最后分享一点个人的实战心得

我在不同项目里用过纯sleep版延迟双删、线程池版本、MQ版本和binlog订阅版本。如果只能保留一条经验,那就是:延迟双删的关键不在“双删”,而在“删除失败可补偿、延迟时间可计算、缓存回写可控制”。只要三个点做到位,这个策略非常稳。

另外一个小技巧是,在更新缓存时给key设置一个合理的过期时间,比如5分钟或者10分钟。这个过期时间不是为了让业务长期生效,而是作为最后一道保险。万一延迟双删所有兜底都失效了,过期时间也能保证脏数据不会永远存在。我见过一些团队把缓存key设置成永久不过期,一旦出问题,只能靠人工清理。这个习惯非常危险。

如果你正在改造缓存一致性方案,先试着从同步删除+简单sleep开始,把时序观察清楚,再逐步引入MQ和补偿机制。不要一开始就上重方案,也不要天真地以为多删一次缓存就能万事大吉。把并发窗口理解透了,这套策略会成为你工具箱里很顺手的一件工具。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询