Ohhnews

分类导航

$ cd ..
foojay原文

Maven构建缓存实践:在Azure DevOps中显著缩短CI构建时间

#maven#构建缓存#azure devops#ci/cd#性能优化

Maven 构建缓存可以显著缩短构建时间,但启用它只是第一步。在本文中,我们分享在 Azure DevOps 流水线中引入该缓存的经验、遇到的问题以及一路走来得到的教训。

引言

Apache Maven 如今在大多数国家已经到了可以合法驾车和饮酒的年龄。作为整个 Java 生态系统中成熟且稳定的构建工具,Maven 被大型企业和开源项目广泛使用。尽管如此,Maven 背后的社区仍在持续改进 Maven 本身、其插件以及围绕它的其他工具。由于 Maven 是开源的,社区一直欢迎提交者之外的修复和贡献,并由此构建了一个不断壮大的社区,其中的人们努力让 Maven 成为更好的产品。

Maven 构建缓存

2019 年,德意志银行的一个团队联系了 Apache Maven 社区,宣布他们实现了“增量构建”和“共享缓存”——这些功能与 Gradle 构建缓存非常相似。借助这些功能,他们将平均构建时间从 45–60 分钟缩短到 1–2 分钟。这是显著的改进!而最棒的是:他们希望将这些功能捐赠给 Apache 软件基金会。

构建缓存如何工作?思路相当简单:

  1. CI 服务器运行构建,并将所有输出存储到共享缓存中。
  2. CI 上的下一次构建可以复用这些输出,前提是其输入没有变化。
  3. 开发者在开始处理某个功能并构建项目时,会从远程缓存中获取其分支的快照。
  4. 修改代码后,开发者再次构建项目。只有发生变更的 Maven 模块会被重新构建。

为了实现这一点,Maven 构建缓存不查看文件时间戳。这些时间戳在多个平台、机器和文件系统之间并不可靠。相反,它利用非加密安全但速度非常快的哈希(例如 XX-hash)来验证输入文件的内容是否发生变化。还有更多启发式规则用于防止不必要的重新构建。讨论它们不是本文的主要目标;相反,我们希望重点介绍如何在真实企业项目中利用这一点。

为此,我们将分享在几个真实企业项目中是如何做到这一点的。这些项目托管在 Azure DevOps 中,但我们推测在其他环境(例如 GitLab 或 GitHub)中也会有类似效果。

基础

启用构建缓存扩展

我们首先将构建缓存扩展添加到项目本身。这意味着,在项目根目录的 .mvn 文件夹中添加一个 extensions.xml 文件,将其加入 Maven 项目,内容如下:

$ xml
<extensions
    xmlns="http://maven.apache.org/EXTENSIONS/1.1.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/EXTENSIONS/1.1.0 https://maven.apache.org/xsd/core-extensions-1.1.0.xsd">
  <extension>
    <groupId>org.apache.maven.extensions</groupId>
    <artifactId>maven-build-cache-extension</artifactId>
    <version>1.3.0</version>
  </extension>
</extensions>

配置构建缓存扩展

在讨论我们遇到的问题之前,有必要先介绍我们最初采用的最小配置。基础配置相对较少,而且确实有效,但真正重要的工作是在此之后才开始的。我们观察到,一些流水线步骤开始失败,因为较早步骤的输出文件缺失。为了配置该扩展,我们在 .mvn 文件夹中添加了另一个名为 maven-build-cache-config.xml 的文件。在这里,我们可以配置应还原哪些额外的构建输出。这对于确保后续流水线步骤正常工作很有必要。其他配置值使用默认值即可,因此我们没有改动它们。所有可配置选项都可以在 构建缓存扩展网页上找到。

在评估缓存是否值得采用时,我们了解到该扩展特别适合多模块 Maven 项目。它不会检查“整个项目是否发生变化”,而是按 Maven 模块进行判断。如果一个模块及其依赖的模块没有变化,该模块的输出就可以从缓存中还原。因此,对于我们那些包含更多更小模块的项目,收益会更大。如果变更仅限于系统的某一部分,Maven 不一定需要重新构建所有内容。如果大部分代码都集中在一个大型模块中,复用先前工作成果的机会就会更少。

对我们的项目而言,这种模块级缓存正是有趣之处。我们的 CI 构建花费大量时间编译、测试和打包那些在拉取请求中通常未被改动的模块。构建缓存为我们提供了一种避免重复这些工作的方法。

在 Azure DevOps 流水线中设置

