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 个用户,如果卡顿时间更长或服务更慢,依然会低估。开环模型从原理上就不存在这个问题。
四、五个关键点
- 开环看到 P99 = 1002 ms,闭环看到 P99 = 2 ms。 同一个服务,差 500 倍。
- 闭环的「请求数」少于期望值,这是协调遗漏最确定、最容易验证的证据——看压测工具报告的总请求数就够了。
- 闭环不是「算错了」,而是「没测到」。 它记录的每个数字都是真实的,只是慢的那段时间里它恰好没在发请求。
- 虚拟用户越多,误差越小,但不会消失。 只要模型是「等响应再发」,协调遗漏就始终存在。
- 修正方法:用恒定到达率(开环)模型;按「计划发送时刻」计算延迟;用直方图存完整分布。
五、动手改造(强烈建议)
| 改动 | 观察什么 |
|---|---|
把 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)的实现在细节上更复杂——但协调遗漏的机制完全一样。
七、一句话带走
判断一份压测报告可不可信,最简单的方法是:看它的总请求数是否等于「到达率 × 时长」。少了很多,就是协调遗漏。