文档目录

3.7 配套代码:层级选择辅助与测试预算计算

对应小节:3.7 层级选择与成本 两件小工具:① 层级选择辅助(把你的问题映射到层级);② 测试成本计算(量化"为什么先做组件基准")。

一、层级选择辅助

// src/main/kotlin/selection/LevelSelector.kt
package selection

enum class TestLevel(val label: String, val minutesPerRun: Int, val needsFullEnv: Boolean) {
    MICRO("微基准(JMH)", 1, false),
    COMPONENT("组件基准(Testcontainers)", 1, false),
    INTEGRATION("集成基准(单服务完整启动)", 5, false),
    E2E("全链路负载", 40, true),
    SOAK("浸泡测试", 120, true),
    CAPACITY("容量测试", 180, true),
}

/** 问题类型 → 该用哪一层 */
enum class QuestionType(val phrase: String, val levels: List<TestLevel>) {
    CODE_SPEED("这段代码/算法快不快?", listOf(TestLevel.MICRO)),
    DATA_ACCESS("这条 SQL / 序列化 / 缓存操作慢不慢?", listOf(TestLevel.COMPONENT)),
    SINGLE_SERVICE("单个服务完整处理一个请求要多久?", listOf(TestLevel.COMPONENT, TestLevel.INTEGRATION)),
    SLO("满足 SLO 吗?拐点在哪?", listOf(TestLevel.E2E)),
    CAPACITY_PLAN("要几个副本?什么规格?", listOf(TestLevel.CAPACITY, TestLevel.E2E)),
    LEAK("有没有内存/连接泄漏?", listOf(TestLevel.SOAK)),
    RESILIENCE("系统垮了会怎样?能自愈吗?", listOf(TestLevel.E2E)),
    WHY_SLOW("为什么慢?(还不知道哪里慢)", emptyList()),   // 特殊:先剖析
}

data class Recommendation(
    val question: QuestionType,
    val levels: List<TestLevel>,
    val note: String,
)

fun recommend(question: QuestionType): Recommendation {
    if (question == QuestionType.WHY_SLOW) {
        return Recommendation(
            question = question,
            levels = listOf(TestLevel.COMPONENT, TestLevel.MICRO),
            note = "⚠️ 先做剖析(火焰图 / JFR / 线程 dump)定位,再用上面这些层级做验证。" +
                   "「为什么慢」不要用压测来回答。"
        )
    }
    return Recommendation(question, question.levels, "")
}

/** 成本估算:一天能做几轮 */
fun roundsPerDay(level: TestLevel, hoursPerDay: Double = 6.0): Int {
    val overhead = if (level.needsFullEnv) 20 else 2      // 环境准备与数据准备的额外开销(分钟)
    val perRun = level.minutesPerRun + overhead
    return (hoursPerDay * 60 / perRun).toInt()
}

fun main() {
    println("=== 问题 → 层级映射 ===\n")
    QuestionType.entries.forEach { q ->
        val r = recommend(q)
        println("「${q.phrase}」")
        println("  → 层级:${r.levels.joinToString(" + ") { it.label }}")
        if (r.note.isNotEmpty()) println("  ${r.note}")
        println()
    }

    println("=== 成本:一天能做几轮实验(按 6 小时有效工作时间)===\n")
    println("%-34s %10s %14s".format("层级", "单次耗时", "一天可做轮次"))
    println("-".repeat(62))
    TestLevel.entries.forEach { level ->
        println("%-34s %8d 分 %12d 轮".format(level.label, level.minutesPerRun, roundsPerDay(level)))
    }
    println()
    println("结论:全链路一天只能试 2~5 个假设,组件基准能试几十个。")
    println("      假设验证次数直接决定你找到正确解的概率 —— 这就是「先用组件基准」的真正理由。")
}

预期输出:

=== 成本:一天能做几轮实验(按 6 小时有效工作时间)===

层级                                     单次耗时       一天可做轮次
--------------------------------------------------------------
微基准(JMH)                              1 分          120 轮
组件基准(Testcontainers)                  1 分          120 轮
集成基准(单服务完整启动)                    5 分           51 轮
全链路负载                                 40 分            6 轮
浸泡测试                                  120 分            2 轮
容量测试                                  180 分            1 轮

注意「全链路 6 轮」这个数字:一天只能验证 6 个假设,而排查一个复杂性能问题往往需要试错十几次。这就是"用全链路定位"在工程上不可行的量化理由。

二、测试预算与成本对比

# tools/test_budget.py
"""
量化"把测试投入放在哪一层"的收益。

用法:
    python3 tools/test_budget.py            # 默认当前投入分布
    python3 tools/test_budget.py 10 20 15 40 15   # 微基准 组件 集成 全链路 浸泡
"""
import sys

LEVELS = ["微基准", "组件基准", "集成基准", "全链路", "浸泡+破坏性"]
COST_PER_RUN = {"微基准": 1, "组件基准": 1, "集成基准": 5, "全链路": 40, "浸泡+破坏性": 120}
# 每元投入能回答的问题数(相对权重,基于"能发现的问题类型覆盖度")
VALUE_WEIGHT = {"微基准": 0.5, "组件基准": 3.0, "集成基准": 1.0, "全链路": 1.5, "浸泡+破坏性": 1.5}