将扩展添加到 Maven 项目只是设置工作的一半。在开发者机器上,构建缓存可以存放在 ~/.m2/build-cache 中。然而,在 Azure DevOps 中,构建代理启动时工作区是干净的。如果没有额外的流水线配置,一次流水线运行产生的缓存在下一次运行时不可用。

有两种解决方法:一种是在配置文件中配置远程仓库;第二种是使用 Azure DevOps Cache@2 任务,我们将在本文中说明。这种方式不允许开发者复用缓存,但设置起来容易得多。如果你还希望在开发者机器上利用缓存,使用远程仓库是一种很好的方式。我们预计自己不需要这样做。

如前所述,为了在 Azure DevOps 流水线运行之间持久化缓存,我们在流水线模板中使用 Cache@2 任务。该任务在作业开始时还原缓存文件夹,并添加一个作业后步骤,在作业成功完成后保存缓存。本质上,我们是在缓存缓存本身 🤡。

在我们的场景中,配置如下:

$ config
- task: Cache@2
  displayName: Use Maven build cache
  inputs:
    key: 'maven-build-cache | "$(Agent.OS)" | pom.xml'
    path: $(MAVEN_BUILD_CACHE_FOLDER)
    restoreKeys: |
      maven-build-cache | "$(Agent.OS)"

这会创建一个不可变的 Azure DevOps 缓存,作用域限于该分支。该分支上的首次构建会填充缓存,后续构建无法覆盖它。我们通常看到后续构建只推送很小的改动,因此不认为这会成为严重问题。最大的收益在于至少拥有了缓存。

$(MAVEN_BUILD_CACHE_FOLDER) 指向构建代理环境中 Maven 构建缓存扩展使用的目录。在我们的流水线定义中,将该变量定义为 $(Pipeline.Workspace)/.m2/build-cache,这也是缓存通常存储的位置。

此外,我们还定义了一个键(以及作为备用的还原键)来标识缓存。起初,很容易想把缓存键设得非常具体。例如,你可能想包含项目所有的 pom.xml 文件。对于 Maven 构建缓存而言,这通常没有必要。因为该扩展已经根据每个模块的输入计算指纹。它“知道”源文件是否变化、插件配置是否变化,或模块依赖是否变化。如果我们把过多同样的信息编码进 Azure DevOps 缓存键,最终就会重复 Maven 的逻辑,并造成不必要的缓存未命中。

在不同流水线中使用缓存

在我们的场景中,构建缓存主要用于拉取请求验证和依赖检查流水线。拉取请求构建应快速提供反馈,而且它们通常只包含对代码库一小部分的更改。这正是模块级缓存带来最大收益的地方。运行依赖检查器不应需要完整构建;这里完全可以正常使用缓存的产物。

我们有意没有在部署流水线中依赖 Maven 构建缓存。对于部署构建,我们希望生成的产物来自一次干净构建。部署流水线并不是我们想通过还原先前构建输出来激进优化性能的地方。

总结来说,我们的设置包括三个部分:

  1. 拉取请求流水线:使用 Maven 构建缓存加速验证。
  2. 依赖检查流水线:无需仅仅为了运行依赖检查插件而重新构建整个项目。
  3. 部署流水线:不依赖还原的构建输出进行构建。

需要特别注意的是,Azure DevOps 缓存的作用域是流水线中检出的分支。这意味着针对不同分支的每个拉取请求都需要先进行一次完整构建,然后才能复用缓存。之后,我们可能会考虑让主部署流水线创建缓存但不消费缓存;这样甚至能改善每个拉取请求的首次构建。由于拉取请求构建只能读取目标分支的缓存,我们届时可以通过构建主分支来“预热”缓存。之后,针对主分支的拉取请求就可以复用该缓存。

我们遇到的具体问题

在将扩展集成到项目时,我们不可避免地遇到了一些问题。

仅还原产物还不够

我们遇到的最初问题之一是,仅还原最终产物对我们的流水线来说还不够。

默认情况下,构建缓存扩展不会还原整个 target 目录。这很重要,因为流水线或构建中的其他工具或插件通常不会只看最终的 .jar、.war 或 .ear 文件。它们还期望模块的 target 文件夹中存在文件。

例如,JaCoCo 和报告工具可能会在如下目录中查找文件:

  • target/classes
  • target/test-classes
  • target/surefire-reports
  • target/site/jacoco

当 Maven 从缓存还原模块,但这些目录缺失时,后续流水线步骤可能会失败或生成不完整的报告。从 Maven 的角度看,该模块在技术上可能已经“构建”完成,但流水线的其余部分仍然期望通常的构建输出布局。

为了解决这个问题,我们配置构建缓存扩展以附加额外的输出目录。在我们的场景中,相关目录如下:

