ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

缓存一致性难题与延迟双删策略详解:从原理到工程实践

缓存一致性难题与延迟双删策略详解:从原理到工程实践 这一讲想聊一个后端老生常谈、但一聊就吵翻天的东西缓存一致性。尤其是那个被无数人提过又嫌弃过的“延迟双删”策略。我最早接触它是在一个电商中台项目里商品详情接口扛不住压力Redis缓存一上QPS是上去了但没过多久就出现了“改价不生效”“库存更新了页面还是旧数据”的投诉单半夜被叫起来查数据发现缓存里是旧值数据库里是新值两边谁也没比谁理直气壮。后来把延迟双删、消息队列补偿、版本号标记、订阅Binlog这几条路都试了一遍才算理清楚什么场景该上什么方案。如果你正在被缓存一致性折腾或者面试前想把这块补明白又或者刚把缓存引入系统还没踩坑这篇文章值得看完。我会把延迟双删的原理、代码怎么落、坑在哪、延迟时间到底怎么估、以及它和别的方案的关系讲透不绕弯子。1. 缓存一致性问题的本质1.1 缓存和数据库是怎么开始“打架”的先别急着聊双删先搞明白一个事缓存和数据库为什么会对不上。缓存本质上是数据库结果的“快照副本”它和数据库是两个独立的存储系统没有事务约束没有强一致协议。只要存在两条独立的写路径就一定会出现数据不一致的时间窗口。最常见的冲突发生在两个动作之间一个是更新数据库的写请求一个是把数据库内容回填到缓存的读请求。这俩如果并发发生节奏稍微错开一点就会造成“数据库已经是新值缓存里却回填了旧内容”的经典事故。我拆一个具体时间线给你看时间 T1写请求 A 更新数据库把商品价格改成 100 元。时间 T2读请求 B 在数据库里拿到旧数据 80 元此时 A 还没提交或者 B 的查询快照是旧的。时间 T3写请求 A 提交成功数据库里是 100 元。时间 T4读请求 B 把 80 元写入缓存。时间 T5后续所有读请求打到缓存上看到的都是 80 元但数据库里是 100 元数据不一致。直到缓存过期这个问题才被兜底消除。问题就出在 T3 和 T4 的并发窗口。如果不做任何缓存清理窗口有多长缓存就有多久是脏数据。在高并发下T2 和 T4 之间的距离可能只有几毫秒但这几毫秒里如果涌进来大量读流量脏数据就会被大规模复制到缓存里影响面远比单次读大。1.2 先更新库还是先操作缓存其实都走不通很多人第一反应是调整操作顺序。那我们把两条主线都走一遍。“先更新数据库再更新缓存”这条路问题非常明显。两个写请求并发时后写缓存的请求可能覆盖前一个请求的新值。比如线程 A 把数据库更新成 10线程 B 把数据库更新成 20此时线程 B 先更新缓存成功线程 A 再更新缓存缓存里最终是 10但数据库是最新值 20缓存被旧事务覆盖一致性直接崩。“先更新缓存再更新数据库”更不靠谱缓存一旦更新成功、数据库执行失败缓存里就是一个数据库中不存在的数据读到的基本就是幻觉数据。“先更新数据库再删除缓存”这就是经典的 Cache Aside 模式。它比前两条路都安全因为删除缓存的操作天然是幂等的删除总比覆盖安全。但读者会问删除缓存就能保证一致吗答案是不能因为还要处理读请求在删除之前把旧数据回填进缓存的并发窗口就是我上面画的 T2 到 T4 那段。这里顺带说一句网上有人争论“更新缓存”和“删除缓存”哪个好。从一致性角度我强烈建议你删缓存而不是更新缓存。更新缓存存在两个问题一是读请求回填的旧值可能覆盖你更新的新值二是一些复杂的缓存结构比如 Hash、ZSet更新成本高很容易写出并行覆盖 Bug。删除缓存虽然会造成一次缓存 miss但它是幂等操作配合重试机制收敛性要比更新缓存好得多。1.3 为什么单纯加过期时间救不了你有人会觉得反正缓存都有过期时间不一致只是暂时的忍忍就过了。这话在非核心业务上说得通但核心数据在缓存过期之前可能已经被大量读到了。一旦脏数据被复制到缓存哪怕过期时间是 30 秒30 秒内所有用户请求都会读到错误数据。在电商大促场景下300 个 QPS 持续 30 秒脏数据影响面就是 9000 个请求这还没算缓存 TTL 被续期的雪上加霜。所以缓存一致性方案的核心目标是尽量缩短脏数据的存在时间并且在无法完全消除的时间窗口内提高快速矫正的概率。延迟双删就是在这个思路上做文章的。2. 延迟双删的核心原理2.1 双删方案的两个阶段拆解延迟双删字面意思就是删两次第二次延迟后再删。它的核心思路是基于对并发时序的补偿。第一次删除发生在数据库更新之前目的是清掉上一次读请求可能回填的旧缓存。第二次删除发生在数据库更新之后延迟一小段时间目的是把那个时间窗口内可能回填进去的旧值再次清掉。第二次删除的目标不是普通读请求而是针对“旧值已经被回填”的破坏性窗口。整个流程完整走一遍是这样的写请求进入服务。先执行缓存删除第一次删除。再执行数据库更新。开始计时等待一个延迟时间。延迟结束后再执行一次缓存删除第二次删除。第二步的删缓存是为了给后续读请求一个空窗口让它们去数据库读新值。第三步更新数据库后读请求在延迟窗口内依旧可能读到旧值并写回缓存所以第四步的第二次删除是关键补偿。但这里有个疑问为什么不是更新数据库后立刻删而是要延迟原因在于一个读请求从数据库读取成功、到把数据写回缓存这个过程是有 IO 时间成本的。如果更新数据库后在 1 毫秒内立即删除那个正在执行“数据库读取-网络传输-缓存写入”的读请求很可能正好在第二次删除之后把旧值写进缓存删除动作就白做了。延迟的目的就是让这个并发窗口内的“最后写入者”把脏数据写完之后再去清除它。2.2 延迟时间到底怎么估算延迟时间定多少是延迟双删方案里最需要动脑子的一步。它不是拍脑袋定一个 500ms 就行而是要根据具体的业务来算。要覆盖的并发窗口长度由两部分组成读请求从数据库读取数据到写回缓存的总耗时加上一个安全裕度。读请求的耗时包括网络传输时间、数据库查询时间、缓存写入时间。在大部分内部服务架构中这个耗时一般不会超过几十毫秒但网络出现抖动、数据库慢查询出现时会显著放大。我一般会给一个公式参考延迟时间 ≥ 一次完整读请求的耗时时长峰值 网络抖动安全阈值举个例子你的服务读请求平均耗时是 30ms峰值是 100ms那延迟时间至少是 100ms 加上 200-300ms 的余量取 500ms 左右是比较稳妥的。延迟时间太短等于没删对并发窗口的覆盖不够延迟时间太长写请求的响应时间会被拖慢。注意延迟的时间是在更新数据库之后、返回写请求之前加的一段阻塞等待这段等待会直接拖长写接口的响应时间对延迟敏感的业务来说这是个明显代价。我也见过一种高吞吐的设计把第二次删除动作挪到异步线程池去执行写请求更新数据库后立刻返回由后台异步任务延迟 500ms 后再删缓存。这样写请求 RT 不会被延迟拖累但异步任务和写请求的生命周期解绑了需要额外处理任务失败、应用重启丢任务的问题工程复杂度有所增加。2.3 为什么“先删一次再删两次”存在边界问题不是所有场景都适合双删。双删解决的是“读写并发时序交错”的缓存一致性问题但在极端并发下双删也无法做到百分百保证一致。举个例子如果读请求特别慢慢到超过了延迟时间那么在第二次删除之后它依然可能把旧值写回缓存造成新一轮脏数据。这种场景下你需要额外的手段去兜底比如给缓存加一个主动标记版本的机制。所以严格的讲延迟双删不是一个百分之百强一致的方案而是一个把不一致概率降到很低的工程方案。这个认知很重要。网上有些文章把双删吹成了“缓存一致性银弹”实际做过的人都知道它只是一个概率收敛方案能保障的是在绝大多数常规并发时序下一致。这也就是为什么我在后面会讲到生产环境往往需要双删配合重试、版本号、Binlog 订阅来组合使用。3. 延迟双删的工程实现方案3.1 一个最小可落地的同步实现最小可落地的方案不引入额外中间件直接在一个业务方法里写同步逻辑。我用一个 Java 实现的示例来讲这个代码逻辑十分直观。Service public class ProductServiceImpl implements ProductService { Autowired private StringRedisTemplate redisTemplate; Autowired private JdbcTemplate jdbcTemplate; private static final String PRODUCT_CACHE_KEY product:detail:; private static final long DELETE_RETRY_DELAY_MS 500; Transactional public void updateProductPrice(Long productId, BigDecimal newPrice) { String cacheKey PRODUCT_CACHE_KEY productId; // 第一次删除清掉旧缓存让后续读请求重新加载 redisTemplate.delete(cacheKey); // 更新数据库 jdbcTemplate.update(UPDATE product SET price ? WHERE id ?, newPrice, productId); // 延后再删一次覆盖读请求并发回填脏数据窗口 new Thread(() - { try { Thread.sleep(DELETE_RETRY_DELAY_MS); redisTemplate.delete(cacheKey); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }).start(); } }这个实现非常简陋但可以帮你快速理解双删的骨架。代码里有两个明显问题第一每次更新都创建新线程在高并发下线程开销很大第二没有处理删除失败的情况。这只是教学演示真正落生产环境至少要做线程池化和失败重试。3.2 生产级实现线程池 延时补偿生产环境的核心原则是能用现成的线程池绝不自己 new Thread删除操作要有重试补偿机制。写请求侧把第二次删除的任务提交到定时线程池由线程池统一延迟执行避免频繁创建线程。使用 ScheduledExecutorService 可以很方便地做延迟调度。下面是一个简单的生产级改造示例Component public class CacheDelayedRemover { private static final ScheduledExecutorService SCHEDULER Executors.newScheduledThreadPool(10, r - { Thread t new Thread(r, cache-delayed-remover); t.setDaemon(true); return t; }); private final StringRedisTemplate redisTemplate; public CacheDelayedRemover(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } public void submitDeleteTask(String cacheKey, long delayMs) { SCHEDULER.schedule(() - { try { redisTemplate.delete(cacheKey); } catch (Exception e) { // 删除失败进入重试补偿逻辑 handleRetry(cacheKey); } }, delayMs, TimeUnit.MILLISECONDS); } private void handleRetry(String cacheKey) { // 简单重试最多重试3次每次间隔200ms for (int i 0; i 3; i) { try { Thread.sleep(200); Boolean deleted redisTemplate.delete(cacheKey); if (Boolean.TRUE.equals(deleted)) { return; } } catch (Exception ex) { Thread.currentThread().interrupt(); } } // 如果重试3次仍失败发告警或者投递到死信队列人工处理 log.warn(cache delete failed after retries, key: {}, cacheKey); } }与之配套写请求方可以改成异步抛出删除任务让写接口快速返回。我把最核心的一点放在这里延迟删除的任务本质上是一个“最终一致性补偿任务”它可能丢失所以一定要搭配告警或者消息队列做闭环。如果你是小型业务系统用调度线程池重试 3 次就够了中型以上团队建议从延时队列或消息队列这条路走。3.3 结合 Redis 的伪删除与版本防竞争延迟双删也可以考虑一些“非主流”但有效的变体。比如不直接删除缓存而是去修改缓存里的占位标记或者执行“伪删除”把 value 变成空结构让业务读取时主动感知“数据已变更”。这个思路往往能降低缓存重建时的并发冲击。原型做法是第二次删除成功后设置一个短暂的逻辑过期标记比如 1 秒的空缓存。在标记存在期间读请求不直接把结果写回缓存而是排队等待写请求的通知或者返回默认值。这相当于把“双删”变成了“SB 随删随回”的模式对读多写少但有强提示需求的业务很管用。不过我必须提醒你这种变体引入了一个新的复杂度同一缓存 key 的读写双方需要协商一个“占位标记”的语义而且标记本身也需要清理。如果标记没清干净后续正常读流量也会被拖累。所以对于常规系统我建议还是走标准双删不要一上来就搞标操作。4. 延迟双删在真实业务中的坑与优化4.1 延迟时间是拍脑袋定的“延迟时间不用太精确吧”这是我听过最多的话。延迟时间的设定直接决定方案的有效性拍脑袋定 500ms 不是不行但要验证。我的建议是先根据日志统计出读请求耗时 P99 和 P999 值。如果 P99 是 50msP999 是 300ms那延迟时间就要往 300ms 以上走。最好再加一半的余量。我实际遇到过一个情况表数据量一大MySQL 偶尔出现 1 秒以上的慢查询读请求的耗时直接被拉爆原来定的 500ms 延迟完全不够用第二次删除之后旧值还会被慢读线程回填。后来只能把延迟调到 1500ms代价是写请求 RT 变差。用异步化改造后写接口 RT 才恢复正常。所以延迟时间必须基于峰值来定而不是平均值。4.2 删除缓存失败怎么办延迟双删最容易被忽略的隐藏问题是第二次删除失败。Redis 操作虽然是 O(1) 级别但网络抖动、连接池耗尽、key 被误删、Redis 主从切换都可能造成删除失败。一旦第二次删除失败缓存里残留的脏数据就会缓存到 TTL 到期。解决思路有三条路一是给删除操作加重试机制这是必须的内存里的简单重试、对账任务都可以。二是把删除失败的任务落到本地数据库一张待删表由清扫任务轮询重试直到成功。三是订阅 Redis 的 key 失效事件对“应删但没删掉”的 key 进行兜底清理。第一条最简单工程上足够覆盖绝大多数场景第二条成本中等能保证最终删除第三条依赖 Redis 的事件通知机制配置和运维成本略高。看团队规模选择即可。4.3 分布式环境下的并发放大问题延迟双删在处理单个缓存 write read 并发时表现不错但多个线程同时更新同一个 key 时情况会变得复杂。举例线程 A 和线程 B 同时并发更新商品价格分别更新为 10 和 20。假设线程 A 先删缓存、再更库为 10线程 B 后删缓存、再更库为 20。由于两次更新交替缓存可能在某个时间点被线程 A 或 B 的延迟删除反复清掉也可能在 B 第二次删除前被某个读请求用 10 块的新数据回填结果库里是 20缓存里是 10又脏了。这种多写并发场景光靠双删是压不住的。需要引入“版本号”机制让缓存值携带数据库的版本标识读请求发现版本不一致主动丢弃数据。版本号方案我会在下一节展开讲这里先留个钩子延迟双删擅长处理“读写并发”不擅长处理“多写并发”后者需要更强的手段。4.4 缓存击穿和缓存雪崩风险延迟双删每次都会导致缓存 miss读请求会直接打到数据库。如果一个热点 key 被频繁更新延迟双删会频繁删缓存大量读流量瞬间涌入数据库极可能导致数据库连接被打满这就是缓存击穿。要降低风险可以把第二次删除之后的缓存重建过程做一个保护。常见做法是在缓存重建时加互斥锁同一时刻只有一个线程去数据库加载数据并回填缓存其他线程短暂等待或返回旧值。这个做法和延迟双删是天然搭档一个负责清理一个负责防击穿组合在一起才是一个比较完整的生产级方案。4.5 阈值与监控你怎么知道方案有效交付一套延迟双删机制不能只写代码就完了还要有可观测性。我建议从三个维度打日志和指标第一次删除的耗时、成功率和删除时是否命中 key。第二次删除的延迟执行队列积压量、执行成功率和重试次数。数据库更新完成到第二次删除完成的时间间隔分布。如果发现第二次删除成功率长期低于 99%先检查 Redis 连接和超时时间。如果发现删除队列积压超过阈值检查线程池大小和调度频率。缓存一致性问题最怕黑盒一定要把每次删除动作的日志打出来出了事才有据可查。5. 延迟双删的进阶替代与结合方案5.1 Cache Aside 延迟双删的关系先明确一个概念延迟双删不是一个独立的架构模式它更接近 Cache Aside 模式的一种补偿增强。Cache Aside 标准套路是先更新数据库再删除缓存而延迟双删在删除缓存前面加了预处理在更新数据库后面加了延后补偿。从系统演进的角度它是在 Cache Aside 方案上的一个高可用改进。如果你团队里有人问“我们已经在用 Cache Aside还需要延迟双删吗”我的建议是如果你们的业务允许偶发一秒内的缓存不一致Cache Aside 够用如果业务要求不一致窗口尽可能短而你们又不想上更重的组件就值得引入延迟双删作为增强。5.2 基于消息队列的最终一致性方案比延迟双删更重但更可靠的方式是把缓存删除动作投递到消息队列由消费者在拿到消息后异步删除缓存。这样写请求不用阻塞等待延迟时间消息队列天然提供了可靠投递、重试机制。流程示例写请求更新数据库成功后发送一条“删除缓存”的消息。消息消费者收到消息后延迟一小段时间可以用 MQ 的延迟消息能力或者消费者内 sleep再执行 Redis 删除。如果删除失败MQ 的重试机制会自动重投。这种方案的优点是把“延迟删”从内存里挪到了消息队列中可靠性高。缺点是需要额外引入 MQ 组件运维和部署成本上升而且并非所有 MQ 都支持延迟消息开源版 RocketMQ 需要安装延迟插件Kafka 原生不支持需要自己做时间轮。5.3 基于 Binlog 订阅的旁路更新方案如果想要更彻底的一致性可以考虑监听数据库的 Binlog在数据变更后由消费组件比如 Canal把变更事件推送到 MQ再由专门的服务更新缓存。这种方案把缓存更新逻辑和业务代码解耦不侵入写接口一致性保障能力也更强。但它的缺点也很明显引入额外组件变多、开发链路变长、排错成本变高不是所有业务都需要走到这一步。我的经验是业务体量小、并发不算高但一致性要求不苛刻用 Cache Aside 延迟双删业务量大一致性和稳定性要求都高且团队有 MQ 和中间件运维能力再考虑消息队列或 Binlog 订阅方案。5.4 版本号方案跨过双删的死角前面我提到多写并发是延迟双删的死角版本号机制正好可以补齐这一块。设计思路是在数据库表中设计一个乐观锁版本号字段每次更新数据库时 version 加 1缓存中同时存放 value 和对应的 version。读请求读取缓存时发现缓存中的 version 与数据库最新 version 不一致就丢弃缓存中的值去数据库加载新数据并回填。这个方案可以完全绕过“双删是否可以覆盖所有读写时序”的问题因为读请求本身会校验版本一致性。但它的成本也很明显数据库表要加字段缓存结构从 String 变成了携带版本号的复合结构读逻辑多一步版本比较而且数据库每次更新都要多查或返回一次版本号。综合消耗不小。所以我的建议是大多数场景先用 Cache Aside 延迟双删 缓存锁兜底版本号方案作为极端场景的升级手段而不是默认方案。6. 常见问题与排查技巧实录6.1 延迟多久才合理最佳答案在你的日志里不在任何文章里。先用监控统计单个读请求从发起查询到缓存写入完成的耗时取 P99 或者 P999 加上 200ms-500ms 安全裕度作为延迟时间。建议直接从 500ms 起步测试观察不一致投诉率和缓存命中率的变化再逐步微调。6.2 为什么删了缓存还是读到旧数据排查顺序分五步先看第二次删除是否执行成功日志和 Redis 慢命令监控有没有删除命令。再看延迟线程池是否拒绝了任务是否出现线程池队列满。检查 Redis key 是否为多个写线程并发更新是否跨过双删保护边界。检查读请求是否有缓存更新时间戳查询是否走了别的旁路通道。看数据库连接层是否有读写分离延迟读请求查的是从库从库同步还没完成。6.3 双删和分布式锁怎么结合如果你想尽量减少多写并发下的不一致概率可以在更新业务上先加分布式锁保证同一时刻只有一个写请求在操作同一个 key。锁粒度要能精确到“业务 key 字段”例如product:price:123。分布式锁的作用是把多写退化成语义上的串行写双删的第二次删除就有了明确的线性时序。不过加锁也有性能损耗适合写并发本身不高的场景。6.4 双删会牺牲多少写性能同步双删的延迟会直接加到写请求耗时上比如延迟 500ms写接口 RT 大概率会上升几百毫秒这对大部分后台管理系统可能无所谓但对开放 API 或支付回调类接口可能不可接受。异步双删可以修复这个问题但一定要考虑丢任务的场景。如果丢失后没有兜底手段那第二次删除基本是在赌运气。6.5 延迟双删是否可以完全替代缓存过期时间不可以。缓存过期时间仍然是最后一道安全兜底。无论双删重试做得多好总有极端情况进程被杀、网络分区、Redis 挂了会导致删除失败。没有 TTL 的缓存就像没有保险的账户一旦出错脏数据会被无限期读到。所以双删可以缩短脏数据窗口但请保留 TTL 做最终防线。7. 我的最终建议延迟双删不是高深技术但它能帮我们重新理解“缓存一致性”这件事的麻烦程度。我做了这么多年后端最大体会是一致性方案没有银弹每个方案都有代价。延迟双删的代价是延迟时间难估、删除失败要补偿消息队列方案的代价是引入新组件和运维复杂度Binlog 订阅的代价是链路过长、排障麻烦。对大多数读多写少、一致性窗口容忍度在 1 秒内的业务Cache Aside 加上延迟双删、缓存重建锁和 TTL 兜底已经是一套非常成熟的组合打法。最后再给一个小技巧正式上线前拿压测工具模拟“更新数据库 并发读缓存”的混合流量脚本里故意把读请求的查询时间拖慢比如造个慢 SQL验证第二次删除在极端并发下能不能兜住。这一步跑通了你的方案才算真正落地可靠。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表