2875 字
约 9 分钟
22
S06-A06 · 构建系统选型:Maven 与 Gradle 的长期成本

构建越来越慢、没人敢动构建脚本——这两件事通常同时出现。一个项目的 pom.xml 或 build.gradle.kts 三五年没重构过,里面躺着"当年为修 CI 加上的"配置和 if 分支,谁也说不清删掉哪一行会让发布断掉。此时构建系统已不是工具,而是没人敢碰的单点。

选型常在比功能表:并行、语法、生态。真正决定三年后你还愿不愿意维护它的,是另外四件事:依赖解析的确定性、任务模型的表达力、增量的可信度、插件生态的存活率。此处只谈这四件事的收益边界与账单到期时点。按 Maven 3.9 / Gradle 8.x 行为核对。


1. 先把能力拆到四个格子

能力 含义 失效症状 最难搬的部分
依赖解析 版本调解、冲突消解、仓库策略 "本地能跑、CI 编译不过" 调解规则(最近优先 vs 最新优先)
任务编排 任务图、并行度、生命周期 顺序不定,产物时好时坏 生命周期语义(Maven 有、Gradle 无)
增量 输入输出指纹、跳过已最新 改了没重编、全量重跑 自定义任务输入声明
插件生态 编译、打包、检查、发布 缺能力只能自己写 公司内部私有插件
# 先量化再决策:把一次构建切成配置、依赖、执行三段
$ /usr/bin/time -v mvn -o -q package 2>&1 | tail -5   # 离线,去掉网络变量
$ ./gradlew --profile build                            # 报告里分配置时间/执行时间
$ ./gradlew --stop && ./gradlew clean build --profile   # 冷启动对照

最容易被忽略的是配置时间:任务开跑之前,光解析构建脚本就可能吃掉总时长一半。这段时间不产出任何编译结果,却随模块数线性增长,是 Gradle 项目"越加模块越慢"的主因。


2. Maven:约定的收益边界

Maven 的核心资产不是 XML,而是生命周期:validate → compile → test → package → verify → install → deploy 是全局共识,插件挂在确定阶段上。被低估的好处是——脚本可以互不相似,流程彼此可比:看陌生项目不必读 pom.xml 就知道 mvn verify 会做什么。

<!-- 父 POM 只做两件事:锁版本、锁插件版本 -->
<dependencyManagement><dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-dependencies</artifactId>
    <version>3.3.6</version><type>pom</type><scope>import</scope>
  </dependency>
</dependencies></dependencyManagement>
<!-- 插件版本必须显式锁,否则随 Maven 默认值漂移 -->
<plugin><artifactId>maven-compiler-plugin</artifactId>
  <version>3.13.0</version><configuration><release>17</release></configuration></plugin>
约定的收益 到期的代价
生命周期统一,CI 脚本可模板化 非标准流程只能靠 exec 插件硬塞,塞完没人看得懂
单继承:一个模块一个 parent 想同时继承两条基线,只能靠 import BOM
声明式,脚本无逻辑 需要逻辑只能用 profile,且不可组合
调解规则简单(最近优先) 结果与声明位置强相关,靠 exclusion 修冲突越修越乱

收益边界很清楚:流程稳定、模块数中等、发布动作标准的项目它最省心。痛点两处:需要条件化逻辑时(多平台产物、可选模块),以及模块过百后串行构建的墙钟时间。


3. Gradle:灵活性从哪里来,账单在哪到期

Gradle 把构建变成一段在 JVM 上执行的程序,任务图是自己组装的 DAG。这是能力,也是负债源头。

灵活性 直接后果 账单到期形式
任务图任意组装 能表达"只在这种情况打包" 凭命令名预测不了会跑什么
脚本即代码 可抽函数、循环、复用 无测试、无评审
Kotlin DSL 编译期检查与补全 改一行脚本要等脚本编译
配置缓存 大项目提速明显 配置期碰 Project 即退化
插件可改写任务 补生态缺口容易 升级从"报错"变"静默不同"
// buildSrc/src/main/kotlin/clarity.java-conventions.gradle.kts
plugins { java; id("org.springframework.boot") }
java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }
tasks.withType<Test>().configureEach {
    useJUnitPlatform()
    systemProperty("spring.profiles.active", "test")  // 唯一的业务判断,必须写清原因
}

分界线是"构建逻辑是否被当成代码管"。放进 buildSrc、有评审、有 TestKit 用例,灵活性是资产;散在各 build.gradle.kts 里就是复利机器——每处 if (project.hasProperty(...)) 都是只在某台机器上成立的分支。


4. 增量与缓存:命中率取决于五个变量

"改了没重编"不是 bug,而是输入声明不完整的必然结果。增量、构建缓存、配置缓存共用一套指纹:任务声明读什么写什么,才能判断可否跳过。Maven 侧粒度粗得多,只跳过"产物已存在"的模块,跳不过"输入没变"的任务——差别不是速度,是指纹粒度。

