Java 集成测试 CI 优化——并行执行、Docker 镜像预热、测试时间压缩
Java 集成测试 CI 优化——并行执行、Docker 镜像预热、测试时间压缩
适读人群:Java 后端开发者、DevOps/CI 工程师 | 阅读时长:约 16 分钟 | 核心价值:将 CI 集成测试时间从 20+ 分钟压缩到 5 分钟以内的完整优化方案
CI 流水线的测试时间,直接决定了开发效率和发布频率。
我们团队有段时间,每次提交 PR,CI 要跑 28 分钟才能出结果。28 分钟,开发可以去喝两杯咖啡,期间已经忘了自己在做什么。慢到一定程度,开发者开始"攒提交",一次推 5 个功能减少 CI 次数,代码审查质量下降,问题发现越来越晚。
那段时间,技术 Leader 找我谈话说:这个 CI 我要么优化,要么弃用——弃用了就不配我们这个团队的技术标准。
结果当然是优化。花了两周时间,把 28 分钟压到了 4 分 20 秒。今天这篇,把所有用过的优化手段系统地写出来。
一、先测量:找到真正的瓶颈
没有测量就没有优化。先用工具找瓶颈:
# Maven Surefire 输出详细测试时间
mvn test -Dsurefire.printSummary=true | grep -E "Tests run:|Time elapsed"
# 更详细的报告
mvn test && python3 - << 'EOF'
import xml.etree.ElementTree as ET
import glob
results = []
for report_file in glob.glob('**/target/surefire-reports/*.xml', recursive=True):
tree = ET.parse(report_file)
root = tree.getroot()
name = root.get('name', report_file)
time = float(root.get('time', 0))
results.append((time, name))
results.sort(reverse=True)
print("最慢的 20 个测试类:")
for time, name in results[:20]:
print(f" {time:.1f}s {name}")
EOF我们的测量结果(优化前):
Total test time: 1680s
Container startup: 45% (756s) - 每个测试类各自启动容器
Spring context load: 25% (420s) - 上下文重复加载
Test execution: 20% (336s)
Network/IO: 10% (168s) - Docker pull 镜像容器启动是最大头,优先解决。
二、优化一:容器共享与并行启动
详细方案在前面的文章里讲过,这里给出 CI 专用的完整配置:
// CI 优化基类(不开启 withReuse,CI 环境每次应当全新容器)
public abstract class CIOptimizedIntegrationTest {
// 所有容器声明为 static final,整个 JVM 进程共享
static final MySQLContainer<?> MYSQL;
static final GenericContainer<?> REDIS;
static final KafkaContainer KAFKA;
static {
MYSQL = new MySQLContainer<>("mysql:8.0.36")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
// 减少 MySQL 初始化时间
.withCommand(
"--innodb-buffer-pool-size=64M",
"--innodb-log-file-size=16M",
"--skip-log-bin" // 测试不需要 binlog
);
REDIS = new GenericContainer<>("redis:7.2-alpine")
.withExposedPorts(6379)
.withCommand("redis-server", "--save", "\"\"", "--appendonly", "no"); // 关闭持久化
KAFKA = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0"))
.withKraft(); // KRaft 模式,不需要 ZooKeeper,启动更快
// 并行启动,不等最慢的那个
Startables.deepStart(MYSQL, REDIS, KAFKA).join();
}
}效果: 容器启动时间从 756s → 40s(并行启动 + 共享实例)
三、优化二:CI 镜像缓存策略
GitHub Actions 镜像缓存
# .github/workflows/integration-test.yml
name: Integration Test
on: [push, pull_request]
jobs:
integration-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: maven # Maven 依赖缓存
# Docker 镜像缓存(关键优化)
- name: Cache Docker images
uses: ScribeMD/docker-cache@0.5.0
with:
key: docker-${{ hashFiles('.github/docker-image-versions.txt') }}
# 预热 Docker 镜像(缓存未命中时拉取)
- name: Pull required Docker images
run: |
docker pull mysql:8.0.36 &
docker pull redis:7.2-alpine &
docker pull confluentinc/cp-kafka:7.5.0 &
wait # 等待所有并行 pull 完成
echo "All images pulled"
- name: Run integration tests
run: mvn verify -P integration-test -T 1C # -T 1C 使用 1 个 CPU 核的线程数
env:
MAVEN_OPTS: "-Xmx2g"docker-image-versions.txt(用于缓存 key):
mysql:8.0.36
redis:7.2-alpine
confluentinc/cp-kafka:7.5.0效果: 镜像 pull 时间从 180s → 0s(缓存命中)或 60s(并行 pull)
四、优化三:Maven 并行执行
# 按模块并行执行(多模块项目)
mvn test -T 1C # 1 个 CPU 核数的线程
# 指定固定并发数
mvn test -T 4 # 4 个并发线程
# 同时配合 JUnit 5 并行执行测试类src/test/resources/junit-platform.properties(CI 专用):
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=same_thread
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.parallel.config.strategy=fixed
# CI 机器通常是 2-4 核,设 4 比较合适
junit.jupiter.execution.parallel.config.fixed.parallelism=4效果: 测试执行时间从 336s → 85s(4 倍并发)
五、优化四:测试分组与按需执行
不是所有测试都需要每次 CI 运行。把测试分成不同组:
// 快速测试(每次 PR 都跑)
@Tag("fast")
class UserServiceUnitTest {...}
// 集成测试(每次 PR 都跑,但比 fast 慢)
@Tag("integration")
class OrderServiceIntegrationTest extends AbstractIntegrationTest {...}
// 慢速 E2E 测试(只在 merge 到 main 时跑)
@Tag("e2e")
@Tag("slow")
class FullSystemE2ETest {...}Maven 配置(按 tag 执行):
<!-- pom.xml -->
<profiles>
<!-- PR 快速检查 -->
<profile>
<id>fast</id>
<properties>
<surefire.groups>fast</surefire.groups>
</properties>
</profile>
<!-- 集成测试 -->
<profile>
<id>integration-test</id>
<properties>
<failsafe.groups>integration</failsafe.groups>
<failsafe.excludedGroups>e2e,slow</failsafe.excludedGroups>
</properties>
</profile>
<!-- 完整测试(main 分支) -->
<profile>
<id>full-test</id>
<!-- 不排除任何 group -->
</profile>
</profiles>CI 配置:
- name: Run fast tests (PR check)
if: github.event_name == 'pull_request'
run: mvn test -P fast
- name: Run integration tests (PR check)
if: github.event_name == 'pull_request'
run: mvn verify -P integration-test
- name: Run full tests (main merge)
if: github.ref == 'refs/heads/main'
run: mvn verify -P full-test六、三个踩坑实录
坑 1:并行测试导致 Spring 上下文重复加载
现象: 开启 JUnit 5 并行执行后,测试速度不升反降,CI 时间从 20 分钟变成了 25 分钟。
原因: 并行执行导致多个 Spring 上下文同时加载,内存不足,触发频繁 GC,反而更慢。
解法: 检查 Spring 上下文是否被有效缓存:
// 确保 @DynamicPropertySource 在所有测试类中保持一致
// 不一致会导致 Spring 创建多个上下文
// 用 --debug 参数查看上下文加载情况
// mvn test -Dspring.test.context.cache.maxSize=10 --debug 2>&1 | grep "Loading"
// 减小并发度,避免内存问题
junit.jupiter.execution.parallel.config.fixed.parallelism=2坑 2:GitHub Actions 镜像缓存不生效
现象: 用了镜像缓存 Action,但每次 CI 还是重新 pull 镜像,缓存命中率为 0。
原因: Docker 镜像缓存依赖 layer hash,但 Docker BuildKit 的 layer 格式和缓存 action 的预期不匹配。
解法: 改用更简单的方式:把镜像预先保存在 CI runner 的本地存储里:
- name: Check Docker image cache
id: cache-docker
uses: actions/cache@v4
with:
path: /tmp/docker-images
key: docker-images-${{ hashFiles('.docker-image-list') }}
- name: Load cached Docker images
if: steps.cache-docker.outputs.cache-hit == 'true'
run: |
docker load < /tmp/docker-images/mysql.tar
docker load < /tmp/docker-images/redis.tar
docker load < /tmp/docker-images/kafka.tar
- name: Pull and save Docker images
if: steps.cache-docker.outputs.cache-hit != 'true'
run: |
mkdir -p /tmp/docker-images
docker pull mysql:8.0.36 && docker save mysql:8.0.36 > /tmp/docker-images/mysql.tar
docker pull redis:7.2-alpine && docker save redis:7.2-alpine > /tmp/docker-images/redis.tar
docker pull confluentinc/cp-kafka:7.5.0 && docker save confluentinc/cp-kafka:7.5.0 > /tmp/docker-images/kafka.tar坑 3:高并发测试时端口耗尽
现象: 高并发跑集成测试时,偶发 BindException: Address already in use,不是固定的某个端口,随机出现。
原因: 大量测试同时建立数据库连接,系统的临时端口(Ephemeral ports)耗尽。
解法:
# Linux 临时扩大可用端口范围
sudo sysctl -w net.ipv4.ip_local_port_range="1024 65535"
sudo sysctl -w net.ipv4.tcp_tw_reuse=1
# 或者在 GitHub Actions 里:
- name: Increase OS port range
run: |
sudo sysctl -w net.ipv4.ip_local_port_range="1024 65535"
sudo sysctl -w net.ipv4.tcp_tw_reuse=1七、优化效果汇总
| 优化项 | 优化前 | 优化后 | 效果 |
|---|---|---|---|
| 容器共享 + 并行启动 | 756s | 40s | -716s |
| Docker 镜像缓存 | 180s | 5s | -175s |
| 测试并行执行 | 336s | 85s | -251s |
| 测试分组(只跑必要测试) | 全跑 1680s | 集成 400s | -1280s |
| Maven 依赖缓存 | 90s | 5s | -85s |
| 合计(PR 场景) | 28分钟 | 4分20秒 | 减少 85% |
4 分 20 秒,是开发者等 CI 的心理舒适区。快到这个程度,大家才会真正把 CI 集成测试当作工作流的一部分,而不是障碍。
