3127 字
约 10 分钟
16
S23-A12 · 预热策略的取舍:预热什么、预热多久、谁来触发

加了个定时预热脚本,日志显示"预热完成,共加载 5000 个键",第一波真实请求照样慢。查下去发现:脚本打的是最简单的商品详情查询,而真实流量里 70% 落在搜索列表与推荐聚合上——那两条路径一行都没被预热到,缓存的 5000 个键里有一大半从来没人读。

这类失败不是"预热没做",而是预热被当成一个动作,而不是一组必须分开计价的投入。数据、连接、代码路径三件事的保鲜期、成本归属、失效条件完全不同,用同一套脚本、同一个时间窗口、同一个"完成"信号去处理,结果必然是其中一两项白做。


1. 三种预热对象,保鲜期从秒到天

对象 预热动作 保鲜期 失效条件 成本落在谁身上
数据 把热点键读进缓存 秒到小时(取决于 TTL) TTL 到期、写入使缓存失效 缓存与数据库(预热流量本身)
连接 预先建连、握手、鉴权 会话级,理论上不过期 对端空闲超时回收、连接被重置 下游连接数上限
代码路径 用代表性请求触发编译 进程级,进程不重启就一直在 重新发布、类被卸载 CPU(编译线程与请求线程争抢)

三者的性质差别决定了两件事:连接预热只能做一次,数据预热要按 TTL 周期重做,代码路径预热只对"这一批请求形态"有效。把它们塞进同一个定时任务,最直接的问题是周期选不出来——按连接的周期做,数据早过期;按数据的周期做,等于每小时重复一次无意义的建连。

// 连接池:minIdle 决定"预热到多少",不是 maxPoolSize
spring:
  datasource:
    hikari:
      minimum-idle: 20              // 启动即建满,代价是下游瞬时 20 个连接
      maximum-pool-size: 50
      connection-timeout: 3000
      idle-timeout: 600000          // 对端比这里更早回收,就等于白建

minimum-idle 与对端 wait_timeout 的关系要核对:如果池子认为连接有效、数据库已经把它掐了,第一次使用会拿到一个坏连接,表现为"预热过但还是报错"。

预热脚本能覆盖多少真实路径,取决于它多像真实流量。这是预热最常失败的地方,也是最难被发现的——因为脚本自己会报"成功"。

差异维度 脚本通常的样子 真实流量 后果
路径分布 单一路径、最简参数 多路径长尾,参数组合复杂 未覆盖的路径全部冷启动
并发度 串行或低并发 高并发打同一批键 缓存击穿与编译竞争
数据规模 全量或固定一批 幂律分布,头部极热 缓存被一堆不读的键占满
依赖深度 只打主查询 触发下游聚合、RPC 扇出 下游连接与线程池没预热

正确的做法不是"写一个更复杂的脚本",而是用真实流量的路径分布来生成预热清单,并且统计覆盖率,让"没覆盖到多少"变成一个可见的数。

// 用访问日志统计路径分布,取累计占比 80% 的路径作为预热清单
SELECT route, COUNT(*) AS hits,
       SUM(COUNT(*)) OVER (ORDER BY COUNT(*) DESC) / SUM(COUNT(*)) OVER () AS cum_ratio
FROM access_log
WHERE ts > NOW() - INTERVAL 7 DAY
GROUP BY route
ORDER BY hits DESC;
// 只保留 cum_ratio <= 0.8 的行,后面的长尾靠延迟加载

覆盖率的分母必须是请求数占比,不是路径数占比。覆盖 80% 的路径、但这些路径只占 20% 的流量,这个数字毫无意义。

2. 预热与缓存:它会把冷数据变成热数据

预热有个反直觉的副作用:缓存容量是固定的,预热塞进去的键会挤掉已经在里面的键。如果预热清单选得不谨慎,等于用一批没人读的数据换掉了一批真热点。

情况 预热的效果 判断依据
预热清单=头部热点 命中率上升,DB 读下降 预热后命中率高于预热前
预热清单偏长尾 挤掉真热点,命中率可能下降 预热后命中率不升反降
预热数据量接近缓存容量 淘汰策略频繁触发,写放大 淘汰次数与预热键数同量级
预热与在线流量同时进行 缓存与 DB 双重压力 预热窗口内 DB QPS 抬升

判据很直接:预热前后的命中率对比,而不是预热加载了多少键。 加载量是投入,命中率才是产出。预热还应该限速,否则它自己就是一次小型的流量攻击。

// 预热限速:并发上限 + 每秒键数上限 + 可中断
warmup --keys hot-keys.txt \
  --concurrency 4 \
  --rate 200/s \
  --timeout 900s \
  --resume-from checkpoint.json
// 失败不阻塞:达到错误阈值就停,别把下游拖垮

--concurrency 的取值要看下游能承受多少,不是看预热想多快完成。预热把下游打挂,比不预热更糟。

3. 谁来触发:三种方式,可靠性不同

触发方式 可靠性 优点 风险
启动时同步执行 高(阻塞就绪) 就绪即预热完成,状态清晰 启动时长被拉长,可能超时就绪检查失败
启动时异步执行 中 启动快 就绪与预热脱钩,需额外状态位
定时任务(发布后触发) 低 与发布解耦 时机不准,可能预热到旧实例或已下线实例
外部探针触发 中 可编排、可重试 探针本身成为依赖,且要鉴权