变量 影响 自查
输入声明完整性 漏一个系统属性或环境变量 → 该重跑时跳过 看 @Input 是否齐全
输出声明准确性 写到声明目录外 → 不命中或产物缺失 看 --info 的缓存键
时钟与 mtime 时间戳回退(git checkout、镜像)→ 假失效 内容哈希而非时间
绝对路径 键含绝对路径 → 换机器不命中 本地与 CI 同挂载路径
环境差异 JDK、OS、locale 进键 → 跨平台失效 只在同平台共享远端缓存
$ ./gradlew build --info | grep -E 'FROM-CACHE|not up-to-date|Caching disabled'
$ ./gradlew clean build --no-build-cache   # 对照组:确认真实耗时
$ ./gradlew --stop                         # 冷热分离,别用常驻守护进程比

"第一次很快、后来变慢"是冷热误读:"第一次"可能是空工程。判断缓存是否有用必须固定对照组——同一提交、同一机器,清空与不清空各跑一次看差值。


5. 多模块与版本统一

方式 机制 适合 代价
父 POM 继承 <parent> 单继承 模块数 < 30、逻辑统一 只能一个父;父改全公司重跑
BOM(import) 只导版本约束,不继承构建配置 多产品线共享版本基线 只锁版本,不锁插件与编译参数
Convention plugin 构建逻辑打成插件按需 apply 模块多、逻辑有分支 插件本身要维护、测试、发版
# gradle/libs.versions.toml —— 单一事实来源,IDE 有补全
[versions]
springBoot = "3.3.6"
[libraries]
starterWeb = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "springBoot" }
[plugins]
boot = { id = "org.springframework.boot", version.ref = "springBoot" }

判断只有一条:版本号出现在几个地方。出现在三个以上 pom.xml 里必然漂移,之后一次"顺手升级"就变成跨模块排查。


6. 速度优化的顺序:先测量

顺序是:配置阶段占比 → 是否全量重跑 → 依赖下载 → 测试耗时 → 并行度 → 编译器与 JVM 参数,越靠前收益越大,最后一项只是边际。判据来自实测拆分:配置占一半的工程,调堆毫无意义。

顺序错了的代价是把优化变成配置堆积:并行、缓存、堆一起加,最后没人知道哪项真起作用,出问题也回退不了。基准要跑多轮取中位:hyperfine --runs 3 './gradlew assemble --offline'。


7. 构建脚本也是生产代码

五条最低要求:进评审(与代码同权)、可测试(Gradle 用 TestKit,Maven 用 maven-invoker-plugin)、拆分(逻辑进 plugin)、写原因(每处分支注明触发场景与失效条件)、定版本(wrapper 固定工具版本)。缺哪条,三年后都以"只有一个人能发版"收账。


8. 迁移触发条件与回退成本

正当理由只有三类:配置时间已成瓶颈、需要表达力做条件化产物、需要细粒度增量与远端缓存。其余(语法短、社区热、想学)不构成依据。

触发条件 收益 回退成本
配置时间占比高 高(配置缓存) 中:翻译不可逆,须留旧 pom.xml
需要条件化产物 高 低:Maven 本就无此能力
需要远端缓存 中高 中:键设计与 CI 改造
只想统一风格 低 高:纯翻译工作
内部插件只有 Maven 版 负 极高:自研或放弃能力

回退成本的关键变量是是否双语并存。保留可用的 pom.xml 直到 Gradle 侧跑满两个发布周期,是唯一现实的保险;删掉之后,回退从"改回去"变成"重建发布流程"。


9. 排错:五种典型现象

现象 常见原因 处理方向
本地过、CI 失败 依赖来自本地仓库或 SNAPSHOT 固定版本、CI 干净仓库
改了代码没重编 输入声明漏项、mtime 回退、键过粗 --info 看跳过原因,关缓存
构建忽快忽慢 守护进程冷热、机器争抢 固定 worker 数,记中位
升级后行为变了 默认值变更、废弃 API 静默降级 升小版本、读 changelog、对照构建
只有一个人能发版 逻辑含本机路径、环境变量、手工步 变量搬进脚本并加注释与测试

10. 边界:这些情况不要折腾

场景 判断 理由
单模块小项目 留在 Maven 或直接脚本 总时长十秒级,改造不划算
团队只有一两人 不引入 convention plugin 维护成本摊到人头上
构建本来就是十秒级 不测、不缓存、不并行 测量本身比节省更耗时
发布流程还在变 先定流程再定工具 固化变动中的流程=双重返工
内部插件生态单一 跟随生态 缺插件=自研=长期维护

一句话:Maven 用一致性换表达力,Gradle 用表达力换一致性成本。选哪个不是技术判断,而是"你更怕哪一种失控"。


11. 自测

  1. 多模块项目构建慢,你怎么在不改任何配置的前提下判断瓶颈在配置阶段还是执行阶段?
  2. Maven 的生命周期给你的最大收益和最大限制,各是什么?
  3. Gradle 的"脚本即代码"在什么条件下是资产、什么条件下是负债?
  4. 缓存开着却不命中,你会先查哪三个变量?
  5. 有人提议从 Maven 迁到 Gradle,你要他先拿出哪两项证据?
  6. 什么情况下"构建脚本进评审"不值得推行?
S06-A06 · 构建系统选型:Maven 与 Gradle 的长期成本
https://www.clxhxhhr.top/posts/6180/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。