Skip to content

缓存一致性与更新策略 ​

S4-009必学高级约 60 分钟JDK 21企业开发进阶 · Redis

1. 本节定位 ​

《缓存穿透、击穿与雪崩》解决"缓存失效时的压力";本节解决**"缓存与数据库不一致"**:

text
这是个「没有完美解」的经典问题 —— 所有方案都在做同一件事:
把「不一致的时间窗口」尽量缩小,并用 TTL 给它设一个上界  ✓✓

★ 本节的实测把三种策略的"不一致读"占比量了出来 ✓

2. 学习目标 ​

  • 能画出"先删缓存再更新 DB"为什么会长期不一致 ★★★;
  • 知道"先更新 DB 再删缓存"为什么更可靠,以及它的残余风险 ★★★;
  • 知道延迟双删的代价与适用场景 ★★★;
  • 知道 TTL 是"不一致的上界",这是最重要的一条 ★★★;
  • 知道"关键业务绕过缓存直读主库"这个兜底手段 ★★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看一个经典的不一致时序 ✗:

text
假设缓存是 100,数据库要改成 200:

  线程A(写):删缓存 ────────────────────→ 更新 DB=200
  线程B(读):        读缓存(空) → 读 DB=100 → 写回缓存 100

  结果:**数据库是 200,缓存是 100** ✗✗
        而且"删缓存"这个动作已经发生过了,**不会再有人来修它** ✗✗

★ 关键点:缓存被写回了一个"已经过期的旧值" ✓✓ —— 这就是所有缓存一致性方案的出发点 ✓

5. 核心概念 ​

5.1 三种策略的实测对比 ★★★ ​

本节的实测(并发读写 300 毫秒,统计"读到的缓存值 < 数据库当前值"的次数):

策略总读取次数读到旧值占比
① 先删缓存,再更新 DB ✗633 万21.2 万3.35%
② 先更新 DB,再删缓存 ✓✓665 万1.7 万0.25%
③ 延迟双删 ⚠️540 万68.7 万12.73%

★★ 三个数据都能解释 ✓✓:

结果原因
① 最高(3.35%) ✗删缓存到更新 DB 之间的窗口里,读线程把旧值写回缓存 ✓
② 最低(0.25%) ✓✓缓存是"被删掉"而不是"被写成旧值" ——即使有读线程写回旧值,紧接着的删除会清掉它 ✓
③ 反而最高(12.73%) ✗因为"延迟"期间不一致窗口被拉长了 ——第二次删除来得更晚 ✓

★ 第三条最反直觉,但很有价值 ✓✓:

"延迟双删"并不是"更安全" ✗

它的本质是"用更长的延迟,换取一个更确定的一致性" ✓

如果你对"延迟期间读到旧值"零容忍,它反而更差 ✓✓

5.2 策略②为什么是"业界默认" ★★★ ​

★ 它的关键优势 ✓✓:

缓存是"被删除",而不是"被写成某个值" ✓

删除是"幂等且安全"的 ——删多了不要紧(下次回源重建);写旧值则是"有毒的" ——它会一直留在缓存里直到 TTL 到期 ✗✗

★ 它的残余风险(更小但存在) ✓:

text
① 读请求 A 在 DB 更新**前**读到旧值,然后**卡住**(如被 GC 停了、网络慢)
② 写请求更新 DB,并删除缓存
③ A 才醒过来,把旧值写进缓存   ← ✗ 又长期不一致了

★ 这个窗口很小(要求读请求在"读 DB"与"写缓存"之间被挂起几毫秒)✓, 但理论上存在 ✓ ——所以还需要 TTL 兜底 ✓✓

5.3 最重要的一条:TTL 是"不一致的上界" ★★★ ​

★ 这句话值得单独记住 ✓✓:

不管用哪种策略,只要缓存有 TTL,"不一致"就最多持续到 TTL 到期 ✓✓

没有 TTL 的缓存,不一致可能是"永久"的 ✗✗

★ 所以工程上的优先级是 ✓:

优先级措施
①给缓存设 TTL(不一致有了上界)★★★
②用策略②降低不一致概率 ✓
③对一致性敏感的操作绕过缓存 ✓✓

