
数据库与缓存高一致性六种写路径实现方案适用场景MySQL Redis · Java / 任意后端 · 读多写少详情、商品、用户信息等本文聚焦写路径DB 更新后如何让 Redis 不长期返回旧值。六种方案由简到繁每种均给出正确做法、典型错误、适用边界。前言什么是「高一致」缓存场景里很少做到分布式强一致。更现实的目标是MySQL 是权威数据源更新 DB 后缓存不应长期返回旧值关键业务改价、下架、库存不能出现可持续的脏读。两个不同的问题问题含义本文是否覆盖DB 与缓存一致改 DB 后缓存何时同步✅ 本文重点并发写正确两人抢同一库存只能一人成功❌ 靠 DB 锁/乐观锁与缓存策略无关重要前提Transactional只管 DBTransactionalpublicvoidupdateProduct(Longid,...){productMapper.update(...);// ✅ 在事务内失败会回滚redisTemplate.delete(key);// ❌ 不在事务内失败不会回滚 DB}Redis、MQ、HTTP不会随 MySQL 事务一起回滚。所有方案都要假设DB 可能已成功缓存操作可能失败需要补偿或兜底 TTL。一、六种方案总览方案写路径概要一致性复杂度典型场景A 先写 DB 再删缓存UPDATE → DELETE准强一致⭐ 低默认首选B 先写 DB 再更新缓存UPDATE → SET 新值准强一致⭐⭐ 中改完立刻海量读C 延迟双删A 异步二次 DELETE准强一致⭐⭐ 中高并发同 key 读写D 版本号写侧 CASUPDATE version → 条件 SET准强一致⭐⭐⭐ 中高多写源、防旧值覆盖E Binlog / MQ 异步失效只写 DBCanal 驱动删缓存最终一致⭐⭐⭐⭐ 高多服务写同表F 分布式事务2PC / Seata 绑 DBRedis理论强⭐⭐⭐⭐⭐ 很高一般不推荐二、方案 A先写 DB再删缓存Write-Invalidate2.1 流程1. UPDATE / INSERT MySQL必要时 Transactional 2. COMMIT 3. DELETE redis:product:detail:{id} 4. 读请求 miss 时再查 DB 并 SET 回填Cache-Aside2.2 正确示例TransactionalpublicProductDTOupdateProduct(Longid,UpdateRequestreq){ProductproductloadAndApply(id,req);productMapper.updateById(product);// 事务提交后再删缓存更稳见 2.4redisTemplate.delete(product:detail:id);returntoDTO(product);}2.3 为什么默认用「删」而不是「直接更新缓存」删缓存evict直接更新缓存put写路径只 DELETE简单要写完整 DTO、设 TTL回填逻辑只在读路径维护一处写、读两套组装易不一致新建/冷门数据不必预热可能写入无人读的 key一个实体多个缓存 key 时好维护漏更新某个 list key 会脏删缓存不是比 put 更安全两者在 Redis 失败时都可能脏读。删缓存胜在简单、懒加载。2.4 典型错误案例错误 1先删缓存再写 DB// ❌ 错误顺序redisTemplate.delete(key);productMapper.update(...);T1: 删缓存 T2: 读线程 miss → 查 DB仍是旧值→ 把旧值写回 Redis T3: 写 DB 完成 结果Redis 长期是旧值直到 TTL 过期正确先写 DB 并提交再删缓存。错误 2以为Transactional能回滚 RedisTransactionalpublicvoidupdate(...){productMapper.update(...);redisTemplate.delete(key);// 若这里抛异常}// DB 已提交取决于异常发生在 commit 前还是后// Redis 删失败 → DB 不会回滚正确Redis 操作失败要日志 重试 告警或依赖短 TTL兜底。错误 3更新多个相关 key 只删了一个// ❌ 只删详情列表缓存仍是旧数据redisTemplate.delete(product:detail:id);// product:list:category:5 仍包含旧价格正确梳理所有衍生 key按前缀删或列表用短 TTL。2.5 仍存在的极小脏读窗口T1: 线程 A UPDATE DB 成功新价 100 T2: 线程 A 尚未 DELETE T3: 线程 B 读 miss → 若读到未提交或只读从库延迟 → 拿到旧价 80 → SET 回 Redis T4: 线程 A DELETE T5: 若 T3 在 T4 之后完成 SET → Redis 又是 80缓解方案 C延迟双删或方案 Dversion 写侧 CAS。三、方案 B先写 DB再更新缓存Write-Through3.1 流程1. UPDATE MySQL 2. SET redis:product:detail:{id} 新 JSON带 TTL 3. 后续读直接 hit无 miss 窗口3.2 正确示例TransactionalpublicProductDTOupdateProduct(Longid,UpdateRequestreq){ProductproductloadAndApply(id,req);productMapper.updateById(product);ProductDTOdtotoDTO(product);redisTemplate.opsForValue().set(product:detail:id,JSON.toJSONString(dto),Duration.ofMinutes(30));returndto;}3.3 与方案 A 的对比维度A 删缓存B 更新缓存更新后第一次读miss多一次 DB直接 hit ✅写路径复杂度低中需拼完整 DTODB 成功 Redis 失败旧缓存仍在 → 脏读旧缓存仍在 → 脏读DB 失败 Redis 成功不会发生先 DB若顺序错了才会3.4 典型错误案例错误 1DB 与 Redis 组装逻辑不一致// 读路径join 分类表、过滤下架商品// 写路径只 put 了 product 表字段缺少 categoryNameredisTemplate.set(key,simpleProductJson);// ❌ 缺字段正确写路径复用同一套toDTO()/ 组装方法。错误 2认为 put 一定比 evict 更安全Redis SET 失败 → 旧缓存仍在 → 用户仍看到旧价正确put 解决的是miss 窗口不解决Redis 失败失败仍需重试或改 evict。错误 3在事务未提交时 putTransactionalpublicvoidupdate(...){productMapper.update(...);redisTemplate.set(key,newJson);// ❌ 事务可能回滚缓存已是新值}正确在afterCommit回调里再 SET Redis或先 commit 再操作缓存。四、方案 C延迟双删Delayed Double Delete4.1 解决什么问题方案 A 在高并发下仍可能出现「旧值被读线程写回 Redis」见 2.5。延迟双删在一段时间后再删一次清掉被误写回的脏缓存。4.2 流程1. DELETE cache第一次可选见注 2. UPDATE DB 3. COMMIT 4. 提交异步任务延迟 N ms如 500ms 5. DELETE cache第二次 6. 接口立即返回不 sleep注业界有两种顺序「先删再写 DB」或「先写 DB 再删」。本文推荐先写 DB 再删 延迟再删若用「先删再写」需更仔细评估窗口。4.3 正确示例异步第二次删除TransactionalpublicProductDTOupdateProduct(Longid,UpdateRequestreq){ProductproductloadAndApply(id,req);productMapper.updateById(product);Stringkeyproduct:detail:id;redisTemplate.delete(key);// 第一次删TransactionSynchronizationManager.registerSynchronization(newTransactionSynchronization(){OverridepublicvoidafterCommit(){scheduleDelayedEvict(key,500);}});returntoDTO(product);}privatevoidscheduleDelayedEvict(Stringkey,longdelayMs){scheduler.schedule(()-redisTemplate.delete(key),delayMs,TimeUnit.MILLISECONDS);}4.4 典型错误案例错误 1在接口里同步Thread.sleep(500)productMapper.update(...);redisTemplate.delete(key);Thread.sleep(500);// ❌ 占用 Tomcat 线程接口变慢redisTemplate.delete(key);正确异步任务延迟删主线程立刻返回。错误 2延迟时间随意写 50ms读 DB 写回缓存 耗时 200ms 50ms 就二次删除 → 旧值还没写回就删了 → 之后旧值又写回 → 仍脏正确延迟 ≥ 「读请求查 DB 回填缓存」的P99 耗时有主从时还要考虑复制延迟。错误 3事务回滚了仍执行延迟删// ❌ update 失败回滚但延迟任务仍删了缓存redisTemplate.delete(key);productMapper.update(...);// 抛异常回滚scheduleDelayedEvict(key,500);正确延迟删放在afterCommit仅提交成功后调度。五、方案 D版本号 / 写侧 CAS乐观控制5.1 解决什么问题并发下慢读线程把旧数据写回 Redis覆盖管理员刚写入的新缓存T1: 管理员更新 DB price100, version13Redis 已是 100/v13 T2: 慢读线程仍拿着 price80, version12 T3: 慢读线程 SET Redis → 若无保护80 覆盖 100 ❌版本号的核心写 Redis 时只允许新版本覆盖旧版本不是每次读都去查 DB 验 version。5.2 数据结构MySQLALTERTABLEproductADDCOLUMNversionBIGINTNOTNULLDEFAULT0;-- UPDATE ... SET version13 WHERE id1 AND version12Redis{version:13,data:{id:1,price:100.00,title:...}}5.3 写路径DB 乐观锁 Redis 条件写TransactionalpublicProductDTOupdateProduct(Longid,UpdateRequestreq){ProductpproductMapper.selectById(id);longoldVerp.getVersion();applyUpdate(p,req);p.setVersion(oldVer1);introwsproductMapper.updateWithVersion(p,oldVer);if(rows0){thrownewConcurrentUpdateException(请重试);}ProductDTOdtotoDTO(p);cacheService.putIfNewer(id,dto,p.getVersion());// 见 5.4returndto;}5.4 Redis Lua仅当 newVersion ≥ 当前 version 才 SET// 语义拒绝旧版本覆盖新版本publicbooleanputIfNewer(Longid,ProductDTOdto,longnewVersion){Stringkeyproduct:detail:id;StringjsonwrapVersion(newVersion,dto);LongokredisTemplate.execute(PUT_IF_NEWER_SCRIPT,List.of(key),json,String.valueOf(newVersion),1800);returnok!nullok1L;}-- KEYS[1]key ARGV[1]json ARGV[2]newVersion ARGV[3]ttllocaloldredis.call(GET,KEYS[1])ifnotoldthenredis.call(SET,KEYS[1],ARGV[1],EX,ARGV[3])return1endlocaloldVertonumber(cjson.decode(old).version)iftonumber(ARGV[2])oldVerthenredis.call(SET,KEYS[1],ARGV[1],EX,ARGV[3])return1endreturn05.5 读路径命中仍只读 Redis不每次查 DBpublicProductDTOgetProduct(Longid){OptionalProductDTOcachedcacheService.get(id);if(cached.isPresent()){returncached.get();// ✅ 不查 DB}ProductpproductMapper.selectById(id);ProductDTOdtotoDTO(p);cacheService.putIfNewer(id,dto,p.getVersion());// miss 回填也走 CASreturndto;}5.6 典型错误案例错误 1每次读 Redis 再 SELECT version 对账// ❌ 缓存失去意义ProductCacheEntryentrygetFromRedis(id);LongdbVersionproductMapper.selectVersion(id);if(entry.getVersion()dbVersion){...}正确version 用在写侧防覆盖读路径 hit 直接返回。必要时抽检 / 异步对账不是每次读都对账。错误 2有 version 但仍用 plain SET// ❌ 慢线程 v12 照样覆盖 v13redisTemplate.set(key,oldJson);正确更新与 miss 回填都走putIfNewer/ Lua CAS。错误 3update 用 versionevict 与 put 混用无规则有时 evict有时 putIfNewer evict 后慢线程第一个 plain SET 旧值 → 仍脏正确定规则——要么统一 putIfNewer要么 evict 后回填必须 CAS。错误 4列表缓存带 versionproduct:list:cat:5 包含 20 个商品 其中一个改了 version → 整页列表 version 无法表达正确version 适合单实体单 key列表用短 TTL 或不缓存。六、方案 EBinlog / MQ 异步删缓存Canal6.1 流程业务服务只 UPDATE MySQL代码里不碰 Redis ↓ Canal 订阅 binlog → 解析变更 ↓ 发送 MQ{ table: product, id: 1, op: UPDATE } ↓ 消费者DELETE product:detail:1或 SET 新值RedisConsumerMQCanalMySQLAppRedisConsumerMQCanalMySQLApp不操作 RedisUPDATE product SET price100binlog变更消息consumeDELETE product:detail:16.2 适用场景多个服务 / Job都会改同一张表应用内 evict 容易漏缓存 key 规则复杂详情 列表 搜索希望集中在消费者维护业务与缓存解耦6.3 一致性最终一致T1: DB 更新成功接口返回 T2: 用户立刻读 → Redis 仍是旧值MQ 未消费完 T3: 数百 ms ~ 数秒后消费者删缓存 T4: 下次读 miss → DB 新值不是「改完立刻一致」是最终一致。6.4 典型错误案例错误 1以为上了 Canal 就强一致改价后 100ms 内用户仍可能看到旧价正确接受延迟窗口或关键读降级查 DB或消费者直接 PUT 新值。错误 2MQ 消费失败无重试DB 已是新价Redis 永远旧价直到 TTL正确MQ重试 死信队列 监控告警。错误 3每个服务仍各自 evict Canal 也删逻辑重复、顺序难控、排查困难正确选定单一失效来源——要么应用内 evict要么 Canal不要两套并行 unless 有明确分工。错误 4消费者删错 key// ❌ binlog 里是 id1删成 product:detail:2正确单元测试 对账任务消息体带主键、表名、变更字段。七、方案 F分布式事务2PC / Seata— 一般不推荐7.1 设想全局事务UPDATE MySQL SET Redis 任一步失败 → 全部回滚7.2 为何不推荐问题说明Redis 非 XA 参与者与 JDBC 同一 2PC 链路工程化难性能差两阶段提交拖慢写接口运维复杂Seata TC、网络分区、超时补偿收益有限方案 A/B 重试往往够用7.3 典型错误案例错误 1为「面试好看」强行上 Seata 绑 Redis复杂度爆炸Redis 抖动导致全局事务阻塞正确默认先 DB 后缓存 失败补偿真正跨库强一致用业务幂等 对账不是 2PC 绑 Redis。错误 2把 Redis 当库存扣减的唯一真相Seata 保证 Redis 与 MySQL 同时扣减 Redis 宕机 → 全链路不可用正确库存、占座以 DB 为准Redis 不承载强一致库存或不缓存余量。八、六种方案对比与选型8.1 对比表方案DB 成功后缓存失败旧值写回 Redis多服务写同表读路径是否加 DBA 删缓存可能脏读到 TTL可能每服务都要 evict否B 更新缓存可能脏读可能每服务都要 put否C 延迟双删同 A二次删缓解写回缓解同 A否D version CAS同 A/B拒绝旧覆盖✅配合 evict/put否写侧防护E CanalMQ消费延迟删后仍可能写回*统一捕获✅否F 分布式事务理论一起失败少复杂否* E 删缓存后若读线程仍 plain SET 旧值还需 D 或 C 配合。8.2 选型决策树是否多个服务写同一张表 ├─ 是 → 优先考虑 ECanalMQ或 Dversion └─ 否 ↓ 同一 key 高并发读写evict 仍偶发脏读 ├─ 是 → C延迟双删或 Dversion CAS └─ 否 ↓ 更新后是否立刻海量读、要避免 miss ├─ 是 → B写时更新失败要重试 └─ 否 → A写时删✅ 默认 是否考虑 Seata 绑 Redis └─ 一般否 → F 仅作了解8.3 组合建议常见生产组合业务推荐组合普通商品详情A TTL 抖动 空值防穿透改价后瞬间热点读B或 A 异步 warming大促同 SKU 高并发C或D微服务多写商品表E 短 TTL 兜底库存 / 秒杀 / 占座不缓存余量DB 锁 唯一约束九、失败补偿清单失败后果补偿DB 成功DELETE 失败脏读重试删短 TTL告警DB 成功SET 失败脏读或 miss重试 put/evict延迟双删任务丢失旧值写回未清监控队列TTLCanal/MQ 积压长时间脏读扩容消费者降级读 DBversion CAS 拒绝写入无缓存下次读 DB可接受或 evict 后由新读回填误用先删后写长期脏读改顺序为先写后删十、面试口述模板约 1 分钟MySQL 是权威源缓存是加速层。写路径我熟悉六种方案默认先写 DB 再删缓存简单回填只在读 miss 时发生若改完立刻大量读用写时更新缓存但要注意 Redis 失败补偿和 afterCommit高并发同 key 若出现旧值写回用延迟双删第二次删除必须异步且在事务提交后调度版本号主要用在写侧 Lua CAS防止慢线程用旧 version 覆盖新缓存读路径命中不必每次查 DB version多服务写同表用Canal 订阅 binlog MQ 异步删缓存是最终一致要有重试和死信分布式事务绑 Redis一般不推荐库存类强一致应直接走 DB 锁不缓存余量。