5.2 配套代码:时长规划与预热判定
对应小节:5.2 预热、稳态与运行时长 两件事:① 由周期推导时长;② 自动判定预热是否结束。
一、时长规划器
// src/main/kotlin/experiment/DurationPlanner.kt
package experiment
import kotlin.math.max
/**
* 压测时长规划。
*
* 核心公式:稳态时长 ≥ 最长周期 × 2
* 1 次 → 只能确认「这个事件发生过」
* 2 次 → 能确认「它的影响是稳定的、可重复的」
*/
data class Periods(
val gcOldGenMinutes: Double = 5.0, // 从 GC 日志推算(两次 Full/Mixed GC 的间隔)
val cacheTtlMinutes: Double = 30.0, // 缓存 TTL(本地 + Redis,取较大者)
val scheduledTaskMinutes: Double = 60.0, // 最长定时任务间隔
val poolRecycleMinutes: Double = 30.0, // 连接池/线程池 idle 回收
val warmupMinutes: Double = 2.0, // 从滚动 CV 判定(第 2 章 2.4)
)
data class DurationPlan(
val longestPeriodMinutes: Double,
val warmupMinutes: Double,
val steadyStateMinutes: Double,
val totalMinutes: Double,
val warnings: List<String>,
)
fun planDuration(p: Periods): DurationPlan {
val longest = listOf(
p.gcOldGenMinutes, p.cacheTtlMinutes, p.scheduledTaskMinutes, p.poolRecycleMinutes
).max()
var steady = longest * 2
val warnings = mutableListOf<String>()
// 下限保护:即使公式算出更短,也至少 3 分钟
if (steady < 3.0) {
warnings += "按公式只需 %.1f 分钟,但低于 3 分钟会混入测量噪声与过渡态——已提升到 3 分钟"
.format(steady)
steady = 3.0
}
// 上限提醒:超过 2 小时成本过高,建议分层
if (steady > 120) {
warnings += "稳态需要 %.0f 分钟(> 2 小时),成本很高。建议:".format(steady)
warnings += " ① 测试环境可调短缓存 TTL(例如 30 分钟 → 5 分钟)"
warnings += " ② 把长周期问题交给专门的浸泡测试(1–12 小时),"
warnings += " 日常优化验证只用 10–20 分钟覆盖 GC 周期"
}
// 检查是否某个周期被显著低估
if (p.scheduledTaskMinutes < p.cacheTtlMinutes * 0.2) {
warnings += "定时任务间隔(%.0f 分钟)远小于缓存 TTL(%.0f 分钟)——确认是否真的存在这么频繁的任务"
.format(p.scheduledTaskMinutes, p.cacheTtlMinutes)
}
return DurationPlan(
longestPeriodMinutes = longest,
warmupMinutes = p.warmupMinutes,
steadyStateMinutes = steady,
totalMinutes = p.warmupMinutes + steady,
warnings = warnings,
)
}
/** 从实际场景推导周期(辅助函数) */
fun periodsFromGcLog(p99GcIntervalMinutes: Double, cacheTtl: Double, taskInterval: Double) =
Periods(
gcOldGenMinutes = p99GcIntervalMinutes,
cacheTtlMinutes = cacheTtl,
scheduledTaskMinutes = taskInterval,
poolRecycleMinutes = cacheTtl / 2, // 池回收通常与 TTL 同量级
)
fun main() {
println("═══ 场景一:典型 Web 服务 ═══")
val p1 = Periods() // 默认值
printPlan(p1)
println()
println("═══ 场景二:轻量服务(周期都较短)═══")
printPlan(Periods(
gcOldGenMinutes = 2.0,
cacheTtlMinutes = 10.0,
scheduledTaskMinutes = 15.0,
poolRecycleMinutes = 5.0,
))
println()
println("═══ 场景三:有每小时对账任务(成本高)═══")
printPlan(Periods(
gcOldGenMinutes = 5.0,
cacheTtlMinutes = 30.0,
scheduledTaskMinutes = 60.0,
poolRecycleMinutes = 30.0,
))
}
private fun printPlan(p: Periods) {
val plan = planDuration(p)
println("各周期:GC %.0f / 缓存 %.0f / 定时任务 %.0f / 池回收 %.0f 分钟"
.format(p.gcOldGenMinutes, p.cacheTtlMinutes, p.scheduledTaskMinutes, p.poolRecycleMinutes))
println("最长周期 = %.0f 分钟".format(plan.longestPeriodMinutes))
println("预热 = %.0f 分钟(不计入统计)".format(plan.warmupMinutes))
println("稳态测量 = %.0f 分钟".format(plan.steadyStateMinutes))
println("总时长 = %.0f 分钟".format(plan.totalMinutes))
plan.warnings.forEach { println(" ⚠️ $it") }
}
预期输出:
═══ 场景三:有每小时对账任务(成本高)═══
各周期:GC 5 / 缓存 30 / 定时任务 60 / 池回收 30 分钟
最长周期 = 60 分钟
预热 = 2 分钟(不计入统计)
稳态测量 = 120 分钟
总时长 = 122 分钟
⚠️ 稳态需要 120 分钟(> 2 小时),成本很高。建议:
① 测试环境可调短缓存 TTL(例如 30 分钟 → 5 分钟)
② 把长周期问题交给专门的浸泡测试(1–12 小时),
日常优化验证只用 10–20 分钟覆盖 GC 周期
二、怎么得到「周期」的实际值
从 GC 日志推算老年代回收周期
# 找出所有 Mixed/Full GC 的时间戳,算间隔
grep -E "Pause Mixed|Pause Full" gc.log | \
awk '{print $1}' | \
python3 -c "
import sys, re
from datetime import datetime
times = []
for line in sys.stdin:
m = re.search(r'\[([\d\-T:.+]+)\]', line)
if m:
try: times.append(datetime.fromisoformat(m.group(1)))
except: pass
if len(times) > 1:
gaps = [(times[i+1]-times[i]).total_seconds()/60 for i in range(len(times)-1)]
gaps.sort()
print(f'老年代 GC 次数: {len(times)}')
print(f'间隔中位数: {gaps[len(gaps)//2]:.1f} 分钟')
print(f'间隔 P90 : {gaps[int(len(gaps)*0.9)]:.1f} 分钟 ← 用它做规划')
else:
print('样本不足,无法计算间隔')
"
从配置里读缓存 TTL 与定时任务
# 缓存 TTL
grep -rn "expireAfter\|TTL\|ttl" src/main/kotlin/ | head -10
redis-cli CONFIG GET maxmemory-policy
# 定时任务
grep -rn "@Scheduled\|scheduleAtFixedRate\|delay(" src/main/kotlin/ | head -10
三、预热判定的封装(Kotlin)
// src/main/kotlin/experiment/WarmupDetector.kt
package experiment
import kotlin.math.sqrt
/**
* 用滚动变异系数判定是否进入稳态。
* 已在第 2 章 2.4 节介绍过原理,这里是可复用的封装。
*/
class WarmupDetector(
private val window: Int = 5,
private val cvThreshold: Double = 0.05,
private val stableBlocks: Int = 3,
) {
private val values = ArrayDeque<Double>()
private var consecutiveStable = 0
/** 传入一个新的观测值,返回 (是否已稳态, 当前CV) */
fun observe(v: Double): Pair<Boolean, Double> {
values.addLast(v)
if (values.size > window) values.removeFirst()
if (values.size < 2) return false to Double.NaN
val mean = values.average()
val variance = values.sumOf { (it - mean) * (it - mean) } / (values.size - 1)
val cv = sqrt(variance) / mean
if (cv < cvThreshold) {
consecutiveStable++
} else {
consecutiveStable = 0
}
return (consecutiveStable >= stableBlocks) to cv
}
val isWarm: Boolean get() = consecutiveStable >= stableBlocks
}
/**
* 预热阶段的通用流程:反复执行一段负载,直到判定为稳态。
* 返回值包含预热耗时的统计信息,便于记录进实验档案。
*/
suspend fun warmupUntilSteady(
maxBlocks: Int = 200,
blockBody: suspend () -> Long, // 返回这一块的耗时(纳秒)
): WarmupResult {
val detector = WarmupDetector()
val trace = mutableListOf<Pair<Long, Double>>()
repeat(maxBlocks) { block ->
val elapsedNs = blockBody()
val (warm, cv) = detector.observe(elapsedNs.toDouble())
trace += elapsedNs to cv
if (warm) {
return WarmupResult(
blocksUsed = block + 1,
cvAtSteady = cv,
trace = trace,
)
}
}
return WarmupResult(
blocksUsed = -1,
cvAtSteady = trace.lastOrNull()?.second ?: Double.NaN,
trace = trace,
)
}
data class WarmupResult(
val blocksUsed: Int, // -1 表示未达到稳态
val cvAtSteady: Double,
val trace: List<Pair<Long, Double>>,
) {
fun summary(): String = buildString {
if (blocksUsed > 0) {
appendLine("✅ 第 %d 块后进入稳态(CV = %.1f%%)".format(blocksUsed, cvAtSteady * 100))
appendLine(" → 预热时长应从实验的稳态窗口中排除")
} else {
appendLine("❌ %d 块内未进入稳态(最后 CV = %.1f%%)".format(trace.size, cvAtSteady * 100))
appendLine(" → 环境噪声过大,先修环境(第 5.4 节)")
}
appendLine()
appendLine(" 前 10 块采样:")
trace.take(10).forEachIndexed { i, (ns, cv) ->
appendLine(" block %2d: %8.1f ns/op CV %s"
.format(i, ns.toDouble(), if (cv.isNaN()) "—" else "%.1f%%".format(cv * 100)))
}
}
}
四、动手改造
| 改动 | 观察什么 |
|---|---|
把 scheduledTaskMinutes 从 60 改成 5 |
稳态时长从 120 分钟降到 10 分钟——理解"周期决定时长" |
| 用 GC 日志脚本算出真实的老年代间隔,替换默认值 | 得到你服务的真实规划值 |
把 cvThreshold 从 5% 改成 2% |
预热需要更久,但数据更稳 |
在 warmupUntilSteady 里打印每块的 CV |
观察收敛过程(渐近,不是突变) |
故意在一个高噪声环境跑 warmupUntilSteady |
它会返回 -1,提示环境有问题 |
五、这段代码的局限
- 周期值需要人工提供:脚本只能帮你从 GC 日志估算老年代周期,但缓存 TTL 与定时任务需要你去看代码/配置。
warmupUntilSteady需要与真实压测脚本集成:它只是一个判定器,实际的流量要靠 k6 或内部压测产生。- 「最长周期 × 2」是保守估计:如果一个周期是 60 分钟,跑 120 分钟成本很高——实践上应该分层(长周期交给浸泡测试)。
- 周期可能随时间变化:数据量增长后,GC 周期会变短(更频繁),所以规划值应该定期更新。