主题
界面
主题
《缓存穿透、击穿与雪崩》解决"缓存失效时的压力";本节解决**"缓存与数据库不一致"**:
这是个「没有完美解」的经典问题 —— 所有方案都在做同一件事:
把「不一致的时间窗口」尽量缩小,并用 TTL 给它设一个上界 ✓✓★ 本节的实测把三种策略的"不一致读"占比量了出来 ✓
先看一个经典的不一致时序 ✗:
假设缓存是 100,数据库要改成 200:
线程A(写):删缓存 ────────────────────→ 更新 DB=200
线程B(读): 读缓存(空) → 读 DB=100 → 写回缓存 100
结果:**数据库是 200,缓存是 100** ✗✗
而且"删缓存"这个动作已经发生过了,**不会再有人来修它** ✗✗★ 关键点:缓存被写回了一个"已经过期的旧值" ✓✓ —— 这就是所有缓存一致性方案的出发点 ✓
本节的实测(并发读写 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%) ✗ | 因为"延迟"期间不一致窗口被拉长了 ——第二次删除来得更晚 ✓ |
★ 第三条最反直觉,但很有价值 ✓✓:
"延迟双删"并不是"更安全" ✗
它的本质是"用更长的延迟,换取一个更确定的一致性" ✓
如果你对"延迟期间读到旧值"零容忍,它反而更差 ✓✓
★ 它的关键优势 ✓✓:
缓存是"被删除",而不是"被写成某个值" ✓
删除是"幂等且安全"的 ——删多了不要紧(下次回源重建);写旧值则是"有毒的" ——它会一直留在缓存里直到 TTL 到期 ✗✗
★ 它的残余风险(更小但存在) ✓:
① 读请求 A 在 DB 更新**前**读到旧值,然后**卡住**(如被 GC 停了、网络慢)
② 写请求更新 DB,并删除缓存
③ A 才醒过来,把旧值写进缓存 ← ✗ 又长期不一致了★ 这个窗口很小(要求读请求在"读 DB"与"写缓存"之间被挂起几毫秒)✓, 但理论上存在 ✓ ——所以还需要 TTL 兜底 ✓✓
★ 这句话值得单独记住 ✓✓:
不管用哪种策略,只要缓存有 TTL,"不一致"就最多持续到 TTL 到期 ✓✓
没有 TTL 的缓存,不一致可能是"永久"的 ✗✗
★ 所以工程上的优先级是 ✓:
| 优先级 | 措施 |
|---|---|
| ① | 给缓存设 TTL(不一致有了上界)★★★ |
| ② | 用策略②降低不一致概率 ✓ |
| ③ | 对一致性敏感的操作绕过缓存 ✓✓ |
思路:不让业务代码负责"删缓存" ✗ —— 而是监听数据库的变更日志(binlog),由独立组件去删 ✓✓
★ 它的三个好处 ✓✓:
| 好处 | 说明 |
|---|---|
| 业务代码零侵入 ★★★ | 不用在每个写方法后加一行"删缓存" ✓ |
| 不会漏删 ★★ | 只要 DB 变了,binlog 一定会有 ✓ |
| 可以重试与削峰 ✓ | 消费失败可以重放 ✓ |
★ 代价 ✓:多了一个组件(Canal / Debezium)+ 链路更长(有延迟) ⚠️
★ 但它本质上还是"删缓存" ✓ ——只是触发的来源变了 ✓✓
这三种场景建议绕过缓存直读主库 ✓✓:
| 场景 | 原因 |
|---|---|
| 扣库存 / 扣余额 ★★★ | 一致性要求极高,且要原子操作 ✓ |
| 支付状态查询 ★★ | 读到旧状态会导致重复支付 ✗✗ |
| 权限校验 ★★★ | 权限刚被收回却仍能访问 ✗✗ |
★★ 这条是实战经验 ✓✓:
别试图让缓存"什么场景都对" ✗
关键业务绕过缓存,比任何缓存策略都可靠 ✓✓
缓存的价值来自"读多写少 + 可接受短暂不一致" —— 违背这个前提的场景就不该用缓存 ✓
| 策略 | 不一致概率 | 写性能 | 适用 |
|---|---|---|---|
| ① 先删缓存再更新 DB ✗ | 最高(3.35%) | 好 | 几乎不用 |
| ② 先更新 DB 再删缓存 ✓✓ | 低(0.25%) | 好 | 绝大多数场景 ✓ |
| ③ 延迟双删 ⚠️ | 更高(12.73%) | 差(多一次延迟) | 写少读多且要求确定性的场景 |
| ④ 订阅 binlog 删缓存 ✓✓ | 低 | 最好(业务无感) | 大型系统 ✓ |
★ 无论选哪种,都要配 TTL ✓✓
@Transactional
public void updateProduct(long id, String name) {
productRepository.update(id, name); // ① 先更新 DB
redis.delete("product:" + id); // ② 再删缓存(失败也不回滚事务)
}★ 注意:删缓存失败要不要回滚 ✓✓:
不要 ✗ ——因为 DB 已经改了,回滚会让"DB 没改但缓存已删" ✓
正确做法:删缓存失败时记日志 + 告警,靠 TTL 兜底 ✓✓
redis.delete(key); // ① 先删
productRepository.update(...); // ② 更新 DB
Thread.sleep(500); // ③ 等过了"读线程写回旧值"的窗口
redis.delete(key); // ④ 再删★ 注意"延迟"要在异步线程里做 ✗ ——不能阻塞主流程 ✓
见 5.1 的 3.35% ✗
代码:labs/redis-lab/src/main/java/cn/kb/learn/redis/CacheConsistencyLesson.java
运行:
cd labs
.\mvnw.cmd -pl redis-lab compile
java "-Dstdout.encoding=UTF-8" -cp redis-lab/target/classes cn.kb.learn.redis.CacheConsistencyLesson实测输出:
策略:先删缓存再更新 DB 总读取 6335258 次,读到旧值 211965 次(3.35%)
策略:先更新 DB 再删缓存 总读取 6646469 次,读到旧值 16654 次(0.25%)
策略:延迟双删 总读取 5396530 次,读到旧值 686867 次(12.73%)★ 三条可落地的结论 ✓:
| # | 结论 | 依据 |
|---|---|---|
| ① | 用"先更新 DB 再删缓存" | 比"先删缓存"低 13 倍 ✗ |
| ② | "延迟双删"的延迟期本身是不一致窗口 | 实测反而最高(12.73%)✓ |
| ③ | TTL 才是真正的兜底 | 它给不一致设了上界 ✓✓ |
| # | 错误 | 说明 |
|---|---|---|
| 1 | 先删缓存再更新 DB ✗✗ | 不一致概率最高(5.1)✗ |
| 2 | 缓存不设 TTL ✗✗ | 不一致可能是永久的 ✓✓ |
| 3 | 更新 DB 与删缓存不在同一事务里,却要求强一致 ✗ | 分布式下做不到 ✓ |
| 4 | 删缓存失败就回滚 DB ✗ | 会造成"DB 没改但缓存已删" ✓ |
| 5 | 以为"延迟双删"一定更好 ✗ | 延迟期本身就是不一致窗口 ✓ |
| 6 | 在扣库存等强一致场景上用缓存 ✗✗ | 读到旧库存 → 超卖 ✓✓ |
| 7 | 在每个写方法里手写删缓存 ⚠️ | 容易漏 → 考虑 binlog 方案 ✓ |
目标:亲手量出"三种策略的不一致概率"。
步骤:
CacheConsistencyLesson,记录三种策略的占比 ✓;sleep 从 5ms 改成 50ms,观察占比进一步上升(窗口更长)★★;验收方式:能说出"你的业务该选哪种策略、TTL 设多久、哪些操作绕过缓存"。
扩展任务:设计一个"商品价格更新"的一致性方案:
public void updateUser(User user) {
redis.delete("user:" + user.getId());
userRepository.save(user);
}这段代码有什么风险?时序是什么? 5. (设计)给"用户余额"设计读写方案:余额变动要准,查询要快。
《Redis 分布式锁》:六代演进,以及每一步暴露的漏洞。
暂无