$ xml
<attachedOutputs>
  <dirNames>
    <dirName>classes</dirName>
    <dirName>test-classes</dirName>
    <dirName>surefire-reports</dirName>
    <dirName>site/jacoco</dirName>
  </dirNames>
</attachedOutputs>

这告诉扩展将这些目录作为缓存输出的一部分进行存储和还原。经过这项更改后,还原的模块再次拥有了流水线下一步所需的文件。

SonarCloud 分析与 Maven 反应器

下一个问题出现在 SonarCloud 分析相关环节。

在引入构建缓存之前,我们的流水线在 Maven 任务本身上使用 Sonar 集成。这意味着构建、测试、打包和 Sonar 分析都属于同一次 Maven 调用。启用缓存后,这种设置变得有问题。

与 compile、test、package 或 install 不同,sonar:sonar 是独立的 Maven 目标,而不是生命周期阶段。初始构建成功完成并创建缓存条目。然而在后续构建中,将生命周期目标与独立的 Sonar 分析目标组合在一起(正如 Maven@4 任务所做的那样)会失败。我们看到构建缓存扩展试图把 sonar:sonar 当作生命周期阶段来还原。

解决方案是将 Sonar 分析拆分到单独的 Maven@4 调用中:

$ config
- task: Maven@4
  displayName: Build and test
  inputs:
    mavenPomFile: 'pom.xml'
    goals: 'package'
    mavenOptions: '$(MAVEN_OPTS)'

- task: Maven@4
  displayName: Run SonarCloud analysis
  inputs:
    mavenPomFile: 'pom.xml'
    goals: 'sonar:sonar'
    mavenOptions: '$(MAVEN_OPTS)'

拆分步骤解决了一个问题,但又引入了另一个问题。

在原始设置中,Sonar 与构建运行在同一个 Maven 反应器内。Maven 可以直接从反应器解析模块之间的依赖。将 Sonar 拆分到第二次 Maven 调用后,该反应器上下文就不存在了。第二个 Maven 进程必须从本地 Maven 仓库解析模块间依赖。

我们正是在这里看到了如下错误:

The following dependencies could not be resolved at this point of the build but seem to be part of the reactor.

这表明我们自己的内部模块无法被解析。

根本原因是第一个 Maven 步骤过去运行的是 package。package 目标会创建模块产物,但不会将它们安装到本地 Maven 仓库。因此,当第二次 Maven 调用开始时,产物存在于 target 中,但不在 Maven 查找它们所依据的本地仓库中。

在这里只缓存 target 还不够。单独的 Maven 调用需要产物满足以下条件之一:

  • 位于同一个反应器中,或
  • 已安装到本地 Maven 仓库中。

由于 Sonar 现在运行在单独的 Maven 进程中,我们将构建步骤改为运行 install,而不再只运行 package。

这样一来,即使某个模块是从缓存中还原的,它的产物也会被放入本地 Maven 仓库,供同一流水线中后续的 Maven 调用使用。

为了让这一点在构建缓存下可靠,我们通过将以下 XML 添加到 maven-build-cache-config.xml 文件,配置 Maven install 目标始终运行:

$ xml
<!-- namespace declarations omitted for brevity -->
<cache>
  <configuration>
    <!-- other config omitted for brevity -->
    <executionControl>
      <runAlways>
        <goalsLists>
          <goalsList groupId="org.apache.maven.plugins"
                    artifactId="maven-install-plugin">
            <goals>
              <goal>install</goal>
            </goals>
          </goalsList>
        </goalsLists>
      </runAlways>
    </executionControl>
  </configuration>
</cache>

构建缓存仍然可以还原构建中开销较大的部分,例如编译、测试和打包。但 install 步骤足够廉价,可以每次运行,并且它能确保后续 Maven 调用可以解析模块产物。

OWASP 依赖检查

我们在 OWASP 依赖检查插件上遇到了类似问题,该插件只在一个专用流水线中运行。最初,依赖检查被集成到主 Maven 执行中。在我们的项目里,它绑定到 verify 阶段并使用 aggregate 目标,这对多模块项目很有用,因为它会生成合并报告。

然而,它也不能很好地与构建缓存配合。依赖检查不会产生我们想从缓存中还原的那种可复用构建输出。它依赖于当前的依赖图和漏洞数据,因此最好将其视为需要在需要时显式运行的步骤。

简化后的设置如下:

$ config
- task: Maven@4
  displayName: Build and install modules
  inputs:
    mavenPomFile: 'pom.xml'
    goals: 'install'
    mavenOptions: $(MAVEN_OPTS)

