当前位置: 首页 > news >正文

数据库与缓存高一致性:六种写路径实现方案

数据库与缓存高一致性:六种写路径实现方案

适用场景:MySQL + Redis · Java / 任意后端 · 读多写少详情、商品、用户信息等
本文聚焦写路径:DB 更新后,如何让 Redis 不长期返回旧值。六种方案由简到繁,每种均给出正确做法、典型错误、适用边界


前言

什么是「高一致」

缓存场景里很少做到分布式强一致。更现实的目标是:

MySQL 是权威数据源;更新 DB 后,缓存不应长期返回旧值;关键业务(改价、下架、库存)不能出现可持续的脏读。

两个不同的问题

问题含义本文是否覆盖
DB 与缓存一致改 DB 后缓存何时同步✅ 本文重点
并发写正确两人抢同一库存只能一人成功❌ 靠 DB 锁/乐观锁,与缓存策略无关

重要前提:@Transactional只管 DB

@TransactionalpublicvoidupdateProduct(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 版本号(写侧 CAS)UPDATE version++ → 条件 SET准强一致⭐⭐⭐ 中高多写源、防旧值覆盖
E Binlog / MQ 异步失效只写 DB,Canal 驱动删缓存最终一致⭐⭐⭐⭐ 高多服务写同表
F 分布式事务2PC / Seata 绑 DB+Redis理论强⭐⭐⭐⭐⭐ 很高一般不推荐

二、方案 A:先写 DB,再删缓存(Write-Invalidate)

2.1 流程

1. UPDATE / INSERT MySQL(必要时 @Transactional) 2. COMMIT 3. DELETE redis:product:detail:{id} 4. 读请求 miss 时再查 DB 并 SET 回填(Cache-Aside)

2.2 正确示例

@TransactionalpublicProductDTOupdateProduct(Longid,UpdateRequestreq){Productproduct=loadAndApply(id,req);productMapper.updateById(product);// 事务提交后再删缓存更稳(见 2.4)redisTemplate.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能回滚 Redis
@Transactionalpublicvoidupdate(...){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(延迟双删)或方案 D(version 写侧 CAS)。


三、方案 B:先写 DB,再更新缓存(Write-Through)

3.1 流程

1. UPDATE MySQL 2. SET redis:product:detail:{id} = 新 JSON(带 TTL) 3. 后续读直接 hit,无 miss 窗口

3.2 正确示例

@TransactionalpublicProductDTOupdateProduct(Longid,UpdateRequestreq){Productproduct=loadAndApply(id,req);productMapper.updateById(product);ProductDTOdto=toDTO(product);redisTemplate.opsForValue().set("product:detail:"+id,JSON.toJSONString(dto),Duration.ofMinutes(30));returndto;}

3.3 与方案 A 的对比

维度A 删缓存B 更新缓存
更新后第一次读miss,多一次 DB直接 hit ✅
写路径复杂度中(需拼完整 DTO)
DB 成功 + Redis 失败旧缓存仍在 → 脏读旧缓存仍在 → 脏读
DB 失败 + Redis 成功不会发生(先 DB)若顺序错了才会

3.4 典型错误案例

错误 1:DB 与 Redis 组装逻辑不一致
// 读路径:join 分类表、过滤下架商品// 写路径:只 put 了 product 表字段,缺少 categoryNameredisTemplate.set(key,simpleProductJson);// ❌ 缺字段

正确:写路径复用同一套toDTO()/ 组装方法。


错误 2:认为 put 一定比 evict 更安全
Redis SET 失败 → 旧缓存仍在 → 用户仍看到旧价

正确:put 解决的是miss 窗口,不解决Redis 失败;失败仍需重试或改 evict。


错误 3:在事务未提交时 put
@Transactionalpublicvoidupdate(...){productMapper.update(...);redisTemplate.set(key,newJson);// ❌ 事务可能回滚,缓存已是新值}

正确:在afterCommit回调里再 SET Redis,或先 commit 再操作缓存。


四、方案 C:延迟双删(Delayed Double Delete)

4.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){Productproduct=loadAndApply(id,req);productMapper.updateById(product);Stringkey="product: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 price=100, version=13,Redis 已是 100/v13 T2: 慢读线程仍拿着 price=80, version=12 T3: 慢读线程 SET Redis → 若无保护,80 覆盖 100 ❌

版本号的核心:写 Redis 时只允许新版本覆盖旧版本,不是每次读都去查 DB 验 version。

5.2 数据结构

MySQL

ALTERTABLEproductADDCOLUMNversionBIGINTNOTNULLDEFAULT0;-- UPDATE ... SET version=13 WHERE id=1 AND version=12

Redis

{"version":13,"data":{"id":1,"price":100.00,"title":"..."}}

5.3 写路径:DB 乐观锁 + Redis 条件写

@TransactionalpublicProductDTOupdateProduct(Longid,UpdateRequestreq){Productp=productMapper.selectById(id);longoldVer=p.getVersion();applyUpdate(p,req);p.setVersion(oldVer+1);introws=productMapper.updateWithVersion(p,oldVer);if(rows==0){thrownewConcurrentUpdateException("请重试");}ProductDTOdto=toDTO(p);cacheService.putIfNewer(id,dto,p.getVersion());// 见 5.4returndto;}

5.4 Redis Lua:仅当 newVersion ≥ 当前 version 才 SET

// 语义:拒绝旧版本覆盖新版本publicbooleanputIfNewer(Longid,ProductDTOdto,longnewVersion){Stringkey="product:detail:"+id;Stringjson=wrapVersion(newVersion,dto);Longok=redisTemplate.execute(PUT_IF_NEWER_SCRIPT,List.of(key),json,String.valueOf(newVersion),"1800");returnok!=null&&ok==1L;}
-- KEYS[1]=key ARGV[1]=json ARGV[2]=newVersion ARGV[3]=ttllocalold=redis.call('GET',KEYS[1])ifnotoldthenredis.call('SET',KEYS[1],ARGV[1],'EX',ARGV[3])return1endlocaloldVer=tonumber(cjson.decode(old).version)iftonumber(ARGV[2])>=oldVerthenredis.call('SET',KEYS[1],ARGV[1],'EX',ARGV[3])return1endreturn0

5.5 读路径:命中仍只读 Redis(不每次查 DB)

publicProductDTOgetProduct(Longid){Optional<ProductDTO>cached=cacheService.get(id);if(cached.isPresent()){returncached.get();// ✅ 不查 DB}Productp=productMapper.selectById(id);ProductDTOdto=toDTO(p);cacheService.putIfNewer(id,dto,p.getVersion());// miss 回填也走 CASreturndto;}

5.6 典型错误案例

错误 1:每次读 Redis 再 SELECT version 对账
// ❌ 缓存失去意义ProductCacheEntryentry=getFromRedis(id);LongdbVersion=productMapper.selectVersion(id);if(entry.getVersion()<dbVersion){...}

正确:version 用在写侧防覆盖;读路径 hit 直接返回。必要时抽检 / 异步对账,不是每次读都对账。


错误 2:有 version 但仍用 plain SET
// ❌ 慢线程 v12 照样覆盖 v13redisTemplate.set(key,oldJson);

正确:更新与 miss 回填都走putIfNewer/ Lua CAS


错误 3:update 用 version,evict 与 put 混用无规则
有时 evict,有时 putIfNewer evict 后慢线程第一个 plain SET 旧值 → 仍脏

正确:定规则——要么统一 putIfNewer,要么 evict 后回填必须 CAS


错误 4:列表缓存带 version
product:list:cat:5 包含 20 个商品 其中一个改了 version → 整页列表 version 无法表达

正确:version 适合单实体单 key;列表用短 TTL 或不缓存。


六、方案 E:Binlog / MQ 异步删缓存(Canal)

6.1 流程

业务服务:只 UPDATE MySQL(代码里不碰 Redis) ↓ Canal 订阅 binlog → 解析变更 ↓ 发送 MQ:{ table: "product", id: 1, op: "UPDATE" } ↓ 消费者:DELETE product:detail:1(或 SET 新值)
RedisConsumerMQCanalMySQLAppRedisConsumerMQCanalMySQLApp不操作 RedisUPDATE product SET price=100binlog变更消息consumeDELETE product:detail:1

6.2 适用场景

  • 多个服务 / Job都会改同一张表,应用内 evict 容易漏
  • 缓存 key 规则复杂(详情 + 列表 + 搜索),希望集中在消费者维护
  • 业务与缓存解耦

6.3 一致性:最终一致

T1: DB 更新成功,接口返回 T2: 用户立刻读 → Redis 仍是旧值(MQ 未消费完) T3: 数百 ms ~ 数秒后,消费者删缓存 T4: 下次读 miss → DB 新值

不是「改完立刻一致」,是最终一致

6.4 典型错误案例

错误 1:以为上了 Canal 就强一致
改价后 100ms 内用户仍可能看到旧价

正确:接受延迟窗口;或关键读降级查 DB;或消费者直接 PUT 新值


错误 2:MQ 消费失败无重试
DB 已是新价,Redis 永远旧价,直到 TTL

正确:MQ重试 + 死信队列 + 监控告警


错误 3:每个服务仍各自 evict + Canal 也删
逻辑重复、顺序难控、排查困难

正确:选定单一失效来源——要么应用内 evict,要么 Canal,不要两套并行 unless 有明确分工。


错误 4:消费者删错 key
// ❌ binlog 里是 id=1,删成 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多服务写同表读路径是否加 DB
A 删缓存可能脏读到 TTL可能每服务都要 evict
B 更新缓存可能脏读可能每服务都要 put
C 延迟双删同 A,二次删缓解写回缓解同 A
D version CAS同 A/B拒绝旧覆盖配合 evict/put(写侧防护)
E Canal+MQ消费延迟删后仍可能写回*统一捕获
F 分布式事务理论一起失败复杂

* E 删缓存后,若读线程仍 plain SET 旧值,还需 D 或 C 配合。

8.2 选型决策树

是否多个服务写同一张表? ├─ 是 → 优先考虑 E(Canal+MQ),或 D(version) └─ 否 ↓ 同一 key 高并发读写,evict 仍偶发脏读? ├─ 是 → C(延迟双删)或 D(version CAS) └─ 否 ↓ 更新后是否立刻海量读、要避免 miss? ├─ 是 → B(写时更新),失败要重试 └─ 否 → A(写时删)✅ 默认 是否考虑 Seata 绑 Redis? └─ 一般否 → F 仅作了解

8.3 组合建议(常见生产组合)

业务推荐组合
普通商品详情A+ TTL 抖动 + 空值防穿透
改价后瞬间热点读B或 A + 异步 warming
大促同 SKU 高并发CD
微服务多写商品表E+ 短 TTL 兜底
库存 / 秒杀 / 占座不缓存余量,DB 锁 + 唯一约束

九、失败补偿清单

失败后果补偿
DB 成功,DELETE 失败脏读重试删;短 TTL;告警
DB 成功,SET 失败脏读或 miss重试 put/evict
延迟双删任务丢失旧值写回未清监控队列;TTL
Canal/MQ 积压长时间脏读扩容消费者;降级读 DB
version CAS 拒绝写入无缓存,下次读 DB可接受;或 evict 后由新读回填
误用先删后写长期脏读改顺序为先写后删

十、面试口述模板(约 1 分钟)

MySQL 是权威源,缓存是加速层。写路径我熟悉六种方案:
默认先写 DB 再删缓存,简单,回填只在读 miss 时发生;
若改完立刻大量读,用写时更新缓存,但要注意 Redis 失败补偿和 afterCommit;
高并发同 key 若出现旧值写回,用延迟双删,第二次删除必须异步,且在事务提交后调度;
版本号主要用在写侧 Lua CAS,防止慢线程用旧 version 覆盖新缓存,读路径命中不必每次查 DB version;
多服务写同表用Canal 订阅 binlog + MQ 异步删缓存,是最终一致,要有重试和死信;
分布式事务绑 Redis一般不推荐,库存类强一致应直接走 DB 锁,不缓存余量。


http://www.cnnetsun.cn/news/3217255.html

相关文章:

  • Windows环境下的Android应用部署:APK-Installer轻量级解决方案
  • 揭秘青龙脚本自动化:从零到一的终极实战指南
  • 禅论自动化分析神器:5分钟掌握通达信缠论画线插件
  • 闭循环低温恒温器的主要技术特点
  • AI优化论文指导录音使用场景识别准整理快,写论文更省事
  • 手把手教你给 Trae 配置魔芋AI:一个 Key 用遍 Claude+GPT+DeepSeek
  • 在Mac上运行Windows软件:Whisky的终极解决方案
  • 从困惑到清晰:openEuler社区问题归属定位的终极指南
  • 轴向磁通电机技术解析:从原理到电动车应用场景
  • 渡口:散文
  • 如何在Mac上运行Windows软件:Whisky完整使用指南
  • Notepad--:如何选择一款真正支持跨平台和中文编码的专业文本编辑器?
  • 5分钟学会QRazyBox:免费开源二维码修复工具,轻松拯救损坏的QR码
  • 飞书多维表格自动备份与清空
  • 从CBD到CID看杭州上城,未来十年AI+产业园区的样板间
  • 性能超过 GLM-5.1,混元 Hy3 两周限免!CodeBuddy 全系产品支持!
  • 50、<简单>数字方阵-对角线-2
  • fre:ac音频转换器:一站式解决所有音频格式转换需求
  • 瑞芯微RV1126B开发板(EASY-EAI-PI2) 人体关键点识别
  • B站视频秒变AI知识库:免费插件实现高效技术学习
  • 如何用Barlow字体为你的设计项目注入加州公路美学?
  • ARISTO Hand的远端过伸与刚柔传感协同机制解析
  • STM32G0B1RE与TPIS1S1385实现高精度人体存在检测方案
  • 终极数据恢复指南:如何使用TestDisk和PhotoRec找回丢失的分区和文件
  • DeepEval终极指南:3个技巧快速识别AI内容矛盾,提升文本一致性检测准确率
  • 循环经济再定调:一场从“扔掉”到“重生”的深刻革命
  • Simple Live跨平台直播聚合应用:告别频繁切换的终极解决方案
  • 新会上线!2026年工业工程与智能制造国际学术会议 (ICIEIM 2026)
  • 传统的SEO失效了?如何把微信群聊数据喂给AI,抢占下一代搜索红利
  • 别再跑腿了!无犯罪记录证明翻译线上办理全攻略(附费用与流程)