文档目录

7.4 缓存:三重防护与三重代价

上一节:7.3 并发与并行 | 下一节:7.5 背压与韧性 配套代码:07-optimization-and-validation/04-caching


一句话结论

缓存是"用一致性和复杂度换延迟"的交易,不是免费的加速。而且它有个反直觉的性质:能改善 P50,却可能让 P99 变差(因为未命中的那部分请求变慢了)。所以缓存必须有防护、有指标、有代价评估。


一、用「把工具放手边」理解缓存

你在修车,工具都在 20 米外的工具间。两种做法:

做法 好处 代价
每次去工具间拿 永远是"最新最全"的 慢
把常用工具放手边 快 工具会占地方;可能不是最新的;要找地方放

缓存完全一样:快,但要占内存、可能过期(数据不一致)、需要管理(失效逻辑)。


二、三个收益

收益 说明 典型量级
降低延迟 内存读取(纳秒)vs 数据库查询(毫秒) 10–1000 倍(单次)
提高吞吐 同样硬件能扛更多请求 取决于命中率
保护下游 减少打到数据库的请求 按命中率折算

注意第一项:单次缓存的收益是巨大的(纳秒 vs 毫秒),但整体收益取决于命中率:

命中率 90% → 90% 的请求快 100 倍,10% 的请求慢一点(缓存开销)
命中率 50% → 一半快,一半慢——P99 可能变差
命中率 10% → 几乎没有收益,但引入了全部复杂度

三、三个代价(必须评估)

❶ 一致性风险 🔴 最危险

场景 后果
缓存更新失败但数据库成功 用户看到旧数据,且不知道会持续多久
多实例缓存不一致 用户刷新两次看到不同结果
缓存过期与写入竞争 旧值覆盖新值(经典的缓存并发问题)

必须回答的问题:

① 数据能容忍多久的不一致?(1 秒?1 分钟?1 小时?)
② 如果缓存挂了,服务能降级到直接查库吗?
③ 缓存与数据库的更新顺序是什么?(先更新库还是先失效缓存?)

三种常见的更新策略:

策略 做法 一致性 复杂度
Cache-Aside(最常用) 读时填充,写时删缓存 最终一致(可能短暂读到旧值) 低
Write-Through 写时同时更新缓存与库 较强 中
Write-Behind 先写缓存,异步刷库 弱(可能丢数据) 高

Cache-Aside 的经典顺序问题:

❌ 先删缓存,再更新数据库
   时刻 T1: 删缓存
   时刻 T2: 另一个请求读缓存未命中,读库(旧值),写回缓存
   时刻 T3: 更新数据库完成
   → 缓存里是旧值,且不会自动修正

✅ 先更新数据库,再删缓存(推荐)
   时刻 T1: 更新数据库
   时刻 T2: 删缓存
   → 后续读会从库加载新值
   (仍有一个极小的窗口:读在 T1 拿到旧值,在 T2 之后写回 → 用"延迟双删"缓解)

❷ 内存成本

成本项 说明
堆内缓存(Caffeine) 占用 JVM 堆,增加 GC 压力(大对象可能触发 Humongous,第 2 章 2.5)
分布式缓存(Redis) 需要额外的服务器/内存
序列化开销 存 Redis 需要序列化/反序列化

关键:缓存必须是有界的(maximumSize 或内存上限),否则会 OOM(第 6.6 节的模式八)。

❸ 复杂度成本

复杂度 表现
失效逻辑 什么时候失效?怎么保证所有实例都失效?
缓存穿透/击穿/雪崩的处理 需要单飞、抖动、空值缓存
监控 命中率、大小、驱逐次数、未命中延迟
调试难度 “为什么这个用户看到旧数据”——比查数据库难得多
测试难度 缓存让测试需要处理状态

四、三重防护(对应三种失效模式)

防护一:单飞(防击穿)

问题:一个热点 key 失效瞬间,1000 个并发请求同时回源 → 数据库瞬间被打爆。

解法:同一 key 的并发回源只执行一次,其他请求等结果。

