Redis 延迟双删还是读到脏数据?一次缓存一致性事故

📅 2026/7/15 5:02:58
Redis 延迟双删还是读到脏数据?一次缓存一致性事故
Redis 延迟双删还是读到脏数据一次缓存一致性事故更新数据库后删缓存。怕并发读写不一致又加了一次延迟删除。结果线上还是读到了旧数据。问题不在“有没有双删”而在你有没有搞清楚缓存一致性从来不是一个delete就能彻底解决的。一、事故现场线上有个商品详情接口。读流程很常见publicProductgetProduct(LongproductId){Stringkeyproduct:productId;ProductcacheredisTemplate.opsForValue().get(key);if(cache!null){returncache;}ProductproductproductMapper.selectById(productId);redisTemplate.opsForValue().set(key,product,10,TimeUnit.MINUTES);returnproduct;}更新商品时Transactional(rollbackForException.class)publicvoidupdateProduct(ProductUpdateCommandcommand){productMapper.update(command);redisTemplate.delete(product:command.getProductId());}看起来没问题。更新数据库后删除缓存。下次读缓存 miss再查数据库回填。但线上偶尔出现后台已经改了价格 用户端还是看到旧价格 过一会儿又恢复正常第一反应是Redis 删除失败了查日志发现删除成功。后来又加了延迟双删productMapper.update(command);redisTemplate.delete(key);executor.schedule(()-redisTemplate.delete(key),500,TimeUnit.MILLISECONDS);问题少了但没有完全消失。二、为什么删了缓存还会读到旧数据先看一个经典并发时序。假设缓存里原来是旧价格product:1001 99 元现在后台把价格改成 89 元。时间线可能是这样T1更新线程开始更新数据库 T2读线程发现缓存 miss T3读线程查数据库读到旧值 99 T4更新线程提交数据库新值 89 T5更新线程删除缓存 T6读线程把旧值 99 写回缓存最后缓存里又变成了product:1001 99 元这就是缓存不一致。注意更新线程确实删过缓存。但读线程在错误的时间点把旧数据又写回去了。三、延迟双删解决的是什么延迟双删的思路是更新数据库前后删一次 过一小段时间再删一次常见写法publicvoidupdateProduct(ProductUpdateCommandcommand){Stringkeyproduct:command.getProductId();redisTemplate.delete(key);productMapper.update(command);sleep(500);redisTemplate.delete(key);}或者异步延迟删productMapper.update(command);redisTemplate.delete(key);scheduledExecutor.schedule(()-redisTemplate.delete(key),500,TimeUnit.MILLISECONDS);第二次删除是为了清理这类脏回填读线程在更新期间查到旧数据 并把旧数据重新写进缓存所以延迟双删不是魔法。它只是赌一件事脏数据回填会在延迟时间内发生第二次删除能把它删掉。四、为什么延迟双删还是可能失败1. 延迟时间不好定如果你延迟 500ms 删除。但某次读请求特别慢T1读线程查数据库 T2更新线程删除缓存 T3500ms 后第二次删除 T4读线程终于返回旧数据 T5读线程把旧数据写回缓存第二次删除已经过去了。旧数据还是被写回缓存。延迟太短兜不住慢查询。延迟太长又会增加实现复杂度和不确定性。2. 删除任务可能丢如果第二次删除靠本地线程池scheduledExecutor.schedule(()-redisTemplate.delete(key),500,TimeUnit.MILLISECONDS);那它不是可靠任务。服务重启、线程池满、进程崩溃都可能导致第二次删除没执行。3. 主从延迟也会影响有些项目读写分离。更新写主库。读线程查从库。如果从库延迟读线程可能在更新提交后仍然读到旧值。然后把旧值回填缓存。这时你只盯着 Redis 删没删很难排查。因为根因在数据库读写链路。4. 缓存没有版本如果缓存对象没有版本号或更新时间旧值和新值在 Redis 里看起来一样“合法”。读线程把旧对象写回去时系统不知道它旧。这也是很多缓存一致性问题难排查的原因。五、正确做法 1先接受“最终一致”大多数缓存场景不应该追求强一致。缓存本质上是性能层。它和数据库之间更适合做最终一致。所以你要先问业务价格能不能短暂旧 库存能不能短暂旧 用户权限能不能短暂旧 账户余额能不能短暂旧 订单状态能不能短暂旧不同业务答案不一样。比如商品描述短暂旧可能能接受。但账户余额、权限状态、支付结果短暂旧就可能出大问题。不要所有数据都套同一套缓存策略。六、正确做法 2更新后删缓存不要先删后改一般推荐先更新数据库 再删除缓存而不是先删除缓存 再更新数据库因为先删缓存后更新数据库中间窗口更容易让读请求查到旧数据库并回填旧缓存。推荐写法Transactional(rollbackForException.class)publicvoidupdateProduct(ProductUpdateCommandcommand){productMapper.update(command);TransactionSynchronizationManager.registerSynchronization(newTransactionSynchronization(){OverridepublicvoidafterCommit(){redisTemplate.delete(product:command.getProductId());}});}注意这里是afterCommit。不要在事务还没提交时就删缓存。否则读线程可能缓存 miss 后读到旧数据库。七、正确做法 3删除失败要能重试最怕的是数据库更新成功 缓存删除失败这时旧缓存会一直存在直到 TTL 过期。所以删除缓存最好不是“打个日志就算了”。可以把删除任务写入本地消息表CREATETABLEcache_delete_task(idBIGINTPRIMARYKEYAUTO_INCREMENT,cache_keyVARCHAR(256)NOTNULL,statusVARCHAR(20)NOTNULL,retry_countINTNOTNULLDEFAULT0,create_timeDATETIMENOTNULL);事务提交后写任务Transactional(rollbackForException.class)publicvoidupdateProduct(ProductUpdateCommandcommand){productMapper.update(command);cacheTaskMapper.insert(product:command.getProductId(),NEW);}后台任务删除Scheduled(fixedDelay1000)publicvoiddeleteCacheTasks(){ListCacheDeleteTasktaskscacheTaskMapper.selectNewTasks();for(CacheDeleteTasktask:tasks){try{redisTemplate.delete(task.getCacheKey());cacheTaskMapper.markSuccess(task.getId());}catch(Exceptione){cacheTaskMapper.markRetry(task.getId(),e.getMessage());}}}这样至少能知道哪些缓存删除失败 失败了几次 什么时候重试 是否需要告警八、正确做法 4关键数据加版本如果缓存对象里有版本号或更新时间就能减少旧值覆盖新值。比如publicclassProductCache{privateLongproductId;privateBigDecimalprice;privateLongversion;privateLocalDateTimeupdateTime;}回填缓存时不要无脑 setredisTemplate.opsForValue().set(key,product);可以做版本判断。思路是如果 Redis 里已有更新版本不允许旧版本覆盖具体实现可以用 Lua 保证原子性。伪代码localoldredis.call(GET,KEYS[1])ifoldfalsethenredis.call(SET,KEYS[1],ARGV[1])return1endlocaloldVersioncjson.decode(old)[version]localnewVersioncjson.decode(ARGV[1])[version]ifnewVersionoldVersionthenredis.call(SET,KEYS[1],ARGV[1])return1endreturn0这不是所有业务都必须做。但对价格、权限、状态这类敏感数据很有用。九、正确做法 5设置合理 TTL缓存一定要有 TTL。不要让旧缓存永久存在。比如redisTemplate.opsForValue().set(key,product,10,TimeUnit.MINUTES);TTL 的作用不是解决所有一致性问题。它是最后兜底。即使删除失败旧数据也不会无限期存在。不同数据 TTL 要分开设计数据类型TTL 建议商品描述可以长一点商品价格短一点或版本控制用户权限短一点变更后强制删除首页配置可较长但要支持主动刷新账户余额尽量不要只依赖缓存十、上线前 checklist看到缓存更新逻辑建议过一遍检查项风险建议是否先删缓存再改库容易回填旧值优先改库后删缓存删除是否在事务提交前读到旧数据库用 afterCommit删除失败是否可重试旧缓存长期存在任务表/MQ 重试是否读写分离从库延迟回填旧值关键读走主库是否有 TTL旧值永久存在必须设置过期时间是否有版本字段旧值覆盖新值版本判断是否强一致业务缓存短暂旧也出事不要简单缓存是否监控缓存删除失败出事后难定位记录和告警十一、总结延迟双删不是不能用。但它不是银弹。它只是尽量清理“读线程把旧值回填缓存”的窗口。真正要稳需要组合拳数据库提交后删缓存。删除失败要能重试。关键数据要有版本。缓存必须有 TTL。强一致数据别只靠缓存兜。最后记住一句话缓存一致性没有绝对完美只有在业务可接受范围内把不一致窗口压到足够小。如果你也在排查 Java 后端线上问题可以关注我。这个系列会持续更新 Spring 事务、线程池、JVM、MySQL、Redis、MQ 的真实踩坑复现。