def analyze(allocation):
    total = sum(allocation)
    if total == 0:
        print("投入不能全为零"); return
    print(f"{'层级':<16}{'投入占比':>10}{'单次成本(分)':>14}{'可做轮次':>10}{'问题覆盖价值':>14}")
    print("-" * 66)
    rows = []
    for level, pct in zip(LEVELS, allocation):
        share = pct / total
        affordable = share * 360 / COST_PER_RUN[level]     # 6 小时 = 360 分钟
        value = share * VALUE_WEIGHT[level]
        rows.append(value)
        print(f"{level:<16}{share:>9.0%}{COST_PER_RUN[level]:>14}{affordable:>10.0f}{value:>14.2f}")
    print("-" * 66)
    print(f"{'合计问题覆盖价值':<16}{sum(rows):>14.2f}")
    print()
    # 给出建议
    comp_share = allocation[1] / total
    if comp_share < 0.3:
        print(f"💡 组件基准只占 {comp_share:.0%}。建议提到 40% 左右 —— 它是性价比最高的一层:")
        print("   迭代快(1 分钟/轮)、能发现慢 SQL / N+1 / 序列化开销等大部分问题。")
    e2e_share = allocation[3] / total
    if e2e_share > 0.35:
        print(f"⚠️  全链路占 {e2e_share:.0%},偏高。它一次 40 分钟,一天只能做 6 轮,")
        print("   不适合用来定位瓶颈。建议把一部分投入转到组件基准。")
    if allocation[4] / total < 0.1:
        print("⚠️  浸泡测试投入不足 10% —— 这是唯一能发现缓慢泄漏的测试,别省这部分。")

if __name__ == "__main__":
    if len(sys.argv) > 1:
        alloc = [float(x) for x in sys.argv[1:6]]
    else:
        alloc = [10, 15, 15, 45, 15]      # 典型的"现状"分布:全链路占大头
    print("当前投入分布:", dict(zip(LEVELS, alloc)))
    print()
    analyze(alloc)
    print()
    print("=" * 66)
    print("推荐分布:", dict(zip(LEVELS, [10, 40, 15, 20, 15])))
    print()
    analyze([10, 40, 15, 20, 15])

预期输出(节选):

当前投入分布: {'微基准': 10, '组件基准': 15, '集成基准': 15, '全链路': 45, '浸泡+破坏性': 15}
...
合计问题覆盖价值              1.50

💡 组件基准只占 15%。建议提到 40% 左右 —— 它是性价比最高的一层
⚠️  全链路占 45%,偏高。它一次 40 分钟,一天只能做 6 轮

==================================================================
推荐分布: {'微基准': 10, '组件基准': 40, '集成基准': 15, '全链路': 20, '浸泡+破坏性': 15}
...
合计问题覆盖价值              2.20

三、什么时候「不该测」的自检清单

## 开始测试之前,先过一遍这五条

- [ ] **功能正确了吗?** 如果功能还在改,性能数据会反复失效。
- [ ] **瓶颈是否已经明确?** 如果已经知道是缺索引,直接修,然后测「修复效果」。
- [ ] **优化收益的理论上限是多少?** 如果某操作只占 P99 的 2%,优化它最多省 1%
      → 先做延迟分解,找大头。
- [ ] **环境稳定吗?** 没有独立环境时,改用组件基准/集成基准,并声明局限。
- [ ] **用户能感知到吗?** P99 从 210 降到 205 ms,用户感知不到
      → 把时间花在别处。

把这五条做成脚本化的检查(可选):

#!/usr/bin/env bash
# tools/should-i-test.sh —— 交互式自检
set -uo pipefail

ask() {
  local q="$1"
  read -r -p "$q [y/N] " ans
  [[ "${ans,,}" == "y" ]]
}

echo "开始测试前的五问:"
echo

if ! ask "1. 功能是否已经正确(不会再改动逻辑)?"; then
  echo "   → 先让功能正确。性能测试会被反复推翻。"
  exit 0
fi

if ask "2. 是否已经明确知道瓶颈在哪(比如已知是缺索引)?"; then
  echo "   → 直接修,然后只测【修复效果】(before/after 对比)。不必先做探索性压测。"
  exit 0
fi

read -r -p "3. 你怀疑的瓶颈占 P99 的大约百分之几?(0-100) " pct
if [[ "$pct" =~ ^[0-9]+$ ]] && [ "$pct" -lt 5 ]; then
  echo "   → 上限只有 ${pct}%。先做延迟分解(第 1 章 1.3 节),找出占大头的部分。"
  exit 0
fi

if ! ask "4. 是否有稳定可控的测试环境(不与其他任务共享)?"; then
  echo "   → 改用组件基准/集成基准,并明确声明『结论不可用于容量规划』。"
  echo "     最不应该的做法:在开发机上压全链路,然后把数据当成真的。"
fi

if ! ask "5. 优化结果用户能感知到吗(或能解决某条 SLO 违约)?"; then
  echo "   → 这不值得投入。把时间花在别处,或者去问业务方真正的痛点。"
  exit 0
fi

echo
echo "✅ 五项都通过,可以开始。建议顺序:剖析定位 → 组件基准验证 → 全链路验收。"

四、动手改造

改动 观察什么
把「全链路」的 minutesPerRun 改成 60 一天可做轮次从 6 降到 4——成本对迭代速度的影响更明显
给 VALUE_WEIGHT 加上你自己的经验权重 得到适合你团队的投入建议
用 test_budget.py 输入你团队的真实投入分布 看看问题覆盖价值能提升多少
把 should-i-test.sh 接进你们的性能任务模板 从流程上防止「无意义的压测」

五、这段代码的局限

  • VALUE_WEIGHT 是主观估值(我按「能发现的问题类型覆盖度」给的经验值),不是客观测量。它的作用是引发讨论,而不是给出标准答案。
  • COST_PER_RUN 是量级估计,你的实际成本(环境准备、数据准备、人力)可能差别很大。
  • 层级选择不是非此即彼:实际工作中常常是「组件基准定位 → 集成基准确认 → 全链路验收」的组合,而不是只选一层。