本节目标:学会先量出构建慢在哪一段,再针对性地用「跳过无用阶段、按模块增量、并行、依赖预拉、CI 缓存、镜像源」六类手段把时间压下来;并准确掌握 4.1 中
-DskipTests的语义变化与正确替代写法。
适用版本:Spring Boot 4.1.x(Java 21)
2.3 构建加速与缓存
前两节把 book-loan 的依赖理顺了,这一节处理它带来的新代价:模块多了、依赖全了,mvn clean verify 从「敲下去就出结果」变成「敲下去先去泡杯咖啡」。构建慢不只是烦,它会改变团队的行为——本地验证变少、提交粒度变大、CI 排队变长。本节把加速拆成可量化、可组合的六类手段。
先给一个量级参考。本机实测(Spring Boot 4.1.1 + Java 21 + Maven 3.9.12)单模块应用的构建耗时:首次约 73 秒(含从远程仓库拉取全部依赖),后续约 38 秒。这两个数字的差距说明了一件事:构建时间的大头往往不在编译,而在依赖解析与下载。多模块工程只会把这个比例放大。
第一步:先量,别猜
优化最忌讳「凭感觉换工具」。先确定慢在哪一段。最直接的办法是分段计时:
time mvn -pl book-loan-boot -am clean package -Dmaven.test.skip=true
-Dmaven.test.skip=true 会跳过测试的编译与执行,先把「非测试部分」的基线量出来;再跑一次带测试的,两者相减就是测试阶段的成本。多跑几次取稳定值,第一次的结果受缓存影响没有参考价值。
要看清具体是哪个插件、哪次仓库访问在耗时,加 -X:
mvn -pl book-loan-boot -am package -X 2>&1 | grep -E "Downloading|Downloaded|BUILD" | head -30
-X(debug)会把每次远程下载、每个插件的执行边界都打出来。常见的三类结论:
| 现象 | 说明 | 对应手段 |
|---|---|---|
大量 Downloading from central | 依赖没被本地缓存,或每次都在解析元数据 | 依赖预拉 + CI 缓存 + 镜像源 |
| 某个插件单独占掉数秒 | 该插件在当前阶段并不必要 | 用 profile 按需触发 |
| 每次都在重编译全部模块 | 没有做模块级增量 | 去掉无谓的 clean,用 -pl -am |
手段一:跳过你不需要的阶段
本地改一行代码、只想确认能编译时,跑全量 verify 是浪费。按需选择阶段:
| 命令 | 做什么 | 典型耗时 |
|---|---|---|
mvn compile | 只编译主代码 | 最短 |
mvn package -DskipTests | 编译 + 打包,不跑测试 | 中 |
mvn verify | 编译 + 测试 + 集成测试 + 校验插件 | 最长 |
-DskipTests 是最常被误用的一个参数,而且它在 4.1 里语义变了,下面单独讲。
手段二:只构建变化的模块
book-loan 有五个模块,但改 book-loan-web 时往往只需要它和它依赖的模块:
mvn -pl book-loan-web -am package
-pl(--projects)指定要构建的模块,-am(--also-make)把它们的上游依赖一并构建。反向的 -amd(--also-make-dependents)则把下游一起带上,适合改了 book-loan-domain 想验证谁受影响时用。
| 参数 | 含义 | 什么时候用 |
|---|---|---|
-pl book-loan-web | 只构建这一个模块 | 只想验证单个模块 |
-pl book-loan-web -am | 连同它依赖的模块 | 改了底层模块,要重新装上游 |
-pl book-loan-domain -amd | 连同依赖它的模块 | 评估改动的影响面 |
注意:跨模块依赖要走本地仓库时,需要先 mvn install 上游模块,否则 -pl 单独构建会找不到刚改的 book-loan-domain。-am 在同一个 reactor 里解决了这个问题,这也是它比 install 再单独构建更省事的原因。
手段三:并行构建
多模块工程是天然的并行场景。Maven 的 -T 参数按线程数并行构建模块:
mvn -T 1C clean package
1C 表示「每个 CPU 核心一个线程」,也可以写具体数字如 -T 4。收益大小取决于模块间的依赖图是否允许并行——book-loan 的 domain → application → infrastructure / web → boot 是一条偏线性的链,并行收益有限;模块间相互独立的工程收益更明显。
两个前提必须满足,否则并行会引入难查的问题:
- 模块之间不能有隐式的构建顺序依赖。比如
book-loan-web在构建时读取book-loan-domain生成的文件,如果没在 POM 里声明依赖,并行时顺序就不确定。 - 插件本身要线程安全。绝大多数官方插件没问题,但少数自研插件可能不是。
并行带来的报错往往表现为「偶发失败」,比串行慢更难排查。先确认构建是确定性的,再开并行。
手段四:依赖预拉与离线构建
既然首次构建的时间主要花在下载依赖上,最有效的办法就是别每次都下。Maven 会把下载过的依赖缓存到本地仓库 ~/.m2/repository,只要缓存命中,后续构建就不联网。
主动把所有依赖(含插件依赖)一次拉齐:
mvn -pl book-loan-boot -am dependency:go-offline
拉齐之后,如果确定不再需要新依赖,可以强制离线:
mvn -o -pl book-loan-boot -am package
-o(offline)会让 Maven 完全不走网络,任何本地缺失的依赖都直接失败。它的价值有两个:一是消除网络抖动带来的不确定性,二是立刻暴露「本地仓库其实不完整」的问题——如果 -o 下构建失败,说明你依赖了某个只在网络可用时才存在的坐标,这种构建在 CI 断网时同样会挂。
dependency:go-offline 并不能覆盖所有情况(部分插件在运行时才解析依赖),所以它拉完之后第一次 -o 构建仍可能失败,补拉一次即可。
手段五:镜像源与 CI 缓存
本地加速靠镜像源,CI 加速靠缓存本地仓库,两者的原理都是「别从中央仓库慢慢拉」。
镜像源配置(~/.m2/settings.xml 里的 mirrorOf=central)在入门卷已讲过,这里补两个生产环境要点:
- CI 环境通常没有开发者的
~/.m2/settings.xml,要么在流水线里显式挂载一份,要么用mvn -s /path/to/settings.xml指定。把 settings.xml 提交进仓库时要确认它不含私服凭据,凭据走环境变量或 CI 的 secret 注入。 mirrorOf不要写*,否则会连公司私服一起劫持,内部依赖直接拉不到。
CI 缓存的目标是 ~/.m2/repository。以 GitHub Actions 为例:
- name: Cache Maven repository
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}
restore-keys: |
maven-${{ runner.os }}-
缓存 key 里放 hashFiles('**/pom.xml') 是关键:POM 变了就换一个新 key,避免用旧缓存里的依赖去构建新版本。restore-keys 提供「部分命中」的兜底——新 key 没命中时,用前缀匹配到最近的一份缓存,只增量下载变化的部分,而不是从零开始。
这里有个和上一节呼应的点:POM 变更会作废整个依赖缓存。所以版本对齐做得好(版本集中、少改动)不仅让依赖冲突变少,也让 CI 缓存命中率更高。
顺带说一个团队级的默认参数机制。把每次都要敲的参数写进 .mvn/maven.config,团队成员和 CI 都会自动生效:
--no-transfer-progress
-Dmaven.artifact.threads=8
--no-transfer-progress 去掉下载进度条(CI 日志里那堆 Downloading 行会短很多),maven.artifact.threads 提高并发下载的线程数。注意这个文件里的参数是所有人共享的,-T 这类会改变构建行为的参数放进去前要想清楚,别让别人的机器被并行构建的偶发问题缠上。
手段六:增量编译与慎用 clean
Maven 的 maven-compiler-plugin 本身支持增量编译:它比较源文件与 .class 的时间戳,只重编译变化的文件。但 mvn clean 会把 target/ 整个删掉,让增量失效,下一次必然全量重编译。
所以本地开发的默认动作不该是 clean:
| 场景 | 命令 | 理由 |
|---|---|---|
| 改了业务代码,想快速验证 | mvn -pl book-loan-web -am compile | 增量编译,只编变化的部分 |
| 改了 POM 或资源文件 | mvn -pl book-loan-web -am package | 不 clean,让插件自己判断 |
| 怀疑构建产物脏了 | mvn clean package | 只在必要时 clean |
| CI 每次构建 | mvn clean verify | CI 环境是干净的,clean 成本可忽略 |
判断标准很简单:只有当你怀疑 target/ 里有不该存在的东西时才 clean。日常改代码不 clean,能省下大量重复编译时间。CI 上则相反——每次都是全新容器,本来就没有历史产物,clean 只是保证语义明确。
4.1 的 skipTests 语义变化
这是本节最容易踩坑的一处,也是 4.1 的破坏性变更之一。
在 4.0 及更早,mvn package -DskipTests 会跳过测试的执行,同时也不会触发测试相关的 AOT 处理。到 4.1,-DskipTests 不再跳过测试的 AOT 处理:Spring Boot Maven Plugin 现在只认 maven.test.skip 这一个属性,与其他核心插件保持一致。也就是说,如果你在 4.1 上继续用 -DskipTests,测试的 AOT 处理仍会跑,构建时间和预期不符。
正确做法是区分两个属性:
| 属性 | 跳过测试执行 | 跳过测试编译 | 跳过测试 AOT 处理(4.1) |
|---|---|---|---|
-DskipTests | 是 | 否 | 否 |
-Dmaven.test.skip=true | 是 | 是 | 是 |
所以:
# 4.1 上想真正跳过测试相关的全部工作
mvn -pl book-loan-boot -am clean package -Dmaven.test.skip=true
# 只跳过执行、仍要编译测试代码(能发现测试代码的编译错误)
mvn -pl book-loan-boot -am clean package -DskipTests
代价要说清楚:maven.test.skip=true 连测试代码都不编译,测试代码里的编译错误会被掩盖。CI 的常规构建不该用它——否则测试代码烂掉很久都没人发现。它适合的场景是「本地快速验证打包产物」「临时排查构建耗时」这类一次性动作。
对照 3.5.x:在 3.5.16 上 -DskipTests 仍能跳过测试相关的 AOT 处理,所以从 3.5 升级到 4.1 时,如果 CI 脚本里写的是 -DskipTests 并且依赖了「跳过 AOT」的效果,构建时间会明显变长——这是升级清单里要提前标注的一项。
手段七:把不常用的插件关掉
有些插件在本地开发时完全是负担,比如生成镜像的 spring-boot:build-image、生成文档的 maven-javadoc-plugin、签名发布的 maven-gpg-plugin。它们的共同点是只在特定场景需要,却被绑在了默认生命周期上。
处理方式是用 profile 把它们隔离,默认不激活:
<profiles>
<profile>
<id>release</id>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>build-image</id>
<goals>
<goal>build-image</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
本地 mvn package 不激活 release profile,镜像构建就不会触发;CI 里发布时用 mvn -Prelease package 显式打开。原则是:默认路径只保留「编译 + 测试 + 打包」,其余都按需触发。
手段收益与代价对照
把上面的手段汇总,方便按场景取舍:
| 手段 | 主要收益 | 代价 / 风险 | 推荐场景 |
|---|---|---|---|
-pl / -am | 少构建无关模块 | 需要理解模块依赖图 | 日常开发 |
-T 1C 并行 | 多模块构建提速 | 非确定性构建会被放大成偶发失败 | 模块独立性好时 |
dependency:go-offline + -o | 消除下载耗时与网络抖动 | 新依赖需手动补拉 | 本地反复构建 |
CI 缓存 ~/.m2/repository | CI 首次构建大幅提速 | key 设计不当会用到脏缓存 | 所有 CI |
| 镜像源 | 下载提速 | mirrorOf=* 会劫持私服 | 国内环境 |
| 不 clean、靠增量编译 | 本地快速迭代 | 产物可能残留旧文件 | 日常改代码 |
-Dmaven.test.skip=true | 跳过测试全流程 | 掩盖测试代码编译错误 | 一次性验证 |
| profile 隔离插件 | 去掉无关阶段 | 配置复杂度上升 | 有重量级插件时 |
常见坑
| 坑 | 表现 | 处理 |
|---|---|---|
4.1 上继续用 -DskipTests 期待跳过 AOT | 构建时间没降,或与 CI 预期不符 | 改用 -Dmaven.test.skip=true |
无脑 mvn clean | 每次全量重编译 | 只在怀疑产物脏时 clean |
| CI 缓存 key 不含 POM 哈希 | 用旧缓存构建新版本,行为诡异 | key 里放 hashFiles('**/pom.xml') |
mirrorOf 写成 * | 公司私服依赖拉不到 | 只镜像 central |
盲目开 -T | 偶发失败,难复现 | 先确认构建是确定性的 |
用 -o 却不清楚缓存是否完整 | 离线构建失败,误以为配置错 | 先用 dependency:go-offline 拉齐 |
小结
- 先量再优化:用
time分段计时、-X看下载与插件耗时,确认慢在依赖下载、测试还是编译。 - 本机实测(4.1.1 + Java 21 + Maven 3.9.12)单模块构建首次约 73 秒、后续约 38 秒,差距说明下载是大头。
- 本地开发用
-pl -am只构建受影响模块,不 clean、靠增量编译;CI 用缓存 + 镜像源,缓存 key 必须绑定 POM 哈希。 -T并行能提速,但前提是构建确定性好,否则会把问题变成偶发失败。- 4.1 的
-DskipTests不再跳过测试的 AOT 处理,要跳过测试相关的全部工作应改用-Dmaven.test.skip=true,并清楚它会掩盖测试代码的编译错误。 - 重量级插件用 profile 隔离,默认构建路径只保留编译、测试、打包。
构建与依赖这条线到这里告一段落:从冲突排查,到版本对齐,再到把构建时间压下来。接下来进入配置管理——同一个镜像怎么在 dev / test / prod 下加载不同配置,见下一节 3.1 多环境配置 。
阅读导航:上一节:2.2 版本对齐与 BOM · 下一节:3.1 多环境配置 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。