Skip to content

约 7 分钟阅读

平均值会骗人:P50、P95、P99 与长尾 ​

上一篇的模拟里,处理时间平均只有 10 毫秒,P99 却有几十到几百毫秒。回到系列开头的问题:订单服务的监控上平均耗时很好看,客服却不断收到“页面转半天”的投诉。这一篇解释平均值为什么会掩盖问题,P50、P95、P99 这些百分位数怎么算、怎么读,以及一个请求同时调用多个下游服务时,慢请求为什么会成倍增加。读完后,你应该能看懂延迟监控上的百分位曲线,知道哪些统计方式是错的,并能估算扇出对尾部延迟的影响。

从十个请求开始 ​

先用十个请求的耗时(教学数据)把概念讲清楚,单位是毫秒:

text
9  10  10  11  11  12  12  13  14  200

平均值是 30.2 毫秒,但十个请求里没有一个接近 30 毫秒:九个在 9 到 14 毫秒之间,一个要 200 毫秒。平均值把那个慢请求摊到了所有请求头上,结果谁也代表不了。

百分位数换了一种描述方式:把所有耗时从小到大排好,P50 是排在 50% 位置的那个值,表示一半请求比它快;P90 是排在 90% 位置的值,表示 90% 的请求比它快,剩下 10% 比它慢。常用的计算方法(最近秩法)是取第 ⌈n × p⌉ 个,n 是请求数,p 是百分比:

text
P50 = 第 ⌈10 × 0.5⌉ = 第 5 个 = 11 毫秒
P90 = 第 ⌈10 × 0.9⌉ = 第 9 个 = 14 毫秒
P99 = 第 ⌈10 × 0.99⌉ = 第 10 个 = 200 毫秒

P50 也叫中位数,描述的是典型请求的体验;P95、P99 描述的是最慢的那一小部分请求,这一部分叫“长尾”。十个样本算 P99 只能取到最大值,没有意义,所以百分位越高,需要的样本越多:要让 P99 有参考价值,至少要几千个请求。

实验:一次调用 2% 变慢,扇出 30 次就有近一半变慢 ​

订单详情接口要同时调用库存、物流、优惠等多个下游服务,等全部返回后才能响应。下面的实验模拟一个下游服务:98% 的调用耗时 8 到 12 毫秒,2% 碰上缓存未命中,要 200 毫秒。每个请求并发调用这个服务 1、5、10、30 次(扇出数),等最慢的一个返回,统计 2000 个请求的耗时。保存为 tail.py:

python
import asyncio, random, time

random.seed(11)

