0.3 配套代码:均值如何骗人,尾部如何放大
对应小节:0.3 延迟是分布,不是数字 要验证的三个结论:① 均值掩盖长尾 ② 尾延迟跨服务放大 ③ 百分位不能直接平均
一、实验一:均值与百分位的差距
构造一个真实的「重尾」延迟分布:99% 的请求很快(4–7 ms),1% 的请求很慢(800–1200 ms)。这正是后端常见的样子——大部分请求走缓存,少数请求穿透到数据库或遇到 GC。
// src/main/kotlin/lesson00/LatencyDistribution.kt
package lesson00
import kotlin.random.Random
/** 取分位数:输入必须已排序。q=0.99 表示"99% 的样本比它小" */
fun percentile(sorted: LongArray, q: Double): Long {
val idx = ((sorted.size - 1) * q).toInt().coerceIn(0, sorted.size - 1)
return sorted[idx]
}
/**
* 生成一个重尾分布:
* 99% 的样本落在 [fastMin, fastMax),1% 落在 [slowMin, slowMax)
* 单位:毫秒(这里用整数模拟,便于观察)
*/
fun heavyTailed(n: Int, slowRatio: Double = 0.01, seed: Int = 42): LongArray {
val rnd = Random(seed)
return LongArray(n) {
if (rnd.nextDouble() < slowRatio) rnd.nextLong(800, 1200)
else rnd.nextLong(4, 7)
}
}
fun main() {
val samples = heavyTailed(200_000)
val sorted = samples.sortedArray()
val mean = samples.average()
println("=== 实验一:均值 vs 百分位(20 万个请求)===")
println("平均值 : %8.2f ms ← 只有这一个数字时,你会以为服务很健康".format(mean))
println("P50 (中位数): %8d ms ← 一半用户的实际体验".format(percentile(sorted, 0.50)))
println("P90 : %8d ms".format(percentile(sorted, 0.90)))
println("P95 : %8d ms".format(percentile(sorted, 0.95)))
println("P99 : %8d ms ← 每 100 个用户就有 1 个这样".format(percentile(sorted, 0.99)))
println("P999 : %8d ms".format(percentile(sorted, 0.999)))
println("最大值 : %8d ms".format(sorted.last()))
println()
println("差距:P99 是平均值的 %.0f 倍。".format(percentile(sorted, 0.99) / mean))
println("在 100 万日请求量下,P99 意味着每天有 %,d 个用户体验到这种延迟。"
.format((1_000_000 * 0.01).toLong()))
println()
// ── 实验二:尾延迟跨服务放大 ────────────────────────────────
demonstrateTailAmplification()
// ── 实验三:百分位不能直接平均 ──────────────────────────────
demonstratePercentileAggregationTrap()
}
二、实验二:尾延迟跨服务放大
正文的结论是:10 个下游各 1% 慢 → 整条链路约 10% 慢。
/**
* 模拟一次"页面加载":并行调用 downstreams 个服务,
* 每个服务有 slowRatio 的概率变慢。只要有一个慢,整页就慢。
*/
fun simulateComposite(trials: Int, downstreams: Int, slowRatio: Double, seed: Int = 7): Double {
val rnd = Random(seed)
var slowPages = 0
repeat(trials) {
var pageIsSlow = false
for (d in 0 until downstreams) {
if (rnd.nextDouble() < slowRatio) { pageIsSlow = true; break }
}
if (pageIsSlow) slowPages++
}
return slowPages.toDouble() / trials
}
fun demonstrateTailAmplification() {
val p = 0.01
println("=== 实验二:尾延迟放大(每个下游各有 1% 概率变慢)===")
println("下游数量 | 整页变慢的概率 | 理论值 1-(1-p)^n")
for (n in listOf(1, 3, 5, 10, 20)) {
val measured = simulateComposite(200_000, n, p)
val theory = 1 - Math.pow(1 - p, n.toDouble())
println("%6d | %12.2f%% | %10.2f%%".format(n, measured * 100, theory * 100))
}
println()
println("注意第 10 行和第 20 行:单个服务 P99 都合格,但整页已经有 10%~18% 是慢的。")
println()
}
预期输出:
下游数量 | 整页变慢的概率 | 理论值 1-(1-p)^n
1 | 1.00% | 1.00%
3 | 2.98% | 2.97%
5 | 4.90% | 4.90%
10 | 9.55% | 9.56%
20 | 18.21% | 18.21%
这就是「每个服务都达标,用户体验却不达标」的数学原因。
三、实验三:百分位不能直接平均
正文说:A 机 P99 = 10 ms、B 机 P99 = 1010 ms,「平均 510 ms」是错的。这段代码把两种算法并排跑出来。
fun demonstratePercentileAggregationTrap() {
// A 实例:健康,1% 慢请求只到 20 ms
// (heavyTailed 的慢尾固定是 800~1200ms,这里手工构造更贴近本场景的两个实例)
val rndA = Random(1)
val a = LongArray(100_000) { if (rndA.nextDouble() < 0.01) rndA.nextLong(15, 25) else rndA.nextLong(4, 7) }
// B 实例:有问题,1% 慢请求到 2000 ms
val rndB = Random(2)
val b = LongArray(100_000) { if (rndB.nextDouble() < 0.01) rndB.nextLong(1800, 2200) else rndB.nextLong(4, 7) }
val p99a = percentile(a.sortedArray(), 0.99)
val p99b = percentile(b.sortedArray(), 0.99)
// ❌ 错误做法:把两个实例的 P99 求平均
val wrong = (p99a + p99b) / 2.0
// ✅ 正确做法:把样本合并(相当于合并直方图的桶)后重新计算
val merged = LongArray(a.size + b.size)
a.copyInto(merged, 0)
b.copyInto(merged, a.size)
val right = percentile(merged.sortedArray(), 0.99)
println("=== 实验三:百分位不能直接平均 ===")
println("A 实例 P99 : %6d ms".format(p99a))
println("B 实例 P99 : %6d ms".format(p99b))
println("❌ 两个 P99 求平均 : %6.0f ms ← 这个数字不代表任何真实用户".format(wrong))
println("✅ 合并样本后计算 : %6d ms ← 这才是真实用户看到的分位数".format(right))
println("误差 : %.0f 倍".format(wrong / right))
println()
println("结论:Prometheus 的 summary 类型、以及任何在客户端算好的百分位,")
println(" 都因为无法跨实例聚合而失真。延迟指标必须用直方图(histogram)。")
}
预期输出形态:
A 实例 P99 : 20 ms
B 实例 P99 : 2000 ms
❌ 两个 P99 求平均 : 1010 ms ← 这个数字不代表任何真实用户
✅ 合并样本后计算 : 20 ms ← 因为 99% 的请求仍然很快
误差 : 50 倍
注意最后一行有多反直觉:合并后的真实 P99 是 20 ms(因为两个实例的慢请求加起来只占 1%,还没到 P99 位置),而「求平均」给出的 1010 ms 高了 50 倍。
这个例子说明:百分位聚合不是「算错一点」,而是可能错得离谱。 两个方向都会错——也可能反向低估。
四、动手改造
| 改动 | 观察什么 |
|---|---|
把 slowRatio 从 1% 改成 5% 或 0.1% |
P99 与均值的关系怎么变?0.1% 时 P99 还看得见慢请求吗? |
把 downstreams 改成 50 |
整页慢的概率有多大?这解释了为什么微服务拆得越细越难保证体验 |
| 在实验三里把 B 实例的慢请求比例从 1% 改成 5% | 合并后的 P99 会跳到多少?(提示:此时 P99 会开始"看见"慢请求) |
| 把实验一的样本量从 20 万改成 1000 | P999 会变得多不稳定?这解释了为什么低流量接口不能用 P999 告警 |
五、这段代码的局限
- 分布是人造的(两种取值),真实延迟分布更连续、更复杂。
- 实验二假设各下游相互独立,现实中它们可能同时变慢(比如共享数据库出问题),此时放大效应会更严重。
- 模拟里没有排队效应——真实系统中,慢请求会占用资源,进一步拖慢后续请求(第 5 节)。
- 这些数字不能用于工程决策,它们只用来建立直觉。