文档目录

5.2 预热、稳态与运行时长

上一节:5.1 单变量与对照组 | 下一节:5.3 重复、抖动与异常值 配套代码:05-experiment-design/02-warmup-and-duration


一句话结论

压测时长不是拍出来的,是由「周期」决定的。 你必须至少覆盖一个完整的最长周期——GC 老年代回收周期、缓存 TTL、定时任务间隔、连接池回收周期。30 秒的压测结论只能当烟雾测试。


一、用「煮饭」理解预热与时长

煮一锅饭:

阶段 状态 对应
前 2 分钟 水还没开,米是硬的 冷启动:JIT 未编译、连接池空、缓存冷
中间 10 分钟 沸腾、米在吸水 过渡态:数据不能代表最终结果
最后 5 分钟 稳定收汁 稳态:可以判断饭好不好

三个关键推论:

  1. 「尝一口」不能判断饭好了没——你需要知道现在处于哪个阶段。
  2. 时间不够,饭就是生的——再好的米也没用。
  3. 不同米需要的时间不同——长粒米和短粒米不一样,就像不同应用需要的预热时间不同。

所以:预热时长要看「是否进入稳态」,运行时长要看「是否覆盖了所有周期性事件」。


二、预热:怎么判断「够了」

❌ 错误做法:固定预热 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 节)

六、本节小结

  1. 预热时长看方差收敛(CV < 5%),不是固定秒数。
  2. 运行时长由最长周期决定:稳态时长 ≥ 最长周期 × 2。
  3. 需要覆盖的四个周期:GC 老年代、缓存 TTL、定时任务、池回收。
  4. 时长公式给出的是严格下限,但成本可能很高——长周期问题应该交给专门的浸泡测试,而不是把每次实验都拖长。
  5. 总时长 = 预热 + 稳态测量;预热不计入统计。
  6. 最小建议:稳态至少 3 分钟(即使公式算出更短)。

七、自测

  1. 一个服务的缓存 TTL 是 15 分钟,GC 老年代回收约 8 分钟一次,有一个每 30 分钟执行的同步任务。按本节的公式,稳态测量至少要多长?如果不按这个时长测,可能漏掉什么?
  2. 预热阶段跑了 60 秒就进入测量,结果 P99 = 180 ms,而之前某次跑了 5 分钟预热得到 P99 = 120 ms。请解释差异,并说明该采信哪一个。
  3. 你的实验很赶时间,只能跑 5 分钟。请说出至少两种「在不延长时长的情况下,尽量降低漏判风险」的做法。
  1. 最长周期 = max(15, 8, 30) = 30 分钟(同步任务),所以稳态测量至少 60 分钟(×2),加预热约 2 分钟。如果不按这个时长测:① 可能完全看不到「缓存过期瞬间的回源尖刺」(15 分钟周期)——此时数据库压力突然上升,P99 飙升;② 看不到「同步任务与请求争抢资源」造成的抖动(30 分钟周期);③ 看不到老年代 GC 的完整行为(8 分钟周期)。结果:实验室里 P99 = 100 ms 达标,上线后每小时出现几次尖刺。折中方案:用 10–20 分钟做日常验证,另跑一次 1 小时的浸泡测试专门覆盖长周期问题。
  2. 差异的原因是预热不足:60 秒时 JIT 可能还在 C1 阶段(第 2 章 2.1 节)、连接池可能刚填满、数据库缓存还没热。应该采信 5 分钟预热的结果(P99 = 120 ms)——但要注意:① 如果用滚动 CV 判定,120 ms 那次是否真的达到 CV < 5%?如果没有,也不能完全采信;② 两次的其他条件(数据量、负载、时间窗口)是否一致?如果不一致,差异可能不全是预热造成的;③ 正确做法是用 CV 收敛来判定预热结束,而不是猜时长,并且把这个判定写进压测脚本。
  3. 两种做法:① 把长周期事件人为提前——例如在实验开始时手动触发缓存失效、手动执行一次定时任务,然后观察系统在事件前后的表现(这样 5 分钟内就能看到跨周期行为,代价是需要额外设计);② 延长采样频率、缩短采样窗口——用更细的粒度(例如 1 秒一个点)观察短窗口内的分布,至少能发现「抖动形态」(如周期性尖刺),虽然不能确认周期长度,但能提示"存在周期性问题,需要专门浸泡测试";③ 另一种思路:降低结论的强度——明确声明「本次实验只覆盖 X 分钟,未验证缓存过期与定时任务的影响」,把这个作为遗留问题记入档案(第 8 节),而不是假装结论完整。