Redis 缓存一致性:一文讲透延时双删原理、并发时序
摘要使用 Redis 缓存数据库查询结果时缓存与数据库之间没有天然的原子性保证。一次普通的“更新数据库、删除缓存”在特定并发时序下仍可能留下旧数据。延时双删通过“删除缓存、更新数据库、等待、再次删除缓存”缩短脏数据存活时间是 Java 后端面试中的高频方案。本文从 Cache Aside 模式出发结合并发时序、Spring Boot 示例、失败补偿和方案对比说明延时双删解决了什么、为什么不能保证强一致性以及生产环境应如何选择。一、问题本质数据库和缓存不是一个事务假设商品价格保存在 MySQL 中同时为了降低数据库压力我们把商品详情缓存在 Redis 中。读请求优先访问 Redis写请求修改 MySQL 后再让缓存失效。问题是MySQL 和 Redis 是两个独立系统MySQL 事务提交成功不代表 Redis 操作一定成功Redis 删除成功不代表 MySQL 更新一定成功多个读写请求并发执行时不同操作可能交叉网络延迟、线程阻塞、GC 和进程宕机都会改变实际执行顺序。因此缓存一致性问题的本质不是简单背诵“先操作谁”而是如何管理数据库与缓存无法原子提交所产生的并发窗口和失败窗口。表面是“先删缓存再更新数据库休眠 500 毫秒再删一次缓存。”这只描述了动作没有解释原理。真正需要回答的是旧值如何进入缓存第二次删除清理的是谁写入的旧值延迟应该覆盖哪个时间窗口二删失败后怎么办二、先理解 Cache Aside 模式延时双删通常建立在 Cache Aside旁路缓存模式上。应用同时管理数据库与缓存缓存只保存数据库数据的副本。2.1 读请求根据业务 Key 查询 Redis缓存命中直接返回缓存未命中查询数据库把数据库结果写入 Redis并设置过期时间返回结果。伪代码如下function query(id): value cache.get(id) if value exists: return value value database.get(id) cache.set(id, value, ttl) return value2.2 写请求为什么倾向于删除缓存写请求常见的基础做法是function update(id, newValue): database.update(id, newValue) cache.delete(id)这里通常选择“删除缓存”而不是“更新数据库后再更新缓存”主要有两个原因。第一并发更新缓存容易发生顺序倒置。请求 A 先更新数据库请求 B 后更新数据库但 B 先写缓存、A 后写缓存最终缓存可能保留 A 的旧版本。第二不是所有写入后的数据都会立刻被读取。每次写库都同步构造并更新缓存会产生额外计算和网络开销。删除缓存后让真正的读请求按需重建更符合 Cache Aside 的职责划分。不过“更新数据库后删除缓存”也不是绝对安全的。如果数据库已经提交而删除缓存失败Redis 会继续保存旧值。因此工程上仍要配合重试、过期时间、消息队列和监控。三、什么是延时双删延时双删的典型流程如下function update(id, newValue): cache.delete(id) // 第一次删除 database.update(id, newValue) // 更新数据库 afterTransactionCommit: schedule(delay): cache.delete(id) // 第二次删除四个动作分别承担不同职责第一次删除缓存让后续读请求不要继续直接命中旧缓存更新数据库修改权威数据源等待一段时间尽量覆盖已经开始但尚未完成回填的读请求第二次删除缓存清除并发读请求可能重新写入的旧值。它叫“双删”并不是因为同一个 Key 一定存在两次而是两个删除动作处理不同阶段的不确定性。第一次删除负责失效已有副本第二次删除负责清理并发窗口中可能产生的脏副本。四、只删除一次为什么仍会出现旧缓存理解延时双删关键是看清“旧值回填”的并发时序。假设数据库中商品价格最初是 100 元Redis 中没有对应缓存。读线程 A 与写线程 B 同时执行。时刻读线程 A写线程 B数据库RedisT1查询 Redis未命中100空T2查询数据库读到 100随后阻塞100空T3第一次删除缓存100空T4更新价格为 200 并提交200空T5把此前读到的 100 回填 Redis200100T6延迟后第二次删除缓存200空在 T2 时刻线程 A 已经拿到了旧值 100但还没来得及写缓存。线程 B 在 T3 删除缓存时Redis 本来就是空的所以第一次删除无法影响 A 手里的旧值。B 在 T4 完成数据库更新后A 才在 T5 把 100 写入缓存。如果没有第二次删除后续请求会一直读到 100直到缓存自然过期。第二次删除的目标正是这种“在途读请求回填的旧值”。4.1 每一步到底解决什么问题第一次删除用于失效写请求开始前已经存在的旧缓存。如果不做第一次删除从写请求开始到最终二删之间读请求仍会命中旧值。更新数据库用于修改权威数据源。缓存只是一份可丢弃、可重建的副本不应成为数据事实的最终来源。延迟不是为了让数据库“慢慢同步”也不是 Redis 的特殊要求。它只是给已经开始的旧读请求留出时间让这些请求先完成可能的旧值回填。第二次删除用于清理并发窗口中的旧值。二删之后新请求缓存未命中会从数据库读取 200 并重建缓存。五、延迟时间应该设置多久面试中经常听到“延迟 500 毫秒”或“延迟 1 秒”。这些数字只能是某个系统中的经验值不能当作通用答案。延迟需要覆盖一次缓存未命中读请求的完整回填链路Redis 查询耗时 数据库连接等待 SQL 执行耗时 对象转换和业务计算 应用到 Redis 的网络耗时 序列化与缓存写入耗时 调度、GC 和网络抖动余量工程上可以统计这条链路的 P99 或 P999 耗时再增加安全余量。例如监控显示完整回填 P99 为 180 毫秒可以从高于该值的延迟开始压测和观察但这不等于所有系统都应采用某个固定毫秒数。延迟太短可能无法覆盖慢查询或线程阻塞旧值会在二删之后才被写回延迟太长脏缓存可能存活更久同时延迟任务积压更多。因此延迟值应来自监控、压测和业务容忍度而不是来自博客中的常数。5.1 必须主动说明的边界假设线程 A 在读取旧值后阻塞了 3 秒而第二次删除只延迟 1 秒A 读到旧值 - B 更新数据库 - 1 秒后 B 二删 - 3 秒后 A 回填旧值旧值仍会在二删之后进入缓存。无论延迟设为多少只要存在更长的阻塞就仍能构造出失败时序。延时双删是一种低成本的最终一致性优化方案。它可以降低旧值长期留在缓存中的概率但不能从理论上保证强一致性。六、Java Spring Boot 演示实现下面给出一份用于说明执行时序的核心实现。示例重点是事务提交和二次删除的关系省略 Controller、DTO 字段和 Repository 的常规代码。6.1 读取商品未命中时回源并回填ServiceRequiredArgsConstructorSlf4jpublicclassProductService{privatestaticfinalDurationCACHE_TTLDuration.ofMinutes(10);privatefinalStringRedisTemplateredisTemplate;privatefinalProductRepositoryproductRepository;privatefinalObjectMapperobjectMapper;privatefinalDelayedCacheDeleteSchedulerdeleteScheduler;publicProductViewquery(longproductId){StringkeycacheKey(productId);StringjsonredisTemplate.opsForValue().get(key);if(StringUtils.hasText(json)){returnfromJson(json);}ProductViewproductproductRepository.findViewById(productId).orElseThrow(()-newIllegalArgumentException(商品不存在));redisTemplate.opsForValue().set(key,toJson(product),CACHE_TTL);returnproduct;}privateStringcacheKey(longproductId){returnproduct:detail:productId;}privateStringtoJson(ProductViewproduct){try{returnobjectMapper.writeValueAsString(product);}catch(JsonProcessingExceptione){thrownewIllegalStateException(商品缓存序列化失败,e);}}privateProductViewfromJson(Stringjson){try{returnobjectMapper.readValue(json,ProductView.class);}catch(JsonProcessingExceptione){thrownewIllegalStateException(商品缓存反序列化失败,e);}}}示例给缓存设置了 TTL。TTL 是最后一道自动恢复手段即使缓存删除链路全部失败旧值也不会无限期存在。但 TTL 不能替代主动失效因为业务通常不能接受旧数据一直保留到自然过期。真实项目还要考虑空值缓存、缓存穿透、热点 Key 重建和缓存击穿。它们与本文的一致性主线不同这里不展开。6.2 更新商品事务提交后再安排二删把下面的方法放入ProductServiceTransactionalpublicvoidupdatePrice(longproductId,BigDecimalnewPrice){StringkeycacheKey(productId);// 第一次删除。Redis 抛出异常时让本次写入失败由上层重试或补偿。redisTemplate.delete(key);intaffectedRowsproductRepository.updatePrice(productId,newPrice);if(affectedRows!1){thrownewIllegalStateException(商品价格更新失败);}if(!TransactionSynchronizationManager.isSynchronizationActive()){thrownewIllegalStateException(当前方法必须运行在 Spring 事务中);}TransactionSynchronizationManager.registerSynchronization(newTransactionSynchronization(){OverridepublicvoidafterCommit(){try{deleteScheduler.schedule(key,Duration.ofMillis(800));}catch(RuntimeExceptionex){// 数据库已提交生产环境必须把任务写入可靠补偿通道。log.error(提交延迟删除任务失败cacheKey{},key,ex);}}});}这里有三个容易忽略的点。第一第二次删除要在事务提交成功后安排。如果更新 SQL 执行完但事务还未提交就开始计算延迟慢事务可能吃掉大部分延迟时间。事务最终回滚时二删任务还可能被错误发送。afterCommit()明确了动作边界。第二afterCommit()中失败已经无法回滚数据库。事务提交后即使调度失败也不能再假装数据库没有更新。因此必须记录上下文并进入可靠补偿流程不能只打印日志后永久丢弃。第三必须经过 Spring 事务代理调用。如果在同一对象内部直接调用该方法Transactional可能因自调用而失效。实际项目应通过清晰的 Service 边界调用并用集成测试验证事务同步确实生效。代码中的 800 毫秒只用于展示参数传递不是推荐值。生产配置应外置并根据前文的回填链路高分位耗时确定。6.3 非阻塞地执行延迟删除ComponentRequiredArgsConstructorSlf4jpublicclassDelayedCacheDeleteScheduler{privatefinalStringRedisTemplateredisTemplate;privatefinalScheduledExecutorServicecacheDeleteExecutor;publicvoidschedule(StringcacheKey,Durationdelay){cacheDeleteExecutor.schedule(()-{try{redisTemplate.delete(cacheKey);log.info(二次删除缓存成功cacheKey{},cacheKey);}catch(Exceptionex){// 演示代码只记录失败生产环境应发送到持久化重试队列。log.error(二次删除缓存失败cacheKey{},cacheKey,ex);}},delay.toMillis(),TimeUnit.MILLISECONDS);}}演示配置如下ConfigurationpublicclassCacheExecutorConfiguration{Bean(destroyMethodshutdown)publicScheduledExecutorServicecacheDeleteExecutor(){ThreadFactoryfactoryrunnable-{ThreadthreadnewThread(runnable);thread.setName(cache-double-delete);thread.setDaemon(true);returnthread;};returnnewScheduledThreadPoolExecutor(2,factory,newThreadPoolExecutor.AbortPolicy());}}为什么不在请求线程里这样写redisTemplate.delete(key);productRepository.updatePrice(productId,newPrice);Thread.sleep(800);redisTemplate.delete(key);Thread.sleep()会占住处理业务请求的线程。写流量上升时大量请求线程进入睡眠线程池容易耗尽接口延迟和超时会迅速放大。不过ScheduledExecutorService也只是更适合讲解。JDK 的ScheduledThreadPoolExecutor默认使用无界延迟队列进程重启时内存任务会丢失任务堆积还可能持续消耗内存。上面的固定值只是工作线程数量并不代表任务队列有容量上限。因此不应把这段进程内调度代码原样作为生产级默认方案。七、失败场景重点延时双删包含 Redis、数据库、事务回调和延迟任务等多个环节。每个环节失败时处理方式都不一样。7.1 第一次删除失败如果 Redis 删除抛出异常而业务要求较高一致性可以让异常终止本次数据库写入交给上层重试。代价是 Redis 故障会影响写接口可用性。如果业务更看重写入可用性也可以继续更新数据库但必须把缓存失效事件写入可靠补偿通道并缩短缓存 TTL、加强告警。这里没有不付代价的选择中止写入牺牲可用性继续写入则接受短暂旧缓存风险。还要注意delete()返回false可能只是 Key 原本不存在不等于 Redis 操作失败网络异常和服务端错误通常以异常表现。7.2 数据库更新失败数据库事务回滚时不应安排第二次删除。第一次删除若已成功只会造成缓存暂时为空。下一个读请求会从数据库读到尚未修改的旧值并重新建立正确缓存。这个场景通常不会产生错误数据但会增加一次数据库查询和缓存重建。第一次删除与数据库事务回滚不具备原子性却可以利用“缓存可重建”这一性质恢复。7.3 第二次删除失败这是最需要补偿的场景。数据库已经提交旧值却可能留在 Redis 中。仅记录日志是不够的生产系统通常需要对瞬时 Redis 超时做有限次数重试使用指数退避和随机抖动避免形成重试风暴重试失败后写入持久化队列或任务表超过最大次数后进入死信队列对积压量、最长延迟和最终失败量配置告警保留人工重放和按业务 Key 修复的能力。缓存删除天然幂等同一个 Key 删除多次最终状态仍是不存在。因此消息可以采用“至少一次”投递。不过事件最好仍携带事件 ID、业务 Key、数据版本、发生时间和追踪 ID便于排查重复、乱序和过期事件。旧事件即使只造成一次多余删除也可能带来缓存击穿和额外回源流量。7.4 进程在延迟期间宕机如果二删任务只存在本机内存中应用在延迟期间重启任务将永久丢失。线程池无法解决持久化问题。这也是生产环境更倾向于延迟消息、Outbox 任务表或 Binlog 订阅的主要原因。7.5 Redis 暂时不可用Redis 故障时不能无限重试否则故障会从缓存层扩散到业务线程和消息消费者。应限制单次重试次数结合熔断、限流和告警读请求是否降级到数据库要根据数据库容量决定不能让缓存流量瞬间击穿数据库。八、生产环境常见改造方案8.1 可靠延迟消息数据库事务提交后发送一条延迟消息消费者到期后删除缓存。消费失败时由消息系统重投超过阈值进入死信队列。难点是“数据库事务提交”和“消息发送”仍不是天然原子操作。如果数据库提交成功但消息未发出二删依旧会丢失。可根据基础设施选择事务消息或采用 Outbox 模式在同一个数据库事务中同时更新业务表并写入事件表再由后台任务可靠投递事件。begin transaction update product insert cache_invalidation_event commit event publisher - message queue - delayed consumer - delete Redis key8.2 订阅 Binlog另一种方式是通过 Canal 等组件订阅 MySQL Binlog。数据库变更提交后订阅者根据表名、主键和变更类型删除相应缓存。优点是缓存失效与业务写代码解耦多个写入口不容易漏删。缺点是引入额外基础设施需要处理 Binlog 位点、事件重复、消费积压、表结构变化和 Key 映射。它仍然是异步链路因此属于最终一致性。8.3 分布式锁或串行化控制可以让同一业务 Key 的读写在分布式锁下串行执行从源头缩小“读旧值后回填”的窗口。但锁会增加网络往返和请求延迟还要处理锁超时、续约、误释放和热点 Key 竞争。如果业务要求极严格的一致性更直接的选择可能是关键读取绕过缓存或把强一致操作限制在数据库事务内。不要为了保留缓存而引入比缓存收益更高的复杂度。8.4 方案对比方案一致性能力性能影响实现成本故障恢复适用场景延时双删概率性改善最终一致性较低低依赖额外补偿允许短暂不一致、改造成本敏感可靠延迟消息最终一致性较可靠异步业务线程影响较小中到高重试、死信、重放已有成熟 MQ 基础设施Canal/Binlog数据库提交后最终一致异步链路有延迟高位点恢复、重复消费写入口多、希望统一失效缓存分布式锁可缩小并发窗口延迟和吞吐损耗较大中到高需要完整锁治理单 Key 竞争可控、强一致诉求较高关键读绕过缓存依赖数据库一致性数据库压力增加低到中数据库自身机制少量强一致核心数据不存在通用最优方案。选择时要同时评估允许的不一致时长、读写比例、热点程度、数据库容量、基础设施成熟度和故障处置能力。九、如何验证延时双删真的生效普通单元测试很难稳定复现并发竞态。可以使用CountDownLatch或测试钩子人为控制顺序清空目标缓存让读线程查询数据库得到旧值然后在回填前等待执行写线程完成第一次删除和数据库提交放行读线程使其把旧值写入 Redis不执行二删时断言 Redis 中仍是旧值执行延迟二删后断言 Redis Key 不存在再次查询断言缓存由数据库新值重建。除了正常时序还应验证三类故障调度器拒绝或消息发送失败后事件是否进入可靠补偿Redis 超时后重试是否有次数限制和退避应用在延迟期间重启后持久化任务是否仍能被消费。监控至少应包含二删任务提交量、执行成功量、失败量、重试量、死信量、最长积压时间以及缓存值版本落后于数据库版本的抽样指标。没有监控的“最终一致性”只是愿望不是可验证的工程属性。十、高频追问与参考回答10.1 什么是延时双删延时双删是在写请求中先删除缓存再更新数据库数据库事务提交后等待一段时间并再次删除缓存。第一次删除用于失效已有旧缓存第二次删除用于清理并发读请求可能回填的旧值。它提高最终一致性的概率但不能保证强一致性。10.2 为什么删除缓存而不是直接更新缓存并发写直接更新缓存可能发生执行顺序倒置使旧版本覆盖新版本。删除缓存后按需从数据库重建逻辑更简单也避免为暂时不会被读取的数据提前构建缓存。但删除失败仍需重试和补偿。10.3 为什么先删除缓存再更新数据库这是延时双删的定义之一。第一次删除让写请求开始后的读请求不再直接命中旧缓存随后二删用于清理更新期间可能回填的旧值。不过它会引入“删缓存后、数据库提交前”的旧值回填窗口所以必须结合第二次删除理解不能只看第一删。10.4 延迟时间应该设置多久没有通用固定值。延迟应覆盖缓存未命中后从数据库查询到写回缓存的完整高分位耗时并留出调度、GC 和网络抖动余量。最终值要通过监控和压测确定并持续观察二删任务积压与旧值存活时间。10.5 第二次删除失败怎么办先对瞬时故障做有限重试仍失败则进入持久化重试队列或任务表超过阈值进入死信队列并告警。缓存删除是幂等操作适合至少一次投递但事件仍应携带业务 Key、版本、事件 ID 和追踪信息。10.6 延时双删能保证强一致性吗不能。如果一个读请求在读取旧值后阻塞的时间超过二删延迟它仍可能在第二次删除之后回填旧值二删任务本身也可能因宕机或 Redis 故障而失败。延时双删是最终一致性优化不是跨 MySQL 与 Redis 的分布式事务。10.7 为什么不能直接使用 Thread.sleep()Thread.sleep()会占用业务请求线程而不做有效工作。高并发下大量睡眠线程会耗尽线程池放大延迟和超时。演示时可以用调度器表达“稍后执行”生产环境则更适合持久化延迟消息或任务表。10.8 使用 ScheduledExecutorService 就够了吗不够。进程内任务会因应用重启丢失默认延迟队列还可能无限增长。它适合演示时序或低风险场景需要可靠性时应使用可持久化任务、延迟消息或 Binlog 订阅并配套重试、死信和告警。10.9 什么时候考虑 Canal 或 Binlog当同一数据有多个写入口、业务代码容易漏删缓存或者希望统一治理缓存失效时可以考虑订阅 Binlog。代价是增加基础设施和运维复杂度还要处理位点恢复、重复事件、消费积压和表到缓存 Key 的映射。10.10 如果业务必须强一致怎么办先确认缓存是否适合承载这类数据。对余额、库存扣减结果等关键读取可以绕过缓存、缩短缓存参与范围或在同一业务 Key 上引入串行化控制。强一致需要明确的协调成本不能靠增加一个固定延迟获得。十一、适用场景与不适用场景延时双删比较适合读多写少缓存收益明显允许短时间读到旧值希望以较低改造成本改善最终一致性已设置合理 TTL并具备基础重试和监控。延时双删不适合余额、支付状态等必须严格一致的核心数据热点 Key 写入频繁延迟删除任务会大量积压无法接受任何旧值窗口系统没有重试、告警和故障恢复能力却把二删当成唯一保障。十二、总结定义先删缓存更新数据库事务提交后延迟再删一次缓存。原理第二次删除清理的是并发读请求可能回填的旧值。延迟根据完整缓存回填链路的高分位耗时估算不背固定数字。可靠性二删失败必须重试、持久化补偿、告警进程内线程池不可靠。边界延时双删改善最终一致性但无法保证强一致性。一个成熟的回答不应该停在“睡眠后再删一次”。真正的重点是能否还原竞态时序、说明失败补偿并知道什么时候应换成可靠消息、Binlog 订阅、锁或直接绕过缓存。缓存一致性没有免费的答案只有与业务容忍度相匹配的工程取舍。