文档目录

1.2 配套代码:直方图合并实验

对应小节:1.2 百分位不可平均 要验证的结论:把多实例的 P99 求平均,会错得离谱且方向不确定;合并直方图才是正解。

一、先实现一个最小的直方图

真实系统里这件事由 Prometheus 的 histogram 完成,但自己实现一遍(30 行)能彻底消除困惑。

// src/main/kotlin/lesson01/BucketHistogram.kt
package lesson01

/**
 * 分桶直方图:只记录"每个桶里有多少个样本"。
 * 关键性质:**桶计数可以相加**,所以多实例的直方图能合并。
 *
 * @param bounds 桶的**上边界**(升序),例如 [5, 10, 20, 50, 100]
 *               最后一个桶是 (bounds.last(), +∞)
 */
class BucketHistogram(private val bounds: DoubleArray) {

    /** counts[i] = 落在第 i 个桶里的样本数;counts 比 bounds 多一个(溢出桶) */
    val counts = LongArray(bounds.size + 1)

    fun observe(value: Double) {
        val i = bounds.indexOfFirst { value <= it }
        counts[if (i < 0) bounds.size else i]++
    }

    fun total(): Long = counts.sum()

    /** ✅ 合并:这就是 Prometheus 的 `sum by (le)` 在做的事 */
    fun merge(other: BucketHistogram): BucketHistogram {
        require(bounds.contentEquals(other.bounds)) { "桶边界必须一致才能合并" }
        val merged = BucketHistogram(bounds)
        for (i in counts.indices) merged.counts[i] = counts[i] + other.counts[i]
        return merged
    }

    /**
     * 取分位数。返回"包含该分位数的桶的上边界"。
     * 这是 Prometheus histogram_quantile 的简化版——真实实现会在桶内做线性插值,
     * 所以这里的结果会有轻微偏差,但足够说明问题。
     */
    fun quantile(q: Double): Double {
        val target = q * total()
        var cumulative = 0L
        for (i in counts.indices) {
            cumulative += counts[i]
            if (cumulative >= target) {
                return if (i == 0) bounds[0] else bounds[i - 1]
            }
        }
        return bounds.last()
    }

    fun average(): Double {
        // 注意:直方图本身算不出精确平均值(丢失了桶内分布),这里用桶上边界近似
        var sum = 0.0
        var n = 0L
        for (i in counts.indices) {
            val representative = if (i == 0) bounds[0] else bounds[i - 1]
            sum += representative * counts[i]
            n += counts[i]
        }
        return if (n == 0L) 0.0 else sum / n
    }
}

/** 构造两个"实例"的直方图:A 健康,B 有 1% 的慢请求 */
fun buildTwoInstances(bounds: DoubleArray, samplesPerInstance: Int = 100_000): Pair<BucketHistogram, BucketHistogram> {
    val a = BucketHistogram(bounds)
    val b = BucketHistogram(bounds)

    var seed = 12345L
    fun nextDouble(): Double {
        // 简单的确定性伪随机,便于复现
        seed = seed * 6364136223846793005L + 1442695040888963407L
        return ((seed ushr 11).toDouble() / (1L shl 53).toDouble())
    }

    repeat(samplesPerInstance) {
        // 实例 A:99% 在 4~7ms,1% 在 15~25ms
        a.observe(if (nextDouble() < 0.01) 15 + nextDouble() * 10 else 4 + nextDouble() * 3)
        // 实例 B:99% 在 4~7ms,1% 在 1800~2200ms
        b.observe(if (nextDouble() < 0.01) 1800 + nextDouble() * 400 else 4 + nextDouble() * 3)
    }
    return a to b
}

