5.1 单变量与对照组
上一节:无 | 下一节:5.2 预热、稳态与运行时长 配套代码:05-experiment-design/01-single-variable
一句话结论
一次实验只改一个变量,其余全部冻结。 没有对照组,就没有结论——你只有「改完之后的一组数字」,无法判断它是不是真的因为你的改动而变化。
一、用「试药」理解单变量
假设一个病人吃了三种药,病好了。哪种药起了作用?
- 可能只有第一种有效;
- 可能只有第三种有效;
- 可能前两种无效,第三种和第一种组合才有效;
- 甚至可能是自愈(和药无关)。
这就是「同时改三处」的问题:你得到了一个结果,但无法归因。
医学上解决这个问题的办法是随机对照试验(RCT):
试验组:只吃新药
对照组:只吃安慰剂
其余条件(年龄、病程、饮食)尽量一致
性能实验完全一样:
| 医学 | 性能实验 |
|---|---|
| 试验组 | 改动后的版本 |
| 对照组(安慰剂) | 改动前的版本(基线) |
| 随机分组 | 同样的负载、数据、环境 |
| 控制变量 | 一次只改一个 |
关键:性能实验里的「安慰剂效应」同样存在——你自己的期望会影响你怎么配置环境、怎么解读数据。这就是为什么需要对照组和客观的判定标准。
二、什么叫「其余全部冻结」
一个合格的实验,除了那一个变量,下面这些都必须完全一致:
| 类别 | 具体项 |
|---|---|
| 代码 | commit hash、依赖版本、构建产物 |
| JVM | JDK 版本、完整启动参数(堆、GC、其他 -XX) |
| 机器 | 实例规格、CPU 型号、是否容器、容器 limit |
| 数据 | 规模、分布、缓存状态、预热程度 |
| 负载 | 压测脚本(必须版本化)、到达率、并发模型、时长 |
| 观测 | 指标采集方式、火焰图采样参数、测量点 |
| 外部条件 | 时间(避开备份/定时任务)、机房/可用区、下游状态 |
这张表就是第 1 章 1.7 节「实验元数据」的完整版。任何一项变了,实验就不是单变量的。
三、四种常见的「伪单变量」实验
❶ 同时改代码和 JVM 参数
❌ 实验:把 -Xmx 从 2g 改成 4g,同时加了一个索引
结果:P99 降了 40%
结论:???(无法归因)
修法:分成两次实验。先改索引(代码层,通常收益更大),测完;再单独调堆。
❷ 对照组和实验组环境不同
❌ 基线跑在旧机器上(4 核),优化版跑在新机器上(8 核)
结论:优化提升了 2 倍(其实是硬件提升的)
修法:同一台机器,或至少同名规格。如果必须换机器,先在两台机器上跑同一个版本,测出硬件差异作为基线修正。
❸ 压测脚本悄悄变了
❌ 第一周的脚本用均匀随机 ID
第二周的脚本改成了幂律分布("更接近真实")
结论:P99 从 200ms 降到 120ms(其实是因为热点集中,缓存命中率上升)
修法:脚本必须版本化,实验时冻结快照(第 1 章 1.7 节)。
❹ 观测工具变了
❌ 基线用 async-profiler 采样(有开销),优化版没开剖析
结论:P99 降了 8%(部分是因为关掉了剖析)
修法:观测配置也要冻结。特别注意:JFR、profiler、详细日志都有开销。
四、对照组的三种形式
| 形式 | 做法 | 适用 |
|---|---|---|
| 时间对照(A/B) | 先跑基线,改代码,再跑一次 | 最常用;但要注意环境随时间漂移 |
| 并行对照(同时跑) | 两个版本同时部署,同时压测 | 消除时间漂移;但需要两套环境,且可能互相干扰 |
| 交叉对照(ABAB) | 基线→改动→回滚→再改动 | 能识别时间漂移;成本高 |
时间对照的隐患:环境会随时间变化(其他服务上线、数据增长、机器老化)。验证方法:跑完实验后,把代码回滚再测一次——如果结果回不到原来的基线,说明环境漂移了。
基线(A) → 优化(B) → 回滚到A(A')
如果 A' ≈ A → 环境稳定,B 的差异可信
如果 A' ≠ A → 环境漂移了,B 的差异需要重新评估
这个「回滚验证」是很多人没做、但成本很低的一步。 它能把「环境漂移」和「真实的优化效果」区分开。
五、一个合格的实验设计模板
## 实验设计:<主题>
### 假设
<一句可被证伪的陈述>
例:「把 orders.status 加上索引后,findByStatus 的 P99 会从 90ms 降到 20ms 以下」
### 变量(只允许一项)
<唯一改动>
例:新增索引 idx_orders_status
### 对照组
<基线是什么>
例:E03 实验的组件基准结果(同一份数据、同一台机器)
### 冻结项(必须逐项确认)
- [ ] 代码 commit 一致(除了那一处改动)
- [ ] JVM 参数一致
- [ ] 机器与容器配置一致
- [ ] 数据集一致(规模 + 分布 + 预热)
- [ ] 压测脚本一致(同一份冻结快照)
- [ ] 观测配置一致(剖析/日志级别)
- [ ] 时间窗口一致(避开定时任务)
### 判定标准(实验前就定好)
- 主要指标:P99
- 预期变化:降低 ≥ 50%
- 显著性要求:Mann-Whitney U,p < 0.05
- 需要几轮:至少 3 轮,取中位数
### 回滚验证
实验后回滚代码再测一次,确认基线可复现
注意「判定标准在实验前就定好」这一条:事后定标准,就是给自己找理由。
六、本节小结
- 一次只改一个变量,其余全部冻结——否则无法归因。
- 没有对照组的实验不构成结论,只有一组数字。
- 四种常见的伪单变量:同时改代码和 JVM 参数、两组环境不同、脚本悄悄变了、观测工具变了。
- 对照组有三种形式:时间对照(最常用)、并行对照(消除时间漂移)、交叉对照(ABAB)。
- 回滚验证(A→B→A)能区分「环境漂移」与「真实效果」,成本低但极少人做。
- 判定标准必须在实验前定好,包括指标、阈值、显著性要求。
七、自测
- 一个同事说:「我改了三个地方,P99 从 200ms 降到 150ms,提升 25%。」你会怎么处理这个结论?
- 你的实验结果是 A(基线)→ B(优化)→ A’(回滚)依次为 P99 = 100 / 70 / 95 ms。请判断这个结论是否可信,并说明理由。
- 为什么「观测工具的变化」也算变量?请举一个具体的例子说明它如何影响结果。
- 这个结论不能采信(但不是全盘否定——它提供了一个线索)。处理方式:① 承认「三处改动共同作用使 P99 降低 25%」这个事实成立,但无法归因到具体哪一处;② 拆成三次单变量实验,逐个验证(每次只保留一处改动,其余回滚);③ 这样做的好处不只是归因——你还能发现某一处改动其实无效甚至有负面作用(三个改动里可能有 1 个是浪费工程量的);④ 如果时间不允许全部拆分,至少应该拆出「收益最可疑」的那一处单独验证。
- 结论基本可信。理由:A=100 与 A’=95 相差 5%,说明环境漂移很小(在合理范围内);B=70 相对两者都有明显下降(降幅 30%/26%),远大于环境漂移的 5%。所以「优化有效」这个结论成立。但还要补充三点:① 5% 的漂移是环境噪声还是测量噪声?需要用噪声底线判断(第 7 节);② 每一轮的样本量、轮数是多少?只有一轮的话结论强度不足;③ 需要做统计检验(第 6 节)确认 70 与 100 的差异不是偶然。如果 A’=120,说明环境在变差,那么 B=70 的效果里可能有一部分是"环境本来就在变好"的假象——这时必须重做实验。
- 因为观测本身会消耗被观测系统的资源(第 0 章 0.9 节)。具体例子:① async-profiler 采样——
-e cpu -i 1ms的高频采样会占用 CPU,让被测服务的 P99 上升 5%~15%;② JFR——settings=profile约 1%~2% 开销,jdk.ObjectAllocationSample在分配密集时开销更大;③ 详细日志——把日志级别从 INFO 调到 DEBUG,可能让延迟翻倍;④ Prometheus 抓取频率——从 30s 改成 5s,/metrics序列化开销增加。所以基线组和实验组的观测配置必须一致,否则你测到的是"观测开销的差异"而不是"代码的差异"。