文档目录

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 空间)。


六、本节小结

  1. 开环 vs 闭环的差别,比工具之间的差别大得多。默认用开环(恒定到达率)。
  2. 工具有各自的强项:k6(脚本化 + CI)、wrk2(尾延迟精度)、Gatling(报告与复杂流程)、JMeter(协议覆盖)。
  3. 压测机自己成为瓶颈的第一信号是「实际 QPS < 设定值」——每次压测完先检查这一项。
  4. preAllocatedVUs、discardResponseBodies、gracefulStop 是三个最容易配错的地方。
  5. 压测写接口要设计数据隔离,否则数据污染会让后续测量失效。

七、自测

  1. 你设定 1000 req/s,k6 报告实际 620 req/s。请说出至少两个可能的原因,以及为什么此时服务端的延迟数据不可用。
  2. 一个团队说「我们用 200 个并发用户压测,P99 是 80 ms,很健康」。请指出这句话里最大的问题,以及你需要的补充信息。
  3. 为什么压测脚本里要用「路由模板」而不是「具体 URL」作为标签?如果用具体 URL 会有什么后果?
  1. 原因:① preAllocatedVUs 不足——k6 需要足够的虚拟用户来承载"已经发出但未返回"的请求,如果并发不够,它会等 VU 释放才能发下一个,于是实际到达率下降;② 压测机资源不足——CPU 打满、临时端口耗尽、TIME_WAIT 堆积、压测机自身 GC 停顿;③ 网络带宽限制。为什么延迟数据不可用:实际只发了 620 req/s 而不是 1000,说明系统承受的负载低于设定——此时测出的延迟偏乐观(负载轻了);更严重的是,如果是因为服务端变慢导致 VU 被占住(闭环效应),那么慢请求被系统性漏采,P99 会被严重低估。必须先修压测侧,数据才有意义。
  2. 最大的问题是**「200 个并发用户」= 闭环模型**,尾延迟会被协调遗漏系统性低估(第 0 章 0.6 节)。需要补充:① 实际到达率是多少 req/s(以及是否等于期望值)?② 负载前提:数据量、数据分布、缓存状态;③ 测量点:客户端还是服务端?④ 跑了多久、几轮、错误率多少?⑤ 压测机与服务是否同机?⑥ P99 是基于多少样本算的、桶边界怎么设的?
  3. 因为 Prometheus 的指标基数会爆炸。如果每个具体 URL(尤其是含 ID 的,如 /orders/12345)都成为一个独立的时间序列,那么:① Prometheus 内存与磁盘占用随 URL 数量线性增长,最终 OOM;② 查询变慢(要扫大量序列);③ 你无法按接口聚合(每个 ID 一个序列,无法算「订单接口整体」的 P99)。正确做法:用路由模板 /orders/{id} 作为标签值,这样所有订单请求汇总成一个序列。