基线、压测方法与性能回归:怎样证明优化真的有效
性能优化需要一个可检验的判断:在相同输入和业务结果下,候选实现是否减少了目标资源或等待,同时保住其他要求。一个字符串编码方法变快,可以用微基准观察;一组服务能否承受峰值流量,则需要包含网络、存储、排队和失败处理的负载测试。
基线是比较起点。代码、数据、环境和负载没有固定时,两次数字的差值同时包含许多变化,很难解释哪一项真正起了作用。
选择实验范围并定义相同工作
微基准、组件与服务测试
| 实验范围 | 适合回答的问题 | 还没有覆盖的部分 |
|---|---|---|
| 方法微基准 | 编码、查找、分配等局部实现成本 | 网络、真实并发、存储与业务流程 |
| 组件集成测试 | 连接池、数据库查询、缓存或队列行为 | 完整调用拓扑与其他组件竞争 |
| 服务负载测试 | 到达率、延迟、goodput、拒绝与资源变化 | 真实用户分布与长期运行的全部情况 |
| 全链路与演练 | 多服务协作、故障降级、积压与恢复 | 仍受数据、拓扑和演练范围限制 |
选择范围时先写出一个具体假设。例如“预估StringBuilder容量能减少同一编码操作的临时分配”,需要测量编码正确性、每操作字节分配和时间;“加缓存能提高读取容量”,则需要包含命中分布、失效与回源压力。
工作单元必须保持一致。每次操作编码16个整数,与每次编码128个整数不是同一批工作;一个HTTP请求返回100条数据,不能与只返回10条的版本直接按RPS排名。涉及异步时,还要决定观察接纳还是最终业务完成。
数据分布决定走哪条路径
数据规模、字段长度、热点比例、缓存热度、读写比和命中/未命中比例都会改变成本。只用一个固定字符串,可能让JIT把工作折叠为常量;只读同一条数据,则可能完全覆盖在缓存里。
可重复数据需要同时具备明确生成方式和适当变化。固定随机种子便于复验,多个规模用于观察成本随工作量的变化;必要时保留热点、长字段、空集合、重复键和极端值。生产采样数据必须先脱敏,敏感字段、凭据和真实可写目标不得直接进入公开实验。
不同数据集之间的差异先作为独立实验维度,不要在比较代码版本时顺便改变数据。业务正确性也要覆盖所有规模,避免把漏处理尾部元素误认为算法提升。
留下能够重新建立实验的信息
保存源码或制品标识、依赖锁定版本、JDK与GC参数、CPU/内存限额、数据生成方法、执行命令和原始输出。记录是为了还原影响结果的条件,不必把宿主私有路径、日历时间和整份环境变量写入公开结果。
容器提供可重复打包,但共享宿主仍可能受到其他进程、CPU配额节流、存储与网络噪声影响。Docker资源约束描述了容器限制方式;微基准正式比较时,应尽量在安静、固定的资源环境重复执行。
JVM测量与压测中常见的偏差
预热、编译和无效工作消除
JVM刚启动时会经历类加载、解释执行、JIT编译和运行反馈积累。把第一轮与预热后的轮次直接比较,会将启动差异算进代码差异。JMH将warmup与measurement区分,帮助控制这些阶段;工程配置与运行入口见 OpenJDK JMH。
如果计算结果没有被使用,编译器可能移除无可观察效果的工作。基准方法应返回结果,或在适当位置交给Blackhole消费。原理与反例见 JMH DeadCode示例。
输入全为编译期常量时,优化器还可能提前计算结果。可变State与参数化输入能减少这类错误,但仍需要审查实际工作是否保留;JMH ConstantFold示例展示了不同写法的影响。
手写循环反复调用方法再除次数,也可能被展开、合并或优化成不同的工作。让JMH负责测量循环;若一个业务操作本身就是批量处理,应把完整批次定义为一个操作,明确输出单位。
fork与重复测量
多个measurement iteration仍可能共享同一个JVM里的编译状态和历史反馈。fork启动独立JVM,可以观察不同进程运行之间的差异,并减少多个基准相互污染;详细例子见 JMH Forking。
增加独立重复和持续时间之前,检查数据是否正确、被测计算是否保留,以及发生器能否送达负载。这些系统性偏差会随每轮实验一起重复,增加轮次无法消除它们。
比较版本时可交替运行基线A、候选B、候选B、基线A,再重复这一组,以减少宿主环境随时间变化造成的单向偏差。正式结果还应保留各次原始分数;只挑最好的一次,会隐去不稳定性。
均值、误差与实际收益
报告至少包含指标单位、分数、误差或分布、样本与重复方式。JMH的 Score ± Error 是所选统计方法下的估计范围;它不会自动判断业务上值得优化多少,也不会替实验设计排除系统性偏差。
一个候选均值快2%,而不同fork之间波动10%,首先应改善测量或增加重复,再判断是否存在稳定差异。误差区间重叠或不重叠,都不宜单独替代完整的比较方法;需要严谨推断时可查 NIST实验数据分析手册 的统计方法与假设。
性能结论还要考虑绝对收益。一个只占总请求1%的方法即使快一倍,对端到端也只有有限改善。记录减少的CPU、分配或成本,并把候选放回相应组件和服务负载中验证。
服务发生器要能施加目标负载
封闭负载收到响应后才继续,系统变慢时发送速率会随之下降;固定到达率负载能保持外部需求,但发生器资源不足时仍会丢弃计划迭代。两者的实现实验见延迟、吞吐、分位数与排队。
服务压测按目的组织:小规模smoke先检查流程与载荷;load观察预期需求;stress逐步寻找过载点;spike观察突发;soak延长运行以发现泄漏、周期任务或逐步积压。相关定义见 k6负载测试类型。选定类型后,还需给出负载形状、持续时间和终止条件,才能确定这一轮观察覆盖什么。
用JMH比较两种编码实现
先验证输出,再采样时间
下载JMH实验包,解压进入 benchmark-lab。Linux与Docker环境下由有Docker权限的普通用户执行。工程固定Maven3.9.12、JMH1.37、JUnit5.13.4,以Java17为源码目标,默认Temurin25运行。
每个操作将一组整数编码为分号分隔字符串,例如 3;17;42;。基线每次循环构造新字符串,候选在同一个StringBuilder内累积:
public static String candidateEncode(int[] values) {
StringBuilder result = new StringBuilder(values.length * 6);
for (int value : values) result.append(value).append(';');
return result.toString();
}输入整数范围为0到99,999,每项最多五位加一个分号,预估容量因此采用6。这个容量依赖当前契约;有负数或更长数字时仍可自动扩容,但不再保证一次预分配足够。
JUnit分别验证0、1、16、128和512个元素,使用独立的预期字符串生成方式核对全部内容。JMH的Setup再次检查两种实现输出一致,方法直接返回结果,测量模式为AverageTime、单位ns/op;这里每个op是一整个数组。
mkdir -p .m2 results
docker run --rm --user "$(id -u):$(id -g)" \
-e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 --entrypoint mvn \
-v "$PWD:/lab" -v "$PWD/.m2:/cache" -w /lab \
maven:3.9.12-eclipse-temurin-25 -B -ntp \
-Dmaven.repo.local=/cache clean verify dependency:copy-dependencies应看到5个测试通过和BUILD SUCCESS,target/classes/META-INF/BenchmarkList应已生成。工程显式配置JMH annotation processor;只有jmh-core依赖而没有生成基准包装代码时,运行器可能找不到基准。
执行短微基准并保留分配数据
docker run --rm --user "$(id -u):$(id -g)" --network none \
--cpus=1 --memory=512m \
-v "$PWD/target:/lab:ro" -v "$PWD/results:/out" \
eclipse-temurin:25.0.4_7-jdk java \
-cp '/lab/classes:/lab/dependency/*' org.openjdk.jmh.Main lab.EncodeBenchmark \
-f 2 -wi 2 -i 3 -w 300ms -r 300ms -prof gc \
-jvmArgs '-Xms128m -Xmx128m' -rf json -rff /out/result.json这里显式使用宿主UID/GID,使结果目录可写,目标源码目录只读;发生器无需网络。每个方法、每个size各有两个fork,fork内先预热两轮,再测三轮,每轮300ms。命令覆盖了源码中较长的默认轮次,以便快速观察机制;短演示不承担正式容量判断。
结果应包含baseline与candidate、size16与128四组,以及 gc.alloc.rate.norm。重点比较:
| 输出 | 单位 | 怎么解释 |
|---|---|---|
| 主指标Score | ns/op | 每次完整数组编码的平均耗时 |
| gc.alloc.rate.norm | B/op | 每次操作的归一化分配量 |
| gc.alloc.rate | MB/sec | 当前运行速度下每秒分配量 |
| gc.count / gc.time | 次 / ms | 该轮观察到的回收活动 |
候选更快时,每秒能做更多操作,因此MB/sec未必同比下降。优先看B/op是否减少,再结合吞吐解释总分配压力。也不要将短基准GC时间简单加到ns/op上,计时区间本来已经经历了这些活动。
results/result.json保留参数、JVM信息和原始测量数组。检查每组measurement和fork是否完整,有无异常或提前退出;只看到最后一张表不足以确认全过程成功。
让一个更少做事的候选失败
工程还提供故意漏最后一个元素的 brokenCandidate。它不参加正常JMH比较,而由下面的正确性负例选中:
if OUTPUT=$(docker run --rm --user "$(id -u):$(id -g)" \
-e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 --entrypoint mvn \
-v "$PWD:/lab" -v "$PWD/.m2:/cache" -w /lab \
maven:3.9.12-eclipse-temurin-25 -B -ntp \
-Dmaven.repo.local=/cache -Dlab.wrongCandidate=true test 2>&1); then
echo '错误候选不应通过'; exit 1
else
printf '%s\n' "$OUTPUT"
case "$OUTPUT" in
*'candidate must preserve every element'*) ;;
*) echo '不是预期的内容差异失败'; exit 1 ;;
esac
fi预期Maven非零退出,EncodingTest报告 candidate must preserve every element。网络下载失败或编译错误不满足这个条件。不要对这个少编码一项的实现继续计算“提升百分比”;先恢复正常参数重跑5个测试。
Java17复验时,把Maven与运行镜像分别换为 maven:3.9.12-eclipse-temurin-17 和 eclipse-temurin:17.0.20_8-jdk,保存另一个结果文件。两个JDK的结果属于两个实验条件;若只想测代码变更,应在同一JDK内比较A与B。
将局部改进用于服务回归
建立有用的接受条件
对服务版本的比较应同时约束正确结果、有效吞吐、延迟分布、错误分类和资源需求。例如在固定40次/秒的到达率下,候选必须真正发出目标流量、保持业务结果正确、没有新增传输错误,且目标分位数和CPU预算符合要求。
阈值要反映业务预算与正常噪声。所有接口统一规定“p99下降5%”会掩盖实际差异:低流量接口的p99样本可能不足,某些接口更关心吞吐或后台完成时间。设定条件前先记录多轮正常基线。
如果允许过载拒绝,应将它与业务成功和传输失败分开,并单独约束拒绝率;不能因为503返回更快而把整体平均延迟下降视为改善。恢复阶段还要检查队列、连接与业务积压是否排空。
从微基准进入真实调用
将编码候选用于实际响应后,再测相同数据与到达率。微基准减少B/op,在线路径可能仍然被数据库、网络或其他对象分配主导;端到端改善小于局部改善是正常结果,需要据此判断投入是否值得。
还应检查异常、取消、空数据、大响应与压缩路径,确认缓存、池化与复用没有引入状态污染。长时间运行用于观察对象保留、连接泄漏和周期性成本;并发与多实例路径则检查共享资源竞争。
结果不稳定时先找变化来源
各fork差异很大时,检查预热是否完成、GC与分配是否变化、是否受到CPU节流和宿主任务干扰。某一数据规模突然退化时,查看算法复杂度、数组或缓冲扩容、缓存工作集和编译变化,不急着把所有结果平均成一个总分。
压测RPS不足时先确认发生器资源和dropped iterations;只有成功请求变快时,查看超时与拒绝是否被排除。测试数据如果被上一轮改写,下一轮可能命中不同状态,需要恢复或为每轮创建独立数据集。
候选进入灰度后,保留版本与配置关联,观察实际业务流量中的延迟、goodput和资源变化;出现退化时可以回退制品或配置。回退前检查数据兼容性,并安排正在执行的工作,随后继续观察性能是否恢复。
权威资料与规范地址
- Docker资源约束:https://docs.docker.com/engine/containers/resource_constraints/
- OpenJDK JMH:https://github.com/openjdk/jmh
- JMH DeadCode:https://github.com/openjdk/jmh/blob/master/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_08_DeadCode.java
- JMH ConstantFold:https://github.com/openjdk/jmh/blob/master/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_10_ConstantFold.java
- JMH Forking:https://github.com/openjdk/jmh/blob/master/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_12_Forking.java
- NIST探索性数据分析:https://www.itl.nist.gov/div898/handbook/eda/eda.htm
- k6负载测试类型:https://grafana.com/docs/k6/latest/testing-guides/test-types/
