同一套压测脚本,同一条 SQL,两个人测出的 P99 差 30%。查了半天发现:一个用压测工具 1.2(默认 keep-alive),另一个用 1.5(默认关闭 keep-alive)——而"连接复用"直接决定了"测的是服务端还是网络"。
实测的可比性不只取决于"机器与数据",还取决于"工具本身":版本、默认参数、计时点、以及它怎么算"完成"。这个"从不被写"有个可理解的原因:工具被认为是"中立的"——它只是"打请求、量时间",不该影响结论。而实际上"它怎么打、怎么量"直接决定了结论指向谁。
而这些在报告里几乎从不被写。
1. 工具的四种"版本差异"
| 差异 | 例子 |
|---|---|
| 默认参数变化 | keep-alive、并发模型 |
| 计时口径变化 | 是否含建连、是否含首字节 |
| 统计方式变化 | 分位算法、是否含失败请求 |
| 依赖库变化 | TLS 实现、HTTP/2 支持 |
第一项最常见:工具升级时"默认值"改了,而脚本没写显式参数——于是"同一脚本"跑出不同结果。第一项的"隐蔽"在于"脚本看起来没变":版本一变、默认值一变,而"同一条命令"的含义就变了——于是"两次测试"实际是"两个实验"。
第三项的"额外"问题是"失败请求怎么算":有的工具把超时算进 P99(拉高),有的直接丢弃(拉低)——而"丢弃"会让"系统越差、延迟越好看"。
第三项很隐蔽:"分位数"的算法不同(插值 vs 最近秩),在样本少时差得很多。
2. 五种"必须写进报告"的项
| 项 | 为什么 |
|---|---|
| 工具名与版本 | 复现的前提 |
| 关键参数(非默认) | 影响结果方向 |
| 计时点定义 | 数字含义 |
| 失败请求处理 | 是否计入分位 |
| 运行环境(客户端侧) | 客户端也可能成瓶颈 |
第五项常被漏:压测客户端自己也可能是瓶颈——"QPS 上不去"有时是"客户端 CPU 打满"而不是"服务端慢"。第五项的"识别"很直接:客户端 CPU 与 GC 要跟服务端一起看。若客户端 CPU 到 90%,那"吞吐上限"是客户端给的,服务端可能还有余量。
// 一次可复现的报告头(六行)
tool: wrk 4.2.0 (keep-alive on, 4 threads, 100 connections)
timer: 从发出请求到收完响应体(不含 DNS)
failures: 5xx 计入统计,但单独标注
client: 8 vCPU / 同机房(RTT 0.2ms),CPU 峰值 40%
target: 4 vCPU / MySQL 8.0.34
runs: 5 轮 × 60s,报 P50/P99 与吞吐
3. 三种"工具本身成为瓶颈"的信号
| 信号 | 含义 |
|---|---|
| 客户端 CPU 打满 | 压不上去 |
| 客户端连接数受限 | 并发上不去 |
| 客户端 GC 频繁 | 延迟被污染 |
第三项的"污染机制"是"计时在客户端进行":请求发出后客户端自己停顿了,回来时"看起来"服务端用了更久——而服务端的真实耗时没变。
第三项在"JVM 写的压测工具"上常见:压测端自己的 GC 停顿会算进"响应时间"——于是"服务端 P99 变差"其实是"客户端 GC"。
修法很直接:看客户端侧的资源指标。若客户端 CPU/GC 有异常,那这一轮的"服务端数据"不可用。
"计时点"的三种定义,会让同一个数字差出一倍:
// 三种计时点的含义(同一请求)
A 只算服务端处理(不含建连/传输)→ 最快的数,适合"比算法"
B 从发出到收完(含建连、传输) → 用户感知的数,适合"比体验"
C 含 DNS 与 TLS 握手(冷启动) → 最慢的数,适合"比首访"
// 报告里必须写清是哪一种;否则"200ms"无法与其他人的"200ms"比较
4. "分位数"的三种算法差异
| 算法 | 差异 |
|---|---|
| 最近秩(nearest rank) | 保守,样本少时偏高 |
| 线性插值 | 平滑,可能"造出"不存在的值 |
| HDR Histogram | 高精度,需工具支持 |
第一项与第二项在"样本少"时差得明显:100 个样本的 P99,两种算法可能差 5–10%。
所以在"对比两次测试"时,必须确认两边用同一种算法——否则"差异"可能只是算法差异。
5. 怎么让"两次测试可比"
| 做法 | 效果 |
|---|---|
| 固定工具版本(锁文件/镜像) | 消除版本差异 |
| 显式写全部关键参数 | 消除默认值差异 |
| 固定计时点与统计方式 | 消除口径差异 |
| 同客户端、同机房 | 消除环境差异 |
第一项的"附带收益"是"参数也固定":镜像里可以放"脚本 + 参数 + 环境",于是"复现"变成"跑同一个镜像",而不是"照文档敲一遍"。
第一项最有效:把压测工具放进容器镜像并固定版本,于是"谁跑都是同一个工具"。
第二项的"显式"要具体到"连默认值也写":--keepalive 显式打开或关闭,而不是"用默认"。
// 显式参数(不留"默认值"的空间)
wrk -t4 -c100 -d60s --latency -H "Connection: keep-alive" http://target/api
// 对比组:显式关闭 keep-alive(用 --no-keepalive 或改造脚本)
// 关键:两组只差这一个参数
"用同一镜像"是最省心的复现方式,而它需要把"三件事"都放进镜像:
// 压测镜像里应包含(缺一项就可能不可比)
- 工具二进制 + 固定版本
- 脚本 + 全部参数(含"默认值"也显式写)
- 计时点与统计方式的说明(README 里一段)
// 用法:docker run bench:wrk-4.2.0 ...
6. 排错:五种"数字对不上"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 两人结果差 30% | 工具版本/默认值 | 固定版本 + 显式参数 |
| 吞吐上不去 | 客户端瓶颈 | 看客户端资源 |
| P99 忽高忽低 | 分位算法/样本少 | 固定算法 + 加样本 |
| 本地快、CI 慢 | 客户端环境不同 | 同镜像/同机房 |
| 服务端指标正常但延迟高 | 客户端 GC | 查客户端 |
第四项在"CI 里跑压测"时常见:CI 的机器弱、网络绕,于是"同一脚本"跑出"更差的数"。第四项的"修法"不是"给 CI 加机器",而是"把压测从 CI 移到专门的压测环境"——因为"CI 的职责是验证功能",而"性能基线"需要稳定环境。
最后一个"验收":用同一镜像、同一参数跑两次,数字应当接近。若不接近,说明"还有未受控的变量"(客户端资源、网络、目标环境)。
// 重复性检查(同一镜像,跑两次)
run1: p99=210ms, tps=1900
run2: p99=214ms, tps=1880 ← 波动 < 3%,可信
run2: p99=380ms, tps=1200 ← 有未受控变量,先查再比
// 判据:自身波动 < 5% 时,"两组对比"才有意义
7. 边界:什么情况不必管工具细节
| 场景 | 为什么 | 做法 |
|---|---|---|
| 数量级差异(10 倍以上) | 工具差异影响小 | 粗测即可 |
| 只判断"方向" | 不需要精确 | 写明"粗测" |
| 同一台机器、同一次会话 | 环境一致 | 记录时间即可 |
| 用同一容器镜像跑 | 版本一致 | 记录镜像 tag |
最后一条与"基线"有关的边界:性能基线会随工具升级而漂移。所以"工具升级"应当被当成"基线重设"的事件——而不是"顺手升一下"。
判断口径一句话:这个数字"会不会被别人拿去复现"。会,就必须写清"工具与参数";不会(自己看趋势),记录时间点就够。
8. 自测
- 工具的四种版本差异里,哪一种最常见?为什么"升级工具"会改变结果?
- 报告里必须写的五项里,哪一项最常被漏?为什么"客户端也会成瓶颈"?
- "客户端 GC 频繁"为什么会污染服务端数据?
- 三种分位算法的差异在哪?样本少时哪一种更保守?
- 怎么让"两次测试可比"?为什么"显式写默认值"是必要的?
- 判断"是否需要记录工具细节"的那个问题是什么?