Redis缓存一致性:延迟双删的原理、落地实现与最佳实践 📅 发布时间:2026/9/16 1:35:23 👁 浏览次数: 做后端的时间长了你会发现缓存一致性是个绕不开的话题。Redis 用得好不好很多时候不是看命中率有多高而是看缓存和数据库之间能不能对得上账。延迟双删就是大家最常挂在嘴边的一种对账手段但很多人对它其实是一知半解知道大概流程是“先删缓存、更新数据库、延迟再删一次”可真要落到代码里延迟时间设多少、什么场景不能用、第二次删除失败怎么办能讲清楚的人并不多。这篇文章我想把延迟双删这件事拆开揉碎聊一遍。我不打算给你讲教科书式的定义而是想结合我自己在真实项目里用 Redis 做缓存治理时踩过的坑和总结出来的经验把延迟双删的适用边界和落地细节一次讲透。如果你正在做缓存与数据库一致性的方案设计或者已经在用延迟双删但心里没底这篇应该能给你省下不少排查问题的时间。1. 延迟双删到底在解决哪种不一致1.1 缓存与数据库不一致的两条“经典路径”先把场景定义清楚。这里讨论的是最典型的缓存架构Redis 作为缓存层MySQL 或者 PostgreSQL 这类关系型数据库作为持久层。读请求先查 Redis命不中就查数据库然后把结果写回 Redis写请求直接改数据库同时需要让 Redis 里的旧数据失效。在这个模型下不一致的根源其实就一句话缓存和数据库是两套独立的存储系统没有事务能同时覆盖它们。你可能会想那我写请求里既更新数据库又更新缓存不就行了吗对不起不行。如果先更新数据库再更新缓存缓存更新失败数据库是新的缓存是旧的不一致。如果先更新缓存再更新数据库数据库更新失败缓存是新的数据库是旧的更乱。所以业界的主流实践是“删除缓存”而不是“更新缓存”让缓存彻底失效等下一次读请求再把新数据加载回去。但删缓存也分先后。先删缓存再更新数据库有一个非常经典的问题请求 A 删掉了缓存里的旧值正准备改数据库这时请求 B 来读数据发现缓存没命中于是去数据库里读到了还没被 A 改掉的旧值写回缓存。等 A 把数据库更新完缓存里躺着的还是 B 写回去的旧值。这就是先删后更的坑。先更新数据库再删缓存呢也存在一个时间窗口请求 A 更新完数据库还没来得及删缓存在这几毫秒内请求 B 读到了缓存里的旧值。窗口很小但确实存在。延迟双删就是一种针对“先删后更”这个路径的修正方案核心思路是用第二次删除来覆盖掉并发读在中间窗口期写回旧缓存的问题。1.2 延迟双删的完整流程与设计动机延迟双删的标准流程是这样的第一次删除缓存 key。更新数据库。休眠一小段时间。第二次删除同一个缓存 key。为什么第二次删除能解决问题回到 1.1 里那个失败场景A 先删缓存B 在 A 更新数据库之前读到了旧值并写回缓存。如果 A 在更新完数据库之后隔一小段时间再删一次缓存那么 B 写回的那个旧值就会被第二次删除清掉。只要第二次删除发生在 B 写回缓存之后缓存里存的就会是数据库更新后的最新值或者至少是空值等下一个读请求再去回源。这里的关键在于“延迟”这两个字。延迟的目的是等所有在第一次删除之后、数据库更新完成之前可能读到旧值并写回缓存的请求全部结束。如果你的系统里读请求耗时大约 50ms写库操作耗时大约 100ms那么第二次删除至少得等 150ms 之后再执行否则你第二次删除的时间点可能早于某些慢读请求写回缓存的时间点等于白删。我见过不少人把延迟双删写成这样public void updateData(String key, Object newValue) { redisTemplate.delete(key); database.update(newValue); Thread.sleep(100); redisTemplate.delete(key); }代码看着没毛病但里面有一个我现在看到就头大的问题同步 sleep。100ms 对单次接口来说不算离谱但如果你这个更新接口被调用得稍微频繁一点100ms 的阻塞会直接压在业务线程上TPS 上不去不说线程池还容易被打满。延迟双删这个方案本身没问题同步 sleep 的写法却是灾难性的。1.3 为什么必须拖那一小段延迟很多同学问第一次删完之后更新数据库紧接着再删一次不行吗为什么要睡一觉答案很简单因为第二次删除要覆盖的是“并发读在窗口期内写回旧值”这个动作而这个动作发生的时间点是不确定的。你可以把第一次删除和更新数据库之间的这个窗口理解成一道门。门开着的时候所有读请求都可能会把数据库里的旧值搬回缓存。如果你更新完数据库后立刻删缓存可能还有一部分读请求正卡在门里它们读到的还是旧值下一秒就把旧值写回缓存了。你的第二次删除反而跑在了它们前面旧值照样存活。所以必须等一段时间等这些请求全部走完再动手清理。延迟时间设短了等于没延迟问题依旧设长了缓存不命中的时间变长读请求会频繁穿透到数据库压力变大。要找到适合自己业务的窗口值就得对读请求耗时有清晰的统计而不是网上搜个 500ms 就天天用。关于这个参数怎么定我后面专门用一节来展开。2. 适用边界别拿延迟双删当万能药2.1 适合用延迟双删的业务特征延迟双删看起来很简单但它不是什么场景都能套的。我自己的判断标准是下面这几条同时满足的用延迟双删没问题第一业务能容忍短暂的不一致。延迟双删从第一次删除到第二次删除之间缓存里可能出现旧值也可能为空这个窗口内读到的数据不是最新的但它会在第二次删除后被修正。像商品详情、用户资料、文章内容这类数据秒级甚至百毫秒级的不一致用户基本感知不到完全可以用。第二读多写少。延迟双删的代价是删除两次缓存这会让缓存命中率在更新动作发生后的短暂窗口内下降如果写操作非常频繁缓存被反复删除读请求全打到数据库上数据库压力会非常大。所以延迟双删适合写少读多的场景频繁更新的热 key 不适合。第三缓存数据可以被重新加载。这意味着数据库里必须有完整的最新数据删除缓存后下一次读请求可以通过回源把新值重建起来。如果你的数据在 Redis 里做了额外加工比如存的是聚合统计结果、热点榜单之类的删掉之后回源重建的成本很高那延迟双删就不太划算你得考虑别的同步策略。2.2 不适合的场景强一致与高并发写最典型的不适合场景是强一致要求高的数据比如账户余额、库存扣减、订单状态。这类数据用户一旦查询到旧值可能直接引导出错误决策你没法跟用户解释“这只是缓存延迟”。延迟双删解决不了强一致问题它的上限只是最终一致而且这个最终一致还得靠第二次删除不失败才能保证。高并发写同一类数据也不适合。如果同一个 key 每秒被更新几十次延迟双删会变成一场灾难每个写请求都要删两次缓存但第一次删除后下一个写请求又来了缓存更新的节奏完全被打乱最后可能在很长一段时间里缓存里始终存的是某个中间状态的旧值。还有一类场景要注意如果缓存本身就是业务数据的唯一来源数据库只是备份那也不该用延迟双删。这种场景下缓存更新策略应该是主动写入保证缓存永远最新而不是删掉之后等回源。2.3 延迟双删与 Cache Aside 模式的关系很多文章会把延迟双删和 Cache Aside旁路缓存模式放在一起说但不太讲得清楚。我理解是这样的Cache Aside 模式的标准做法是读请求未命中缓存时回源写缓存写请求更新数据库后删除缓存也就是“先更库再删缓存”。这个模式本身已经能覆盖大部分场景它的盲点在于更新数据库和删除缓存之间存在一个极短的窗口可能会有读请求读到旧值。延迟双删实际上是 Cache Aside 的一种加厚版本它在“删除缓存”这个动作上加了保险。传统的 Cache Aside 只删一次延迟双删删两次用第二次删除来覆盖掉窗口期内的并发读回写。所以你不用担心延迟双删和 Cache Aside 冲突它们不是并列关系而是强化关系。我实际项目里的建议是核心业务优先按 Cache Aside 标准来做也就是更新数据库后直接删缓存删除动作加失败重试。只有在读请求非常频繁、窗口期容易放大导致旧值长时间残留时才引入延迟双删作为加强手段。这样可以把逻辑复杂度控制在合理范围内。3. 落地实现细节代码、参数与线程模型3.1 一个可用的延迟双删代码模板下面这个代码模板是我在项目里用过的简化版基于 Spring Boot 和 RedisTemplate。我把核心逻辑抽了出来方便你在自己的工程里改造。Component public class CacheDoubleDeleteSupport { Autowired private RedisTemplateString, Object redisTemplate; Autowired private ScheduledExecutorService delayedDeleteExecutor; /** * 执行延迟双删 * * param key 缓存key * param delayMillis 第二次删除的延迟时间需要根据业务评估 */ public void executeWithDoubleDelete(String key, long delayMillis, Runnable updateDbAction) { // 第一次删除 redisTemplate.delete(key); // 更新数据库 updateDbAction.run(); // 异步延迟执行第二次删除 delayedDeleteExecutor.schedule(() - { try { redisTemplate.delete(key); } catch (Exception e) { // 记录日志进入补偿重试 log.error(double delete cache failed, key: {}, key, e); retryDeleteAsync(key); } }, delayMillis, TimeUnit.MILLISECONDS); } private void retryDeleteAsync(String key) { // 这里可以接 MQ、本地消息表或简单的定时重试 // 简单做法再延迟 1s 重试一次超过 3 次则告警 } }你会发现我把第二次删除放进了线程池的延迟任务里而不是同步 sleep。这样业务线程更新完数据库就可以立即返回不会阻塞。delayedDeleteExecutor 的创建方式建议单独配置不要直接用业务线程池避免删缓存这种低优先级动作影响核心业务。3.2 延迟时间到底设多少才合理延迟时间是延迟双删方案里最容易拍脑袋的参数也是最容易出问题的参数。设短了旧值可能还在被并发读写回设长了缓存空窗期太长数据库压力上去了。我的做法是先统计两个耗时指标读请求从到缓存查到回源写缓存的完整耗时也就是一次读请求在缓存未命中情况下的处理时间记为 R。更新数据库操作的耗时记为 W。第二次删除的延迟时间 T 应该满足这个条件T W R 网络抖动余量。为什么这么算因为第一次删除后真正可能导致旧值写回缓存的读请求必须在数据库更新完成之前就把旧值读出来并写回缓存。数据库更新完成的时刻是 W而读请求写回缓存的时刻最晚不会超过发起读请求后的 R 时间。如果一个读请求在数据库更新完成之后才发起它读到的是新值不会写回旧值。所以关键的旧值写回时间理论上最大值就是 W R再留一点网络余量T 取比它大一些的值才保险。举个例子你的缓存回源接口平均耗时 80ms数据库更新平均耗时 50ms考虑到慢请求和网络波动余量再加 100ms那 T 至少应该设为 300ms 左右。如果你们系统读请求特别慢有 200ms 甚至更慢的那 T 就得往 500ms 以上放。这个值应该通过监控数据去调而不是拍脑袋。3.3 第二次删除尽量异步化把第二次删除放到异步任务里还有一个额外的好处即使删除动作本身失败也不会影响主流程。同步 sleep 的问题我在前面提到了这里再补一个容易忽略的点如果业务服务器因为重启、发布等原因在 sleep 期间进程被杀掉第二次删除根本没机会执行缓存一致性照样出问题。而异步延迟任务虽然也有丢任务的风险但至少不会把普通业务线程拖下水。如果你不想自己维护线程池也可以用 Redis 的过期时间做兜底第一次删除后更新数据库然后给缓存 key 设置一个很短的过期时间比如 2 秒同时异步延迟去删。就算第二次删除失败key 也会在过期时间后自动失效最终一致性的保证又多了一层。不过要注意这个做法会拉长不一致窗口只适合延迟容忍度高的场景。3.4 与分布式锁结合的常见场景当同一个 key 的写并发较高时单纯靠延迟双删很难保证顺序A 写旧值B 写新值两次删除混在一起最终缓存里可能是旧值也可能被删空但延迟双删自身无法区分哪个写请求的状态更接近最终态。这时候我见过不少团队会给更新操作加一把分布式锁把同一个 key 的写请求串行化。加锁后延迟双删的流程就变成获取分布式锁第一次删缓存更新数据库异步延迟第二次删缓存释放锁。注意顺序锁的持续时间必须覆盖第二次删除否则锁释放后下一个写请求进来两个请求的删除动作还是会交叉。锁的粒度建议精确到业务 key 级别不要用全局锁否则并发能力基本归零。这里要提醒一点分布式锁不是延迟双删的必需组件它只解决写写并发导致的顺序问题。如果你的业务更新频率不高完全不需要引入锁锁本身在 Redis 异常时还有失效风险反而增加了复杂度。4. 延迟双删常见的坑与补救方案4.1 第一次删除失败问题比想象中隐蔽很多人关注第二次删除失败却忽略了第一次删除也可能失败。第一次删除的作用是让后续的读请求不要命中旧值如果这一步失败了后面的更新数据库和第二次删除再完美也没用因为缓存里始终是旧值读请求不会发起回源第二次删除删了个寂寞。第一次删除失败的原因通常是这几类Redis 连接超时、Redis 执行命令超时、网络瞬断、误删了别的 key也就是 key 串了。解决方案加一层重试机制是最基本的同时要记录失败日志能做到告警更好。如果你有监控系统建议对“删除缓存失败”这个事件做专门的指标统计而不是只靠业务日志去翻。4.2 第二次删除失败怎么办重试链路的四种做法第二次删除失败是延迟双删方案里最经典的坑。延迟窗口之后旧值如果还在缓存里业务能容忍的短暂不一致就会变成长期不一致。我梳理一下补救手段按实现成本从低到高排第一种是同步重试。删除失败后立刻陷入一个重试循环可以设置重试次数上限比如 3 次每次间隔 200ms 左右。优点是实现简单缺点是重试过程依然占用业务线程而且如果 Redis 整体不可用重试再多也是空转。第二种是异步重试。先把失败 key 丢到一个内存队列或者 MQ 里由消费者去重试删除。好处是不阻塞业务线程坏处是引入了消息组件且内存队列在应用重启时会丢消息。第三种是本地消息表。把失败的 key 写进数据库由定时任务扫描再删。可靠性和复杂程度都更高一般核心业务才值得上。第四种是订阅 MySQL binlog 做异步删除。严格说这已经不是延迟双删的范畴了而是用一个独立组件监听数据库变更然后删除对应的缓存 key。这适合那些对一致性和可维护性要求都很高的场景。我实际项目里的大部分情况用“异步重试 缓存过期时间兜底”就能覆盖。毕竟延迟双删本身就不是强一致方案没必要为了它引入一套复杂的补偿系统。4.3 主从延迟会让第二次删除提前失效很多人是在 Redis 做了主从架构之后才发现延迟双删“不灵了”。原因在于如果读请求走的是 Redis 从节点而第一次删除只删了主节点的 key从节点上的旧值还在读请求依然可能命中从节点的旧值。就算你第二次删除在延迟之后执行把主从节点的 key 都删了但主从复制本身有延迟从节点可能在删除命令同步之前又被读请求写回旧值。这种情况下延迟时间不能只覆盖业务读耗时还要覆盖主从复制延迟。我的建议是把延迟时间适当放大比如在原计算结果上加 500ms 到 1s然后设置一个较短的缓存过期时间作为最后防线。如果你的业务对一致性要求已经敏感到了这个程度我会建议直接考虑 binlog 异步删除方案别在主从复制场景里硬撑延迟双删。4.4 常见问题速查表现象可能原因处理思路缓存长时间不更新第一次删除失败或第二次删除失败增加删除失败重试缩短缓存过期时间兜底更新完成后短时间读到旧值延迟窗口内的并发读正常回写确认是否业务可容忍判定延迟时间是否需要加长并发高时不一致概率明显上升写请求交叉两次删除被覆盖对同 key 写请求加分布式锁串行化主从架构下删除经常无效主从复制延迟大于第二次删除延迟调大延迟时间或者改用 binlog 异步删除方案接口 RT 明显上涨第二次删除用了同步 sleep改成线程池延迟任务异步执行Redis 抖动时数据不一致删除命令超时/连接失败做删除失败补偿不能只打日志不处理5. 从延迟双删走向最终一致性更稳的路径参考5.1 延迟双删不应该成为你的唯一方案延迟双删在真实项目里的定位我越来越觉得应该是一个“过渡方案”或者“兜底方案”而不是核心的一致性保障手段。原因很简单它靠的是时间窗口估算没有机制能保证第二次删除一定发生在最后一个旧值写回之后。窗口值设得再大理论上也会有极端慢请求超出预期。所以我在做缓存治理时会把延迟双删和几个基础手段配合起来用缓存 key 全部设置过期时间且过期时间不能太长最好在 5 分钟以内删除缓存动作加失败告警对核心 key 的更新操作尽量走异步删除补偿。这样即使延迟双删失效过期时间也会兜底不至于让脏数据无限期存活。5.2 基于 binlog 的异步删除为什么更稳如果你问我现在新项目要上缓存一致性方案我会优先推荐什么我会说是“更新数据库 监听 binlog 异步删除缓存”也就是很多人说的 binlog 增量订阅方案。核心思路是数据库写入成功后通过 Canal 或者 Debezium 这类组件订阅 binlog 变更事件拿到变更的数据主键然后去删除对应的缓存 key。这个方案的好处有两个一是删除动作不再依赖业务代码里的延迟估算binlog 事件是在数据库提交后才生成的天然保证了“数据库已经更新”随后执行删除缓存顺序天然正确二是删除失败可以靠消息队列重试可靠性比在业务线程里做重试高得多。代价是需要额外搭建 Canal/Debezium 组件增加运维成本但如果你已经有一个消息中间件在用这个成本其实可控。5.3 我的实践选择什么项目用什么方案如果是一个并发量不大、团队规模小的系统我会直接用延迟双删 短过期时间实现成本低出问题的概率也低。如果是一个中大型系统有专门的中间件团队核心数据的一致性要求又高我会在主链路上采用 binlog 异步删除方案同时保留缓存过期时间做最后兜底。延迟双删在这种体系里反而显得多余因为 binlog 方案已经覆盖了它想解决的问题。如果你现在正准备在代码里引入延迟双删我的建议是先想清楚下面三个问题你的业务能容忍多久的不一致你的读请求回源写缓存的耗时是否稳定如果第二次删除失败你有没有兜底手段这三个问题都有答案之后再动手写代码也不迟。