5.4 更强的方案:订阅 binlog 异步删缓存 ★★ ​

思路:不让业务代码负责"删缓存" ✗ —— 而是监听数据库的变更日志(binlog),由独立组件去删 ✓✓

★ 它的三个好处 ✓✓:

好处说明
业务代码零侵入 ★★★不用在每个写方法后加一行"删缓存" ✓
不会漏删 ★★只要 DB 变了,binlog 一定会有 ✓
可以重试与削峰 ✓消费失败可以重放 ✓

★ 代价 ✓:多了一个组件(Canal / Debezium)+ 链路更长(有延迟) ⚠️

★ 但它本质上还是"删缓存" ✓ ——只是触发的来源变了 ✓✓

5.5 什么时候干脆别用缓存 ★★★ ​

这三种场景建议绕过缓存直读主库 ✓✓:

场景原因
扣库存 / 扣余额 ★★★一致性要求极高,且要原子操作 ✓
支付状态查询 ★★读到旧状态会导致重复支付 ✗✗
权限校验 ★★★权限刚被收回却仍能访问 ✗✗

★★ 这条是实战经验 ✓✓:

别试图让缓存"什么场景都对" ✗

关键业务绕过缓存,比任何缓存策略都可靠 ✓✓

缓存的价值来自"读多写少 + 可接受短暂不一致" —— 违背这个前提的场景就不该用缓存 ✓

5.6 三种方案的最终对照 ★★★ ​

策略不一致概率写性能适用
① 先删缓存再更新 DB ✗最高(3.35%)好几乎不用
② 先更新 DB 再删缓存 ✓✓低(0.25%)好绝大多数场景 ✓
③ 延迟双删 ⚠️更高(12.73%)差(多一次延迟)写少读多且要求确定性的场景
④ 订阅 binlog 删缓存 ✓✓低最好(业务无感)大型系统 ✓

★ 无论选哪种,都要配 TTL ✓✓

6. 递进示例 ​

6.1 最小示例:策略② ​

java
@Transactional
public void updateProduct(long id, String name) {
    productRepository.update(id, name);      // ① 先更新 DB
    redis.delete("product:" + id);           // ② 再删缓存(失败也不回滚事务)
}

★ 注意:删缓存失败要不要回滚 ✓✓:

不要 ✗ ——因为 DB 已经改了,回滚会让"DB 没改但缓存已删" ✓

正确做法:删缓存失败时记日志 + 告警,靠 TTL 兜底 ✓✓

6.2 业务示例:延迟双删 ​

java
redis.delete(key);                 // ① 先删
productRepository.update(...);     // ② 更新 DB
Thread.sleep(500);                 // ③ 等过了"读线程写回旧值"的窗口
redis.delete(key);                 // ④ 再删

★ 注意"延迟"要在异步线程里做 ✗ ——不能阻塞主流程 ✓

6.3 反例:先删缓存再更新 DB ​

见 5.1 的 3.35% ✗

7. 完整可运行代码与实测 ​

代码:labs/redis-lab/src/main/java/cn/kb/learn/redis/CacheConsistencyLesson.java

运行:

powershell
cd labs
.\mvnw.cmd -pl redis-lab compile
java "-Dstdout.encoding=UTF-8" -cp redis-lab/target/classes cn.kb.learn.redis.CacheConsistencyLesson

实测输出:

text
  策略:先删缓存再更新 DB         总读取 6335258 次,读到旧值 211965 次(3.35%)
  策略:先更新 DB 再删缓存        总读取 6646469 次,读到旧值  16654 次(0.25%)
  策略:延迟双删                  总读取 5396530 次,读到旧值 686867 次(12.73%)

★ 三条可落地的结论 ✓:

#结论依据
①用"先更新 DB 再删缓存"比"先删缓存"低 13 倍 ✗
②"延迟双删"的延迟期本身是不一致窗口实测反而最高(12.73%)✓
③TTL 才是真正的兜底它给不一致设了上界 ✓✓

