文档目录

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 周期会变短(更频繁),所以规划值应该定期更新。