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——队列开始单调增长就是拐点已经到了。