5.2 预热、稳态与运行时长
上一节:5.1 单变量与对照组 | 下一节:5.3 重复、抖动与异常值 配套代码:05-experiment-design/02-warmup-and-duration
一句话结论
压测时长不是拍出来的,是由「周期」决定的。 你必须至少覆盖一个完整的最长周期——GC 老年代回收周期、缓存 TTL、定时任务间隔、连接池回收周期。30 秒的压测结论只能当烟雾测试。
一、用「煮饭」理解预热与时长
煮一锅饭:
| 阶段 | 状态 | 对应 |
|---|---|---|
| 前 2 分钟 | 水还没开,米是硬的 | 冷启动:JIT 未编译、连接池空、缓存冷 |
| 中间 10 分钟 | 沸腾、米在吸水 | 过渡态:数据不能代表最终结果 |
| 最后 5 分钟 | 稳定收汁 | 稳态:可以判断饭好不好 |
三个关键推论:
- 「尝一口」不能判断饭好了没——你需要知道现在处于哪个阶段。
- 时间不够,饭就是生的——再好的米也没用。
- 不同米需要的时间不同——长粒米和短粒米不一样,就像不同应用需要的预热时间不同。
所以:预热时长要看「是否进入稳态」,运行时长要看「是否覆盖了所有周期性事件」。
二、预热:怎么判断「够了」
❌ 错误做法:固定预热 N 轮或 N 秒
「JMH 默认预热 5 轮,那就够了。」
「压测前先跑 60 秒预热。」
为什么错:不同应用需要的预热量差几十倍。一个启动加载 500 个类的应用,和一个 20 个类的工具,不可能同时进入稳态。
✅ 正确做法:看方差收敛(滚动 CV)
已在第 2 章 2.4 节详述。核心判据:
| CV(变异系数) | 判断 |
|---|---|
| > 10% | 明显没稳,继续预热 |
| 5% – 10% | 接近稳态,再等等 |
| < 5% | 可以开始测量 |
| < 2% | 环境很干净,数据可信度高 |
同时要覆盖的四件事(JVM 之外还有):
| 需要预热的 | 怎么预热 |
|---|---|
| JIT 编译 | 跑真实流量(不是空跑) |
| 连接池 | 发出足够请求让池填满 |
| 数据库缓存 | 让热点数据进 PG 的 shared_buffers |
| 应用缓存 | 让本地/Redis 缓存达到稳态命中率 |
| 配置/密钥加载 | 确保不在测量期做一次性的远程调用 |
三、运行时长:由「周期」推导
必须覆盖的四个周期
① GC 老年代回收周期
→ Young GC 可能几百毫秒一次,但 Old GC / Full GC 可能几分钟到几十分钟一次
→ 用 GC 日志里的时间戳推算
② 缓存 TTL 与缓存满载周期
→ 如果缓存 TTL 是 30 分钟,至少跑 1 小时才能看到"过期-回源"的完整过程
→ 本地缓存的淘汰周期也要算(例如 Caffeine 的 maximumSize 达到稳态的时间)
③ 业务定时任务周期
→ 每小时对账?每 5 分钟同步?这些都必须在测量窗口内至少出现一次
→ 如果定时任务与请求争抢资源,你的 P99 尖刺可能来自它
④ 连接池/线程池的回收与重建周期
→ 有些池有 idle timeout(例如 30 分钟),连接会被回收重建
→ 正常状态下没问题,但回收瞬间可能有抖动
时长的推算公式
最小稳态时长 = max(GC老年代周期, 缓存TTL, 定时任务间隔, 池回收周期) × 2
为什么要 ×2:一次只能确认「这个事件发生过」,两次才能确认「它对结果的影响是稳定的、可重复的」。
参考时长表
| 场景 | 最小稳态时长 | 说明 |
|---|---|---|
| 微基准(纯计算) | 秒级 | 用滚动 CV 判定 |
| 组件基准(单次查询) | 几十秒 | 主要看 JIT 与连接池预热 |
| 集成基准(单服务) | 3–5 分钟 | 覆盖 young GC 周期 |
| 全链路 SLO 验收 | 10–20 分钟 | 覆盖缓存与池的周期 |
| 尖峰测试 | 5–10 分钟 | 含恢复观察期 |
| 浸泡测试 | 1 小时起 | 覆盖完整业务周期(第 3 章 3.5) |
| 容量曲线(阶梯) | 每级 2–5 分钟 | 总 20–60 分钟 |
注意「稳态时长」与「总时长」的区别:
总时长 = 预热时长 + 稳态测量时长(+ 恢复观察期)
↑ 不计入统计
四、一个实用的时长计算器
// src/main/kotlin/experiment/DurationPlanner.kt
package experiment
import kotlin.math.max
data class Periods(
val gcOldGenMinutes: Double = 5.0, // 从 GC 日志推算
val cacheTtlMinutes: Double = 30.0, // 缓存 TTL
val scheduledTaskMinutes: Double = 60.0, // 最长定时任务间隔
val poolRecycleMinutes: Double = 30.0, // 池 idle 回收
val warmupMinutes: Double = 2.0, // 从滚动 CV 判定
)
fun planDuration(p: Periods): String {
val longestPeriod = listOf(
p.gcOldGenMinutes, p.cacheTtlMinutes, p.scheduledTaskMinutes, p.poolRecycleMinutes
).max()
val steadyMinutes = longestPeriod * 2
val totalMinutes = p.warmupMinutes + steadyMinutes
return buildString {
appendLine("═══ 压测时长规划 ═══")
appendLine()
appendLine("各周期:")
appendLine(" GC 老年代周期 : %.0f 分钟".format(p.gcOldGenMinutes))
appendLine(" 缓存 TTL : %.0f 分钟".format(p.cacheTtlMinutes))
appendLine(" 定时任务最长间隔 : %.0f 分钟".format(p.scheduledTaskMinutes))
appendLine(" 池回收周期 : %.0f 分钟".format(p.poolRecycleMinutes))
appendLine()
appendLine("最长周期 = %.0f 分钟".format(longestPeriod))
appendLine()
appendLine("建议:")
appendLine(" 预热阶段 : %.0f 分钟(不计入统计)".format(p.warmupMinutes))
appendLine(" 稳态测量 : %.0f 分钟(= 最长周期 × 2)".format(steadyMinutes))
appendLine(" 总时长 : %.0f 分钟".format(totalMinutes))
appendLine()
appendLine("理由:至少覆盖 2 个最长周期。")
appendLine(" 1 次 → 只能确认「这个事件发生过」")
appendLine(" 2 次 → 能确认「它的影响是稳定的、可重复的」")
appendLine()
if (steadyMinutes < 3) {
appendLine("⚠️ 即使按计算只需 %.0f 分钟,也建议至少测 3 分钟。".format(steadyMinutes))
appendLine(" 因为太短的窗口会混入测量噪声与过渡态。")
}
if (steadyMinutes > 120) {
appendLine("⚠️ 稳态需要超过 2 小时,成本很高。考虑:")
appendLine(" - 缩短缓存 TTL(测试环境可调)以缩短所需时长")
appendLine(" - 或把长周期问题交给浸泡测试单独覆盖,日常实验用较短窗口")
}
}
}
fun main() {
println(planDuration(Periods()))
println()
println("─".repeat(60))
println()
println(planDuration(Periods(
gcOldGenMinutes = 2.0,
cacheTtlMinutes = 10.0,
scheduledTaskMinutes = 15.0,
poolRecycleMinutes = 5.0,
)))
}
预期输出:
═══ 压测时长规划 ═══
各周期:
GC 老年代周期 : 5 分钟
缓存 TTL : 30 分钟
定时任务最长间隔 : 60 分钟
池回收周期 : 30 分钟
最长周期 = 60 分钟
建议:
预热阶段 : 2 分钟(不计入统计)
稳态测量 : 120 分钟(= 最长周期 × 2)
总时长 : 122 分钟
理由:至少覆盖 2 个最长周期。
1 次 → 只能确认「这个事件发生过」
2 次 → 能确认「它的影响是稳定的、可重复的」
看这个结果:如果服务有一个 1 小时的对账任务,严格按公式需要跑 2 小时——成本很高。
这就是为什么需要「分层」:
| 问题类型 | 用哪种测试 | 时长 |
|---|---|---|
| 短周期问题(GC、连接池) | 常规压测 | 10–20 分钟 |
| 长周期问题(缓存过期、定时任务冲突) | 浸泡测试(专门跑) | 1–12 小时 |
| 日常优化验证 | 常规压测 | 5–10 分钟(覆盖 GC 即可) |
不要为了「覆盖所有周期」把每次实验都拖到几小时——那会让迭代速度崩溃(第 3 章 3.7 节)。把长周期问题交给专门的浸泡测试。
五、三个常见的时长错误
| 错误 | 后果 |
|---|---|
| 预热数据混入统计 | P99 虚高(被冷启动污染) |
| 稳态时长 < 3 分钟 | 覆盖不到一个完整的 GC 周期,结果不稳定 |
| 没覆盖业务周期 | 错过定时任务/缓存过期引起的尖刺(上线后才发现) |
| 只测一次就下结论 | 无法区分真实差异与噪声(第 7 节) |
六、本节小结
- 预热时长看方差收敛(CV < 5%),不是固定秒数。
- 运行时长由最长周期决定:
稳态时长 ≥ 最长周期 × 2。 - 需要覆盖的四个周期:GC 老年代、缓存 TTL、定时任务、池回收。
- 时长公式给出的是严格下限,但成本可能很高——长周期问题应该交给专门的浸泡测试,而不是把每次实验都拖长。
- 总时长 = 预热 + 稳态测量;预热不计入统计。
- 最小建议:稳态至少 3 分钟(即使公式算出更短)。
七、自测
- 一个服务的缓存 TTL 是 15 分钟,GC 老年代回收约 8 分钟一次,有一个每 30 分钟执行的同步任务。按本节的公式,稳态测量至少要多长?如果不按这个时长测,可能漏掉什么?
- 预热阶段跑了 60 秒就进入测量,结果 P99 = 180 ms,而之前某次跑了 5 分钟预热得到 P99 = 120 ms。请解释差异,并说明该采信哪一个。
- 你的实验很赶时间,只能跑 5 分钟。请说出至少两种「在不延长时长的情况下,尽量降低漏判风险」的做法。
- 最长周期 = max(15, 8, 30) = 30 分钟(同步任务),所以稳态测量至少 60 分钟(×2),加预热约 2 分钟。如果不按这个时长测:① 可能完全看不到「缓存过期瞬间的回源尖刺」(15 分钟周期)——此时数据库压力突然上升,P99 飙升;② 看不到「同步任务与请求争抢资源」造成的抖动(30 分钟周期);③ 看不到老年代 GC 的完整行为(8 分钟周期)。结果:实验室里 P99 = 100 ms 达标,上线后每小时出现几次尖刺。折中方案:用 10–20 分钟做日常验证,另跑一次 1 小时的浸泡测试专门覆盖长周期问题。
- 差异的原因是预热不足:60 秒时 JIT 可能还在 C1 阶段(第 2 章 2.1 节)、连接池可能刚填满、数据库缓存还没热。应该采信 5 分钟预热的结果(P99 = 120 ms)——但要注意:① 如果用滚动 CV 判定,120 ms 那次是否真的达到 CV < 5%?如果没有,也不能完全采信;② 两次的其他条件(数据量、负载、时间窗口)是否一致?如果不一致,差异可能不全是预热造成的;③ 正确做法是用 CV 收敛来判定预热结束,而不是猜时长,并且把这个判定写进压测脚本。
- 两种做法:① 把长周期事件人为提前——例如在实验开始时手动触发缓存失效、手动执行一次定时任务,然后观察系统在事件前后的表现(这样 5 分钟内就能看到跨周期行为,代价是需要额外设计);② 延长采样频率、缩短采样窗口——用更细的粒度(例如 1 秒一个点)观察短窗口内的分布,至少能发现「抖动形态」(如周期性尖刺),虽然不能确认周期长度,但能提示"存在周期性问题,需要专门浸泡测试";③ 另一种思路:降低结论的强度——明确声明「本次实验只覆盖 X 分钟,未验证缓存过期与定时任务的影响」,把这个作为遗留问题记入档案(第 8 节),而不是假装结论完整。