4.1 负载生成器:先选模型,再选工具
上一节:无 | 下一节:4.2 两类现象,两条路径 配套代码:04-toolchain/01-load-generators 前置:0.6 协调遗漏(本节是它的工程落地)
一句话结论
选「开环还是闭环」比选「用哪个工具」重要得多。 同一份代码、同一个服务,两种模型测出的 P99 可能差几百倍——而工具之间的差别通常只有几成。
一、用「跑步机」理解开环与闭环
健身房里有两台跑步机:
跑步机 A(闭环):跟着你跑
你跑得快,它就快;你跑不动了,它就慢下来。
结果:你永远不会「跑崩」,因为机器会迁就你。跑了 30 分钟,你觉得自己状态不错。
跑步机 B(开环):按设定速度跑
设定 12 km/h,不管你跟不跟得上。你跟不上就掉下来(或者被拖着走)。
结果:你会真实地暴露极限——什么时候开始吃力、什么时候彻底跟不上。
对应到压测:
| 闭环(VU 模型) | 开环(到达率模型) | |
|---|---|---|
| 机制 | 收到响应才发下一个请求 | 按固定节奏发,不管响应 |
| 服务变慢时 | 自动减少发压 | 继续按计划发压(请求开始排队) |
| 结果 | P99 被系统性低估 | 暴露真实尾延迟 |
| 类比 | 「你跑不动机器就减速」 | 「机器不等你」 |
复习第 0 章 0.6 节:当服务端卡顿 1 秒时,闭环工具在这 1 秒内几乎不发请求,所以这次卡顿只被记录成 1 个慢样本,而不是几百个。服务越糟,报告越好看。
结论:默认用开环。 只有在明确要测「固定并发下的体验」时才用闭环。
二、工具对照表
| 工具 | 模型支持 | 脚本语言 | 适合场景 | 注意 |
|---|---|---|---|---|
| k6 | 开环 + 闭环(多种 executor) | JavaScript | 首选:脚本化、CI 友好、阈值断言、多场景 | 需要 preAllocatedVUs 配够 |
| Gatling | 开环(injection profile) | Scala DSL | 报告漂亮、表达力强 | JVM 自身需要预热与调优 |
| wrk2 | 开环(恒定速率 + HdrHistogram) | Lua(弱) | 验证尾延迟、轻量高并发 | 脚本能力弱,适合单一接口 |
| JMeter | 闭环为主 | GUI/XML | 协议覆盖广、插件生态 | 资源占用高,GUI 模式不适合正式压测 |
| Vegeta | 开环 | CLI | 简单场景、快速验证 | 报告与生态弱 |
| Locust | 开环/闭环 | Python | 分布式压测 | 单机性能一般 |
选择建议:
需要脚本化 + CI + 阈值断言 → k6
需要最精确的尾延迟验证 → wrk2(配合 HdrHistogram)
需要复杂业务流与漂亮的报告 → Gatling
需要 HTTP 之外的协议(JDBC/MQ等) → JMeter 或自研
不要同时维护两套压测脚本——结果会不一致,而且没人知道哪个是对的。选一套作为主,另一套只用于交叉验证(第 0 章 0.6 节的「方法 4」)。
三、压测机自己成为瓶颈:五个迹象
这是「数据不可信」的第一嫌疑。每次压测完,先检查这一项。
| 迹象 | 怎么查 | 含义 |
|---|---|---|
| ① 实际 QPS < 设定值 | k6 报告的 http_reqs.rate vs 设定值 |
最确定的信号,偏差 > 5% 就不该用 |
| ② 压测机 CPU 打满 | 压测期间看压测机的 top |
生成请求的能力不足 |
③ 临时端口耗尽 / TIME_WAIT 堆积 |
ss -s、netstat |
连接复用不足 |
| ④ 压测机 GC 停顿 | JVM 系工具看自己的 GC | 客户端自身抖动污染延迟 |
| ⑤ 压测机与服务同机 | 人工确认 | 互相抢 CPU,两个方向都失真 |
① 是最重要的,而且最容易自动化检查:
# 分析前必须先跑这一步
python3 - <<'PY'
import json
m = json.load(open("docs/experiments/E0x/results/k6-summary.json"))["metrics"]
actual = m["http_reqs"]["rate"]
expected = 500.0
print(f"设定 {expected} req/s, 实际 {actual:.1f} req/s, 偏差 {actual/expected-1:+.1%}")
print("✅ 可用" if abs(actual/expected - 1) <= 0.05 else "❌ 压测机是瓶颈,数据作废")
PY
四、k6 的核心概念(其他工具大同小异)
| 概念 | 说明 | 对应第几章 |
|---|---|---|
| executor | 决定流量模型:constant-arrival-rate(开环)、ramping-arrival-rate(开环阶梯)、constant-vus(闭环)、shared-iterations |
第 3 章 3.4 |
| scenarios | 一个脚本里可以定义多个场景,各自用不同的 executor | — |
| thresholds | 阈值断言,超标时以非零码退出(CI 门禁的基础) | 第 8 章 |
| checks | 业务断言(状态码、响应内容),不计入失败率 | — |
| custom metrics | 自定义 Trend/Rate/Counter/Gauge |
第 1 章 1.1 |
| tags | 给指标打标签——用路由模板,不要用具体 URL | 第 1 章 1.2(基数) |
| summaryTrendStats | 控制报告里输出哪些分位数(默认不含 P99.9) | — |
三个容易被忽略的配置:
// ① preAllocatedVUs 不足 → 实际到达率低于设定值(协调遗漏在工具层面的体现)
{ executor: 'constant-arrival-rate', rate: 1000, preAllocatedVUs: 50, maxVUs: 200 } // ❌
{ executor: 'constant-arrival-rate', rate: 1000, preAllocatedVUs: 1000, maxVUs: 5000 } // ✅
// ② discardResponseBodies → 避免压测客户端自己成为瓶颈(响应体解析很贵)
discardResponseBodies: true
// ③ gracefulStop → 压测结束后等待在飞请求完成,避免末尾数据不完整
{ executor: 'constant-arrival-rate', gracefulStop: '30s' }
五、一个完整的压测脚本应该包含什么
✅ 明确的 executor(模型)
✅ 足够的 preAllocatedVUs
✅ 真实的请求分布(幂律热点,而不是均匀随机)
✅ 路由模板作为 tag(防指标基数爆炸)
✅ thresholds(可判定的通过/失败标准)
✅ summaryTrendStats 包含 P99.9
✅ 原始数据落盘(--out json)
✅ discardResponseBodies
✅ 一个「幂等/只读」的测试目标(不要压写接口除非设计好了数据隔离)
特别注意最后一条:压测写接口会产生数据污染,而且数据量会持续增长影响后续测量。如果必须压写路径,要设计数据清理或分片隔离(每次用不同的 ID 空间)。
六、本节小结
- 开环 vs 闭环的差别,比工具之间的差别大得多。默认用开环(恒定到达率)。
- 工具有各自的强项:k6(脚本化 + CI)、wrk2(尾延迟精度)、Gatling(报告与复杂流程)、JMeter(协议覆盖)。
- 压测机自己成为瓶颈的第一信号是「实际 QPS < 设定值」——每次压测完先检查这一项。
preAllocatedVUs、discardResponseBodies、gracefulStop是三个最容易配错的地方。- 压测写接口要设计数据隔离,否则数据污染会让后续测量失效。
七、自测
- 你设定 1000 req/s,k6 报告实际 620 req/s。请说出至少两个可能的原因,以及为什么此时服务端的延迟数据不可用。
- 一个团队说「我们用 200 个并发用户压测,P99 是 80 ms,很健康」。请指出这句话里最大的问题,以及你需要的补充信息。
- 为什么压测脚本里要用「路由模板」而不是「具体 URL」作为标签?如果用具体 URL 会有什么后果?
- 原因:①
preAllocatedVUs不足——k6 需要足够的虚拟用户来承载"已经发出但未返回"的请求,如果并发不够,它会等 VU 释放才能发下一个,于是实际到达率下降;② 压测机资源不足——CPU 打满、临时端口耗尽、TIME_WAIT堆积、压测机自身 GC 停顿;③ 网络带宽限制。为什么延迟数据不可用:实际只发了 620 req/s 而不是 1000,说明系统承受的负载低于设定——此时测出的延迟偏乐观(负载轻了);更严重的是,如果是因为服务端变慢导致 VU 被占住(闭环效应),那么慢请求被系统性漏采,P99 会被严重低估。必须先修压测侧,数据才有意义。 - 最大的问题是**「200 个并发用户」= 闭环模型**,尾延迟会被协调遗漏系统性低估(第 0 章 0.6 节)。需要补充:① 实际到达率是多少 req/s(以及是否等于期望值)?② 负载前提:数据量、数据分布、缓存状态;③ 测量点:客户端还是服务端?④ 跑了多久、几轮、错误率多少?⑤ 压测机与服务是否同机?⑥ P99 是基于多少样本算的、桶边界怎么设的?
- 因为 Prometheus 的指标基数会爆炸。如果每个具体 URL(尤其是含 ID 的,如
/orders/12345)都成为一个独立的时间序列,那么:① Prometheus 内存与磁盘占用随 URL 数量线性增长,最终 OOM;② 查询变慢(要扫大量序列);③ 你无法按接口聚合(每个 ID 一个序列,无法算「订单接口整体」的 P99)。正确做法:用路由模板/orders/{id}作为标签值,这样所有订单请求汇总成一个序列。