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 会抛异常——这提醒你:集群内所有实例必须用同一套桶配置 |