一个缓存命中率 96%,团队认为效果很好。直到某次压测发现:缓存挂掉之后,数据库只承受了 1.6 倍的流量——说明这个缓存实际只挡住了很小一部分实际工作,那 96% 的命中大多是极便宜的、本来就不会打到数据库的请求。
命中率是一个容易被误读的指标,因为它只统计"有没有命中",不统计"命中省下了多少"。要判断缓存是否真的有用,至少还要看两个数:回源成本的节省量,以及失效时的放大倍数。
1. 三个口径各自回答什么
| 口径 | 公式 | 回答 |
|---|---|---|
| 命中率 | 命中次数 / 总请求 | 有多少请求没回源 |
| 加权命中率 | Σ(命中 × 单次回源成本) / Σ(全部请求 × 成本) | 省下了多少工作量 |
| 失效放大 | 缓存不可用时的回源量 / 正常回源量 | 缓存挂了会怎样 |
三个口径的使用顺序也有讲究:先算加权命中率判断"值不值得",再算失效放大判断"风险",最后才看命中率做日常监控。顺序反了,就会拿一个美化过的数字去做价值判断。
第一项最常见,也最容易美化:大量低成本的命中会把比率推高,而真正昂贵的那些请求可能一直没命中。
第二项把"成本"引入进来,才回答"这个缓存值不值得"。第三项是容量规划的依据——它决定"缓存故障"是不是一个灾难。
// 同一份数据的三个数字,结论完全不同
命中率: 96%(看起来很好)
加权命中率: 41%(昂贵的请求大量回源)
失效放大: 1.6 倍(数据库能承受,缓存其实可有可无)
2. 命中率为什么会被美化
| 美化方式 | 机制 |
|---|---|
| 低价值请求占比高 | 便宜请求命中多,拉高整体 |
| 分母含"不该走缓存"的请求 | 健康检查、静态资源 |
| 多次读取同一键 | 一次加载多次命中 |
| 缓存自身兜底(本地缓存) | 统计层看不到外层 |
第三项很微妙:一个键加载一次后被读一万次,命中率看起来接近 100%,但它只省了一次数据库查询。这类"读多写零"的键对命中率贡献极大,对成本节省贡献极小。
第四项在两级缓存里常见:本地缓存命中被记成"命中",但它没有挡住任何回源——因为本地缓存本来就是从分布式缓存拿的。
3. 加权命中率怎么算
| 步骤 | 做法 |
|---|---|
| 1 标注成本 | 按接口或按键类型给"单次回源成本"(耗时或资源) |
| 2 采集分布 | 记录每类请求的命中/未命中次数 |
| 3 加权求和 | 按成本加权,得到"节省比例" |
采样方式也要注意:按请求采样,不要按接口采样。按接口取平均会掩盖"同一接口内 heavy 与 light 混合"的情况,而这种情况恰恰是最需要看清楚的。
关键在第一步:成本不必精确,分档就够。把回源成本分成"轻(内存计算)、中(单表查询)、重(多表聚合或远程调用)"三档,比追求精确的成本模型更实用。
// 三档成本模型:够用且可维护
light : 本地计算、字典查询 权重 1
medium : 单表索引查询、单次缓存读 权重 5
heavy : 多表聚合、远程调用、搜索 权重 50
加权命中率 = Σ(命中数 × 权重) / Σ(总数 × 权重)
// 若 heavy 类的加权命中率很低,说明缓存没帮到最贵的那部分
4. 失效放大倍数
| 缓存状态 | 回源量 |
|---|---|
| 正常 | 1(基准) |
| 缓存全挂 | N 倍(等于原命中量) |
| 缓存部分失效(热点键被淘汰) | 可能接近 N |
| 缓存与数据库同时降级 | 叠加 |
放大倍数还有一个二阶效应容易被忽略:回源量翻倍时,下游的耗时也会上升(数据库在更高负载下变慢),于是总时延可能涨三四倍而不是两倍。这就是"缓存故障往往演变成雪崩"的机制。
第二项的 N 就是"缓存挡住的比例"的倒数:挡住 60% 意味着失效时回源 2.5 倍。所以"缓存挂了能不能扛"这个问题,答案不在缓存配置里,而在数据库的余量里。
// 估算失效放大的最直接方式:看回源请求数与数据库容量
redis-cli info stats | grep -E "keyspace_hits|keyspace_misses"
// 假设命中 h、未命中 m:挡住比例 = h/(h+m)
// 放大倍数 ≈ (h+m)/m;若数据库余量 < 该倍数,缓存失效即故障
还有一个与容量规划直接相关的用法:用失效放大倍数反推"需要多少数据库余量"。如果缓存挡住了 60% 的请求,那么数据库至少要能承受 2.5 倍突发;这个数字应当写进容量表,而不是留在缓存团队的口头共识里。
// 容量表的第四行:缓存失效场景
正常回源 QPS:2000
缓存失效 QPS:5000(放大 2.5 倍)
数据库安全上限:6000
结论:可承受缓存失效,但余量仅 20%,需保留降级开关
5. 三个口径的联动判断
| 命中率 | 加权命中率 | 结论 |
|---|---|---|
| 高 | 高 | 缓存有效,关注失效放大 |
| 高 | 低 | 缓存只挡住了便宜请求,重新设计键 |
| 低 | 高 | 昂贵请求命中,便宜回源无妨——可接受 |
| 低 | 低 | 缓存基本没用,考虑移除 |
第三行是个反直觉但重要的情况:命中率低不代表缓存无效,如果没命中的都是便宜请求,那么回源成本本来就不高。此时优化的方向不是提高命中率,而是确保昂贵请求始终命中。
// 判断顺序:先看加权,再看整体
if weighted_hit > 0.7: 缓存有效 → 评估失效放大
elif weighted_hit < 0.3: 几乎无效 → 移除或重设计
else: 局部有效 → 找出未命中的 heavy 类,单独优化
6. 排错:五种"命中率好但没效果"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 命中率 95% 但库压力没降 | 命中集中在便宜请求 | 算加权命中率 |
| 缓存挂了就雪崩 | 挡住比例高且库无余量 | 降级方案 + 余量评估 |
| 加了缓存 QPS 没涨 | 瓶颈不在数据库 | 先定位瓶颈 |
| 命中率忽高忽低 | 键设计不稳定或 TTL 抖动 | 统一键规则 |
| 两级缓存命中率高但慢 | 本地命中仍需一次远程校验 | 检查层级职责 |
排错时还可以直接对比两个时间点的数据:缓存正常时与某次缓存故障时的回源量。这个比值是最真实的放大倍数,比任何公式估算都可靠——前提是那次故障被记录下来。
第四项常源于"键里带时间戳"这类设计:每秒的键都不同,命中率自然低且波动。这类问题的修复不在缓存参数,而在键的定义。
7. 边界:什么情况只需看命中率
| 场景 | 为什么 | 做法 |
|---|---|---|
| 请求成本高度均匀 | 加权与简单平均等价 | 只看命中率 |
| 缓存只保护单一接口 | 成本单一 | 看该接口的命中率 |
| 纯静态内容 | 成本几乎为零 | 命中率只反映带宽 |
| 已有压测覆盖失效场景 | 放大倍数已知 | 不必重复算 |
最后一条与指标治理有关:把加权命中率写进看板,而不是只写命中率。只有有人看,键设计的问题才会被发现;只写命中率,等于给缓存系统发了一张永久的合格证。
判断口径一句话:不同请求的回源成本是否差一个数量级以上。差距大,必须加权;差距小,命中率就够。
8. 自测
- 三个口径分别回答什么问题?为什么命中率高可能意味着缓存无效?
- "一个键加载一次被读一万次"为什么对命中率贡献大、对成本节省贡献小?
- 加权命中率的成本为什么要分档而不是精确计算?三档模型怎么切?
- 失效放大倍数与"缓存挡住的比例"是什么关系?它决定了哪个工程决策?
- "命中率低但加权命中率高"意味着什么?此时优化方向是什么?
- 判断"是否必须加权"的那个问题是什么?