为什么书里自带压测器
现成的压测工具很多,wrk、hey、vegeta、k6、JMeter 各有所长。卷二仍然自带一个 cmd/loadgen,原因不是“重复造轮子”,而是书里的实验需要三件现成工具不容易同时满足的事:
- 能精确控制开环与闭环两种到达模型;
- 能把超时、拒绝、完成分开统计,而不是只给一个“成功率”;
- 能直接调用 arch-lab 的内存适配器,不经过网络也能复现排队行为。
这不意味着自带工具更好。做真实 HTTP 压测时,wrk 或 k6 的成熟度更高;做协议级或浏览器级压测,JMeter、Playwright 更合适。本附录先讲书里的 loadgen 怎么用,再讲换成外部工具时要注意什么。
loadgen 的两个模式
| 模式 | 参数 | 行为 | 适合观察 |
|---|---|---|---|
| 开环 | -mode open -rate 250 | 按固定速率发起,不等待上一个响应 | 排队、超时、协调遗漏 |
| 闭环 | -mode closed -workers 8 | 固定 worker,收到响应再发下一个 | 吞吐上限、稳定状态 |
开环的关键是“到达率不受系统速度影响”。系统变慢时,请求继续按原速率进来,队列会增长,超时会出现;这正是生产峰值的行为。闭环相反:系统变慢,worker 被占住,到达率自动下降,队列不会无限增长,所以它容易给出“看起来还行”的结果。
两者测出的“延迟”含义不同。开环的 P99 包含排队时间;闭环的 P99 只包含被服务请求的耗时,排队的请求根本没有发出来。比较两组数字前,先确认模式一致。
参数表
| 参数 | 默认 | 含义 | 注意 |
|---|---|---|---|
-mode | closed | open 或 closed | 报告里必须写明 |
-rate | 100 | 开环每秒请求数 | 只对 open 生效 |
-workers | 8 | 闭环并发 worker 数 | 只对 closed 生效 |
-duration | 10s | 压测持续时间 | 不含预热 |
-warmup | 2s | 预热时间,结果不计入 | 避免 JIT/缓存冷启动污染 |
-timeout | 500ms | 单请求超时 | 应大于目标 P99,小于 SLO 上限 |
-scenario | P1 | 固定场景 P1 或 P2 | 决定读写比与商品分布 |
-seed | 1 | 随机种子 | 固定种子才能复现 |
-out | 空 | 结果 JSON 路径 | 建议写进统一归档目录 |
命令示例:
go run ./cmd/loadgen \
-mode open -rate 250 -duration 30s -warmup 5s \
-timeout 500ms -scenario P1 -seed 1 \
-out .local/reports/load/p1-open.json
闭环示例:
go run ./cmd/loadgen \
-mode closed -workers 16 -duration 30s -warmup 5s \
-timeout 500ms -scenario P2 -seed 1 \
-out .local/reports/load/p2-closed.json
参数不要一次改多个。先固定场景和超时,只调速率或 worker 数;否则结果变化时无法判断是哪个参数导致的。
输出字段
loadgen 每次运行输出一行摘要,并写一份 JSON。摘要字段如下:
| 字段 | 含义 | 怎么读 |
|---|---|---|
offered | 计划发起的请求数 | 开环等于 rate × duration |
started | 实际发出的请求数 | 明显小于 offered 说明调度跟不上 |
completed | 在超时前完成的请求数 | 吞吐的分母应统一 |
timed_out | 超时的请求数 | 与 completed 相加应接近 started |
rejected | 被限流或熔断拒绝的请求数 | 拒绝不是错误,是保护生效 |
rps | 每秒完成数 | 必须说明按 completed 还是 started 算 |
max_in_flight | 最大在途请求数 | 开环下能反映排队深度 |
p50/p99/p999 | 分位数延迟 | 报告里至少给 p99 |
wall | 实际墙钟时间 | 与 duration 的差异说明收尾开销 |
一个常见错误是把 started 当吞吐。开环下 started 由速率决定,跟系统能力无关;真正的吞吐是 completed / wall。另一个错误是把 rejected 算进错误率:限流拒绝是预期行为,应该单独统计,否则限流一生效,错误率反而“变好”。
固定场景 P1 与 P2
书里只用两个压测场景,避免每章换一套数据。
| 场景 | 读写比 | 数据规模 | 目的 |
|---|---|---|---|
| P1 读多写少 | 90% 查询、10% 创建 | 1 万 SKU、10 万订单 | 缓存、分页、读写分离 |
| P2 突发流量 | 基线的 3 倍,持续 30 秒 | 同 P1 | 限流、舱壁、熔断、降级 |
P1 的商品分布遵循 80/20:20% 的 SKU 承担 80% 的查询。这不是精确的生产分布,而是为了稳定复现热 key 和缓存倾斜。P2 在 P1 的基础上把到达率乘 3,持续时间缩短到 30 秒,观察系统在过载后的恢复情况,而不是只看峰值瞬间。
数据准备放在 testdata/,用固定种子生成:
go run ./cmd/loadgen -gen-data -scenario P1 -seed 1 -out testdata/p1
生成后不要每次压测重新造数据。数据变了,缓存命中率、索引选择和分页深度都会变,前后结果不可比。
流量回放
真实流量的价值在“分布”,不在“内容”。回放前先做三件事。
第一,脱敏。 去掉用户 ID、手机号、地址、支付单号,替换成稳定哈希。哈希要保留同一用户的关联性,否则购物车、订单列表这类场景会失真。
第二,抽样。 按小时或按接口分层抽样,不要只取峰值一小时。峰值样本适合测极限,平峰样本适合测缓存命中;两者混在一起,结论会互相抵消。
第三,拟合到达过程。 访问日志给的是“什么时候来了一个请求”,压测器需要的是“按什么分布发请求”。最简单的做法是按秒聚合,得到每秒请求数序列,再用固定速率或泊松过程近似。生产流量通常有突发性,纯泊松会低估峰值,必要时用实测的每秒序列直接驱动。
回放脚本要做的最小映射:
| 日志字段 | 压测字段 | 处理 |
|---|---|---|
| 时间戳 | 到达时间 | 按秒聚合,保留突发 |
| method + path | 请求模板 | 归一化 ID,保留路由 |
| 请求体大小 | payload 大小 | 分桶,不保留原文 |
| 响应码 | 预期结果 | 只用于校验,不参与构造 |
| 用户 ID | 会话键 | 稳定哈希,保留关联 |
回放不是把日志原样重发。原样重发会把测试环境的库存、订单和幂等键迅速打乱,第二遍就无法复现。正确做法是准备一套可重置的数据集,每轮压测前恢复。
一次真实运行
下面是 2.2 使用的开环与闭环对照,命令与原始输出保存在 .local/reports/be-arch/be-arch-perf-02-2-load-model-traffic-replay-open-vs-closed.txt。环境是 macOS darwin/arm64、Go 1.26.0,单次运行,不作为稳定基准:
# httptest sink: 4 concurrent slots, 25ms service => theoretical 160 rps
# open: rate=250/s duration=6s timeout=500ms; closed: workers=8 duration=6s timeout=500ms
open planned=1499 started=1499 finished=1499 completed=194 timed_out=1305 offered_rps=230.71 max_in_flight=127 p99=503ms
closed started=876 completed=876 timed_out=0 rps=144.89 max_in_flight=8 p99=68ms
这段输出说明三件事:
- 开环下系统理论上只能服务 160 rps,却按 250 rps 灌入,结果 1305 个请求超时,
max_in_flight涨到 127,排队非常明显; - 闭环下 8 个 worker 把到达率自动压到约 145 rps,没有超时,P99 只有 68ms;
- 同一系统、同一服务时间,两种模式给出完全不同的“好坏”,所以报告必须写明模式。
如果只贴闭环结果,会得出“系统很健康”的结论;如果只贴开环结果,会得出“系统完全不行”。两个都对,但回答的是不同问题。
延迟从哪一段开始算
同一个接口,客户端测出 120ms,服务端日志显示 30ms,两边都没错,只是起点不同。
| 口径 | 起点 | 包含 | 适合回答 |
|---|---|---|---|
| 客户端 | 发出请求前 | 连接、排队、网络、服务、响应传输 | 用户实际感受 |
| 服务端入口 | 收到首字节 | 中间件、业务、下游、序列化 | 服务自身处理能力 |
| 业务内部 | 进入 handler | 业务逻辑与下游调用 | 定位代码热点 |
| 下游单次 | 发起调用前 | 连接池等待、网络、下游处理 | 判断依赖影响 |
报告里写“P99 20ms”而不写口径,等于没写。第 1 章讨论尾部延迟时默认用客户端口径,因为它最接近用户体验;第 5 章分析慢调用扩散时用业务内部口径,因为它能排除网络和排队。两种数字不要混在同一张表里比较。
还有两个细节:
- 连接池等待算不算延迟?算。它和排队一样,是用户真实等待的一部分;
- 服务端时间戳用哪个时钟?同一台机器内用单调时钟;跨机器比较要先对时,否则会出现负延迟。
采样与统计显著性
分位数是从样本里估出来的,样本少的时候 P99 很不稳定。一个粗略的经验:
| 指标 | 至少需要的样本量 | 原因 |
|---|---|---|
| P50 | 几百 | 中位数收敛快 |
| P95 | 几千 | 尾部开始稀疏 |
| P99 | 一万以上 | 1% 的尾部才有统计意义 |
| P999 | 十万以上 | 否则只是几个离群点 |
30 秒、250 rps 的压测大约产生 7500 个请求,P99 对应 75 个样本,勉强可用;P999 只有 7 个,不能下结论。要谈 P999,要么延长时长,要么提高速率,要么明确写“样本不足,仅供参考”。
另一个问题是多次运行之间的波动。同一配置跑三次,P99 可能是 210ms、208ms、260ms;第三次的差异可能来自 GC、后台任务或机器温度。报告应给区间而不是单点,例如“P99 在 208–260ms 之间,中位数约 210ms”。只挑最好的一次写,是压测里最常见的美化手法。
结果 JSON 的最小字段
loadgen 写出的 JSON 至少包含下面这些字段。换成外部工具时,也建议映射到同一结构:
{
"version": 1,
"mode": "open",
"scenario": "P1",
"seed": 1,
"rate": 250,
"duration_seconds": 30,
"timeout_ms": 500,
"offered": 7500,
"started": 7498,
"completed": 6100,
"timed_out": 1300,
"rejected": 98,
"rps_completed": 203.3,
"max_in_flight": 127,
"latency_ms": {"p50": 42, "p95": 180, "p99": 503, "p999": 900},
"environment": "darwin/arm64 go1.26.0",
"started_at": "2026-11-02T16:01:49Z"
}
version 用于以后字段变化时兼容;environment 和 started_at 用于判断两次结果能否比较;latency_ms 必须注明单位。没有这些字段的“结果截图”,过两周就无法解释。
结果归档
每次压测至少保留四样东西:
.local/reports/load/<date>-<scenario>-<mode>/
command.txt 完整命令与环境变量
summary.json loadgen 原始输出
metrics.txt 服务端指标快照(QPS、P99、错误率、连接数)
notes.md 当次假设、异常、是否可复现
目录名带日期、场景和模式,避免三天后分不清哪次是哪次。command.txt 要包含 go version、机器架构和 git 提交号;没有提交号,代码一改就无法回溯。
归档不是形式。第 12 章要比较改造前后,比较的前提是两次运行使用同一场景、同一数据、同一模式、同一超时。少一项,数字就没有可比性。
换成外部工具时
如果团队已经有 wrk、k6 或 vegeta,不必改用书里的 loadgen。换成外部工具时,注意下面几点:
| 事项 | 要求 |
|---|---|
| 到达模型 | 明确是固定速率还是固定并发,报告里写清 |
| 超时 | 客户端超时与服务端预算一致,不要一层 5s 一层 500ms |
| 连接复用 | 记录是否 keep-alive,连接数会影响结果 |
| 统计口径 | 延迟从发出算还是从连接建立算,必须统一 |
| 错误分类 | 超时、拒绝、业务错误分开统计 |
| 数据准备 | 每轮重置,避免缓存和库存被前一轮污染 |
| 机器负载 | 压测机和被测服务不要抢同一颗 CPU |
| 预热 | 至少跑一轮不计入结果的预热 |
外部工具的输出格式不同,但上面的字段要能映射到同一张表。映射不了,就说明口径没对齐,结论不能直接比较。
压测前的十二条检查
- 目标写清楚:是找瓶颈、验容量,还是验降级?
- 场景固定:P1 还是 P2,读写比是多少?
- 数据固定:种子、规模、商品分布是否与上次一致?
- 模式固定:开环还是闭环,速率或 worker 数是多少?
- 超时固定:客户端、服务端、下游是否一致?
- 预热执行:冷启动结果是否已排除?
- 机器干净:压测机与被测服务是否争抢 CPU?
- 指标就绪:服务端 QPS、P99、错误率、连接数能否同时采集?
- 日志级别:是否误开 debug,导致日志本身成为瓶颈?
- 依赖状态:数据库、缓存、消息队列是否在预期状态?
- 回滚预案:压测把环境打挂后,怎么恢复?
- 归档路径:结果写到哪里,谁负责保存?
十二条里最容易漏的是第 7 和第 9 条。压测机和被测服务在同一台笔记本上抢 CPU,数字会随风扇转速变化;debug 日志在高 QPS 下会把磁盘打满,延迟曲线出现与业务无关的尖峰。
建议把这张清单贴进压测脚本的注释里,每跑一轮逐条打勾。靠记忆检查十二项,漏掉的通常正好是当次最关键的那一项。
清单之外还有一条隐性前提:压测目标必须可观测。如果服务端没有 QPS、P99、连接数和错误分类,压测只能得到一个客户端数字,无法判断瓶颈在哪一层。
压测机不是被测机
最容易被忽略的一条:压测器自己也会消耗 CPU、内存和网络。把 loadgen 和被压服务放在同一台机器上,两者争抢资源,数字会同时被高估和低估。
| 部署方式 | 影响 | 建议 |
|---|---|---|
| 同机同进程 | 内存适配器直接调用,无网络开销 | 只用于 L1/L2 逻辑验证 |
| 同机不同进程 | 共享 CPU、内存带宽、网卡 | 记录 CPU 使用率,结论降级 |
| 同机不同容器 | 受 cgroup 限制,但仍共享内核 | 给两侧都设 CPU 配额 |
| 不同机器 | 最接近生产 | 仍要记录网络往返时间 |
书里的 L1/L2 压测大多在同机完成,因此结论只用于比较“改造前后”的相对变化,不用于推算生产实例数。第 2 章和第 13 章涉及容量时,会明确说明是“本机相对值”还是“可外推的目标值”。
常见错误与边界
第一,只跑一次就下结论。单次运行受调度、GC、缓存冷热影响,至少跑三次,看波动范围。
第二,用平均值代替分位数。平均 20ms 的系统可能有 5% 的请求超过 500ms,用户体验由后者决定。
第三,把压测当容量证明。压测通过只说明“在这个数据、这个模式、这个时长下没垮”,不等于生产能扛住同样的峰值。
第四,忽略拒绝。限流生效后,完成率下降但错误率可能不变;如果不单独统计 rejected,会误以为系统变差。
第五,压测数据和生产分布差太远。商品全均匀、用户全活跃、缓存全命中,测出的吞吐没有参考价值。
第六,不记录环境。换了机器、换了 Go 版本、换了数据库参数,数字就不能和历史比较。
第七,把压测器自己当成无成本的观察者。压测机 CPU 打满时,发压节奏会失真。
第八,用不同数据量比较前后。数据量变化会改变索引选择与缓存命中率,和代码改动混在一起。
第九,只测成功路径。失败、超时、降级路径同样需要压测,否则保护机制永远没被验证过。
小结
卷二自带 loadgen,是为了精确控制开环/闭环、区分超时与拒绝,并能直接调用内存适配器。参数一次只改一个,输出至少看 completed、timed_out、rejected、max_in_flight 和 p99。固定场景 P1/P2、固定种子、固定数据,是前后可比的前提。换成 wrk、k6 等工具时,先对齐到达模型、超时、连接复用和统计口径,再谈数字。
压测的价值不在“跑出一个好看的 QPS”,而在“知道系统在什么条件下开始变坏、坏的时候是什么样子”。附录 B 把这些读数整理成速查表,附录 C 给出故障演练的检查清单。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。