Maven 多模块构建六分四十秒:从依赖解析到并行编译

改一行注释等七分钟出镜像。先拆耗时分布,再上 -T 1C、surefire 并行、-nsu 与 .m2 缓存,最后配合 Docker 分层,压到一分五十秒。

CI 上的构建时间是慢慢变长的,没人会为它单独开一次优化任务。等大家开始抱怨,已经是六分四十秒了:改一行注释,等七分钟出镜像;一天提十个 PR,光等构建就是一个多小时。

更难受的是本地。mvn clean install -DskipTests 四分钟起步,跑一次完整测试接近六分钟,改代码的节奏被切得很碎。有个同事干脆把模块拆出来单独开了一个工程,本地只编译自己那一块,提交前再全量跑一次——这本身就是个信号:构建已经慢到大家在绕着它走了。

这次花了一个下午把它压到一分五十秒,记录一下每一步的实际收益,以及几个看起来有用其实没用的做法。

一、先把这六分四十秒拆开

优化之前先量化,否则很容易优化错地方。我的办法很土:用 time 分段测,再用 -pl 单模块测。

time mvn -B clean install -DskipTests -Dstyle.color=never
# real 6m40s

time mvn -B -pl order-service -am clean install -DskipTests
# real 4m12s

第一行是全量,第二行只构建 order-service 及其依赖模块。两个数字说明:order-service 这条链占了 63%,剩下 37% 分散在其他十几个模块里。

再拆细一点,把 Maven 各阶段的时间打出来:

mvn -B clean install -DskipTests -Dstyle.color=never \
  -Dorg.slf4j.simpleLogger.showDateTime=true \
  -Dorg.slf4j.simpleLogger.dateTimeFormat=HH:mm:ss 2>&1 | grep -E "^\[INFO\] --- "

拿到的时间分布是这样:

阶段 耗时 占比 备注
依赖解析与插件下载 1 分 05 秒 16% 有 SNAPSHOT 每次检查更新
编译(compile) 1 分 20 秒 20% 17 个模块串行
测试(surefire) 3 分 30 秒 52% 486 个用例,串行
打包与资源处理 35 秒 9% 含 frontend 资源拷贝
其他(enforcer、checkstyle) 10 秒 3% —

结论很清楚:一半以上的时间在跑测试,其次是编译和依赖解析。于是按「测试 → 编译 → 依赖」的顺序动手。

二、串行改并行:-T 1C

Maven 3 就支持多线程构建,-T 1C 表示「每个 CPU 核跑一个线程」。这一条改动最小、收益最直接:

time mvn -B -T 1C clean install -DskipTests
# real 3m10s

从 6 分 40 降到 3 分 10,主要来自 17 个模块的编译并行。但并行有前提,我们踩过三个。

第一,模块之间不能有隐含的顺序依赖。Maven 会按依赖关系拓扑排序,但如果你在插件里做了「A 模块构建时去读 B 模块的 target 目录」这种事,并行就会读到半成品。我们有一个模块在 generate-resources 阶段拷贝兄弟模块的产物,改成显式声明依赖之后才好。

第二,插件要线程安全。老版本的 maven-antrun-plugin、部分自定义插件在并行下会互相覆盖临时目录。判断方法是并行构建两次,对比产物的哈希,不一致就有问题。

第三,-T 和 -pl 混用时注意语义。mvn -T 1C -pl order-service -am 是「只构建这条链路,链路内部并行」,不会把无关模块拉进来,这个组合很实用。

顺带一提,加了 -T 之后日志会交错,出错时很难读。加 -B(批处理模式)关掉进度条,再用 --log-file 落盘,出问题按模块名 grep 就行。

三、真凶是测试,不是编译

3 分 30 秒的测试里,486 个用例,平均每个 0.43 秒。听起来不快不慢,但其中有 40 多个是「伪单元测试」:启动 Spring 上下文、连真实的 H2、跑完再关掉。一个这样的用例要 6 到 8 秒,40 个就是 5 分钟——只是被并行掉的模块分摊了。

处理办法有三层,按收益排序。

第一层,surefire 并行。JUnit 5 支持按类并行,配 fork 数:

<plugin>
  <artifactId>maven-surefire-plugin</artifactId>
  <configuration>
    <forkCount>2C</forkCount>
    <reuseForks>true</reuseForks>
    <parallel>classes</parallel>
    <threadCount>4</threadCount>
  </configuration>
</plugin>

forkCount=2C 起两个 JVM 进程,threadCount 控制每个进程内的线程数。注意 H2 这种共享文件锁的测试不能并行,会随机失败,要用 <groups> 把它们单独排除出去串行跑。

第二层,把「伪单元测试」改成真单元测试。启动 Spring 上下文的那 40 个用例里,大部分只是想测一个 Service 的分支逻辑,用 Mockito 直接 new 对象就行。改造完,这部分时间从 5 分钟掉到 20 秒。这是收益最大的一步,也是最费人力的。

