《Spring Boot 实战》2.3 构建加速与缓存

本节讲 Maven 构建加速:先给出定位慢在哪里的步骤,再逐项拆解跳过无用阶段、按模块增量构建、并行构建、依赖预拉与离线、CI 缓存本地仓库、镜像源与增量编译等手段,重点说明 4.1 中 -DskipTests 不再跳过测试 AOT 处理这一语义变化及替代写法。

本节目标:学会先量出构建慢在哪一段,再针对性地用「跳过无用阶段、按模块增量、并行、依赖预拉、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 verifyCI 环境是干净的,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/repositoryCI 首次构建大幅提速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 多环境配置 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计