def pct(xs, p):                      # 最近秩法:排好序,取第 ceil(n×p%) 个
    xs = sorted(xs)
    return xs[max(0, -(-len(xs) * p // 100) - 1)]

async def downstream():
    # 库存服务:98% 的调用 8–12 ms,2% 碰上缓存未命中,要 200 ms
    cost = 0.2 if random.random() < 0.02 else random.uniform(0.008, 0.012)
    await asyncio.sleep(cost)

async def request(fanout):
    t = time.perf_counter()
    await asyncio.gather(*(downstream() for _ in range(fanout)))   # 并发调用,等最慢的那个
    return (time.perf_counter() - t) * 1000

async def main():
    for fanout in (1, 5, 10, 30):
        lat = []
        for _ in range(20):          # 每批 100 个请求并发,共 2000 个
            lat += await asyncio.gather(*(request(fanout) for _ in range(100)))
        slow = sum(x > 100 for x in lat) / len(lat)
        print(f"扇出 {fanout:2d}:平均 {sum(lat) / len(lat):5.1f} ms,P50 {pct(lat, 50):5.1f},P95 {pct(lat, 95):5.1f},"
              f"P99 {pct(lat, 99):5.1f},超过 100 ms 的 {slow:5.1%}(理论 {1 - 0.98 ** fanout:5.1%})")

asyncio.run(main())
bash
docker run --rm -v "$PWD":/lab -w /lab capacity-lab python tail.py

实际输出:

text
扇出  1:平均  17.1 ms,P50  13.0,P95  14.3,P99 206.3,超过 100 ms 的  2.2%(理论  2.0%)
扇出  5:平均  34.3 ms,P50  15.8,P95 205.4,P99 208.8,超过 100 ms 的  9.8%(理论  9.6%)
扇出 10:平均  54.7 ms,P50  19.4,P95 208.8,P99 210.4,超过 100 ms 的 18.9%(理论 18.3%)
扇出 30:平均 111.0 ms,P50  31.9,P95 217.2,P99 220.6,超过 100 ms 的 44.6%(理论 45.5%)

延迟是真实等待测得的,下游服务的快慢是按设定的比例随机抽取的,脚本固定了随机种子,所以重复运行时慢请求的比例相同,耗时数值有零点几毫秒的差异。实测耗时比设定值多出两三毫秒,来自事件循环调度和定时器精度。

平均值为什么会骗人 ​

延迟分布上的平均值与百分位,以及扇出放大慢请求的比例

先看扇出为 1 的一行:平均 17 毫秒,P50 13 毫秒,P95 14 毫秒,P99 却是 206 毫秒。这个服务的耗时分成两堆,绝大多数在 10 毫秒附近,少数在 200 毫秒附近,平均值 17 毫秒落在两堆之间的空地上,没有任何请求的耗时接近它。监控上只看平均值,你会认为这个服务很快,而且看不出有 2% 的请求慢了将近 20 倍。

2% 听起来不多,但落到用户身上就不是 2%。打开一次订单详情页,浏览器要请求页面、接口和若干资源,一次会话里还要打开好几个页面。如果一次会话涉及 50 个请求,每个请求有 2% 的概率很慢,那么至少碰上一次慢请求的会话占 1 − 0.98⁵⁰ ≈ 64%。慢请求的用户往往又是最重要的用户:数据最多、订单最多的账户,查询也最慢。所以对外承诺的指标通常写成“99% 的请求在 200 毫秒内返回”,而不是“平均 50 毫秒”。

扇出把长尾放大 ​

再看扇出的几行。一个请求同时调用多个下游,必须等最慢的那个返回,只要其中任何一个碰上 200 毫秒,整个请求就是 200 毫秒。每次调用变慢的概率是 2%,n 次调用全部都快的概率是 0.98ⁿ,至少有一次变慢的概率是:

text
1 − 0.98ⁿ    n = 5 时 9.6%,n = 10 时 18.3%,n = 30 时 45.5%

实验结果和公式吻合。扇出 10 时,P95 已经是 209 毫秒:下游服务的 P98 级别的慢,变成了上游的 P82 级别的慢。扇出 30 时,接近一半的请求都超过 100 毫秒,连 P50 也从 13 毫秒涨到了 32 毫秒,因为同时等 30 个调用,就算都不碰上缓存未命中,也要等其中最慢的那个。

所以,扇出越大的服务,对下游的尾部延迟越敏感。下游服务的 P99 是 200 毫秒,对它自己来说是“1% 的请求有点慢”,对一个扇出 30 的上游来说却是常态。改善的方向有几个:

  • 减少扇出。 合并成批量接口,一次调用取回多个结果;能不调的下游不调。
  • 给下游调用设超时并降级。 优惠信息 50 毫秒还没回来,就先返回不带优惠的详情,页面稍后再补。并发调用的超时和取消怎样收敛,见 异步任务编排:成功、失败、超时和取消怎么收敛。
  • 对冲请求。 一次调用等待超过这个下游的 P95 还没返回,就向另一个副本再发一次,用先回来的结果。它用少量额外请求换取更短的尾部,只适用于可以安全重复执行的读请求。
  • 消除长尾本身的成因。 缓存未命中、垃圾回收停顿、锁竞争,以及上一篇讲的利用率过高导致的排队,都会制造长尾。

百分位数的常见误用 ​

不能把多个百分位求平均。 两台机器,A 处理了 9900 个请求,P99 是 50 毫秒;B 处理了 100 个请求,P99 是 900 毫秒。把两个 P99 平均得到 475 毫秒,这个数没有任何含义。正确的做法是把两台机器的原始耗时合在一起,或者把它们的直方图合并,再算整体的 P99。同理,把每分钟的 P99 平均成一小时的 P99 也是错的。

直方图算出的是近似值。 监控系统通常不保存每个请求的耗时,而是统计落在各个区间(桶)里的请求数,再估算百分位。以 Prometheus 为例,先把各实例的桶相加,再计算:

text
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))

它假设请求在桶内均匀分布,结果的精度取决于桶的划分。如果桶只有 100 毫秒和 1 秒两档,P99 只能在这之间插值,误差可能很大。桶的边界要围绕关心的阈值来设,比如承诺“99% 在 200 毫秒内”,就要有一个 200 毫秒的桶。

压测工具可能隐藏排队时间。 很多压测工具是“发一个、等它回来、再发下一个”。服务卡住时,工具也跟着停下来不发了,那段时间本应到达的请求根本没有发出,自然也没有被统计成慢请求,测出来的尾部延迟就比实际好看。这个问题叫协调遗漏(coordinated omission)。第 05 篇的实验按固定节奏发送请求、从计划发送的时刻开始计时,就是为了避开它。压测工具里,wrk2 的 -R 参数、k6 的 constant-arrival-rate 执行器都按固定速率发送请求。

在服务器上看延迟 ​

一份能用来判断问题的延迟数据,至少要同时有这几项:

要看的数据用途
P50、P95、P99(可能的话加 P99.9)典型体验和长尾,分开看
请求数(QPS)判断百分位的样本是否足够,以及延迟变化是否随流量变化
错误率和超时数超时的请求往往不计入耗时统计,会让百分位看起来变好
按接口、按下游拆开的延迟找出是哪个接口、哪个下游拖长了尾部

P99 突然升高时,先看同一时间的 QPS 和利用率:如果流量和利用率都上去了,多半是排队,回到第 05 篇的思路;流量没变而 P99 升高,要找慢下游、垃圾回收停顿、锁竞争或缓存失效。只有 P50 升高、P99 变化不大,说明所有请求都变慢了,常见原因是 CPU 被别的进程占用或者依赖整体变慢。

回到最初的问题 ​

订单服务的平均耗时 40 毫秒,用户却说慢,现在可以沿着系列的顺序把原因找出来。先确认干活的是谁、有多少:几个进程,每个进程几个线程,还是协程(01)。再看请求的时间花在等待还是计算上,等待靠并发重叠,计算靠多核并行(02)。线程不能比 CPU 能承受的多太多,否则每个请求都被拖慢(03)。共享数据要保证原子性,锁要用得正确,避免竞态和死锁(04)。线程池按 Little 定律配置,利用率留出余量,避免排队时间急剧上升(05)。最后,用百分位而不是平均值来衡量结果,留意扇出对尾部的放大(06)。

在这一切之后,系统还会遇到流量超过处理能力的时刻,这时需要限流、背压和超时来保护自己。这部分在 连接之外:吞吐、扇出与背压 和 Python 并发实战 中有更多工程细节。想回到系列开头按问题选读,见并发基础:一个服务怎样同时处理很多请求。

别急,先让缓存热一下。