"加了缓存,P99 从几百毫秒掉到几十毫秒,可大促还是被打挂了。"这不是缓存没起作用,而是它起的是另一种作用。加速缓存的目标是命中率,削峰缓存的目标是泄压能力;前者的容量按工作集算,后者的容量按并发洪峰算。两者共用同一个实例、同一份 TTL、同一条降级路径时,机制只会对其中一个有效。
要盯住的不是"缓存命中率多少",而是:这套缓存真正服务的是哪个目标,被牺牲的那个目标,凭什么一直没被发现。
1. 两个目标,两套容量算法
加速回答"平均请求快不快",削峰回答"洪峰那几十毫秒里,下游还活着吗"。两者的输入变量完全不同:
| 比较项 | 加速用法 | 削峰用法 |
|---|---|---|
| 目标量 | 命中率、P99 延迟 | 峰值吸收率、回源请求峰值 |
| 容量按什么算 | 工作集(活跃键集合) | 并发洪峰 × 单次回源耗时 |
| 数据特征 | 热点集中、更新少 | 瞬时重复率高、可合并 |
| 过期策略 | 可以长,旧一点代价小 | 必须短,否则旧值放大不一致 |
| 失效后果 | 变慢 | 下游被打穿 |
| 成功判据 | 请求不出进程 | 洪峰期间回源数被压到下游容量以下 |
// 加速:容量 = 工作集 × 放大系数(典型量级 1.2–2,按对象大小实测核对)
容量 ≈ Σ(热键数 × 单键字节) × 1.5
// 削峰:容量与被缓存的数据量无关,与"同时进来的请求数"有关
容量 ≈ QPS峰值 × 单次回源耗时 × 合并率
// 合并率为 0 时,缓存容量再大也不削峰,只是把请求排队到缓存后面
第二行是全部误区的来源:削峰缓存的容量甚至可以远小于工作集,因为它真正买的不是"数据放得下",而是"同一份数据在峰值窗口内只回源一次"。
两个目的共用一套机制时,结果不是"两个都做到一点",而是两个都达不到:容量按工作集买(加速的算法),过期时间按命中率调(加速的判据),淘汰按最近最少使用(加速的偏好);于是削峰需要的"合并窗口"与"峰值可用的空闲容量"被逐项占掉。被牺牲的是削峰,它不会在月度报表里出现——平时的命中率依旧漂亮,峰值出事时的归因往往是"流量超预期",而不是"缓存被当成了加速器"。
2. 命中率高,不等于能削峰
命中率是窗口内的比例,削峰关心的是窗口内的绝对并发。这两个量可以同时成立又同时失效:
// 命中率:看起来很漂亮,但它对这个窗口内同时有多少请求无感
rate(cache_hits[1m]) / rate(cache_requests[1m])
// 峰值吸收率:真正该看的量
1 - rate(origin_requests[10s]) / rate(cache_requests[10s])
// ─── 用 10s 窗口而不是 1m:削峰的有效性只在那几十毫秒里成立
| 现象 | 加速视角的解读 | 削峰视角的解读 |
|---|---|---|
| 命中率 98% | 很好 | 无意义,2% 落到 20 倍流量上就是 40% 回源 |
| 命中率高但回源尖峰仍在 | 不矛盾 | 命中的是"分散的键",没合并"同一个键的并发" |
| 缓存里有数据、请求仍打穿 | 不矛盾 | 未命中的那批请求同时在建,互不知情 |
关键区分:**加速靠热点集中生效,削峰靠请求可合并生效。**热点集中让命中率高;请求可合并让并发的重复回源收敛成一次。前者能在平均值上体现,后者只在峰值上体现。所以只看命中率,永远看不到削峰有没有做。
把同一段时间按两种粒度各看一遍,差别就会显形:
// 1 分钟粒度:削峰缺失被平均掉
00:00 命中率 98% 回源 120 QPS 下游容量 500 ── 看起来很安全
// 10 秒粒度:同一分钟里的真实形状
00:00:10 命中率 71% 回源 1400 QPS 下游容量 500 ── 打穿
00:00:20 命中率 99% 回源 40 QPS 下游容量 500 ── 空闲
// ─── 峰值吸收率必须在 10s 甚至 1s 窗口上算,1m 窗口是加速视角的度量
3. 共用一个实例的三个后果
| 后果 | 机制 | 谁被牺牲 |
|---|---|---|
| 峰值时最不耐压的恰恰是加速用的那部分 | 长 TTL 的热键占了内存与连接,峰值时淘汰把它们挤掉,重建请求集中砸向下游 | 削峰能力 |
| 淘汰策略按加速目标调(LRU/LFU) | 峰值期的瞬时重复键是"新键",最容易被淘汰,合并窗口被破坏 | 削峰能力 |
| 降级路径写成了"缓存挂了走 DB" | 缓存故障时全部流量同时落库,熔断没设,等于把削峰装置变成单点 | 可用性,且两套目标同时失效 |
第三行值得单独说:**"缓存挂了会怎样"在两套目标下的正确答案不同。**加速用法下,答案是"变慢,可接受";削峰用法下,答案是"下游当场被打穿,必须先限流再降级"。同一个回答不可能同时成立,而通常写在文档里的那一句,是按加速场景写的。
// 降级路径必须独立于缓存本身,且默认关闭而不是默认放开
─── fallback:
─── on_cache_error: reject_with_retry_after // 不是直连下游
─── local_guard: 每实例最多 200 QPS 回源 // 本地阀门,不走共享存储
─── breaker: 下游错误率 > 30% 时打开,半开探测
淘汰策略是最典型的"按加速目标配置、在削峰场景下帮倒忙"的一项:
// 共用实例时,配置项都按加速视角写下
maxmemory 8gb
maxmemory-policy allkeys-lru // 峰值期涌入的瞬时重复键是"新键",最先被淘汰
// ─── 削峰需要的恰恰是"新键留得住、合并窗口内不消失"
// ─── 若必须共用:给削峰数据单独的 db 或实例,并放宽配额的淘汰权重
4. 过期策略、单飞与降级:只对一边有效的机制
| 机制 | 对加速的作用 | 对削峰的作用 | 错配后果 |
|---|---|---|---|
| 长 TTL | 提高命中率 | 放大不一致窗口 | 削峰拿到了更旧的批量数据 |
| 短 TTL | 命中率下降 | 缩短陈旧窗口、提高合并机会 | 加速侧的收益被自己削掉 |
| 单飞(single-flight) | 几乎无收益 | 核心机制 | 无它则削峰只是"缓存了数据没缓存请求" |
| 请求合并(批处理回源) | 无收益 | 核心机制 | 缓存命中率高仍会打穿 |
| 逻辑过期 + 异步重建 | 可用 | 削弱(旧值期间并发仍在) | 削峰窗口内旧值被放大 |
// 单飞:只对"同一个键的并发回源"有效,对"分散键的高命中"毫无帮助
CompletableFuture<T> load(String key) {
return inflight.computeIfAbsent(key, k ->
CompletableFuture.supplyAsync(() -> origin.load(k))
.whenComplete((v, e) -> inflight.remove(k))); // 完成即摘除
}
// ─── 注意:inflight 本身是本地结构,多实例部署时每实例各合并一次
// ─── 真正的削峰倍数 = 实例数,不是无限
这也是"请求合并只对削峰有效"的准确含义:它把并发变成一次调用,命中率指标上不会出现任何变化,所以在以命中率为唯一 KPI 的团队里,它永远排不进需求。
穿透、击穿、雪崩这三个老问题,在两套目标下的处置优先级也不同:
| 问题 | 加速视角的处置 | 削峰视角的处置 | 差异根源 |
|---|---|---|---|
| 穿透(查不存在的键) | 空值缓存、布隆过滤器 | 同上,但必须限制回源并发 | 加速怕慢,削峰怕同时 |
| 击穿(单键重建) | 互斥重建,代价是延迟 | 单飞是核心,且要限排队长度 | 加速可以等,削峰不能排队 |
| 雪崩(大量同时过期) | 加随机抖动 | 过期时间本来就要短,抖动幅度必须更大 | 削峰侧 TTL 更短,集中过期更频繁 |
同一套手段,在两个目标下的参数取值相差一个量级:**抖动幅度、互斥粒度、排队上限,全都要按目标重算。**照搬加速场景的参数,等于把削峰窗口留给了下游。
5. 排错:五种现场
| 现象 | 真实原因 | 处理方向 |
|---|---|---|
| 大促回源 QPS 是平时的十几倍 | 命中率高但未合并,峰值全是同键并发 | 上单飞,再按实例数校正预期 |
| 缓存故障后下游直接崩 | 降级路径直连下游、无限流 | 降级默认拒绝 + 本地阀门 |
| 加了缓存 P99 反而抖动 | 大键/热键淘汰引发集中重建 | 拆键、预热,或把加速与削峰数据分实例 |
| 数据陈旧投诉集中在大促 | 为命中率调长了 TTL,削峰场景下被放大 | 两套 TTL:加速长、削峰短 |
| 扩容缓存后削峰无改善 | 买的是容量,不是合并能力 | 削峰的瓶颈在并发不在内存 |
6. 边界:什么时候不必区分
| 场景 | 结论 | 理由 |
|---|---|---|
| 读多写少的静态数据 | 只做加速 | 峰值与均值差异小,合并收益趋零 |
| 峰值与均值差异小于 2 倍 | 只做加速 | 削峰的容量算法退化,两套机制重合 |
| 下游本身能扛住峰值 | 只做加速 | 削峰买的是"下游不被打穿",前提不存在 |
| 数据可容忍较长陈旧 | 明确选加速 | 此时短 TTL 是纯损失 |
| 缓存只用于单实例本地 | 只做加速 | 单飞退化成本地锁,跨实例合并无从谈起 |
四个量足以判断这套缓存到底在服务谁:命中率(加速)、峰值吸收率(削峰)、回源请求峰值(削峰的下游视角)、缓存故障时的错误率(降级是否独立)。前两个一起看,才能看出是不是"命中率很高、削峰为零"。
自测
- 命中率 98% 而大促回源 QPS 翻十倍,这两个事实为什么不矛盾?
- 削峰缓存的容量该按什么算,为什么它可以远小于工作集?
- 单飞机制在多实例部署下的真实削峰倍数是多少?
- 为提升命中率调长 TTL,对削峰目标造成了什么具体损失?
- "缓存挂了会怎样"这个问题,两套目标下的答案差在哪一步?
- 什么条件下区分加速与削峰,收益低于管理成本?