3123 字
约 10 分钟
21
S12-A09 · 缓存预热与首小时风险:上线那一刻最脆弱

发版后五分钟数据库 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. 自测

  1. 稳态命中率 95% 时,冷态数据库读放大约多少倍,怎么估?
  2. 为什么"整体命中率只掉了几个点"可能掩盖严重问题?
  3. 主动预热的清单应该按什么取,凭业务经验取会错在哪?
  4. 预热失败时默认应该阻塞启动还是放行,判断依据是什么?
  5. 多级缓存预热为什么默认先集中层?
  6. 预热脚本本身把 DB 打满,是哪两个参数没显式给?
S12-A09 · 缓存预热与首小时风险:上线那一刻最脆弱
https://www.clxhxhhr.top/posts/6229/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。