2.4 配套代码:用数据判定稳态 + 冷启动测量
对应小节:2.4 预热、稳态与冷启动 两个工具:① 滚动变异系数(CV)自动判定稳态;② 冷启动分段计时。
一、工具一:滚动 CV 判定稳态
// src/main/kotlin/lesson02/SteadyStateDetector.kt
package lesson02
import kotlin.math.sqrt
import kotlin.random.Random
private var sink = 0L
fun work(data: IntArray): Long {
var s = 0L
for (v in data) s += v
return s
}
/**
* 滚动统计窗口:记录最近 N 个采样点,计算均值与变异系数 CV = 标准差/均值。
* CV 收敛到小值以下 → 认为进入稳态。
*/
class RollingStats(private val window: Int) {
private val values = ArrayDeque<Double>()
fun add(v: Double): Pair<Double, Double> {
values.addLast(v)
if (values.size > window) values.removeFirst()
val mean = values.average()
if (values.size < 2) return mean to Double.NaN
val variance = values.sumOf { (it - mean) * (it - mean) } / (values.size - 1)
val cv = sqrt(variance) / mean
return mean to cv
}
val size: Int get() = values.size
}
/**
* 每 `blockSize` 次调用采样一次,直到 CV 连续 `stableBlocks` 次低于阈值。
*
* @return 进入稳态所需的调用次数;若始终未达到则返回 -1
*/
fun findSteadyState(
blockSize: Int = 2_000,
maxBlocks: Int = 200,
cvThreshold: Double = 0.05,
stableBlocks: Int = 3,
window: Int = 5,
): Pair<Int, List<Triple<Int, Double, Double>>> {
val rnd = Random(42)
val data = IntArray(4096) { rnd.nextInt() }
val stats = RollingStats(window)
val trace = mutableListOf<Triple<Int, Double, Double>>()
var consecutiveStable = 0
var totalCalls = 0
repeat(maxBlocks) { block ->
val t0 = System.nanoTime()
repeat(blockSize) { sink += work(data) }
val nsPerOp = (System.nanoTime() - t0).toDouble() / blockSize
totalCalls += blockSize
val (mean, cv) = stats.add(nsPerOp)
trace += Triple(totalCalls, nsPerOp, cv)
if (!cv.isNaN() && cv < cvThreshold) {
consecutiveStable++
if (consecutiveStable >= stableBlocks) {
return totalCalls to trace
}
} else {
consecutiveStable = 0
}
}
return -1 to trace
}
fun main() {
println("=== 用滚动 CV 判定稳态 ===")
println("判据:连续 3 个采样窗口的 CV < 5%\n")
println("累计调用次数 单次耗时(ns/op) 滚动CV 状态")
println("-".repeat(64))
val (steadyAt, trace) = findSteadyState()
trace.take(20).forEachIndexed { i, (calls, ns, cv) ->
val status = when {
cv.isNaN() -> "采样中"
cv > 0.10 -> "未稳"
cv > 0.05 -> "接近"
else -> "✅ 稳定"
}
println("%,14d %12.1f %10s %s".format(
calls, ns, if (cv.isNaN()) "—" else "%.1f%%".format(cv * 100), status))
}
println()
if (steadyAt > 0) {
println("✅ 在累计 %,d 次调用后进入稳态".format(steadyAt))
println(" 即使用固定预热策略,也应该至少预热这么多")
} else {
println("❌ 未能在 %d 个窗口内进入稳态".format(trace.size))
println(" 说明环境噪声过大——这本身就是重要发现(见第 5 章的噪声底线)")
}
println()
println("sink = $sink")
}
二、预期输出形态
累计调用次数 单次耗时(ns/op) 滚动CV 状态
----------------------------------------------------------------
2,000 1180.4 — 采样中
4,000 612.1 45.2% 未稳
6,000 391.7 52.8% 未稳
8,000 310.2 41.3% 未稳
10,000 265.4 26.7% 未稳
12,000 248.9 14.2% 未稳
14,000 239.1 7.8% 接近
16,000 234.6 4.1% ✅ 稳定
18,000 232.8 2.9% ✅ 稳定
20,000 231.9 2.4% ✅ 稳定
✅ 在累计 16,000 次调用后进入稳态
注意 CV 曲线是「从大到小收敛」,而不是突然达标。这就是为什么「固定预热 5 轮」不可靠——你不知道自己的程序需要几轮。
三、工具二:冷启动分段测量
#!/usr/bin/env bash
# tools/measure-coldstart.sh —— 测量服务的冷启动时间
# 用法:tools/measure-coldstart.sh <启动命令...>
set -uo pipefail
APP_CMD=("$@")
LOG="docs/experiments/E0x-coldstart/results/startup.log"
mkdir -p "$(dirname "$LOG")"
T0=$(date +%s%3N) # 毫秒精度
echo "t0_process_launch=$T0"
# 启动服务(后台)
"${APP_CMD[@]}" > "$LOG" 2>&1 &
APP_PID=$!
echo "pid=$APP_PID"
# ① 等到端口/健康检查就绪
T1=""
for i in $(seq 1 600); do
if curl -sf http://127.0.0.1:8080/health > /dev/null 2>&1; then
T1=$(date +%s%3N)
break
fi
sleep 0.1
done
if [ -z "$T1" ]; then
echo "❌ 60 秒内未就绪,放弃"
kill "$APP_PID" 2>/dev/null
exit 1
fi
echo "t1_healthy=$T1"
echo "→ 启动到健康检查通过:$((T1 - T0)) ms"
# ② 从健康检查通过后开始持续打请求,找到「性能进入稳态」的时刻
echo
echo "持续打请求,观察 P99 什么时候稳定:"
for i in $(seq 1 30); do
R=$(curl -s -o /dev/null -w "%{time_total}" http://127.0.0.1:8080/orders/1)
echo " 第 ${i} 次请求耗时: ${R}s"
sleep 1
done
# ③ 收尾
kill "$APP_PID" 2>/dev/null
echo
echo "冷启动报告:"
echo " - 启动就绪耗时 : $((T1 - T0)) ms"
echo " - 达到稳态耗时 : 见上面的请求耗时曲线(通常需要几十秒到几分钟)"
echo " - ⚠️ 「健康检查通过」≠「性能达稳态」——两者可能差几分钟"
四、为什么「健康检查通过」不等于「能扛流量」
| 时刻 | 状态 | 能扛多少 |
|---|---|---|
| 端口监听 | 能响应,但所有代码在解释执行 | 几乎不能扛 |
| 健康检查通过 | 类加载完成、基本路径可用 | 可能只有稳态的 1/3 |
| 预热完成 | 热点代码进入 C2、连接池填满、缓存预热 | 达到设计容量 |
这就是滚动发布要「预热后再接流量」的原因。
五、动手改造
| 改动 | 观察什么 |
|---|---|
把 cvThreshold 从 5% 改成 2% |
需要的预热量会显著增加——「稳态」的标准是可以选的,越严越贵 |
把 blockSize 从 2000 改成 200 |
CV 会很难收敛(噪声占比变大)——说明采样窗口太短会让稳态判定失效 |
把 window 从 5 改成 20 |
收敛更慢但更稳——这是「灵敏度 vs 稳定性」的权衡 |
| 在跑的同时开一个后台任务(比如编译、下载) | CV 可能永远收敛不到 5%——这就是环境噪声对测量的影响 |
| 把稳态判定接进你的压测脚本 | 从此不再写「预热 60 秒」这种硬编码 |
六、这段代码的局限
- CV 判定是启发式的:如果程序有周期性(比如 GC 每 10 秒一次),CV 可能在一个较大值上稳定下来,此时需要结合 GC 日志判断。
- 它测的是「微基准级」的稳态。真实服务的稳态还包括连接池、缓存、数据库预热,判定要复杂得多(第 3、4 章)。
- 冷启动脚本中的
curl计时包含脚本自身开销,精度只到毫秒级。生产测量应该用专业的压测工具。