
做后端开发的一定被缓存和数据库不一致的问题折磨过。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 标准执行流程拆解延迟双删的标准流程一句话就能概括更新数据库删除缓存等待一段时间再次删除缓存。拆开看是这样的先执行数据库更新并且确保事务提交成功。事务提交后立刻删除一次缓存。线程或者异步任务休眠一段预设的时间。休眠结束后再一次删除缓存。有人会问第一次删除缓存不就已经把旧值删掉了吗第二次删除到底在防谁防的是第一次删除到第二次删除之间可能出现的“旧值回写缓存”。我们把时间线拉直数据库更新完成假设在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消费者收到消息后再删除一次缓存。这里给一个简化的流程描述更新数据库事务提交。同步删除Redis缓存。发送一条带延迟的MQ消息payload里带上cacheKey和业务标识。MQ延迟队列在指定时间后将消息投递给消费者。消费者收到消息后执行第二次缓存删除。如果删除失败根据失败次数决定重试或者记录告警由补偿任务兜底。使用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连接池出现排队现象一次回源写缓存耗时接近400ms300ms根本覆盖不住。之后我把时间改成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和补偿机制。不要一开始就上重方案也不要天真地以为多删一次缓存就能万事大吉。把并发窗口理解透了这套策略会成为你工具箱里很顺手的一件工具。