34.1 性能指标 (延迟 / 吞吐 / P99)

一句话

延迟看分位数,吞吐看拐点,两者都被利用率串在一起:平均延迟骗人,P99 才是那 1% 用户真实经历的时间,而队列在 70% 利用率之后开始非线性爆炸。

四个指标,各回答一个问题

指标 定义 单位 回答什么 典型误用
吞吐 单位时间完成的请求数 QPS / TPS 系统能扛多少 用峰值 CPU 下的数字当容量
延迟 单个请求从发出到收到响应 ms 用户等多久 只看平均值
并发 同时在处理中的请求数 个 需要多少连接 / 内存 把并发当 QPS
错误率 失败请求 / 总请求 % 是否在「成功但很慢」之外还有第三种失败 压测时把超时算成成功

🔑 四个一起看才有意义。QPS 上去了但错误率 5%、P99 3 秒,这叫 “把系统压垮了”,不叫 “扛住了”。

平均值的欺骗:一个数字看穿

一组样本:97 个请求 20ms,3 个请求 500ms。

指标 数值 用户感受
平均 34.4ms 「我们平均延迟 34ms,很好」
P50 20ms 一半人很快
P95 20ms 95% 的人很快
P99 500ms 每 100 个用户里有 1 个等半秒
P999 / max 500ms 长尾极端值

按 100 万日请求算,P99 = 1% = 每天 1 万个请求要等 500ms。平均值把这个事实藏进了 34ms 里。

更狠的例子:99 个 10ms + 1 个 1s → 平均 19.9ms,看起来 “20ms 内搞定”,实际 1% 用户等了 1 秒。

分位数的计算陷阱

陷阱 错法 后果 正法
对分位数求平均 把 10 台机器的 P99 加起来除以 10 高估或低估,永不等于真实 P99 用直方图桶,先合并桶再算分位
样本量不足 1 分钟只有 20 个请求,算 P99 实际只是「第 20 个值」,噪声 每个统计窗口 ≥ 1000 个样本,P999 要 ≥ 10000
指数平均 用 avg 的滑动平均算分位 不稳定,无法合并 用 HdrHistogram / Prometheus histogram
混合不同接口 把 /health 和 /search 混在一个 P99 里 健康检查稀释了慢接口 按接口 / 路由打标签

💡 为什么分位数不能平均:P99 不是线性量。A 机器 1000 请求 P99=20ms,B 机器 10 请求 P99=500ms,两者 P99 平均 260ms;但全局 P99 是 500ms (合并后第 1009 个样本仍是那个 500ms)。而按请求量加权也不对——正确做法是合并直方图。

用可运行的代码看这件事

package main

import (
	"fmt"
	"sort"
)

// 模拟两个实例:A 流量大且快,B 流量小但很慢
var instA = append(repeat(20, 990), repeat(500, 10)...)
var instB = append(repeat(20, 9), repeat(500, 1)...)

func repeat(v int, n int) []int {
	out := make([]int, n)
	for i := range out {
		out[i] = v
	}
	return out
}

// pct 用「最近秩」算分位数:idx = ceil(p*n)-1
func pct(xs []int, p float64) int {
	s := append([]int(nil), xs...) // 不改动入参
	sort.Ints(s)
	idx := int(float64(len(s))*p+0.999999) - 1
	if idx < 0 {
		idx = 0
	}
	return s[idx]
}

func avg(xs []int) float64 {
	sum := 0
	for _, v := range xs {
		sum += v
	}
	return float64(sum) / float64(len(xs))
}

func main() {
	all := append(append([]int(nil), instA...), instB...)

	fmt.Printf("A:n=%d avg=%.2fms p99=%dms\n", len(instA), avg(instA), pct(instA, 0.99))
	fmt.Printf("B:n=%d avg=%.2fms p99=%dms\n", len(instB), avg(instB), pct(instB, 0.99))
	fmt.Printf("把两台机器的 P99 取平均:%dms  ← 错\n", (pct(instA, 0.99)+pct(instB, 0.99))/2)
	fmt.Printf("合并全部样本再算 P99:%dms  ← 对\n", pct(all, 0.99))
	fmt.Printf("全局 avg=%.2fms(看着没事),全局 p99=%dms(真有事)\n", avg(all), pct(all, 0.99))
}

输出:

A:n=1000 avg=24.80ms p99=20ms
B:n=10 avg=68.00ms p99=500ms
把两台机器的 P99 取平均:260ms  ← 错
合并全部样本再算 P99:500ms  ← 对
全局 avg=25.23ms(看着没事),全局 p99=500ms(真有事)

第 4 行和第 5 行是本节的全部结论:平均值 25ms 的 “健康系统”,P99 是 500ms。

吞吐与延迟:由利用率串起来的非线性关系

M/M/1 排队模型 (单队列单服务台,泊松到达):

响应时间倍数 = 1 / (1 - ρ)          ρ = 利用率 = 到达率 / 服务率
平均排队长度 L = ρ / (1 - ρ)
利用率 ρ 排队长度 L 响应时间倍数 直观感受
0.50 1.0 2× 很闲
0.70 2.3 3.3× 开始有波动
0.80 4.0 5× 抖动明显
0.90 9.0 10× 一次流量尖峰就雪崩
0.95 19.0 20× 基本不可用
0.98 49.0 50× 排队自我放大

