文档目录

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 计时包含脚本自身开销,精度只到毫秒级。生产测量应该用专业的压测工具。