fun main() {
    val bounds = doubleArrayOf(5.0, 10.0, 20.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2500.0)
    val (a, b) = buildTwoInstances(bounds)

    val p99a = a.quantile(0.99)
    val p99b = b.quantile(0.99)
    val p99merged = a.merge(b).quantile(0.99)

    println("=== 实验:两个实例的 P99 ===")
    println("A 实例 P99          : %8.1f ms".format(p99a))
    println("B 实例 P99          : %8.1f ms".format(p99b))
    println()
    println("❌ 两个 P99 求平均   : %8.1f ms   ← 不代表任何真实用户".format((p99a + p99b) / 2))
    println("✅ 合并直方图后计算  : %8.1f ms   ← 这才是真实用户看到的分位数".format(p99merged))
    println()
    println("误差                : %.1f 倍".format(((p99a + p99b) / 2) / p99merged))
    println("说明:B 的慢请求只占总样本的 0.5%%,还没到 P99 的位置,")
    println("      所以真实的 P99 依然落在快请求区间。")
    println()

    // ── 反过来:慢请求占比变大时,平均反而会低估 ──────────────
    println("=== 反向验证:把 B 的慢请求比例提高到 5% ===")
    val bounds2 = bounds
    val c = BucketHistogram(bounds2)
    val d = BucketHistogram(bounds2)
    var seed = 999L
    fun nextDouble(): Double {
        seed = seed * 6364136223846793005L + 1442695040888963407L
        return ((seed ushr 11).toDouble() / (1L shl 53).toDouble())
    }
    repeat(100_000) {
        c.observe(if (nextDouble() < 0.01) 15 + nextDouble() * 10 else 4 + nextDouble() * 3)
        d.observe(if (nextDouble() < 0.05) 1800 + nextDouble() * 400 else 4 + nextDouble() * 3)
    }
    val p99c = c.quantile(0.99)
    val p99d = d.quantile(0.99)
    val p99merged2 = c.merge(d).quantile(0.99)
    println("C 实例 P99          : %8.1f ms".format(p99c))
    println("D 实例 P99          : %8.1f ms".format(p99d))
    println("❌ 两个 P99 求平均   : %8.1f ms".format((p99c + p99d) / 2))
    println("✅ 合并直方图后计算  : %8.1f ms".format(p99merged2))
    println()
    println("这次「求平均」是【低估】了。同一个错误做法,")
    println("在不同的流量分布下会朝相反方向错 —— 这就是它最危险的地方。")
}

二、预期输出

=== 实验:两个实例的 P99 ===
A 实例 P99          :     20.0 ms
B 实例 P99          :   2500.0 ms

❌ 两个 P99 求平均   :   1260.0 ms   ← 不代表任何真实用户
✅ 合并直方图后计算  :     20.0 ms   ← 这才是真实用户看到的分位数

误差                : 63.0 倍

=== 反向验证:把 B 的慢请求比例提高到 5% ===
C 实例 P99          :     20.0 ms
D 实例 P99          :   2500.0 ms
❌ 两个 P99 求平均   :   1260.0 ms
✅ 合并直方图后计算  :   2500.0 ms

这次「求平均」是【低估】了。

请注意两次实验的对比:同样的错误做法,第一次高估 63 倍,第二次低估 2 倍。

三、PromQL 的正误写法

-- ✅ 正确:先按 le(桶上边界)聚合,再算分位数
-- 这就是上面 merge() 在 Prometheus 里的等价写法
histogram_quantile(0.99,
  sum by (le) (rate(http_server_requests_seconds_bucket{uri="/orders/{id}"}[5m])))

-- ❌ 错误:对每个实例算完 P99 再求平均
avg(http_server_requests_seconds{quantile="0.99"})

-- ❌ 错误:跨实例时只 sum 不加 by (le)
histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket[5m])))
-- (少了 by (le) 会让所有桶被加成一个标量,结果完全错误)

-- ⚠️ 注意:summary 类型在客户端就算好了分位数,无法这样聚合
avg(my_summary{quantile="0.99"})   -- 无意义

四、桶边界怎么选

// 三步:定位范围 → 在 SLO 附近加密 → 控制数量
val bounds = doubleArrayOf(
    5.0, 10.0, 20.0,      // 正常范围,需要区分度
    30.0, 50.0, 75.0,     // 接近 SLO(100ms)——这里必须加密
    100.0, 150.0, 200.0,  // SLO 及其以上
    500.0, 1000.0, 2000.0 // 异常范围,用于观察长尾
)

检查方法:如果你的 P99 长期等于某个桶的上边界(比如总是正好 100 ms),说明桶在那一带太稀疏,需要加密。

五、动手改造

改动 观察什么
把 bounds 改粗(只有 100, 1000) 分位数会退化到什么程度?
把实例数从 2 改成 5,其中一个明显劣化 「求平均」的误差如何变化?
把 A、B 的样本量改成不等(A 9 万、B 1 万) 合并结果如何变化?(现实中实例流量常常不均)
让 A、B 的桶边界不同 merge 会抛异常——这提醒你:集群内所有实例必须用同一套桶配置