34.5 性能测试与负载模型
一句话
压测的价值主要取决于负载模型是否像真实流量,工具只占一小部分;用闭环压测报出的 QPS 会系统性高估容量,因为系统变慢时它自动降低了发压速率,把「没发出去的请求」从统计里悄悄删掉了。
六种压测,六个不同问题
| 类型 | 回答的问题 | 做法 | 时长 | 通过标准 |
|---|---|---|---|---|
| 冒烟 | 脚本和环境通不通? | 1–10 并发跑通全链路 | 1–5 分钟 | 错误率 0,数据正确 |
| 负载 | 预期峰值下能不能满足 SLO? | 按生产峰值 (或 1.2×) 恒速压 | 30 分钟–2 小时 | P99、错误率、资源都达标 |
| 压力 | 系统的拐点/上限在哪? | 阶梯加压直到 QPS 不涨/错误率抬头 | 30–60 分钟 | 找到 knee,记录崩溃形态 |
| 尖峰 | 突发 5–10 倍流量能不能扛? | 瞬间拉高,保持 1–5 分钟 | 10–30 分钟 | 不雪崩,恢复时间可接受 |
| 浸泡 | 跑久了会不会漏? | 70%–80% 负载跑 8–72 小时 | 8–72 小时 | 内存/连接/GC 平稳,无泄漏 |
| 破坏 | 依赖挂一个会怎样? | 杀实例、断网、注入延迟 | 30–60 分钟 | 降级正确,不级联失败 |
🔑 一次压测只回答一个问题。把「找上限」「验 SLO」「泡 24 小时」混在一次里,结果通常是三个问题都没答清楚。
负载模型五要素
| 要素 | 要定义什么 | 常见错误 | 正确做法 |
|---|---|---|---|
| ① 到达模型 | 开环 (固定速率) 还是闭环 (固定并发) | 只用 ab/固定线程数 | 入口流量用开环;连接池/会话型用闭环;多数系统是混合 |
| ② 流量比例 | 读/写/搜索/支付各占多少 | 只压 GET,或 100% 读 | 按生产日志的接口比例混合 |
| ③ 数据分布 | 热点比例、记录大小、冷热分布 | 均匀随机、全小 payload | 复刻热点 (如 20% key 承担 80% 请求)、大小分布 |
| ④ 节奏 | 思考时间、ramp-up、pacing | 0 思考时间、瞬间拉满 | 按真实会话设置 Z;5–15 分钟 ramp-up |
| ⑤ 生命周期 | 压多久、何时降载 | 只跑 1 分钟 | ramp → hold → ramp-down,预热与稳态分开统计 |
⚠️ 模型错了,工具再准也没用。100% 读、均匀 key、小 payload、1 万行数据,压出来的「5000 QPS」在真实流量下可能连 500 QPS 都撑不住。
开环 vs 闭环:coordinated omission
| 维度 | 闭环 (Closed) | 开环 (Open) |
|---|---|---|
| 发压逻辑 | N 个并发,完成一个再发下一个 | 按固定时间表发,不管上一个是否完成 |
| 系统变慢时 | 发压自动变慢 | 到达率不变,请求堆积 |
| 适合 | 数据库连接、会话、线程池 | 网关入口、真实用户流量 |
| 工具 | ab、JMeter 固定线程、Locust 固定用户 | wrk2、vegeta、k6 constant-arrival-rate |
| 风险 | 低估延迟、高估容量 (CO) | 把系统压垮,需要控制速率上限 |
一个 500ms 的卡顿,被 P99 藏起来了
设定:目标 100 QPS (每 10ms 一个),总时长 30 秒;服务平时 2ms,在 [10s, 10.5s) 卡死 500ms。单线程发压,发一个等一个。
package main
import (
"fmt"
"sort"
"time"
)
const (
targetRPS = 100
duration = 30 * time.Second
stallFrom = 10 * time.Second
stallTo = 10*time.Second + 500*time.Millisecond
normal = 2 * time.Millisecond
stalled = 500 * time.Millisecond
)
func service(at time.Duration) time.Duration {
if at >= stallFrom && at < stallTo {
return stalled // 卡死窗口:任何落进来的请求都要 500ms
}
return normal
}
// 单线程定速发压:发一个等一个。卡死期间“本该发而未发”的请求不会被记录。
func main() {
interval := time.Second / targetRPS
var naive, corrected []time.Duration
now, next := time.Duration(0), time.Duration(0)
plannedInStall, sentInStall := 0, 0
for next < duration {
if next >= stallFrom && next < stallTo {
plannedInStall++
}
send := next
if now > send { // 上一个请求还没返回,实际发送被推迟
send = now
}
if send >= stallFrom && send < stallTo {
sentInStall++
}
finish := send + service(send)
naive = append(naive, finish-send) // 从“实际发出”算
corrected = append(corrected, finish-next) // 从“计划发出”算
now = finish
next += interval
if now < next {
now = next // 空闲时等到下一个时间点
}
}
fmt.Printf("计划发出 %d 个;卡死窗口 [10s,10.5s) 内计划 %d 个,实际只发出 %d 个\n",
int(duration/interval), plannedInStall, sentInStall)
report("naive ", naive)
report("corrected", corrected)
}
func report(name string, xs []time.Duration) {
s := append([]time.Duration(nil), xs...)
sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
p := func(q float64) time.Duration {
idx := int(float64(len(s))*q+0.999999) - 1
if idx < 0 {
idx = 0
}
return s[idx]
}
var sum time.Duration
over := 0
for _, v := range s {
sum += v
if v > 100*time.Millisecond {
over++
}
}
fmt.Printf("%s n=%d avg=%.2fms p50=%v p99=%v max=%v (>100ms: %d)\n",
name, len(s), float64(sum)/float64(len(s))/float64(time.Millisecond),
p(0.50), p(0.99), s[len(s)-1], over)
}
输出:
计划发出 3000 个;卡死窗口 [10s,10.5s) 内计划 50 个,实际只发出 1 个
naive n=3000 avg=2.17ms p50=2ms p99=2ms max=500ms (>100ms: 1)
corrected n=3000 avg=7.25ms p50=2ms p99=260ms max=500ms (>100ms: 50)
三个数字,三种命运:
| 指标 | naive (实际发出时刻) | corrected (计划发出时刻) | 差距 |
|---|---|---|---|
| 平均 | 2.17ms | 7.25ms | 3.3× |
| P99 | 2ms | 260ms | 130× |
| max | 500ms | 500ms | 一样 |
| >100ms 样本 | 1 个 | 50 个 | 50× |
最大值在两种算法里都是 500ms,所以「看 max」不能救你;P99 才是会被骗的那个。naive 只记录了 1 个慢请求;另外 49 个请求虽然最终也发出了,但 naive 按「实际发出时刻」计时,把它们的排队等待算成了 0——这部分时间被 omitted 了。
怎么修正
| 做法 | 说明 |
|---|---|
| 用开环定速发压 | 按计划时间发,不因响应慢而减速 (wrk2、k6 constant-arrival-rate、vegeta) |
| 记录 intended start | 闭环压测时,记录「本该发出的时间」,延迟 = 完成时间 - 计划时间 |
| 用 HdrHistogram | 支持记录修正后的延迟分布,避免分位数近似误差 |
| 报告两个数字 | naive 与 corrected 都给,明确口径;只给 naive 等于隐瞒 |
| 压测机不能成为瓶颈 | 客户端 CPU、端口、TLS 握手、单机带宽都要监控 |
💡 开环发压也有代价:如果系统真的崩溃,请求会无限堆积,压测机和服务都可能被打挂。生产级做法是开环 + 上限 + 熔断:到达率按计划,但在途请求超过阈值时停止发压并记录。
工具选型
| 工具 | 模型 | 协议 | 脚本 | CO 友好 | 适合 |
|---|---|---|---|---|---|
| wrk2 | 开环定速 (-R) | HTTP/1.1 | Lua | ✅ | 单接口吞吐/延迟曲线 |
| k6 | 开环/闭环场景 | HTTP、WebSocket、gRPC | JavaScript | ✅ (constant-arrival-rate) | 多接口混合、CI 集成 |
| vegeta | 开环定速 | HTTP | Go/命令行 | ✅ | 快速恒定速率压测 |
| JMeter | 闭环为主 | HTTP、JDBC、JMS | GUI/XML | ⚠️ 需插件/改造 | 企业既有体系、复杂协议 |
| Locust | 闭环 (用户数) | HTTP | Python | ⚠️ 需自定义 | 灵活场景、Python 团队 |
| ghz | 开环 | gRPC | 命令行/JSON | ✅ | gRPC 接口 |
| ab | 闭环 | HTTP/1.1 | 无 | ❌ | 只做冒烟,不要用来报容量 |
k6 的开环场景:
import http from 'k6/http';
export const options = {
scenarios: {
steady: {
executor: 'constant-arrival-rate',
rate: 500, // 每秒 500 个请求
timeUnit: '1s',
duration: '10m',
preAllocatedVUs: 200,
maxVUs: 2000, // 系统变慢时允许更多 VU 维持速率
},
},
thresholds: {
http_req_failed: ['rate<0.001'],
http_req_duration: ['p(99)<300'],
},
};
export default function () {
http.get('http://api.internal/items?page=1');
}
wrk2:
wrk2 -t4 -c200 -d300s -R2000 --latency http://api.internal/items
# -R2000 是目标速率 (开环),-c200 是连接数上限
环境与数据:压测结果的第一大误差源
| 项 | 要求 | 不满足的后果 |
|---|---|---|
| 机器规格 | CPU/内存/磁盘/网络与生产同构 | 压出的容量不可迁移 |
| 数据量 | 接近生产 (至少覆盖索引深度、分片数) | 查询计划、缓存命中率全变 |
| 数据分布 | 热点比例、大小分布、冷热分层 | 均匀数据低估锁争用和热点 |
| 缓存状态 | 预热到生产命中率,或明确测冷启动 | 第一轮 100% 回源,P99 虚高 |
| 环境隔离 | 独立环境/影子流量,无邻居干扰 | 噪声让结果不可复现 |
| 版本固定 | commit、镜像、配置、DB 参数一致 | 不知道测的是哪个版本 |
| 监控齐全 | 客户端 + 服务端 + 下游 + 系统 | 只看到「慢」,看不到「为什么慢」 |
两个具体的失真机制:
- B+ 树高度:百万行约 3 层,亿行约 4 层;更关键的是 buffer pool 命中率——测试库能全放内存,生产库要随机 IO,同一条 SQL 可能差 10 倍。
- 查询计划:1 万行时优化器可能选全表扫,1 亿行时可能选索引;测试数据太小,根本压不出真实的执行计划。
⚠️ 别用「生产数据脱敏后的 1%」直接压:数据量变了,索引选择性、缓存命中率、锁冲突都会变。至少要让数据量、热点分布、索引层级三个特征接近。
采集什么:客户端和服务端都要
| 层 | 必采指标 | 用途 |
|---|---|---|
| 客户端 | 计划发送时间、实际发送时间、延迟 (两种口径)、错误率、连接复用、客户端 CPU/端口 | 发现 CO 和发压瓶颈 |
| 服务端 | QPS、P50/P95/P99/P999、错误率、CPU、内存、GC 停顿、队列长度 | 判断 SLO 与资源 |
| 连接池 | 活跃/空闲/等待数、获取连接耗时、超时次数 | 定位排队 |
| 下游 | QPS、延迟、错误率、连接池、限流 | 定位级联瓶颈 |
| 系统 | load、上下文切换、重传、磁盘 await、网络带宽 | 区分 CPU/IO/网络 |
🔑 只记录服务端处理耗时,会把排队时间、网络时间、客户端时间全部漏掉。端到端 P99 和服务端 P99 的差值,往往就是答案。
结果解读:吞吐-延迟曲线的四个阶段
四个阶段依次是:线性 → 拐点 → 饱和 → 崩溃;表中的前两行都属于线性阶段,只是并发不同。
一条典型的曲线 (数值示意,闭环模型:平均延迟 ≈ 并发 / QPS,忽略思考时间):
| 并发 | QPS | P50 | P99 | 阶段 | 现象 |
|---|---|---|---|---|---|
| 1 | 200 | 4ms | 8ms | 线性 | 延迟稳定,资源空闲 |
| 10 | 700 | 10ms | 35ms | 线性 | 吞吐随并发线性涨 |
| 32 | 1050 | 20ms | 90ms | 接近拐点 | 延迟开始抬头 |
| 64 | 1100 | 40ms | 180ms | 拐点 (knee) | QPS 不涨,P99 翻倍 |
| 128 | 1080 | 90ms | 900ms | 饱和 | 排队主导,吞吐停滞 |
| 256 | 800 | 250ms | 5s | 崩溃 | 超时/重试放大,吞吐下降 |
拐点判定规则
QPS 增幅 < 10%,同时 P99 增幅 > 50% → 到达拐点
上例中,并发 32→64:QPS 只涨 4.8%,P99 从 90ms 涨到 180ms (+100%),拐点在 64 并发、1100 QPS。
规划容量 = 拐点 QPS × 70% = 1100 × 0.7 = 770 QPS
如果生产峰值是 700 QPS,余量只有 10%,一次尖峰就会越过拐点;如果峰值是 1000 QPS,必须优化或扩容,而不是「压测过了 1100 就行」。
⚠️ 拐点不是固定的:缓存命中率、数据量、下游延迟、GC 都会移动它。每次大版本发布后重新压,别拿半年前的数字做容量规划。
压测报告模板
| 部分 | 必须写清 | 示例 |
|---|---|---|
| 目标 | SLO + 业务峰值 | 大促 1000 QPS,P99 < 300ms,错误率 < 0.1% |
| 负载模型 | 开环/闭环、比例、数据分布、时长 | 开环 1000 QPS 恒速 30min,读写 8:2,20% 热点 key |
| 环境 | 机器、版本、数据量、依赖规格 | 8C16G × 4,MySQL 8C32G,订单表 5000 万行 |
| 结果 | QPS、P50/P99/P999、错误率、资源 | 1000 QPS 下 P99 420ms,API CPU 78%,DB CPU 85% |
| 瓶颈 | 证据链 (USE/profile/慢查询) | 连接池等待 120ms + 慢查询缺索引 |
| 结论 | 是否达标、容量、余量 | 不达标;优化后 P99 180ms,容量 1400 QPS |
| 风险 | 未覆盖的场景、下次压测计划 | 未测尖峰/故障注入;下周补 |
一份合格的报告,读者不看图表也能回答三个问题:压的是什么模型、瓶颈在哪、结论能不能用于生产容量规划。
与其他章节的边界
| 章节 | 负责什么 | 本节的关系 |
|---|---|---|
| 34.1 性能指标 | 指标定义、分位数、利用率红线 | 本节用它的口径出报告 |
| 34.2 性能建模 | 压测前预测上限、瓶颈 | 用模型设计压测点,用压测校准模型 |
| 34.5 本节 | 负载模型、执行、结果解读 | — |
| 50 可观测性 | 生产监控、SLO、告警 | 压测指标应与生产同源 |
| 56.1 混沌工程 | 故障注入、破坏性实验 | 「破坏」类压测的延伸 |
| 57.1 压测方法论 | 容量治理流程、周期性压测 | 本节讲单次怎么做,57.1 讲怎么持续做 |
反模式
| 反模式 | 后果 |
|---|---|
只用闭环压测 (ab/固定线程) | 低估 P99、高估容量 (CO) |
| 不设 ramp-up,瞬间拉满 | 连接池/缓存/JIT 未预热,数据不可用 |
| 测试数据量远小于生产 | 索引、缓存、查询计划全失真 |
| 均匀随机 key,不造热点 | 锁争用、热 key、缓存击穿压不出来 |
| 只压 GET,不压写/混合链路 | 真实资源竞争 (锁、事务、连接) 没覆盖 |
| 压测机和被测服务混部 | 客户端和服务器抢 CPU,结果互相污染 |
| 把超时算成成功 | 错误率 0,但用户全在等超时 |
| 只看服务端耗时 | 漏掉排队、网络、客户端 |
| 只跑一次就下结论 | 噪声、缓存、邻居干扰都可能让结果偏 20% |
| 压完不清理数据 | 下次压测数据量变了,结果不可比 |
| 用平均延迟写报告 | 老板满意,用户超时 |
| 拿压测 QPS 直接当线上容量 | 没扣 70% 余量,也没考虑拐点漂移 |
自检
Q1 为什么闭环压测会高估容量?用「10 并发、正常 100ms、卡顿 1s」举例说明。
▸ 答案:闭环下吞吐 = 并发 / 延迟。正常时 10/0.1 = 100 QPS;卡顿 1s 时变成 10/1 = 10 QPS。压测机在卡顿期间只发了 10 个请求,而生产环境的开环流量在同一秒会发 100 个,多出来的 90 个会排队、会超时。闭环把这些「没发出去」的请求从统计中删掉了,于是报告里只有 10 个 1s 的样本,P99 可能仍是毫秒级,容量被高估。修正:开环定速,或按「计划发送时间」计算延迟。
Q2 一份压测报告写「5000 QPS,P99 50ms,错误率 0」,但上线后大量超时。列三个最可能的原因。
▸ 答案:① 负载模型失真:只压了单接口/只读/均匀 key,生产是混合读写 + 热点 + 大 payload;② 数据规模失真:测试库小,索引和缓存命中率远好于生产;③ 闭环 + CO:压测机没发出真实流量,P99 被低估;④ 环境差异 (网络、TLS、邻居、下游规格);⑤ 只测服务端耗时,漏掉网关/网络排队。任选三个都能解释「压测很好、上线超时」。
Q3 如何确定系统的拐点和规划容量?给出可执行的判定规则。
▸ 答案:用开环阶梯加压 (或闭环逐步加并发),记录每个点的 QPS、P99、错误率、资源。判定:QPS 增幅 < 10% 且 P99 增幅 > 50% 时到达拐点;同时看队列长度/饱和度是否单调增长。规划容量取拐点 QPS 的 70%,并为峰值系数留余量。拐点会随数据量、缓存命中率、版本变化,发布后要重测。
Q4 为什么压测数据不能只用生产数据的 1%?举两个具体机制。
▸ 答案:① 索引与查询计划:数据量小的时候优化器可能选全表扫,B+ 树高度、buffer pool 命中率也不同,同一条 SQL 的耗时可能差 10 倍;② 热点与锁争用:1% 数据下 key 分散,锁冲突和缓存击穿几乎压不出来,生产的热点 key 会把单分片打满;③ 缓存命中率、连接池行为、GC 压力也会随数据量变化。至少要保证数据量、热点分布、索引层级接近生产。
Q5 压测时客户端 CPU 100%,服务端 CPU 30%,QPS 上不去。结论是什么?怎么改?
▸ 答案:客户端成了瓶颈,这轮结果不能用于容量结论。原因可能是 TLS 握手、JSON 序列化、单机端口耗尽 (TIME_WAIT)、连接数上限、压测脚本效率低。改法:分布式发压 (多台压测机)、复用连接、减少客户端序列化开销、监控客户端 CPU/端口/带宽;或者用更轻量的协议/工具 (wrk2、vegeta)。只有确认客户端余量充足,服务端的数字才可信。