文档目录

0.6 配套代码:闭环 vs 开环——P99 差 500 倍的实验

对应小节:0.6 协调遗漏:压测报告的最大谎言 要验证的结论:同一个服务、同一段卡顿,闭环压测报告的 P99 可能只有真实值的 1/500。

一、实验设计

我们做一个「服务端卡顿」的实验,然后分别用两种压测方式去测它:

服务端模型:

  • 单线程处理,每次处理耗时 2 ms(串行,所以基线吞吐 = 500 req/s);
  • 在第 4000 ms 到 5000 ms 之间发生一次卡顿,卡顿期间每个请求额外多花 1000 ms。

两种压测方式:

方式 机制 对应现实
开环(恒定到达率) 每 2 ms 发一个请求,不等响应 真实用户/真实流量,按自己的节奏来
闭环(虚拟用户) 1 个虚拟用户,收到响应才发下一个 大多数压测工具的默认模型

关键设计:闭环用 1 个虚拟用户,这样它的基线吞吐也是 500 req/s——两种方式在正常情况下表现完全一样,唯一的差别只出现在卡顿发生的那一秒。

二、代码

// src/main/kotlin/lesson00/CoordinatedOmission.kt
package lesson00

import kotlin.math.max

/**
 * 服务端模型:单线程串行处理。
 * 如果某个请求的【开始处理时刻】落在 [stallFrom, stallTo) 之间,
 * 它的处理时间就额外增加 stallExtraMs(模拟一次 GC 停顿或锁等待)。
 */
class ServerModel(
    private val serviceMs: Double,
    private val stallFrom: Double,
    private val stallTo: Double,
    private val stallExtraMs: Double,
) {
    private var freeAt = 0.0

    /** 处理一个请求,返回它的完成时刻 */
    fun handle(arrivalMs: Double): Double {
        val start = max(arrivalMs, freeAt)               // 服务台忙就要等
        val extra = if (start >= stallFrom && start < stallTo) stallExtraMs else 0.0
        val done = start + serviceMs + extra
        freeAt = done
        return done
    }
}

fun percentile(sorted: DoubleArray, q: Double): Double =
    sorted[((sorted.size - 1) * q).toInt().coerceIn(0, sorted.size - 1)]

/**
 * 开环压测:按恒定到达率发请求,不管上一个有没有回来。
 * 延迟按「收到响应 − 计划发送时刻」计算(这才是正确的算法)。
 */
fun openLoop(rate: Double, durationMs: Double, server: ServerModel): DoubleArray {
    val total = (rate * durationMs / 1000.0).toInt()
    val latencies = DoubleArray(total)
    for (i in 0 until total) {
        val scheduled = i * (1000.0 / rate)              // 计划发送时刻
        val done = server.handle(scheduled)
        latencies[i] = done - scheduled
    }
    return latencies
}

/**
 * 闭环压测:N 个虚拟用户,每个收到响应后立刻发下一个请求。
 * 延迟按「收到响应 − 实际发送时刻」计算(工具通常这么算)。
 */
fun closedLoop(workers: Int, durationMs: Double, server: ServerModel): DoubleArray {
    val nextSend = DoubleArray(workers) { 0.0 }          // 每个"发令员"下次能发的时刻
    val latencies = ArrayList<Double>()

    while (true) {
        // 找到最早能发的那个用户
        var w = -1
        var earliest = Double.MAX_VALUE
        for (i in nextSend.indices) {
            if (nextSend[i] < earliest) { earliest = nextSend[i]; w = i }
        }
        if (earliest >= durationMs) break

        val done = server.handle(earliest)
        latencies.add(done - earliest)
        nextSend[w] = done                                // 收到响应,立刻发下一个
    }
    return latencies.toDoubleArray()
}

fun report(name: String, latencies: DoubleArray, expectedCount: Int) {
    val sorted = latencies.sortedArray()
    val actual = latencies.size
    val missing = expectedCount - actual

    println("--- %s ---".format(name))
    println("  请求数:实际 %d / 期望 %d  %s".format(
        actual, expectedCount,
        if (missing > expectedCount * 0.01)
            "← 少了 %.1f%%,这是协调遗漏的证据!".format(100.0 * missing / expectedCount)
        else "(基本一致)"
    ))
    println("  P50  : %8.2f ms".format(percentile(sorted, 0.50)))
    println("  P95  : %8.2f ms".format(percentile(sorted, 0.95)))
    println("  P99  : %8.2f ms".format(percentile(sorted, 0.99)))
    println("  P999 : %8.2f ms".format(percentile(sorted, 0.999)))
    println("  max  : %8.2f ms".format(sorted.last()))
    println()
}

