ARTICLE DETAIL

资讯详情

深耕网站SEO优化与搜索引擎排名提升的一线实战洞察。

缓存不一致难题:延时双删策略的原理、实现与工程实践

缓存不一致难题:延时双删策略的原理、实现与工程实践 1. 从一次诡异的缓存不一致说起那天下午系统监控突然报警一个核心商品详情页的访问量在几分钟内飙升但转化率却断崖式下跌。我第一反应是缓存击穿赶紧去看Redis缓存键都在命中率也正常。但用户反馈的截图让我心里一沉页面上显示的商品库存是100件用户下单时却提示“库存不足”。这明显是数据库里的真实库存可能已售罄和缓存里展示的库存还是旧的对不上了——经典的缓存与数据库数据不一致问题。我们团队用的也是最常见的“Cache-Aside”模式也就是先读缓存缓存没有再读库然后回写缓存。更新数据时也是先更新数据库再删除缓存。理论上这套流程在并发不高时是没问题的。但线上流量一大各种时序上的“巧合”就会让这个小概率事件变成必然。我排查了当时的日志发现就是在一次商品库存更新比如秒杀扣减时虽然先更新了DB再删了缓存但在删除缓存后、新缓存建立前的那一刹那有大量请求涌进来穿透去读了数据库的旧值然后又把这个旧值塞回了缓存导致脏数据“长生不老”。这个问题就是“延时双删”策略要解决的核心场景。它不是银弹但在特定并发场景下是性价比极高的解决方案。今天我就结合这次踩坑把“延时双删”的里里外外、为什么需要它、以及怎么用好它掰开揉碎了讲清楚。2. 缓存不一致的“罪魁祸首”并发读写下的时序陷阱要理解“延时双删”必须先看清它要对付的敌人是什么。很多人觉得“先更新数据库再删除缓存”已经能保证一致性了其实不然。在高并发下这个操作组合会暴露出一个时间窗口让数据“错位”。2.1 经典“先更新DB再删除Cache”的漏洞我们把这个过程拆解假设有两个线程或进程在几乎同时操作线程A写操作执行更新比如UPDATE product SET stock stock - 1 WHERE id 1。此时数据库里stock从 100 变成了 99。线程B读操作在线程A更新DB后但删除缓存前的这个极短间隙内发起了一个读取商品1的请求。此时缓存里还是旧的库存值100。线程B发现缓存有数据虽然是旧的直接返回给了用户。用户看到了错误的库存100。线程A继续执行删除缓存键product:1。线程C另一个读操作在线程A删除缓存后发起读取请求。此时缓存为空于是线程C去查询数据库读到了最新的库存99然后将{stock: 99}写回缓存。看起来从线程C之后所有请求都能读到正确值了问题就出在线程B读到的旧数据已经影响到了业务比如错误地引导了用户但这还不是最严重的。更隐蔽的问题发生在后续。2.2 致命组合缓存删除与缓存重建的竞态条件上面场景中线程C把正确数据写回了缓存似乎修复了问题。但考虑下面这个更复杂的时序这才是“延时双删”要解决的核心难题线程A写更新数据库stock99。线程A删除缓存product:1。线程B读此时缓存已被删除缓存未命中。线程B去查询数据库。注意由于网络延迟、数据库负载、GC停顿等原因线程B的查询请求可能在线程A的更新提交之后才到达数据库但它读取到的数据可能仍然是旧版本例如如果数据库是主从架构线程B的查询可能被路由到了尚未同步完成的从库。我们假设这里线程B读到了旧的 stock100。线程B将读到的旧数据{stock: 100}写入缓存。结果缓存中被错误地塞入了旧数据stock100并且由于缓存有过期时间这个脏数据可能会持续存在一段时间影响所有后续的读请求直到缓存过期或被再次更新。这个场景的关键在于在线程A删除缓存之后到线程B将新其实是旧数据写入缓存之前存在一个时间窗口。如果有其他请求在这个窗口内穿透到数据库并且读到了尚未完全同步的旧数据就会用旧数据污染缓存。“先更新DB再删除缓存”的策略无法避免因数据库主从延迟、或线程调度导致的“读旧数据写缓存”问题。注意这里说的“旧数据”不一定是很久以前的数据。在极高并发下哪怕主从延迟只有几毫秒或者线程A更新提交后、线程B的查询在极短时间内被调度执行都可能读到未更新的视图。这在高并发秒杀、热点更新场景下几乎必然发生。3. “延时双删”的作战方案主动清场与二次确认“延时双删”就是为了填补上面那个致命的时间窗口而设计的。它的核心思想很直接既然一次删除后可能还有“漏网之鱼”旧的缓存数据被重建那我就在一个确定旧数据重建已经完成的时间点之后再删一次确保清理干净。它的标准操作时序如下第一次删除在更新数据库之前先删除缓存。执行更新执行数据库更新操作。延时等待主动等待一个短暂的时间比如几百毫秒到1秒。这个时间的目的是让所有在第一次删除缓存前就可能已发起的、可能读到旧数据的并发读请求完成它们的“读库-写缓存”操作。第二次删除延时结束后再次删除缓存。3.1 为什么是“先删缓存再更新数据库”你可能会注意到第一步的顺序变了。在经典策略里我们是“先更新DB再删缓存”。而在延时双删的第一步我们采用了“先删缓存”。这是为什么这其实是为了解决另一个并发问题在“先更新DB再删缓存”下如果删除缓存失败缓存就会一直脏下去。而“先删缓存”即使失败代价也更小只是导致一次缓存穿透数据仍是正确的。但更重要的原因是配合双删策略第一步删除是为了清空战场让所有后续的读请求都直接穿透到数据库。这样当我们在更新DB后延时再删第二次时目标就非常明确——清除掉那些在“清空战场”后仍然可能因为主从延迟等原因而用旧数据重建的缓存。如果把两次删除连起来看第一次删除宣告“我要更新了所有缓存都失效都去读DB吧”。更新DB第二次删除在给足时间让那些“读旧DB”的请求写完缓存后进行“清扫”确保缓存里是最新数据或为空等待下一次正确读取。3.2 关键参数“延时”到底多久这是“延时双删”的灵魂也是最多人困惑的地方。延时时长t不是随便设的它必须大于一个关键值“主从数据库同步延迟时间” “一次读业务操作耗时”。主从同步延迟你的数据库从库同步主库数据需要时间。在云服务或容器化环境下这个延迟可能不稳定通常建议取一个经验值比如 200ms 到 500ms。你可以通过监控数据库的Seconds_Behind_Master等指标来评估峰值延迟。一次读业务操作耗时指的是一个读请求从发现缓存缺失到查询数据库再到执行完业务逻辑、最后将数据写入缓存的整个时间。这个时间可以从应用监控中获取如链路追踪的跨度时间。所以一个经验公式是延时时间 t 平均主从延迟 平均读业务耗时 缓冲时间(100-200ms)。例如你的系统平均主从延迟是 50ms一次读业务操作包括网络IO、序列化平均是 20ms那么t可以设置为50 20 100 170ms通常取整为 200ms 是一个比较稳妥且常见的值。实操心得不要为了“绝对安全”而把延时设得特别长比如好几秒。这会显著增加写操作的响应时间影响用户体验。双删本身是一种权衡用写性能的轻微损失换取更高的一致性保证。通常在业务能容忍的范围内比如写接口RT增加200ms将t设置在 200ms 到 1s 之间是常见的做法。对于一致性要求极高的金融类业务可能会结合更复杂的方案如串行化队列而不是无限增大t。4. 代码实现与工程化要点理解了原理我们来看看怎么在代码里实现它。这里以Java Spring Boot环境为例提供一个模板化的实现思路。注意这只是一个示例真实场景需要根据你的技术栈进行调整。4.1 基础实现模板我们通常会在Service层的方法上通过AOP面向切面编程或直接编码来实现双删逻辑。Service public class ProductService { Autowired private ProductMapper productMapper; Autowired private RedisTemplateString, Object redisTemplate; // 商品库存扣减示例 Transactional public void deductStock(Long productId, Integer quantity) { String cacheKey product: productId; // 1. 第一次删除缓存 redisTemplate.delete(cacheKey); log.info(第一次删除缓存: {}, cacheKey); try { // 2. 执行数据库更新 productMapper.decreaseStock(productId, quantity); // 假设这是个更新操作 // 这里可以包含其他业务逻辑... } finally { // 3. 延时后第二次删除缓存 // 使用异步任务避免阻塞主线程。这里用Spring的Async示例需配置线程池。 asyncDoubleDelete(cacheKey); } } Async(taskExecutor) // 指定一个线程池 public void asyncDoubleDelete(String cacheKey) { try { // 延时例如200毫秒 Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error(延时双删等待被中断, e); } // 第二次删除 Boolean result redisTemplate.delete(cacheKey); log.info(第二次删除缓存: {}, 结果: {}, cacheKey, result); } }4.2 工程化进阶考量直接把Thread.sleep写在业务代码里是很初级的做法在生产环境中需要考虑更多1. 异步化与线程池隔离第二次删除必须异步执行绝不能阻塞写请求的响应。如上例所示使用Async或手动提交到线程池。务必为这个双删任务配置独立的、有界队列的线程池防止因为缓存删除慢如Redis网络波动导致线程池耗尽拖垮整个应用。Configuration EnableAsync public class AsyncConfig { Bean(doubleDeleteExecutor) public Executor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); // 设置队列容量防止内存溢出 executor.setThreadNamePrefix(double-delete-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 拒绝策略由调用者线程执行 executor.initialize(); return executor; } } // 使用时指定Bean名称 Async(doubleDeleteExecutor) public void asyncDoubleDelete(String cacheKey) { ... }2. 删除失败的重试机制网络抖动可能导致删除失败。对于第二次删除尤其是异步的需要增加重试逻辑。可以使用Spring Retry注解或更灵活地将删除任务丢入一个可靠的消息队列如RocketMQ、Kafka由消费者保证最终删除。// 使用Spring Retry (需引入spring-retry依赖) Async(doubleDeleteExecutor) Retryable(value {RedisCommandTimeoutException.class}, maxAttempts 3, backoff Backoff(delay 100)) public void asyncDoubleDeleteWithRetry(String cacheKey) { // ... sleep ... redisTemplate.delete(cacheKey); }3. 延时时间的动态化将延时时间t配置在配置中心如Nacos、Apollo而不是硬编码。这样可以根据数据库监控指标主从延迟动态调整。4. 防止“双删风暴”如果一个热点key被频繁更新会导致频繁的双删操作。可以考虑对同一个key的第二次删除请求进行“合并”或“去重”。例如在异步删除前先检查一下这个key是否在最近比如t时间内已经被计划删除了如果是则跳过本次任务。这需要借助一个共享的内存缓存如Caffeine或Redis set来记录待删除的任务。5. “延时双删”的适用边界与常见误区没有一种方案是万能的“延时双删”也不例外。它有效但也有明显的代价和局限。5.1 适用场景读多写少但写并发依然不低这是它的主战场。如果写极少用简单的“先更新DB再删缓存”可能就够了。对一致性要求较高但无法接受“读写锁”或“串行化”带来的性能骤降双删是一种折中一致性强度介于“最终一致”和“强一致”之间。存在数据库主从架构且有一定同步延迟这是触发缓存脏数据重建的主要诱因双删专门为此设计。业务上能容忍写请求有少量延迟增加因为引入了延时等待。5.2 不适用场景与代价写极其频繁的场景例如一个计数器每秒被更新上万次。频繁的双删会导致缓存几乎永远无效失去缓存意义同时给Redis带来巨大压力。这种场景可能需要考虑直接操作缓存或者使用其他一致性方案。对写操作响应时间极度敏感即使异步化第一次删除和更新DB的操作仍然是同步的且整个写链路变长RT响应时间会增加t(延时时间) 异步调度开销。无法容忍任何短暂的数据不一致双删只能极大降低不一致窗口和概率无法做到100%的强一致。在延时t内读请求仍然可能读到旧数据因为缓存空读到了尚未同步的从库。如果业务要求强一致需要考虑“读写都走主库”、“使用分布式锁在更新期间阻塞读”或“使用数据库事务与缓存事务如Redis事务但非绝对”等更重型的方案代价是性能。系统复杂度增加引入了异步任务、重试、线程池管理、延时参数调优等复杂度。5.3 必须绕开的坑误区一双删可以保证强一致。这是最大的误解。双删只是一种“最终一致性”的优化手段它通过主动清理加速了数据一致的过程并减少了脏数据存在的窗口。在延时期间不一致依然存在。误区二延时时间越长越好。前面已经分析过过长的时间会损害写性能需要根据实际监控数据找到一个平衡点。误区三同步执行第二次删除。绝对禁止这会让你的写接口RT暴增并发量上来后直接拖垮服务。误区四不处理删除失败。尤其是第二次异步删除必须有失败重试或补偿机制否则策略就失效了一半。误区五滥用双删。对于所有写操作都上双删。应该只对核心的、对一致性敏感的业务数据使用。对于不重要的配置信息、用户非关键数据使用简单的删除策略甚至设置较短的过期时间即可。6. 方案对比与选型思考在实际架构选型时“延时双删”只是众多缓存一致性方案中的一员。把它放在整个图谱里看能更清楚它的位置。方案核心逻辑一致性强度性能影响写复杂度适用场景Cache-Aside (先更新DB再删缓存)经典模式先写库后删缓存。弱最终一致。存在前述的并发读写脏缓存问题。低仅一次删除低写并发极低或可接受短暂不一致的业务。Write-Through (写穿透)同时更新缓存和数据库通常缓存层提供此功能。较强取决于实现。高同步写缓存DB中需要缓存与DB强同步写入性能要求不极端。Write-Behind (写回)先更新缓存异步批量写回DB。弱有数据丢失风险。低仅写缓存高写入吞吐量要求极高可容忍少量数据丢失如点赞数。分布式锁更新数据时用分布式锁锁住Key阻塞所有读写。强一致。极高串行化高对一致性要求绝对严格如库存扣减的最终校验环节。串行化队列将对同一Key的读写请求都放入一个内存队列顺序执行。强一致。高吞吐受限很高极端热点Key的更新如秒杀场景。延时双删先删缓存更新DB延时后再删缓存。加强的最终一致。大幅缩短不一致窗口。中增加固定延时中读多写不少主从有延迟对一致性要求高于普通最终一致但无法承受强一致性能代价的场景。从表格可以看出“延时双删”是在一致性、性能和复杂度之间取得的一个非常不错的平衡点。它没有强一致方案那么大的性能损耗又比简单的删除策略可靠得多。对于大多数互联网业务如电商商品信息、社交内容、用户资料等它往往是首选方案。7. 结合其他模式构建健壮缓存层“延时双删”不是孤立的在实际系统中我们通常会把它和其他模式组合使用形成一道防线。1. 给缓存设置合理的过期时间 (TTL)这是最后一道保险。即使双删失败了或者出现了极端情况脏缓存数据也会在TTL之后自动失效达到最终一致。TTL不宜过短否则缓存命中率低也不宜过长否则不一致时间久。根据业务数据变更频率设置几分钟到几小时不等。2. 使用本地缓存标记在应用服务器本地如Guava Cache用一个很小的缓存来记录哪些Key正在被更新。当读请求到来时先检查本地标记。如果Key正在更新则本次读请求可以短暂等待如几毫秒或直接读主库避免读到从库的旧数据。这需要与双删配合在更新开始时设置标记在第二次删除后清除标记。这能进一步压缩不一致窗口。3. 监听数据库Binlog这是一个更彻底但也更重的方案。通过Canal、Debezium等工具监听数据库的变更日志Binlog当发现数据更新时由这个独立的组件来删除或更新缓存。这个方案将缓存更新逻辑与业务代码解耦保证了顺序性。此时“延时双删”可以作为这个方案在极端高并发下的一个补充——在Binlog监听器删除缓存后再异步延时删一次以应对监听器本身可能存在的延迟或失败。我个人的经验是对于绝大多数业务“Cache-Aside 延时双删 合理TTL”这套组合拳已经足够应对99%的缓存一致性问题。先把这套基础但有效的方案做稳、做透监控好你的主从延迟和缓存命中率再根据业务发展的实际痛点考虑是否要引入更复杂的方案。技术选型永远是适合的才是最好的。希望这篇近万字的拆解能让你下次面对缓存不一致的报警时心里更有底。
返回列表