文档目录

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 节)。
  • 这些数字不能用于工程决策,它们只用来建立直觉。