发版后五分钟数据库 CPU 打满,命中率还在 30% 往上爬——不是缓存设计有问题,而是系统在冷态被按稳态的容量假设对待了。预热要回答的不是"要不要暖缓存",而是首小时 DB 承受几倍于稳态的读,以及谁兜住这个倍数。
1. 首小时为什么是风险窗口
| 成因 | 触发方式 | 冲击强度 | 持续时间 |
|---|---|---|---|
| 重启/发版 | 进程内存清空,本地缓存归零 | 高:集中层也未命中 | 数分钟至数十分钟 |
| 主动失效 | 大批 key 同时过期或被清 | 中高:集中在某一批 key | 取决于回填速度 |
| 新实例扩容 | 新节点本地缓存为空,却分到流量 | 高:单节点对 DB 放大 | 与扩容批次相关 |
差别在于是否有"别人还热着":重启是全冷,扩容是局部冷。局部冷最危险——大盘命中率只掉几个点,却掩盖了新节点 0% 命中。
判据:冷态下的数据库读 QPS ≈ 稳态读 QPS × (1 − 命中率)。命中率 95% 时冷态读放大约 20 倍;这就是首小时的量级来源,不是估算误差。
2. 预热的三条路径
| 路径 | 做法 | 成本 | 覆盖面 | 失败形态 |
|---|---|---|---|---|
| 主动加载 | 启动前按热点清单批量灌入 | 一次批量读 + 内存 | 只覆盖清单内的 key | 清单过期、灌入把 DB 再打一遍 |
| 被动渐进 | 启动后放小流量,让真实请求回填 | 时间 | 覆盖真实访问集 | 首小时质量下降、慢请求堆积 |
| 懒加载 | 不预热,靠首次访问回填 | 零 | 全量(但滞后) | 冷 key 集中打 DB |
三者不互斥,实际是主动打底 + 被动补面 + 懒加载兜底。关键不是哪种更好,而是能接受多长的"低命中窗口"。
3. 主动预热的覆盖面问题
预热的有效性取决于清单质量,三个维度都要给答案:
| 维度 | 问题 | 判断依据 |
|---|---|---|
| 热点粒度 | 预 key 还是预集合? | 集合类 key 预租户级,不要预"全部" |
| 数据规模 | 预热数据能否放进缓存预算 | 预热量应占缓存容量的小比例,按 S12 容量预算核对 |
| 耗时 | 预热要多久 | 超过发布窗口就得分批或降级为部分预热 |
warmup:
source: topn_access_log # 从上周期访问日志取热度,而不是凭经验
window: 7d
coverage_target: 0.8 # 覆盖 80% 访问量所需的最小 key 集
max_keys: 200000
concurrency: 8 # 关键:并发过高等于对 DB 做一次压测
rate_limit_qps: 500
on_failure: partial # 失败不阻塞启动,记录缺口
coverage_target 最易被忽略:按访问量累积分布取前 N 个 key,通常少量 key 即覆盖大部分请求(典型长尾)。预"全部"既慢又费内存,还把预热本身变成一次 DB 压力事件。
4. 预热的边界:预什么、预多少、失败了怎么办
| 决策 | 保守做法 | 激进做法 | 什么时候选激进 |
|---|---|---|---|
| 预什么 | 只预读多写少、可容忍陈旧的 key | 含一致性敏感的 key | 一致性要求低、可容忍短暂旧值 |
| 预多少 | 覆盖 60–80% 访问量 | 全量 | 缓存容量充裕且预热可并行 |
| 失败处理 | 部分预热 + 记录缺口,继续启动 | 阻塞启动直到完成 | 冷态 DB 完全兜不住 |
| 预热时机 | 启动前离线灌入 | 启动中并行 | 启动时间敏感 |
"预热失败怎么办"必须先定:默认部分成功也放行,并在监控标出"本次预热覆盖率";阻塞启动只用于"冷态必崩",且要配超时。
5. 穿透防护与预热的配合
预热解决"有 cache 但空",穿透防护解决"没有这个 key"。两者错位会在首小时叠加:清单外的 key 变成穿透流量直落 DB。
// 回填互斥 + 空值短缓存:冷态下把同一 key 的并发压成一次回源
String get(String key) {
String v = cache.get(key);
if (v != null) return v;
if (!lock.tryLock(key, 200, MS)) { // 抢不到锁就短暂等待
return cache.getWithRetry(key, 50, MS); // 退回旧值或默认值
}
try {
v = loadFromDb(key);
cache.set(key, v != null ? v : NULL_MARK, v != null ? ttl : shortTtl);
return v;
} finally { lock.unlock(key); }
}
要点:空值也要缓存(短 TTL),否则不存在的 key 会反复穿透;加锁等待必须远小于接口超时,否则锁成瓶颈。
6. 扩容时的雪崩机制
新实例没有本地缓存,却按实例数均分流量,于是它把 1/N 的请求全部打到集中层,集中层再打到 DB。扩容本是为了分担压力,冷启动阶段反而放大了 DB 读。
# 新实例上线:先摘流量 → 预热 → 小流量验证 → 逐步加权重
./lb weight --instance $NEW --weight 0
./warmup --instance $NEW --coverage 0.8 --qps 500
curl -s "http://$NEW:8080/health/cache" | jq '.hit_rate' # 确认命中率已起
./lb weight --instance $NEW --weight 10 --step 10 --interval 60s
关键判断:新实例命中率未接近稳态前,权重不加满,否则它就是一个持续向 DB 泄漏读流量的节点。
7. 限流与预热的关系
这里的限流不是常规保护手段,而是把首小时流量压到 DB 能承受的倍数,与预热是同一件事的两面:
| 组合 | 效果 | 代价 |
|---|---|---|
| 只限流不预热 | DB 不崩,但首小时大量请求降级 | 用户体验受损 |
| 只预热不限流 | 命中率高,但预热缺口处的突发仍能打穿 | 依赖清单准确 |
| 限流 + 预热 | 首小时可控,缺口被限流兜住 | 需要两套配置同时对齐 |
限流阈值应当按"冷态可承受读 QPS"设定,而不是按稳态 QPS。稳态阈值在冷态下等于没设。
8. 发布顺序的编排
| 顺序 | 适用场景 | 风险点 |
|---|---|---|
| 先扩容 → 再预热 → 最后发版本 | 读放大为主的系统 | 预热期间新旧版本共存,缓存结构可能不兼容 |
| 先预热 → 再摘流量 → 发版本 | 缓存结构有变更 | 摘流量本身造成一次小型冷启动 |
| 先发版本 → 靠流量渐进回暖 | 缓存结构无变更、QPS 低 | 首小时直接裸露给用户 |
| 先摘流量 → 预热 → 灰度放量 | 影响面大的核心接口 | 编排复杂,需要可观测支撑 |
依据只有两个:缓存结构是否变更(决定要不要先摘流量)、冷态 DB 能否扛全量读(决定要不要先预热)。
9. 多级缓存的预热顺序
本地层(L1)与集中层(L2)谁先暖,答案取决于回源路径:
| 顺序 | 路径 | 结果 |
|---|---|---|
| 先 L2 再 L1 | L1 未命中 → L2 命中 → 回填 L1 | 只有第一波请求触达 L2,DB 压力最小 |
| 先 L1 再 L2 | L1 命中则永不触达 L2 | L1 覆盖不全时缺口直打 DB |
| 同时预热 | 两套清单并行灌入 | 快,但可能暖了两份不一致的数据 |
默认先 L2:集中层是共享的、回源成本高;L1 让真实流量自然回填即可。反过来的场景是 L1 命中率极高且 L2 容量紧张,此时才值得单独灌 L1。
10. 预热效果怎么观测
别只看命中率,它掩盖新老实例差异。三个观察点:
# 1) 命中率爬升曲线:按实例分组,才能看出新节点是否为 0
sum(rate(cache_hits[1m])) by (instance) / sum(rate(cache_requests[1m])) by (instance)
# 2) 回源 QPS:冷态应快速回落并稳定,不回落说明清单有缺口
sum(rate(cache_misses[1m]))
# 3) DB 读 QPS 与 P99:与稳态基线对比,看放大倍数
sum(rate(mysql_global_status_questions[1m]))
判据:回源 QPS 应呈"快升后回落"。只升不落说明命中的 key 与访问的 key 不是同一批,清单取错了维度。
11. 灰度与流量比例
灰度比例决定预热压力的分摊:10% 流量时冷态放大的绝对量小,跳到 50% 则放大量同比上升。
| 灰度比例 | 适合的预热策略 | 观察窗口 |
|---|---|---|
| 1–10% | 可不预热,靠自然回填 | 看回源 QPS 是否随比例线性上升 |
| 10–50% | 主动预热 + 限流 | 看命中率是否在窗口内进入稳态区间 |
| 50–100% | 必须预热,否则按全量读放大估算 | 看 DB P99 是否回到基线 |
放量的门槛不是"没报错",而是"命中率已进入稳态区间"。
12. 预热脚本自身的成本
最讽刺的失败是:为了不让 DB 打满,先用预热脚本把它打满一次。控制点三个:
# 预热脚本的自我保护:限速、并发上限、失败快速放弃、进度可中断
warmup --keys hot.keys --qps 500 --concurrency 8 \
--timeout-per-key 200ms --max-error-rate 0.05 \
--checkpoint warmup.state --resume
--qps/--concurrency 必须显式给,默认常为无限制;--checkpoint 让中断可续,避免重跑再打一遍;--max-error-rate 防止 DB 吃紧时继续加压。
13. 排错
| 现象 | 判断 | 处理 |
|---|---|---|
| 发版后 DB CPU 打满但命中率在涨 | 冷态读放大,属预期但幅度过高 | 提前预热 + 首小时限流 |
| 整体命中率正常,DB 仍被打 | 新实例局部冷,被大盘平均掩盖 | 命中率按实例分组看 |
| 回源 QPS 只升不落 | 预热的 key 与访问的 key 不一致 | 用访问日志而非业务经验取清单 |
| 预热完命中率没变 | key 被 LRU 挤掉或 TTL 太短 | 查容量与 TTL,别只加预热量 |
| 扩容后集中层 QPS 反升 | 新节点本地缓存为空 | 先摘流量再逐步加权 |
| 预热脚本导致 DB 抖动 | 脚本无并发/QPS 上限 | 显式限速 + 断点续跑 |
14. 边界
| 场景 | 判断依据 | 更合适的做法 |
|---|---|---|
| 数据量小、可全量常驻 | 冷态放大的绝对值低 | 不预热,接受首小时 |
| 命中率本就低(<50%) | 预热改善空间有限 | 先解决缓存设计,而非预热 |
| 恢复极快(秒级回填) | 低命中窗口短 | 只做限流,不做预热 |
| 缓存结构每次发版都变 | 清单维护成本高于收益 | 缩小变更面,用版本化 key |
| 一致性敏感、不可读旧值 | 预热引入旧值窗口 | 不预热,用限流与排队 |
一句话选择:冷态读放大倍数 × 首小时时长 才是预热要买的东西;买不到就买限流。
15. 自测
- 稳态命中率 95% 时,冷态数据库读放大约多少倍,怎么估?
- 为什么"整体命中率只掉了几个点"可能掩盖严重问题?
- 主动预热的清单应该按什么取,凭业务经验取会错在哪?
- 预热失败时默认应该阻塞启动还是放行,判断依据是什么?
- 多级缓存预热为什么默认先集中层?
- 预热脚本本身把 DB 打满,是哪两个参数没显式给?