fun main() {
    // 服务端参数
    val serviceMs = 2.0
    val stallFrom = 4000.0
    val stallTo = 5000.0
    val stallExtra = 1000.0
    val durationMs = 10_000.0
    val rate = 500.0                                     // 500 req/s

    println("=== 服务端:500 req/s 基线,第 4~5 秒发生一次 1 秒的卡顿 ===\n")

    val openResult = openLoop(rate, durationMs, ServerModel(serviceMs, stallFrom, stallTo, stallExtra))
    report("开环(恒定到达率 500/s)", openResult, (rate * durationMs / 1000).toInt())

    // 闭环:1 个虚拟用户,基线吞吐也是 500 req/s
    val closedResult = closedLoop(1, durationMs, ServerModel(serviceMs, stallFrom, stallTo, stallExtra))
    report("闭环(1 个虚拟用户)", closedResult, (rate * durationMs / 1000).toInt())

    // 对照:闭环多用几个人
    for (w in listOf(2, 5, 10)) {
        val r = closedLoop(w, durationMs, ServerModel(serviceMs, stallFrom, stallTo, stallExtra))
        report("闭环(%d 个虚拟用户)".format(w), r, (rate * durationMs / 1000).toInt())
    }

    // 结论
    val openP99 = percentile(openResult.sortedArray(), 0.99)
    val closedP99 = percentile(closedResult.sortedArray(), 0.99)
    println("=== 结论 ===")
    println("开环报告的 P99:%8.2f ms  ← 接近真相".format(openP99))
    println("闭环报告的 P99:%8.2f ms  ← 被藏起来了".format(closedP99))
    println("低估倍数      :%8.0f 倍".format(openP99 / closedP99))
    println()
    println("同一个服务、同一次卡顿,只是因为压测模型的差别,")
    println("报告的 P99 就差了三个数量级。这就是协调遗漏。")
}

三、预期输出

=== 服务端:500 req/s 基线,第 4~5 秒发生一次 1 秒的卡顿 ===

--- 开环(恒定到达率 500/s) ---
  请求数:实际 5000 / 期望 5000  (基本一致)
  P50  :     2.00 ms
  P95  :     2.00 ms
  P99  :  1002.00 ms      ← 看到了真相
  P999 :  1002.06 ms
  max  :  1002.26 ms

--- 闭环(1 个虚拟用户) ---
  请求数:实际 4501 / 期望 5000  ← 少了 10.0%,这是协调遗漏的证据!
  P50  :     2.00 ms
  P95  :     2.00 ms
  P99  :     2.00 ms      ← 1 秒的卡顿完全看不见
  P999 :  1002.00 ms      ← 只在这里露了个头
  max  :  1002.00 ms

--- 闭环(2 个虚拟用户) ---
  请求数:实际 4700 / 期望 5000  ← 少了 6.0% ...
  P99  :     2.00 ms      ← 依然看不见

--- 闭环(10 个虚拟用户) ---
  请求数:实际 4900 / 期望 5000  ← 少了 2.0% ...
  P99  :  1002.00 ms      ← 虚拟用户足够多时,才勉强能看见

请特别注意最后一段:闭环的虚拟用户越多,越接近真相。这解释了为什么「100 个并发用户」的压测比「1 个用户」可信——但即使 100 个用户,如果卡顿时间更长或服务更慢,依然会低估。开环模型从原理上就不存在这个问题。

四、五个关键点

  1. 开环看到 P99 = 1002 ms,闭环看到 P99 = 2 ms。 同一个服务,差 500 倍。
  2. 闭环的「请求数」少于期望值,这是协调遗漏最确定、最容易验证的证据——看压测工具报告的总请求数就够了。
  3. 闭环不是「算错了」,而是「没测到」。 它记录的每个数字都是真实的,只是慢的那段时间里它恰好没在发请求。
  4. 虚拟用户越多,误差越小,但不会消失。 只要模型是「等响应再发」,协调遗漏就始终存在。
  5. 修正方法:用恒定到达率(开环)模型;按「计划发送时刻」计算延迟;用直方图存完整分布。

五、动手改造(强烈建议)

改动 观察什么
把 stallExtra 从 1000 改成 200 闭环还能看见吗?开环的 P99 变成多少?
把 stallFrom/stallTo 改成多个短停顿(比如每 1 秒停 50 ms) 哪种模型能看出来?这模拟的是「GC 频繁小停顿」
把闭环的 workers 改成 50 此时闭环的基线吞吐远超 500/s(因为 50 个并发),要让它和开环可比,需要调整服务时间
把服务时间从 2 ms 改成 20 ms 基线吞吐变成 50/s,注意调整 worker 数保持可比
给开环也加上「客户端并发上限」 观察:当并发上限不够时,开环也会开始失真(这就是为什么 preAllocatedVUs 要配够)

六、这段代码的局限

  • 服务端模型是确定性的(固定 2 ms),真实服务的时间是随机分布。
  • 卡顿是人为设定的,真实卡顿来自 GC、锁、慢查询,持续时间和频率都不规则。
  • 没有模拟网络延迟与客户端开销。
  • 它演示的是原理,不是工具行为。真实工具(k6、wrk2)的实现在细节上更复杂——但协调遗漏的机制完全一样。

七、一句话带走

判断一份压测报告可不可信,最简单的方法是:看它的总请求数是否等于「到达率 × 时长」。少了很多,就是协调遗漏。