做后端的时间长了,你会发现缓存一致性是个绕不开的话题。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 RedisTemplate<String, 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 方案已经覆盖了它想解决的问题。
如果你现在正准备在代码里引入延迟双删,我的建议是先想清楚下面三个问题:你的业务能容忍多久的不一致?你的读请求回源写缓存的耗时是否稳定?如果第二次删除失败,你有没有兜底手段?这三个问题都有答案之后,再动手写代码也不迟。