性能之巅第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微基准

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


系列文章索引: