基准测试是最容易被欺骗的技术活动之一。编译器会优化掉你的测试代码,CPU会在空闲时降频,操作系统会缓存你的文件,而你看到的"平均值"可能掩盖了最致命的长尾延迟。
第9章 基准测试:如何不被数字欺骗
9.1 故事:消失的99分位
2024年初,某SaaS公司正在选型消息队列。他们在相同硬件上测试了Kafka和RabbitMQ,结果让人意外:
| 指标 | Kafka | RabbitMQ |
|---|---|---|
| 平均吞吐 | 800K msg/s | 120K msg/s |
| 平均延迟 | 2.1 ms | 1.8 ms |
"Kafka吞吐是RabbitMQ的6倍多,但延迟只差0.3ms,选Kafka吧。"技术总监拍了板。
上线三个月后,客户投诉:"你们的消息推送为什么偶尔会卡5秒钟?"
问题出在哪里?
原始测试脚本只采集了平均延迟。当SRE重新压测并查看P99和P99.9时,真相浮出水面:
| 指标 | Kafka | RabbitMQ |
|---|---|---|
| P50延迟 | 1.9 ms | 1.7 ms |
| P95延迟 | 2.5 ms | 2.8 ms |
| P99延迟 | 3.1 ms | 5.2 ms |
| P99.9延迟 | 12 ms | 45 ms |
| Max延迟 | 5.2 s | 120 ms |
Kafka的Max延迟出现了5.2秒的尖刺——这是GC导致的。Kafka默认使用G1GC,在高吞吐下触发Mixed GC,STW时间随堆内存增长而延长。而RabbitMQ使用C++编写,没有GC停顿。
教训:
- 平均数会骗人——必须看P50/P95/P99/P99.9
- 最大值(Max)不能忽略——它代表了用户体验的最差情况
- 测试时长要足够——短测试根本触发不了GC峰值
9.2 基准测试的五大原则
graph TD
A[基准测试原则] --> B[可重复性]
A --> C[可对比性]
A --> D[统计显著]
A --> E[预热充分]
A --> F[隔离干扰]
B --> B1[相同硬件]
B --> B2[相同软件版本]
B --> B3[相同配置]
C --> C1[单一变量]
C --> C2[其他保持一致]
D --> D1[足够样本量]
D --> D2[置信区间]
E --> E1[JVM预热]
E --> E2[缓存预热]
E --> E3[连接池预热]
F --> F1[专用机器]
F --> F2[关闭无关进程]
F --> F3[固定CPU频率]原则1:可重复性
# 记录测试环境快照
uname -a > env.log
cat /proc/cpuinfo >> env.log
cat /proc/meminfo >> env.log
sysctl -a >> env.log
java -version >> env.log
# 确保每次测试前环境一致
echo 3 > /proc/sys/vm/drop_caches # 清除页缓存(测试用)
sync && echo 3 > /proc/sys/vm/drop_caches原则2:可对比性——只改变一个变量
# 错误:同时改了两个变量
测试A:Kafka + 机械硬盘 + 默认配置
测试B:RabbitMQ + SSD + 调优配置
# 无法判断差异来自消息队列还是硬件或配置
# 正确:只改变消息队列类型
测试A:Kafka + SSD + 相同调优配置
测试B:RabbitMQ + SSD + 相同调优配置原则3:统计显著——不要迷信单次结果
import numpy as np
from scipy import stats
# 运行20次,取结果
results = []
for i in range(20):
result = run_benchmark()
results.append(result)
# 计算置信区间(95%)
mean = np.mean(results)
sem = stats.sem(results) # 标准误
interval = stats.t.interval(0.95, len(results)-1, loc=mean, scale=sem)
print(f"平均值: {mean:.2f} ms")
print(f"95%置信区间: {interval[0]:.2f} ~ {interval[1]:.2f} ms")
# 如果两个配置的置信区间不重叠,差异才是统计显著的9.3 常见陷阱与对策
陷阱1:编译器优化掉测试代码
// 错误:测试代码可能被JIT优化掉
public void test() {
long start = System.nanoTime();
int sum = 0;
for (int i = 0; i < 1000000; i++) {
sum += i; // JIT发现sum没有被使用,直接删除整个循环!
}
long end = System.nanoTime();
System.out.println(end - start);
}
// 正确:使用Blackhole消费结果
import org.openjdk.jmh.infra.Blackhole;
@Benchmark
public void testFixed(Blackhole blackhole) {
int sum = 0;
for (int i = 0; i < 1000000; i++) {
sum += i;
}
blackhole.consume(sum); // 强制JIT保留计算结果
}陷阱2:CPU降频与睿频
# 查看当前CPU频率
watch -n 1 "cat /proc/cpuinfo | grep MHz"
# 你会发现:空闲时CPU频率很低,测试开始后逐步升高
# 对策:禁用CPU节能模式,固定最高频率
sudo cpupower frequency-set -g performance
# 或设置固定的最小/最大频率
sudo cpupower frequency-set -f 2.5GHz陷阱3:环境干扰
# 错误:在共享开发机上跑基准测试
# 其他同事的进程会抢占CPU、占用内存、产生磁盘IO
# 正确:使用专用裸金属或隔离的虚拟机
# 检查是否有干扰
mpstat -P ALL 1 # 查看每个CPU的干扰情况
pidstat 1 # 查看其他进程的CPU占用9.4 推荐工具链
fio——存储性能测试之王
# 随机读测试(模拟数据库场景)
fio --name=randread \
--directory=/mnt/ssd \
--rw=randread \
--bs=4k \
--size=4G \
--numjobs=16 \
--iodepth=32 \
--runtime=300 \
--group_reporting
# 输出解读:
# read: IOPS=250k, BW=1000MiB/s, lat (usec): min=50, max=5000, avg=120
# 关键看avg和max,差距大说明有抖动
# 顺序写测试(模拟日志场景)
fio --name=seqwrite \
--directory=/mnt/ssd \
--rw=write \
--bs=1M \
--size=10G \
--direct=1 \
--fsync_on_close=1sysbench——全能基准工具
# CPU测试
sysbench cpu --cpu-max-prime=20000 --threads=8 run
# 内存测试
sysbench memory --memory-block-size=1K --memory-total-size=10G run
# 线程测试
sysbench threads --threads=64 --thread-yields=100 run
# MySQL测试
sysbench oltp_read_write \
--mysql-host=localhost \
--mysql-user=root \
--mysql-password=xxx \
--mysql-db=test \
--tables=10 \
--table-size=100000 \
--threads=16 \
--time=300 \
runJMH——Java微基准标准
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Fork(2) // 启两个JVM实例取平均
@Warmup(iterations = 5, time = 1) // 预热5轮
@Measurement(iterations = 10, time = 1) // 正式测试10轮
public class StringConcatBenchmark {
@Benchmark
public String stringBuilder() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100; i++) {
sb.append(i);
}
return sb.toString();
}
@Benchmark
public String stringPlus() {
String s = "";
for (int i = 0; i < 100; i++) {
s += i; // 每次创建新String对象
}
return s;
}
}9.5 结果解读框架
graph TD
A[基准测试结果] --> B[吞吐量指标]
A --> C[延迟指标]
A --> D[资源指标]
A --> E[稳定性指标]
B --> B1[QPS/TPS]
B --> B2[MB/s]
C --> C1[平均值]
C --> C2[P50/P95/P99/P99.9]
C --> C3[Max]
D --> D1[CPU使用率]
D --> D2[内存占用]
D --> D3[磁盘IO]
E --> E1[标准差]
E --> E2[异常值数量]
E --> E3[结果波动范围]黄金法则:
| 指标类型 | 看什么 | 健康标准 |
|---|---|---|
| 延迟 | P99和Max | P99 < 用户感知阈值(通常200ms) |
| 吞吐 | 饱和点 | 找到吞吐不再增长的拐点 |
| 资源 | 利用率 | CPU 60-80%最佳,太低浪费,太高抖动 |
| 稳定 | 标准差 | 标准差/平均值 < 0.1(10%以内) |
9.6 本章总结
mindmap
root((基准测试))
五大原则
可重复
可对比
统计显著
预热充分
隔离干扰
常见陷阱
编译器优化
CPU降频
环境干扰
平均值误导
工具链
fio
sysbench
JMH
iperf3
结果解读
看P99而非平均
看Max而非忽略
置信区间验证核心要点:
- 平均数是谎言——P99和Max才是真实用户体验
- 测试环境要锁死——任何变动都会让结果不可比
- 预热是必须的——JVM、缓存、连接池都需要时间稳定
- 样本量要足够——至少10轮以上,计算置信区间
- 工具选择要专业——fio测磁盘、iperf3测网络、JMH测Java微基准
"基准测试不是为了证明谁更快,而是为了在相同的规则下,发现真实的行为差异。自欺欺人的测试,比没有测试更危险。"
系列文章索引: