《后端架构设计·高性能与高可用》附录 A 压测工具与脚本

卷二使用的压测工具与脚本速查:为什么自带 loadgen、开环与闭环的参数和输出字段、固定场景 P1/P2 的数据准备、流量回放的脱敏与到达过程拟合、延迟口径与采样显著性、结果 JSON 的最小字段、一次真实运行记录,以及压测前后必须检查的十二条事项。

为什么书里自带压测器

现成的压测工具很多,wrk、hey、vegeta、k6、JMeter 各有所长。卷二仍然自带一个 cmd/loadgen,原因不是“重复造轮子”,而是书里的实验需要三件现成工具不容易同时满足的事:

  1. 能精确控制开环与闭环两种到达模型;
  2. 能把超时、拒绝、完成分开统计,而不是只给一个“成功率”;
  3. 能直接调用 arch-lab 的内存适配器,不经过网络也能复现排队行为。

这不意味着自带工具更好。做真实 HTTP 压测时,wrk 或 k6 的成熟度更高;做协议级或浏览器级压测,JMeter、Playwright 更合适。本附录先讲书里的 loadgen 怎么用,再讲换成外部工具时要注意什么。

loadgen 的两个模式

模式参数行为适合观察
开环-mode open -rate 250按固定速率发起,不等待上一个响应排队、超时、协调遗漏
闭环-mode closed -workers 8固定 worker,收到响应再发下一个吞吐上限、稳定状态

开环的关键是“到达率不受系统速度影响”。系统变慢时,请求继续按原速率进来,队列会增长,超时会出现;这正是生产峰值的行为。闭环相反:系统变慢,worker 被占住,到达率自动下降,队列不会无限增长,所以它容易给出“看起来还行”的结果。

两者测出的“延迟”含义不同。开环的 P99 包含排队时间;闭环的 P99 只包含被服务请求的耗时,排队的请求根本没有发出来。比较两组数字前,先确认模式一致。

参数表

参数默认含义注意
-modeclosedopen 或 closed报告里必须写明
-rate100开环每秒请求数只对 open 生效
-workers8闭环并发 worker 数只对 closed 生效
-duration10s压测持续时间不含预热
-warmup2s预热时间,结果不计入避免 JIT/缓存冷启动污染
-timeout500ms单请求超时应大于目标 P99,小于 SLO 上限
-scenarioP1固定场景 P1 或 P2决定读写比与商品分布
-seed1随机种子固定种子才能复现
-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
预热至少跑一轮不计入结果的预热

外部工具的输出格式不同,但上面的字段要能映射到同一张表。映射不了,就说明口径没对齐,结论不能直接比较。

压测前的十二条检查

  1. 目标写清楚:是找瓶颈、验容量,还是验降级?
  2. 场景固定:P1 还是 P2,读写比是多少?
  3. 数据固定:种子、规模、商品分布是否与上次一致?
  4. 模式固定:开环还是闭环,速率或 worker 数是多少?
  5. 超时固定:客户端、服务端、下游是否一致?
  6. 预热执行:冷启动结果是否已排除?
  7. 机器干净:压测机与被测服务是否争抢 CPU?
  8. 指标就绪:服务端 QPS、P99、错误率、连接数能否同时采集?
  9. 日志级别:是否误开 debug,导致日志本身成为瓶颈?
  10. 依赖状态:数据库、缓存、消息队列是否在预期状态?
  11. 回滚预案:压测把环境打挂后,怎么恢复?
  12. 归档路径:结果写到哪里,谁负责保存?

十二条里最容易漏的是第 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 给出故障演练的检查清单。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查