- task: Maven@4
  displayName: Run OWASP Dependency Check
  inputs:
    mavenPomFile: 'pom.xml'
    goals: 'org.owasp:dependency-check-maven:aggregate'
    mavenOptions: '$(MAVEN_OPTS)'
```## 结果

现在我们已经安装并配置了该扩展,接下来回顾一下最初为什么要试验它。使用缓存扩展真的帮到我们了吗?它带来了什么价值?

需要明确的是,我们的主要目标并不是让每一次构建都神奇地变快。目标更具体:缩短拉取请求构建的反馈时间。这类构建运行频繁,处于代码评审的关键路径上,而且在许多情况下,它们验证的变更只涉及代码库的一小部分。因此,它们很适合使用构建缓存。

在启用构建缓存扩展之前,一次完整的拉取请求流水线构建大约需要 18 分钟,其中 14 分钟用于编译、运行测试和打包应用。另外 4 分钟用于运行一些次要的作业前后步骤,以及 SonarCloud 分析。每当有新变更推送到 Azure DevOps,无论变更大小,都必须重新运行整个 18 分钟的构建。

启用构建缓存后,某个分支的首次构建仍基本等同于完整构建。这是预期之中的:此时还没有可恢复的有用内容,收益会体现在后续构建中。然而,一旦初始缓存创建完成,改进就非常明显了。在最好的情况下,Maven 的 `install` 步骤只需一分钟!平均来看,根据变更范围以及其他模块对这些变更的依赖程度,Maven 的 `install` 步骤大约需要五到六分钟。这使得整体流水线构建时长减少了 50%!

我们还观察到,这些更短的构建带来了另一个好处,更偏向运营层面的影响。我们能更早收到针对拟议变更的反馈。知道只需等待 9 分钟而不是 18 分钟,我们就不太会想切换去做别的任务。我们知道自己可以快速修复问题,然后继续评审流程。

在实践中,这从几个方面提升了交付速度:

- **更短的反馈循环。** 构建失败、测试失败和静态分析问题会更早报告出来。这让开发者能在变更仍记忆犹新时更容易修复小问题。
- **代码评审期间等待更少。** 推送变更后,评审者不必等待太久就能看到拉取请求重新通过。这有助于让评审持续进行。
- **更有信心做小变更。** 如果每次拉取请求构建都要花很长时间,人们总会倾向于把多个变更批量放在一起。更快的验证让拉取请求更容易保持小而聚焦。
- **更好地利用 CI 容量。** 构建代理仍然在工作,但它们花在重复执行那些已经构建过的模块的昂贵步骤上的时间更少。当多个拉取请求同时活跃时,这一点尤其有用。

如前所述,我们最初以为恢复最终产物就足够了。实际上,后续步骤还依赖 `target` 目录中的文件,例如编译后的类、测试类、Surefire 报告和 JaCoCo 输出。如果不恢复这些输出,Maven 可能会认为某个模块已经恢复,但报告或分析步骤仍会失败,或者产生不完整的结果。

## 经验教训

我们学到的最重要一点是:构建缓存不仅是一项技术优化。它改变了你将构建视为开发流程一部分的方式。

在引入缓存之前,我们大多把构建看成一个大的步骤:Maven 启动,Maven 构建所有内容,过一段时间后流水线要么通过,要么失败。构建缓存迫使我们要更仔细地审视构建的每个部分会生成哪些产物、后续步骤需要哪些产物,以及哪些步骤可以安全地从缓存中恢复。

这一点在缓存本身之外也很有用。它让流水线变得更明确。

例如,将 SonarCloud 分析和 Dependency Check 拆分为独立的 Maven 调用,使流水线更容易理解。它也让职责更清晰:

- 构建步骤负责编译、测试、打包并安装模块;
- SonarCloud 步骤负责执行代码分析;
- Dependency Check 步骤负责执行依赖分析;
- 部署流水线生成我们部署所用的产物。

这种分离帮助我们避免把每条流水线都当成同一种构建。拉取请求流水线与部署流水线的目标不同。拉取请求构建应提供快速反馈。部署构建应干净、可复现且可信。这些目标相互关联,但并不完全相同。

从组织角度看,最大的好处是更短的反馈循环。当开发者更快获得反馈时,团队花在等待 CI 上的时间就更少。快速处理评审意见、保持拉取请求推进、避免上下文切换都会变得更容易。这不会自动让团队变快,但它消除了每天拖慢团队的一个小摩擦点。

## 结论

该总结了。如果这篇文章让你跃跃欲试并想开始实践,以下是要注意的几个要点:

1. **衡量当前的构建时间。** 不要从配置开始,而要从基线开始。查看主分支上最近的几次构建,以及最近的几次拉取请求构建。试着理解[时间花在哪里](https://maarten.mulders.it/2024/03/measure-your-maven-build/):编译、测试、打包、分析工具、依赖检查,还是其他环节。没有这个基线,就很难判断缓存是否会有帮助。
2. **检查后续流水线步骤需要哪些文件。** 不要假设恢复最终产物就足够了。如果你使用 JaCoCo、Surefire 报告、SonarCloud、Dependency Check 或其他会检查构建输出的工具,请确保它们所需的文件要么被重新生成,要么从缓存中恢复。
3. **谨慎处理独立的 Maven 调用。** 如果一个 Maven 步骤构建项目,另一个 Maven 步骤分析项目,请记住第二个步骤不再拥有第一个步骤的 reactor 上下文。如果它需要你自己的模块,这些模块应当能在本地 Maven 仓库中获取。在我们的案例中,这意味着要运行 `install`,并配置 `install` 目标始终运行。
4. **把第一版视为需要调优的东西。** 这个扩展很强大,但每个项目都有自己的构建结构、插件和报告步骤。我们第一次并没有把所有事情都做对。有用的做法是:启用它,检查恢复了什么,修复缺失的输出,并显式标记那些应始终运行的目标。

回顾过去,我们发现构建缓存并不是一个可以轻松“打开”的开关。它需要我们逐步引入,同时了解流水线的行为。这样做让我们更好地洞察流水线,也更好地分离了关注点。最重要的是,我们获得了信心:我们正在做正确的事——无论是在如何构建方面(流水线、测试等),还是在构建什么方面(产品本身)。

## 参考与延伸阅读

- [Maven 构建缓存扩展](https://maven.apache.org/extensions/maven-build-cache-extension/)
- [xxHash](https://xxhash.com/)
- [构建缓存配置参考](https://maven.apache.org/extensions/maven-build-cache-extension/build-cache-config.html)
- [德意志银行最初宣布有意贡献构建缓存](https://lists.apache.org/thread/z26sm8wvcqq0rcb59z7ngjxlb9swcm7p)
- [德意志银行最初的合并请求](https://github.com/apache/maven/pull/526)
- [衡量你的 Maven 构建](https://maarten.mulders.it/2024/03/measure-your-maven-build/)
- [JVM 上可复现构建之路](https://maarten.mulders.it/2026/09/the-road-to-reproducible-builds-on-the-jvm/)## 参与讨论
[Maven ![](/today/faster-maven-builds-part-2/Apache_Maven_logo.svg.png)](/today/faster-maven-builds-part-2/)  
[![](/today/author/nicolas-frankel/nicolas-frankel.jpg)Nicolas Frankel](/today/author/nicolas-frankel/)2021年10月14日 3,368 次浏览

### [更快的 Maven 构建(第 2 部分):Docker 内部](/today/faster-maven-builds-part-2/)
[Maven](/today/category/maven/)[性能](/today/category/performance/)

[开发者工具 ![](/today/introducing-jenesis/jenesis-hero.jpg)](/today/introducing-jenesis/)  
[![](/today/author/rafael-winterhalter/rafael-winterhalter.jpg)Rafael Winterhalter](/today/author/rafael-winterhalter/)2026年10月8日 734 次浏览

### [推出 Jenesis:你早已写好构建脚本](/today/introducing-jenesis/)
[开发者工具](/today/category/developer-tools/)[工具](/today/category/tools/)[Maven](/today/category/maven/)

[Maven ![](/today/improving-mavens-dependencyanalyze-or-not/cover.jpg)](/today/improving-mavens-dependencyanalyze-or-not/)  
[![](/today/author/nicolas-frankel/nicolas-frankel.jpg)Nicolas Frankel](/today/author/nicolas-frankel/)2025年4月28日 3,155 次浏览

### [改进 Maven 的 dependency:analyze……改还是不改?](/today/improving-mavens-dependencyanalyze-or-not/)
[Maven](/today/category/maven/)[Spring](/today/category/spring/)

[Maven ![](/today/introduction-to-maven-toolchains/toolchain-minipoll-550x510.png)](/today/introduction-to-maven-toolchains/)  
[![](/today/author/mthmulders/mthmulders.jpg)Maarten Mulders](/today/author/mthmulders/)2022年1月25日 19,790 次浏览

### [Maven Toolchains 简介](/today/introduction-to-maven-toolchains/)
[Maven](/today/category/maven/)