第三层,只跑受影响的模块。CI 里用 git diff --name-only 找出改动的模块,只对它们跑测试,其余模块只编译不测试:

CHANGED=$(git diff --name-only origin/main...HEAD | cut -d/ -f2 | sort -u | paste -sd,)
mvn -B -T 1C -pl "$CHANGED" -am test

这个做法有风险:跨模块的隐性耦合会漏测。我们的兜底是「main 分支合并时跑全量」,PR 阶段只跑增量。权衡下来可接受。

四、依赖解析:1 分 05 秒里有 40 秒是白等的

settings.xml 里如果配了 snapshot 仓库,Maven 默认每天检查一次更新,检查动作是每个 SNAPSHOT 依赖发一次 HEAD 请求。我们的项目依赖三个内部 SNAPSHOT,每次构建都要走一轮。

[INFO] Downloading from snapshots: .../order-api/1.4.0-SNAPSHOT/maven-metadata.xml

加 -nsu(no snapshot updates)能跳过这一步,但代价是拿不到别人刚推的 SNAPSHOT。CI 上我们改成「只在 main 分支更新快照」,PR 构建一律 -nsu:

mvn -B -T 1C -nsu clean install -DskipTests

另外两件事:

一是本地仓库要缓存对。CI 的缓存 key 用 pom.xml 的哈希,而不是每次都清空。之前我们的流水线每次跑在干净的 runner 上,1 分 05 秒里有一大半是重新下载依赖;加上 .m2 缓存之后,这部分降到 12 秒。

二是别用 dependency:go-offline 当「预热依赖」的银弹。它只解析 pom 里声明的依赖,插件自身的依赖、profile 里的可选依赖常常漏掉,构建时仍然联网。要在离线环境构建,更靠谱的是先跑一次完整构建,再 -o 离线跑第二次。

优化项 优化前 优化后 手段
依赖解析 1 分 05 秒 12 秒 .m2 缓存 + -nsu
编译 1 分 20 秒 32 秒 -T 1C
测试 3 分 30 秒 55 秒 surefire 并行 + 去掉伪单测
打包 35 秒 20 秒 资源插件去掉重复拷贝
合计 6 分 40 秒 1 分 50 秒 —

五、镜像构建的分层配合

CI 快了,Docker 构建还在拖后腿:每次 COPY . . 之后所有层缓存失效,RUN mvn package 重新下一遍依赖,多花两分钟。

标准解法是先只拷 pom,把依赖层单独缓存,再拷源码:

FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
COPY */pom.xml ./
RUN mvn -B -q dependency:go-offline -DskipTests || true
COPY . .
RUN mvn -B -T 1C package -DskipTests

|| true 是必要的:go-offline 会因为它自己解析不到的可选依赖失败,但已经把能下都下完了,失败不影响后续构建。这个写法不优雅,但比每次重下依赖强。

多模块项目还要注意 COPY */pom.xml ./ 这一行:只要有一个模块的 pom 改了,这一层就失效,下面的 go-offline 重跑。实践中这比「改一行 Java 就重下依赖」的频率低得多,够用了。

六、还有哪些没做

有两个方向我试过但这次没上。

一是 mvnd(Maven Daemon)。它常驻一个 JVM,省掉每次启动的 JVM 开销和类加载,本地构建能再快 25% 到 30%。CI 上收益不明显,因为每次都是新容器。如果团队本地构建频繁,值得让每个人装一下。

二是 Maven 3.9 的 build cache 扩展,能把「输入相同」的模块直接复用上次产物。它对「改了 A 模块、B 到 Z 都没改」的场景很有用,但我们的 CI 是 clean 起步,缓存命中率上不去,最后没启用。如果将来改成增量构建,这个扩展会很有价值。

真正该做但短期做不了的,是把 17 个模块收敛成 6 个。模块拆得太细,每个模块的固定开销(插件执行、资源处理、jar 打包)加起来占了近一分钟。这件事牵扯到团队边界,不是技术能决定的,先记在技术债里。

小结

  • 先量化再优化。time 加 -pl 单模块计时,十分钟就能得到一张可信的耗时分布表。
  • -T 1C 是最便宜的收益,但要先确认没有隐含的跨模块顺序依赖和线程不安全的插件。
  • 构建慢的大头通常在测试。40 个「启动 Spring 上下文」的伪单测能吃掉五分钟,改成 Mockito 单测比任何参数调优都有效。
  • 依赖解析的时间大多花在 SNAPSHOT 更新检查上,PR 构建用 -nsu,CI 用 pom 哈希做 .m2 缓存。
  • Docker 构建要先拷 pom 预热依赖层,再拷源码,否则改一行代码就重下全部依赖。
  • dependency:go-offline 不是完备的依赖预热,离线构建前最好先跑一次完整构建。
查看「工程效能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

Maven 多模块构建六分四十秒:从依赖解析到并行编译 - 起航社区