// ✅ Caffeine 的 get(key, mappingFunction) 是【原子】的 → 天然单飞
val value = cache.get(key) { k ->
    loadFromDb(k)      // 只会有一个线程执行,其他线程等待结果
}

验证方法:在数据库侧观察——击穿发生时,pg_stat_activity 会显示同一 SQL 有大量并发执行;修复后,同一 key 只会看到 1 次。

防护二:TTL 抖动(防雪崩)

问题:大批 key 在同一时刻失效(比如同一批写入、TTL 相同)→ 数据库流量瞬间翻倍。

解法:给 TTL 加随机抖动。

val baseTtl = Duration.ofMinutes(10)
val jitter = baseTtl.toMillis() / 5      // ±20%
val actualTtl = baseTtl.toMillis() + Random.nextLong(-jitter, jitter)

验证方法:观察数据库 QPS 曲线——修复前会有周期性脉冲,修复后曲线变平滑。

防护三:空值缓存(防穿透)

问题:查询不存在的 key(比如恶意构造的 ID)每次都会穿透到数据库。

解法:把"不存在"也缓存起来(用短 TTL)。

private val NULL_SENTINEL = Order(id = -1)

val result = cache.get(key) { k ->
    repo.findById(k) ?: NULL_SENTINEL       // 缓存空值,TTL 短一些
}
return if (result === NULL_SENTINEL) null else result

验证方法:观察数据库查询次数——恶意请求不应导致数据库压力。


五、四个必须监控的指标

指标 为什么
命中率 最核心——低于 70% 时要问「缓存设计是否合理」
未命中时的延迟 未命中的请求承担了「缓存开销 + 回源」,可能比不用缓存更慢
缓存大小 / 驱逐次数 频繁驱逐说明容量不足或 key 太多
回源 QPS 直接反映对下游的保护效果

关键洞察:只看命中率不够,必须同时看「未命中的延迟」:

命中率 90%,命中延迟 0.1ms,未命中延迟 200ms
→ P50 ≈ 0.1ms(好)
→ P99 ≈ 200ms(和不用缓存一样!因为 P99 落在未命中的那 10% 上)

命中率 99%,未命中延迟 200ms
→ P99 ≈ 0.2ms(这时缓存才真正改善了 P99)

这就是「缓存改善 P50 但不改善 P99」的数学原理——要让 P99 改善,命中率必须高于 99%。


六、多级缓存

请求
  ↓
L1:进程内缓存(Caffeine)    纳秒级,但各实例独立
  ↓ 未命中
L2:分布式缓存(Redis)       亚毫秒级,共享,但有网络开销
  ↓ 未命中
L3:数据库                    毫秒级,权威数据源

各层的特点:

层 延迟 一致性 容量 失效难度
L1(进程内) 纳秒 最差(各实例独立,失效通知有延迟) 小(占堆) 难(要广播失效)
L2(Redis) 亚毫秒 中 大 中
L3(DB) 毫秒 权威 最大 —

L1 的最大问题:多实例之间的失效通知。改了数据后,你能删掉 Redis 的 key,但很难立刻清掉所有实例的本地缓存。

两种处理方式:

方式 做法 一致性
短 TTL L1 的 TTL 设得很短(如 5–10 秒) 最终一致(最多旧 10 秒)
广播失效 通过消息队列广播失效消息 秒级一致(需要额外组件)

建议:如果数据一致性要求高,就不要用 L1——只用 Redis 这一层,复杂度低得多。


七、什么时候「不该」用缓存

情况 为什么
读少写多 缓存刚写入就被更新,命中率极低
key 基数极大且访问分散 命中率天然低(每个 key 只被访问一次)
一致性要求强 缓存的复杂度不值
收益 < 10% 且引入新失败模式 不值得(第 5 章 5.6 节的"统计显著 ≠ 值得做")
缓存掩盖了真正的问题(如 N+1) 先修根因(第 7.1 节的顺序)

判断方法:先算「命中率的理论上限」:

如果 key 有 100 万个,每天访问 100 万次,且访问均匀分布
→ 每个 key 平均每天访问 1 次 → 缓存没有意义(不会有第二次访问)

