Spring Boot 性能测试实战——JMH + Spring Boot Test 基准测试方案
Spring Boot 性能测试实战——JMH + Spring Boot Test 基准测试方案
适读人群:Java/Spring Boot开发工程师 | 阅读时长:约14分钟 | 核心价值:掌握在Spring Boot项目中集成JMH做方法级基准测试,以及用Spring Boot Test做接口级性能测试
单元测试说通过,压测说不行
2021年,我在做一个订单批量查询的功能优化。我用JUnit写了单元测试,验证功能正确,跑了几次看了响应时间,觉得差不多。
提交上去,测试同学压测了一下,反馈:"批量查100条订单要1200ms,太慢了。"
我说不可能,我本地测的时候100ms不到。
测试同学说:"你是用JUnit直接跑的吧?JUnit不是性能测试工具,它没有JVM预热,数据也不是随机的,代码执行路径也不够真实。"
她说的对。JUnit单元测试的执行时间不能代表实际性能,原因有三:
- JVM刚启动,JIT还没完成编译优化,代码都在解释执行
- 数据量小,各种缓存都是热的
- 只跑了一次,没有统计意义
这就是JMH存在的原因:它是专门为JVM代码做基准测试的工具,解决了上面这三个问题。
JMH 基础入门
项目依赖
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.37</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.37</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- 生成可执行的benchmark jar -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<finalName>benchmarks</finalName>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jmh.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>JMH 注解详解
@BenchmarkMode(Mode.AverageTime)
// 测量模式:
// Mode.AverageTime 平均执行时间(最常用)
// Mode.Throughput 吞吐量(ops/s)
// Mode.SampleTime 采样每次执行时间,可以统计百分位数
// Mode.SingleShotTime 单次执行时间(用于测冷启动)
// Mode.All 全部模式
@OutputTimeUnit(TimeUnit.MICROSECONDS)
// 输出时间单位:ns/us/ms/s
@State(Scope.Thread)
// 状态对象作用域:
// Scope.Thread 每个线程独立一份State(默认,无共享)
// Scope.Benchmark 所有线程共享一份State(测并发竞争)
// Scope.Group 同组线程共享
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
// 预热:5次迭代,每次1秒,结果不计入统计
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
// 正式测量:10次迭代,每次1秒
@Fork(2)
// 运行2个独立的JVM进程,防止JVM状态影响结果
@Threads(4)
// 4个线程并发执行(测并发性能)在 Spring Boot 项目中集成 JMH
Spring Boot项目使用JMH有一个难点:Spring容器需要启动,但JMH默认运行在独立JVM里。解决方案是手动启动Spring容器:
// src/test/java/benchmarks/OrderServiceBenchmark.java
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
@Warmup(iterations = 3, time = 2)
@Measurement(iterations = 5, time = 3)
@Fork(value = 1, jvmArgs = {"-Xms2g", "-Xmx2g", "-XX:+UseG1GC"})
public class OrderServiceBenchmark {
private static ApplicationContext springContext;
private OrderService orderService;
private OrderQueryRepository orderQueryRepo;
// Spring上下文由static字段持有,整个JMH进程只初始化一次
static {
SpringApplication app = new SpringApplication(BenchmarkApplication.class);
app.setAdditionalProfiles("benchmark"); // 使用benchmark profile
springContext = app.run();
}
@Setup(Level.Trial) // 每次测试开始前执行一次
public void setup() {
orderService = springContext.getBean(OrderService.class);
orderQueryRepo = springContext.getBean(OrderQueryRepository.class);
}
// ===== 基准:当前实现 =====
@Benchmark
public List<OrderVO> batchQuery_currentImpl() {
List<Long> orderIds = generateRandomOrderIds(100);
return orderService.batchQueryOrders(orderIds);
}
@Benchmark
public List<OrderVO> batchQuery_optimized() {
List<Long> orderIds = generateRandomOrderIds(100);
return orderService.batchQueryOrdersOptimized(orderIds);
}
// ===== 参数化测试:不同批量大小 =====
@Param({"10", "50", "100", "500"})
private int batchSize;
@Benchmark
public List<OrderVO> batchQuery_differentSize() {
List<Long> orderIds = generateRandomOrderIds(batchSize);
return orderService.batchQueryOrders(orderIds);
}
// ===== 并发竞争测试 =====
@GroupThreads(4)
@Group("concurrent")
@Benchmark
public OrderVO concurrent_read() {
long orderId = 10001 + (long)(Math.random() * 10000);
return orderService.getOrderById(orderId);
}
@GroupThreads(1)
@Group("concurrent")
@Benchmark
public void concurrent_write() {
// 同时有写操作时,读操作的性能如何
orderService.updateOrderStatus(randomOrderId(), 2);
}
// ===== 工具方法 =====
private List<Long> generateRandomOrderIds(int count) {
List<Long> ids = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
ids.add(10001L + (long)(Math.random() * 100000));
}
return ids;
}
// ===== 运行入口 =====
public static void main(String[] args) throws Exception {
Options opt = new OptionsBuilder()
.include(OrderServiceBenchmark.class.getSimpleName())
.resultFormat(ResultFormatType.JSON)
.result("benchmark-results.json")
.build();
Collection<RunResult> results = new Runner(opt).run();
// 自定义输出
for (RunResult result : results) {
BenchmarkResult br = result.getPrimaryResult();
System.out.printf("Benchmark: %s%n", br.getLabel());
System.out.printf(" Score: %.3f ms/op%n", br.getScore());
System.out.printf(" Error: ±%.3f ms%n", br.getScoreError());
}
}
}Spring Boot Test 接口级性能测试
JMH测方法,但有时候需要测整个HTTP接口(包含Spring MVC处理、拦截器、序列化等):
// src/test/java/performance/OrderApiPerformanceTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class OrderApiPerformanceTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
private static final int WARM_UP_REQUESTS = 100;
private static final int MEASUREMENT_REQUESTS = 1000;
private static final int CONCURRENT_THREADS = 20;
@Test
@org.junit.jupiter.api.Order(1)
void warmUp() {
// JVM预热:先跑100次,不统计
for (int i = 0; i < WARM_UP_REQUESTS; i++) {
restTemplate.getForObject(
"http://localhost:" + port + "/api/order/100" + i,
String.class
);
}
}
@Test
@org.junit.jupiter.api.Order(2)
void measureGetOrderPerformance() throws InterruptedException {
List<Long> responseTimes = Collections.synchronizedList(new ArrayList<>());
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(CONCURRENT_THREADS);
// 启动并发线程
for (int t = 0; t < CONCURRENT_THREADS; t++) {
int threadId = t;
new Thread(() -> {
try {
startLatch.await(); // 等待统一开始信号
int requestsPerThread = MEASUREMENT_REQUESTS / CONCURRENT_THREADS;
for (int i = 0; i < requestsPerThread; i++) {
long orderId = 10001L + (threadId * requestsPerThread) + i;
long start = System.nanoTime();
ResponseEntity<String> response = restTemplate.getForEntity(
"http://localhost:" + port + "/api/order/" + orderId,
String.class
);
long elapsed = (System.nanoTime() - start) / 1_000_000; // ms
responseTimes.add(elapsed);
// 断言响应正确
assertThat(response.getStatusCode().is2xxSuccessful())
.as("Order %d should return 2xx", orderId)
.isTrue();
}
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
doneLatch.countDown();
}
}).start();
}
startLatch.countDown(); // 发送开始信号
doneLatch.await(60, TimeUnit.SECONDS);
// 统计结果
Collections.sort(responseTimes);
long p50 = responseTimes.get((int)(responseTimes.size() * 0.50));
long p95 = responseTimes.get((int)(responseTimes.size() * 0.95));
long p99 = responseTimes.get((int)(responseTimes.size() * 0.99));
double avg = responseTimes.stream().mapToLong(l -> l).average().orElse(0);
System.out.printf("Performance Results (%d requests, %d threads):%n",
MEASUREMENT_REQUESTS, CONCURRENT_THREADS);
System.out.printf(" Average: %.1fms%n", avg);
System.out.printf(" P50: %dms%n", p50);
System.out.printf(" P95: %dms%n", p95);
System.out.printf(" P99: %dms%n", p99);
// 断言性能标准
assertThat(p99).as("P99 should be < 500ms").isLessThan(500);
assertThat(p95).as("P95 should be < 300ms").isLessThan(300);
}
}踩坑实录
坑1:JMH里的@Setup初始化Spring容器失败
现象: JMH Benchmark里初始化Spring容器时,@Value注入的数据库连接信息为空,导致数据库连接失败。
原因: JMH在独立JVM里运行,不会自动加载src/test/resources/application-test.properties,需要手动指定配置文件。
解法: 在JVM启动参数里指定配置:
@Fork(value = 1, jvmArgs = {
"-Dspring.config.location=classpath:application-benchmark.properties"
})坑2:不同Benchmark方法之间有状态干扰
现象: 单独跑batchQuery_currentImpl时,Score是45ms。但把batchQuery_optimized也加进来一起跑时,batchQuery_currentImpl的Score变成了15ms,不一致。
原因: batchQuery_optimized先跑,它预热了数据库的Buffer Pool和Spring的缓存。当batchQuery_currentImpl再跑时,所有数据都在内存里,性能虚高。
解法: 每个Benchmark方法的顺序是随机的,不能保证隔离。对于有状态依赖的测试,要么每次测试前清理缓存,要么在@Setup(Level.Invocation)里做重置(但注意Level.Invocation有额外开销)。
总结
Spring Boot项目性能测试的两个层次:
- JMH:方法级别,精确到微秒,适合测算法和核心逻辑
- Spring Boot Test:接口级别,包含完整的框架开销,更接近真实情况
两者结合:先用Spring Boot Test发现接口级别的性能问题,再用JMH精确定位到具体方法,再用Arthas线上验证。
