性能之巅第9章:基准测试

📑 目录

基准测试是最容易被欺骗的技术活动之一。编译器会优化掉你的测试代码,CPU会在空闲时降频,操作系统会缓存你的文件,而你看到的"平均值"可能掩盖了最致命的长尾延迟。


第9章 基准测试:如何不被数字欺骗

9.1 故事:消失的99分位

2024年初,某SaaS公司正在选型消息队列。他们在相同硬件上测试了Kafka和RabbitMQ,结果让人意外:

指标KafkaRabbitMQ
平均吞吐800K msg/s120K msg/s
平均延迟2.1 ms1.8 ms

"Kafka吞吐是RabbitMQ的6倍多,但延迟只差0.3ms,选Kafka吧。"技术总监拍了板。

上线三个月后,客户投诉:"你们的消息推送为什么偶尔会卡5秒钟?"

问题出在哪里?

原始测试脚本只采集了平均延迟。当SRE重新压测并查看P99和P99.9时,真相浮出水面:

指标KafkaRabbitMQ
P50延迟1.9 ms1.7 ms
P95延迟2.5 ms2.8 ms
P99延迟3.1 ms5.2 ms
P99.9延迟12 ms45 ms
Max延迟5.2 s120 ms

Kafka的Max延迟出现了5.2秒的尖刺——这是GC导致的。Kafka默认使用G1GC,在高吞吐下触发Mixed GC,STW时间随堆内存增长而延长。而RabbitMQ使用C++编写,没有GC停顿。

教训

  1. 平均数会骗人——必须看P50/P95/P99/P99.9
  2. 最大值(Max)不能忽略——它代表了用户体验的最差情况
  3. 测试时长要足够——短测试根本触发不了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=1

sysbench——全能基准工具

# 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 \
    run

JMH——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和MaxP99 < 用户感知阈值(通常200ms)
吞吐饱和点找到吞吐不再增长的拐点
资源利用率CPU 60-80%最佳,太低浪费,太高抖动
稳定标准差标准差/平均值 < 0.1(10%以内)

9.6 本章总结

mindmap
  root((基准测试))
    五大原则
      可重复
      可对比
      统计显著
      预热充分
      隔离干扰
    常见陷阱
      编译器优化
      CPU降频
      环境干扰
      平均值误导
    工具链
      fio
      sysbench
      JMH
      iperf3
    结果解读
      看P99而非平均
      看Max而非忽略
      置信区间验证

核心要点

  1. 平均数是谎言——P99和Max才是真实用户体验
  2. 测试环境要锁死——任何变动都会让结果不可比
  3. 预热是必须的——JVM、缓存、连接池都需要时间稳定
  4. 样本量要足够——至少10轮以上,计算置信区间
  5. 工具选择要专业——fio测磁盘、iperf3测网络、JMH测Java微基准

"基准测试不是为了证明谁更快,而是为了在相同的规则下,发现真实的行为差异。自欺欺人的测试,比没有测试更危险。"


系列文章索引