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