加了个定时预热脚本,日志显示"预热完成,共加载 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. 自测
- 三种预热对象里,哪一种的保鲜期由对端的空闲超时决定?
- 预热脚本报"成功"却对真实流量无效,最可能的原因是什么?
- 预热覆盖率的分母为什么必须是请求数占比而不是路径数?
- 预热后命中率下降,说明预热清单出了什么问题?
- 预热失败但服务正常启动,为什么比不预热更糟?
- 命中率衰减速度决定了预热周期的哪个上限?