如果访问是幂律的(前 1% 占 40%)
→ 缓存能覆盖大部分热点 → 有意义

八、本节小结

  1. 缓存是"用一致性和复杂度换延迟"的交易,不是免费的加速。
  2. 三个收益:降延迟、提吞吐、保护下游。
  3. 三个代价:一致性风险(最危险)、内存成本、复杂度成本。
  4. 三重防护:单飞(防击穿)、TTL 抖动(防雪崩)、空值缓存(防穿透)。
  5. Cache-Aside 的正确顺序:先更新数据库,再删缓存。
  6. 必须监控四个指标:命中率、未命中时的延迟、缓存大小/驱逐、回源 QPS。
  7. 缓存改善 P50 但不一定改善 P99——要让 P99 改善,命中率必须高于 99%。
  8. 多级缓存中 L1 的最大问题是多实例失效——一致性要求高就别用 L1。
  9. 五种"不该用缓存"的情况,其中最重要的一条:不要用缓存掩盖 N+1 这类根因。

九、自测

  1. 一个接口加了缓存后 P50 从 20 ms 降到 2 ms,但 P99 从 80 ms 涨到 200 ms。请解释原因,并说出你会怎么改进。
  2. 一个热点 key 每次失效时,数据库会瞬间出现 1000 个相同查询。请说出这是哪种失效模式、对应的防护手段,以及验证方法。
  3. 一个团队说:「我们的缓存命中率 85%,很好。」请说出你还要追问哪两个指标,以及为什么。
  1. 原因:① P50 改善——85%–90% 的请求命中缓存,这些请求快了 10 倍;② P99 变差——未命中的那 10%–15% 请求现在要承担「缓存查询(未命中)+ 回源查询 + 写缓存」的额外开销,比不用缓存时更慢;P99 恰好落在未命中的那批请求上。改进方向:① 提高命中率——检查缓存容量是否不足(驱逐次数)、TTL 是否太短、key 设计是否合理;② 降低未命中的代价——用单飞避免并发回源、并行化"回源 + 写缓存"(回源后异步写)、减少回源查询本身的耗时(优化 SQL);③ 考虑分层——热点数据放进程内缓存(L1),进一步降低未命中率;④ 如果一致性要求不高,可以延长 TTL 并加抖动。关键判断:如果命中率无法显著提升(比如 key 太分散),那么缓存对这个接口可能不值得——因为它改善的是 P50,而 SLO 通常看 P99。
  2. 失效模式:缓存击穿(单个热点 key 失效时,大量并发请求同时回源)。防护手段:单飞(single-flight)——保证同一 key 的并发回源只执行一次,其他请求等待并复用结果。Caffeine 的 cache.get(key, mappingFunction) 是原子的,天然具备这个能力;如果是手写的 getIfPresent + put,必须自己加锁或用一个 per-key 的 Mutex。验证方法:① 在数据库侧观察——击穿时 pg_stat_activity 会显示同一条 SQL 有几十/几百个并发执行;修复后同一 key 只看到 1 次;② 在应用侧加一个「回源次数」计数器,正常情况下回源次数应该 ≈ 缓存失效次数(而不是 ≈ 并发请求数);③ 用压测复现:让一个热点 key 过期,同时发 1000 个并发请求,观察数据库的并发查询数。
  3. 还要追问:① 未命中时的延迟是多少?——这是最关键的补充。如果命中延迟 0.1 ms 而未命中延迟 200 ms,那么 15% 的未命中意味着 P85 之后的所有分位数都接近 200 ms——P99 完全没有改善(第 5.6 节的数学原理)。要让 P99 改善,命中率通常需要 > 99%。② 缓存大小与驱逐次数——如果驱逐频繁(接近 maximumSize),说明容量不足,实际有效命中率可能低于统计值;也可能是 key 基数太大,缓存设计本身不适合。还可以追问第三点:回源 QPS——它直接反映对下游的保护效果;以及一致性策略——85% 命中率下,如果有 15% 的请求读到旧数据,业务能否接受?