预热失败但服务起来了,比没预热更糟。 因为容量规划与告警都按"预热过"的假设配的,而实际状态是半预热:一部分路径热、一部分冷,冷的那部分在高峰期的表现比完全冷启动还难归因。

// 就绪位与预热状态绑定:未完成就绪就不接流量,而不是等它"稍后好"
@Component
public class WarmupGate implements ApplicationListener<ApplicationReadyEvent> {
    private final AtomicBoolean ready = new AtomicBoolean(false);

    @Override
    public void onApplicationEvent(ApplicationReadyEvent e) {
        warmupExecutor.submit(() -> {
            try {
                warmup.run();                 // 失败会抛,不吞
                ready.set(true);              // 只有全成功才置位
            } catch (Exception ex) {
                metrics.counter("warmup.failed").increment();
                // 保持 false:由就绪检查把实例摘出,等下一次滚动替换
            }
        });
    }
    public boolean isReady() { return ready.get(); }
}

这里有个明确取舍:把预热放进就绪判定,等于用发布时长换容量确定性。当预热时长接近或超过发布窗口时,这个交换就不划算,需要退回到"分路径预热 + 长尾延迟加载"。

4. 判据与度量:覆盖率不如关键路径命中

"预热到多少算够"没有统一答案,但有两个候选判据,性质不同:

判据 定义 优点 失效方式
路径覆盖率 预热清单覆盖的请求占比 可量化、可对比 覆盖了路径但没覆盖参数分布,仍会穿透
关键路径命中 核心链路(下单、搜索首屏)预热后命中率达标 直接对应业务 覆盖面窄,长尾仍冷

工程上更实用的是分层判据:核心链路用"命中率达标",其余路径用"覆盖率"兜底,剩下长尾明确接受延迟加载。把三者写进同一张表,比争论一个统一阈值有意义。

预热收益随实例数双向变化:

实例数变化 收益侧 稀释侧
实例数增加 每台都能立刻承载,扩容有效 每台都预热一次,总预热流量成倍放大
实例数减少 单台承载更重,预热更关键 预热总量下降,但单台预热时长不变
频繁扩缩容 冷启动被消除 预热流量约为稳态流量的数倍,下游按稳态配的容量会被打穿
// 四个必须留的数:覆盖、首波错误率、耗时、命中率衰减
warmup_covered_ratio   // 预热清单 / 真实请求分布 的覆盖占比
warmup_first_wave_5xx  // 预热完成后 5 分钟内的 5xx 占比
warmup_duration_p95    // 预热耗时 p95,按版本分组
warmup_hit_decay       // 预热后 30 分钟的命中率相对首分钟的衰减

最后一项最容易被忽略:命中率衰减速度决定了预热周期的上限。衰减到稳态一半的时间如果短于发布间隔,说明这次预热的意义只在发布后那几分钟。


5. 排错:五种"预热了但没用"

现象 成因 处理方向
脚本报成功,首波请求仍慢 预热路径与真实路径分布不一致 用访问日志生成清单,统计覆盖率
预热后命中率反而下降 预热键挤掉了真热点 按命中率而非加载量评估,缩小清单
预热期间下游报错 预热并发与速率未限,等于小规模压测 加并发与速率上限,可中断可续跑
每次发布后都要人盯着 预热失败未被就绪检查拦住 预热状态并入就绪位,失败不接流量
预热耗时越滚越长 清单只增不减,长尾键不断累积 清单按周期重算,旧键随 TTL 自然退出

把预热清单按路径分组,发布后对比各组首小时 P99,慢的那组就是没被覆盖的。

6. 边界:什么情况不值得专门预热

场景 为什么价值低 替代做法
流量极低 预热流量相对真实流量过大,成本占比失衡 首次访问延迟加载
请求路径单一 第一条请求就把路径与缓存带热 依赖天然的首请求预热
缓存本身填充极快 少量请求即可覆盖热点 不做预热,只做并发保护(防击穿)
数据变化极快 预热数据在几分钟内失效 缩短 TTL 或改用计算缓存
实例数极多且频繁伸缩 每台预热一次的放大效应超过收益 常驻少量热实例 + 其余延迟加载

判断口径是一个比值:预热带来的首小时错误率下降,是否大于预热流量对下游造成的额外压力。这个比值算不出来的时候,先做"只记录不预热"——把首波错误率与命中率曲线记录下来,一个发布周期之后自然就有了答案。


7. 自测

  1. 三种预热对象里,哪一种的保鲜期由对端的空闲超时决定?
  2. 预热脚本报"成功"却对真实流量无效,最可能的原因是什么?
  3. 预热覆盖率的分母为什么必须是请求数占比而不是路径数?
  4. 预热后命中率下降,说明预热清单出了什么问题?
  5. 预热失败但服务正常启动,为什么比不预热更糟?
  6. 命中率衰减速度决定了预热周期的哪个上限?
S23-A12 · 预热策略的取舍:预热什么、预热多久、谁来触发
https://www.clxhxhhr.top/posts/6377/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。