🔑 容量规划的红线是 70% 利用率,不是 100%。70% 之后,同样的流量增长会让延迟成倍上涨;90% 利用率下,一次 10% 的流量波动就能把 P99 打穿。

推论:吞吐不是一直加上去的。压测时你会看到 QPS 随并发上升,到某一点后 QPS 不再涨、延迟暴涨、错误率抬头——这就是拐点 (knee),也是系统的真实容量上限。报容量要报拐点处 QPS 的 70%。

利特尔法则:把三个指标串起来

L = λ × W
并发数(L) = 到达率(λ) × 单请求停留时间(W)

算一遍:接口 200 QPS、平均延迟 50ms → 平均并发 = 200 × 0.05 = 10 个请求在途。

反过来用:连接池只有 10 个连接、每请求占 50ms → 理论吞吐上限 = 10 / 0.05 = 200 QPS。想扛 400 QPS,要么加连接、要么把 50ms 降到 25ms。这是排查 “压不上去” 最快的算式:先算理论上限,再对比实测。

目标怎么定:从业务倒推,不从机器倒推

步骤 做法 例子
① 定体验 用户能感知的阈值 搜索页 200ms、支付确认 1s、导出报表 30s
② 定 SLI 可测量的指标 P99 延迟、错误率、可用性(见 35.2 SLA / SLO / SLI / 错误预算)
③ 定 SLO SLI + 目标 + 窗口 P99 < 200ms,统计窗口 28 天
④ 算容量 峰值 QPS × 冗余系数 见下

算一遍容量 (100 万 DAU 的电商):

日均请求 = 100 万 DAU × 20 次/人 = 2000 万
平均 QPS = 2000 万 / 86400 ≈ 231
峰值系数 = 3(晚高峰)→ 峰值 ≈ 700 QPS
按 70% 利用率红线 → 需要扛住 700 / 0.7 = 1000 QPS 的容量

采集在哪一层:不同层的指标含义不同

层 你看到的是 陷阱
客户端 用户真实体验(含 DNS、TLS、网络) 不含机房内部,样本可能偏(只有活跃用户上报)
接入层 / 网关 全量请求、可按路由聚合 排队时间算进去了,但看不到后端耗时分布
服务端 处理耗时(不含网络) 容易漏掉排队时间,看着很美
数据库 / 依赖 下游耗时 一次请求可能放大成 N 次查询

排查顺序固定:客户端 P99 高 → 看是哪一段 (DNS/TLS/网络/服务端) → 服务端内部哪一层 → 哪个下游。没有分段数据,P99 只是一句 “慢”。

反模式

反模式 后果
用平均延迟定 SLO 1% 用户的 500ms 被 34ms 的平均值抹平,用户流失查不出原因
把多台机器的 P99 求平均 得到的数字既不是任何一台,也不是全局,纯噪声
压测只报最大 QPS,不报延迟与错误率 把系统压到错误率 20% 的「最大 QPS」当容量
容量按 100% 利用率规划 上线即抖,一次尖峰全站超时
只测单接口,不测混合链路 真实的资源竞争(连接池、锁、CPU)压不出来
1 分钟窗口算 P999 样本不足 100 个,P999 实际是最大值
把健康检查混进业务 P99 慢接口被稀释,告警永远不响
优化平均值而不是长尾 平均降 2ms,P99 不动——用户毫无感知

自检

Q1 监控显示某接口平均延迟 25ms、P99 500ms。老板问 “能不能优化到 200ms 以内”,你应该先做什么?

▸ 答案:先定位长尾的构成,而不是直接优化代码。检查三件事:① 500ms 这批请求是否集中在某个下游、某个分片、某台机器 (按标签拆 P99);② 是否是排队 (对比服务端处理耗时与网关端到端耗时,差值就是排队与网络);③ 是否周期性出现 (GC、定时任务、连接池耗尽、缓存集中失效)。长尾通常只由少数原因造成,找出那 1% 的共同特征,往往一处修复就把 P99 拉下来;盲目优化平均路径,P99 一分不动。

Q2 两台机器,A 的 P99 是 20ms,B 的 P99 是 500ms。你怎么得出系统的真实 P99?

▸ 答案:必须拿到原始分布 (直方图桶或全量样本),把两台机器的样本合并后重新计算分位。分位数不可平均、也不可简单按流量加权——它们都不是线性可加的统计量。如果只有 P99 这一个数字可用,那它不足以得出全局 P99,只能得出 “全局 P99 ≥ 500ms” 这一个下界。

Q3 系统在 60% CPU 利用率下跑得很好,扩容到 90% 后延迟翻了 5 倍。机器变快了吗?解释并给出一个可执行的红线。

▸ 答案:机器没变,是排队。按 M/M/1,响应时间倍数 ≈ 1/(1-ρ):ρ=0.6 → 2.5×,ρ=0.9 → 10×,涨的 4 倍差异全来自排队而非计算。可执行红线:峰值利用率不超过 70%,并按 70% 处的吞吐做容量规划;同时监控队列长度 (在途请求数) 而不只是 CPU——队列开始单调增长就是拐点已经到了。