一次压测报了"P99 280ms",另一个同事拿同样的脚本在自己机器上跑出 620ms。两人对着日志核对了半天——脚本一样、参数一样、目标服务一样,而结果差一倍。最后发现:一个人的机器上"压测工具"是 4 线程,另一个是默认单线程。
实测的"可复现"不是"给脚本",而是"给一个能一键跑起来的东西"。这个"组成"很容易被简化成"我给你脚本":而"脚本"只承载了五分之一的信息——其它四项(环境、数据、判定、记录)都在"作者的脑子里"。
而这个东西的组成比多数人以为的多:脚本、参数、环境、数据、以及"怎么判断跑对了"。
1. 复现包的五件套
| 项 | 缺了会怎样 |
|---|---|
| 脚本 + 参数 | 手动补参数,容易不同 |
| 运行环境(工具版本/机器规格) | 数不同但不知道为何 |
| 数据(输入与数据规模) | 结果不可比 |
| 判定标准(怎么算"跑对") | 跑完了也不知道是否一致 |
| 记录(时间/提交号) | 无法回溯 |
第四项最被忽略:没有"判定标准"时,"跑出来了"与"跑对了"是两件事第四项的"缺失"有个典型后果:对方跑出"完全不同的数",而双方都认为自己对——因为"对"的定义从未被写下来。
——于是"对方说他的数不一样"变成了"各说各话"。
2. "一键跑"的三种形态
| 形态 | 成本 |
|---|---|
| 脚本 + README | 低(易漂移) |
| 容器镜像(含工具与数据) | 中(最稳) |
| 编排文件(compose/k8s) | 高(含服务依赖) |
第二项的"收益"在于"默认值被固定":工具版本、参数、数据都写死在镜像里,于是"没人能"不小心"改了默认值"——而"默认值不同"正是最常见的不可复现来源。
第二项是"最稳的性价比":把"工具、参数、数据样例"都打进镜像,于是"谁跑都是同一套"。
第三项在"需要被测服务"时必要:**用 compose 起"服务 + 数据库 + 压测端"**第三项的"边界"在"真实规模":compose 起的库只有"小数据",压不出"真实数据量下的瓶颈"——所以它复现"功能与配置",幅射不到"容量"。
,于是"环境"也被复现。
// compose 起的"最小复现环境"(三件)
services:
db: { image: "mysql:8.0.34", env_file: db.env }
app: { build: ".", depends_on: ["db"], ports: ["8080:8080"] }
bench:{ image: "bench:wrk-4.2.0", depends_on: ["app"], command:
["wrk","-t4","-c100","-d60s","--latency","http://app:8080/api"] }
// 收益:一条 docker compose up 就复现"服务 + 压力"
3. "数据"怎么随包走
| 做法 | 适用 |
|---|---|
| 生成脚本(确定性种子) | 大数据量(推荐) |
| 小样本文件(随镜像) | 小数据量 |
| 导出快照 + 恢复脚本 | 真实分布 |
第一项的"验证"很便宜:跑两次生成脚本,比对 md5sum——不同就说明"有非确定性来源"(时间、随机、并发顺序)。
第一项的关键是"确定性":同一个种子生成同一份数据——否则"每次跑数据都不同",结果不可比。
第三项在"必须是真实分布"时用:快照里的数据量要够(否则"压不出瓶颈")第三项的"敏感数据"处理是"脱敏但保形"(字段长度/分布保持,值替换)——因为"形状"影响性能,"值"不影响。
,而它带来"体积与敏感数据"的问题。
// 确定性数据生成的三个要求
1 固定随机种子(seed=42)
2 固定生成规则(字段分布、长度分布、空值比例)
3 固定规模(行数、并发 key 数)
// 判据:同一脚本跑两次,md5 相同
4. "判定标准"怎么写
| 内容 | 例子 |
|---|---|
| 预期量级 | P99 在 200–400ms |
| 关键前置条件 | 预热 60s、丢弃前 10% 样本 |
| 一致性检查 | 结果条数、校验和 |
| 允许的波动 | 两次自身差异 < 5% |
第二项在"JVM/Go"这类"有 JIT 与预热"的服务上影响最大:不预热的前 30 秒可能是"慢 3 倍"——于是"包不包含预热"直接决定"数是不是可比"。
第二项是"最容易被漏"的:"预热"与"丢弃"会显著改变数字,而"没写"就等于"每个人自己决定"。
第四项是"可比性"的前提:先证明"自己跑两次接近",再谈"和别人比"。第四项的"门槛"建议写死:自身两次差异 > 5% 时,"与别人比"的结论不可用——先修"自身波动",再谈"横向对比"。
// 一份"判定标准"(四行,贴进 README)
run: 预热 60s → 采样 300s → 丢弃前 30s
expect: p99 200–400ms, tps 1800–2200
invariant: 成功率 ≥ 99.5%,结果条数与输入一致
self_variance: 两次同环境 p99 差异 < 5%
"复现包"的目录结构应当"自解释"——别人不用问就知道从哪开始:
// 复现包的结构(五项与目录对应)
bench-repro/
README.md # 一行命令 + 判定标准 + 预期区间
bench.sh # 脚本(第一行 echo 完整命令)
data/gen.py # 确定性数据生成(固定 seed)
env/Dockerfile # 工具与版本(含参数默认值)
out/RESULT.md # 本次记录(提交号/机器/命令原文/数字)
// 判据:新手照 README 一行命令能跑出"可比的数"
5. "记录"最小集
| 记录 | 为什么 |
|---|---|
| 提交号/镜像 tag | 代码与工具版本 |
| 机器规格 | 核数/内存/网络 |
| 时间与时区 | 与"其它变更"对齐 |
| 运行命令原文 | 便于重放 |
第四项的"自动化"很轻:脚本第一行就 echo 出完整命令(含所有参数),于是"日志里自带记录"。
第四项很实际:"参数在 README 里写着"与"实际敲的命令"常常不同——所以记"原文"。
"数差一倍"时先看"三个默认值",比"核对代码"快得多:
// 三个最容易不同的"默认值"
1 压测工具的线程/连接数(wrk: -t/-c)
2 客户端的 keep-alive(影响是否复用连接)
3 服务端的"预热状态"(是否刚启动)
// 用法:这三项对齐后,绝大多数"差一倍"会消失或变成"差 10%"
6. 排错:五种"复现失败"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 数差一倍 | 工具线程/参数不同 | 容器化 + 显式参数 |
| 结果不可比 | 数据不同 | 确定性生成 |
| 跑完不知道对不对 | 缺判定标准 | 写预期与不变量 |
| 换机器就不同 | 规格不同 | 记录规格 + 同规格对比 |
| 过一周就忘了怎么跑 | 无记录 | 记录命令原文 |
第五项的"修复成本"很高:没有记录时,"当时的数"就只是"一个传说"——而它会被引用成"基线"。
最后一个"交付形态"建议:把复现包当成"实测记录的附件",而不是"另一个仓库"。
// 两种交付形态的差别
散落:脚本在 A 目录、结论在文档、数据在某人的机器 → 三个月后无人能跑
打包:一个目录含"脚本 + 数据生成 + 环境 + 结论" → 三个月后一条命令复现
// 判据:三个月后的自己,能不能"不问任何人"重跑一遍
7. 边界:什么情况不必做复现包
| 场景 | 为什么 | 做法 |
|---|---|---|
| 一次性粗测(只看量级) | 不需精确 | 记时间与机器即可 |
| 只给自己用 | 自己记得 | 简单脚本 |
| 差异极大(10 倍级) | 工具差异影响小 | 粗测即可 |
| 已在 CI 里固定跑 | 环境已受控 | 跟随 CI |
判断口径一句话:"这个数会不会被别人引用、或与将来的自己对比"。最后一条与"基线"有关的边界:"基线"不是"一个数",而是"一个数 + 复现它的方法"。只给数不给方法,"基线"在下一次就被质疑——而"质疑"会消耗比"做复现包"更多的时间。
会(基线、对外报数、跨版本对比),就必须给"能一键跑的复现包";不会(一次性粗看),记时间与机器就够。
8. 自测
- 复现包的五件套里,哪一项最被忽略?"跑出来了"与"跑对了"的差别是什么?
- "容器镜像"为什么是"最稳的性价比"?它在什么情况下还不够?
- "确定性数据生成"的三个要求是什么?为什么"随机种子"是关键?
- "判定标准"的四个内容里,哪一项最容易被漏?它为什么显著影响数字?
- 为什么"记录命令原文"比"README 里的参数"更可靠?
- 判断"是否需要复现包"的那个问题是什么?