8. Debug 观察点 ​

  1. 把三种策略各跑三次:观察占比的波动(说明它是概率问题,不是"必然发生")★;
  2. 把写线程的数量提高:观察三种策略的差距是否拉大 ✓;
  3. 给缓存加 TTL 并观察"不一致持续多久":验证 TTL 是上界 ★★;
  4. 在策略②里去掉"删缓存"那一步:观察占比飙升 ✗✗。

9. 常见错误 ​

#错误说明
1先删缓存再更新 DB ✗✗不一致概率最高(5.1)✗
2缓存不设 TTL ✗✗不一致可能是永久的 ✓✓
3更新 DB 与删缓存不在同一事务里,却要求强一致 ✗分布式下做不到 ✓
4删缓存失败就回滚 DB ✗会造成"DB 没改但缓存已删" ✓
5以为"延迟双删"一定更好 ✗延迟期本身就是不一致窗口 ✓
6在扣库存等强一致场景上用缓存 ✗✗读到旧库存 → 超卖 ✓✓
7在每个写方法里手写删缓存 ⚠️容易漏 → 考虑 binlog 方案 ✓

10. 动手实验 ​

目标:亲手量出"三种策略的不一致概率"。

步骤:

  1. 跑 CacheConsistencyLesson,记录三种策略的占比 ✓;
  2. 把"先删缓存"改成"先更新 DB",观察占比下降 ★★;
  3. 把延迟双删的 sleep 从 5ms 改成 50ms,观察占比进一步上升(窗口更长)★★;
  4. 给缓存加一个 1 秒 TTL,观察"不一致"最终会自愈 ✓✓;
  5. 给扣库存加缓存并压测:观察是否超卖(说明强一致场景不该用缓存)⚠️。

验收方式:能说出"你的业务该选哪种策略、TTL 设多久、哪些操作绕过缓存"。

扩展任务:设计一个"商品价格更新"的一致性方案:

  • 价格改动的频率:每天几十次 ✓;
  • 读取的频率:每秒几千次 ✓;
  • 要求:最坏情况下不能超过 1 秒的旧价格 ✓;
  • 给出:策略选择 + TTL + 是否绕过缓存 + 理由 ★★

11. 练习题 ​

  1. 判断:延迟双删一定比"先更新 DB 再删缓存"更一致。(对 / 错)
  2. 填空:三种策略里不一致概率最低的是 ______;让"不一致"有上界的关键手段是 ______。
  3. 简答:为什么"删缓存失败不应该回滚 DB 事务"?
  4. (代码阅读)
java
public void updateUser(User user) {
    redis.delete("user:" + user.getId());
    userRepository.save(user);
}

这段代码有什么风险?时序是什么? 5. (设计)给"用户余额"设计读写方案:余额变动要准,查询要快。

参考答案(要点)
  1. 错。实测延迟双删反而最高(12.73%) ✗ ——因为它的"延迟期"本身就是不一致窗口 ✓★
  2. 先更新 DB 再删缓存;给缓存设 TTL ✓✓
  3. 因为 DB 已经改了,回滚会导致"DB 是新的、缓存却删了"的错位状态 ✗ —— 正确做法是记日志 + 告警,靠 TTL 兜底 ✓
  4. 风险:删除与更新之间存在窗口,读线程会把旧值写回缓存 ✗✗ —— 时序:A 删缓存 → B 读 DB(旧) → B 写回缓存(旧) → A 更新 DB(新) → 缓存是旧的 ✗✓
  5. 要点:余额查询走缓存(读多写少),但余额变动必须直连主库 + 行锁/乐观锁 ✓, 变动后主动删除缓存 ✓,且缓存 TTL 要短 ✓✓

12. 掌握标准 ​

  • [ ] 能画出"先删缓存"的不一致时序 ★★★;
  • [ ] 知道策略②为什么更可靠,以及它的残余风险 ★★★;
  • [ ] 知道"延迟双删的延迟期本身就是不一致窗口" ★★★;
  • [ ] 记住"TTL 是唯一能给不一致设上界的手段" ★★★;
  • [ ] 知道哪些场景应该绕过缓存 ★★★;
  • [ ] 知道 binlog 方案的核心价值(业务零侵入) ★★。

13. 扩展资源 ​

14. 下一步学习 ​

《Redis 分布式锁》:六代演进,以及每一步暴露的漏洞。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引