三个 p95 的平均值不是任何一个 p95
一句话总结
百分位数的计算方法不止一种,而且无法合并。把各区间的 p95 求平均得到的值,既不是任何一个区间的 p95,也不是整体的 p95。
为什么需要它
在性能会议上,经常会出现这样的表——分片 A 的 p95 是 92 毫秒,分片 B 是 171 毫秒,分片 C 是 818 毫秒。有人把三个数求平均,写下“整体 p95 是 360 毫秒”。这个数字是错的。用同样的数据把原始值合并后重新测量,是 253 毫秒。这次换另一个人按流量占比做加权平均——189 毫秒。这也是错的,而且这次错向了相反的方向。
还有一个再次出现偏差的地方。对同样的样本问同样的“p95”,不同的工具会给出不同的值。采用最近秩(nearest-rank)的工具,返回样本中实际存在的值,采用线性插值的工具,则会构造出两个样本之间的值。如果样本只有 20 个,这个差别会拉开到 140 毫秒与 178 毫秒。如果昨天用的工具和今天用的工具不同,明明什么也没有改变,p95 却看起来变差了。
这两种偏差都在仪表板上悄悄发生。没有人看到错误,数字看起来合理,SLO 的判定就是依据这个数字做出的。
工作原理
百分位数是顺序统计量。它是从排好序的样本中取出某个位置的值,所以方法的分歧就在于“如何确定位置”。最近秩直接使用第 k = ceil(q x n) 个值。线性插值则构造一个 h = (n - 1) x q 这样的实数位置,并在前后两个样本之间按比例划分。样本多时,两者的差别会消失,而样本少,或者尾部有离群值时,差别就会拉得很大。
无法合并的原因更为根本。平均值由总和与个数构成,所以把各部分的总和相加就得到整体,而百分位数是排好序的位置,所以把各部分的位置相加,并不会得到整体的位置。Prometheus 文档也说了同样的话——预先计算好的分位数彼此无法合并,可以合并的是桶。
所以正确的路只有两条。一条是把原始观测值全部倒进同一个桶,重新排序。这样准确,但必须带着全部观测值。另一条是把直方图的桶相加。为每个区间统计“50 毫秒以内多少次,100 毫秒以内多少次”,这些个数就可以直接相加。再从相加后的表中重新估算百分位数即可。代价是,它回答的是桶而不是值,所以边界决定了误差。边界稀疏,估计值就会被抹平到桶内的某个位置;边界密集,虽然变准确了,时间序列却会增加。HdrHistogram 这样的数据结构之所以存在,就是因为这种权衡。
还有一点,合并后的百分位数有一条守得住的界限。整体的 p95 必定落在各区间 p95 的最小值与最大值之间。因为即使每个区间都是 95% 在该值以内,混在一起之后,95% 也在最大值以内。所以“合并后得出的值比所有区间都大”这样的报告,应该怀疑的是计算,而不是数据。
在现场相遇的样子
最常见的事故,是仪表板把各区间的 p95 求平均后画成一条线。仅占流量 10% 的缓慢区间把整体 p95 抬高了一倍,而平均线却把这件事藏起来了。反过来,如果用加权平均,缓慢的区间就被抹掉,得到的是比实际更乐观的图景。两者都辜负了“用平均值是往安全的一侧出错”这种直觉。
在压力测试中也会发生同样的事。有人习惯把负担较重的测试分成三次来运行,再把每次运行的 p95 求平均写进报告。如果每次运行的对象不同,或者负载不同,这个平均值就什么也没测量。正确的做法是,为每次运行留下原始响应时间,合并后重新计算,所以需要养成不丢弃工具原始输出(hey -o csv、k6 的结果文件)的习惯。
在时间轴上也有同样的陷阱。把每分钟计算好的 p95 画成一小时的图,再叠加一条平均线,这样的仪表板很常见,但那条线并不是这一小时的 p95。1 分钟 p95 的平均值,会把流量少的分钟和流量多的分钟按相同的权重统计,所以事故发生在流量集中的时段时,结果比实际更低。如果需要一小时的 p95,就必须把这一小时的桶相加后重新求。
换工具时也有需要确认的事。有的工具会把计算方法写进文档,有的不会。先用同样的数据把两个工具各运行一次,看看值会不会分歧,这样以后遇到“p95 比上周变差了”的报告,就能区分是工具的原因还是服务的原因。如果样本有几万个,两种方法的差别就会消失,所以这项确认必须在样本少的一侧(短窗口、流量少的区间)进行才有意义。
下一项实验要做什么
用固定种子生成的三个区间的延迟样本,亲手实现两种百分位数计算方法,确认值会分歧。然后把各区间 p95 的简单平均和加权平均与整体 p95 比较,看到它们向两个方向出错,并确认整体 p95 位于各区间 p95 的最小值与最大值之间这条界限。接着实现把直方图的桶相加来合并的方法,并把边界分别改成稀疏的和密集的,测量误差。最后把 hey 运行两次,测量合并原始数据得到的值与对 p95 求平均得到的值之差,并制作并提交一个能正确合并多次运行的工具。