2216 字
约 7 分钟
17
S06-A25 · 构建的依赖图:为什么"改了 A 却重编了整个仓库"

一个 40 模块的工程,改一行代码,"全量重编 12 分钟"。而 Maven/Gradle 都号称"增量构建"。查下来两件事:模块之间是"循环依赖的假象"(A 依赖 B,B 又通过测试依赖 A),以及**"共享的父 POM 打开了 clean"**——于是每次构建都从零开始。

增量构建的前提是"依赖图是干净的":无环、无"被顺手全清"。这个"不是工具问题"值得强调:换 Gradle 也好、开构建缓存也好,"有环"与"每次 clean"都会让它们"形同虚设"——**于是"优化工具"成了"绕过根因"的常见路线".
而这两件事都不是"工具的问题"。

1. "全量重编"的三种成因

成因 表现
构建脚本含 clean 每次都从零
依赖图有环(含测试/插件) 无法"只重编下游"
产物"无版本/时间戳" 无法判断"是否变化"

第一项最常见:mvn clean install 写进了发布脚本第一项的"传染"很广:"发布脚本"被"本地开发"照抄——**于是"本地也 clean",而"本地构建慢"被归因到"机器不行".
——于是"增量"从来没被启用过.

2. "依赖图"的三类边

边 是否需要"重编下游"
compile 依赖 需要
test 依赖 需要(测试代码变了)
provided/runtime 视情况

第二项的"识别"很机械:mvn dependency:tree -Dscope=test 能看到"测试依赖"——而"环"常常只在"test 范围"里出现,于是"编译期看着干净".
第二项是"环"的高发区:A 的测试依赖 B,B 的 main 依赖 A——于是"A → B → A"形成环;而"Maven 能容忍"(编译期不参与),
"增量构建"却不能**.

"环"要"能被画出来",否则"拆"无从下手:

// 三步"看依赖图"
1 mvn dependency:tree -Dscope=compile      # 编译期依赖
2 mvn dependency:tree -Dscope=test         # 测试依赖(环常在这里)
3 grep -r "<artifactId>" pom.xml           # 找"互相引用"的两个模块
// 判据:A 出现在 B 的树里、B 又出现在 A 的树里 → 环成立

3. "有环"的三种代价

代价 表现
无法增量 每次都"全量"
无法并行 调度器排不出"拓扑序"
无法"局部发布" 只能"整体升级"

第二项容易被忽略:并行构建要求"依赖图无环"第二项的"后果"是"构建时间不可预测":"有环"时调度器只能"猜一个顺序",而"猜错"就要"重跑"——**于是"同样代码,两次构建耗时不同".
——有环时"并行"退化成"串行",而且顺序不确定.

"移除 clean"要"分脚本",否则"发布"会"带脏产物":

// 两种脚本的分工
本地/开发:mvn -o install -DskipTests        (不 clean,靠增量)
发布/CI: mvn clean verify -Dmaven.repo.local=...  (干净,但要"远程缓存"兜底)
// 判据:本地"第二次构建"应当在"秒级",而不是"分钟级"

4. "拆环"的三种做法

做法 说明
把"共享类型"下沉到独立模块 最常见
用"接口 + 实现"分离 依赖"接口模块"而非"实现"
用"测试用具模块" 把 test-jar 挪到 test-support

第三项的"落地"要"两个模块":B-test-support(只放测试用具)+ B(主代码)——于是 A 依赖 support,B 依赖 support,而"support 不依赖任何人",环断了.
第三项是"最见效"的:
"A 的测试依赖 B"里,B 往往是"测试数据/工具"**——把它挪成 B-test-support,A 只依赖 support,环就断了.

"产物是否变化"要"按内容判",而不是"按时间":

<!-- 反例:每次都"新时间戳",下游认为"变了" -->
<version>1.0-SNAPSHOT</version>
<!-- 正解:固定版本 + 内容哈希(或用"构建缓存"按输入哈希判定) -->
<version>1.0.3</version>
<!-- 判据:同一份源码两次构建,产物"哈希相同"才算"可复用" -->

5. "增量是否生效"的三个观察

观察 判据
第二次构建耗时 应"显著短于"第一次
输出日志 出现 "up-to-date"/"skipped"
只有下游模块编译 上游"未被编译"

第一项最直观:"第二次构建 12 分钟"就说明"增量没生效"第一项的"对照"要"同一台机器、同一份代码":"清空 target 后构建一次"与"紧接着再构建一次"——**两个数一样,就是"没有增量".
——而它常被"以为是机器慢".

"并行"要先"限并发",否则它会"掩盖"依赖图问题:

// 三步"并行调参"
1 先串行:-T 1 → 记录"总耗时"(基线)
2 再并行:-T 4C → 若"不比基线快",说明"有环或资源争用"
3 若"更快但偶发失败" → 多半是"模块间有隐式顺序依赖"(共享文件/端口)
// 判据:并行"稳定更快",才算"图是干净的"

6. 排错:五种"增量构建问题"

现象 成因 处理方向
每次都全量 脚本含 clean 拆"发布"与"本地"
改了 A 编全仓库 依赖图有环 拆环
上游没改也重编 时间戳/快照版本 用哈希/固定版本
并行无效 有环/资源冲突 修图/限并发
换机器后全量 无"构建缓存" 开缓存

"加机器"与"修图"的性价比要算清,因为前者"看起来更快":

// 两种"提速"的账
加机器:CI 从 4 核到 16 核 → 12 分钟变 4 分钟(成本:每月 X 元)
修图:拆环 + 去 clean + 缓存 → 12 分钟变 2 分钟(成本:一次性 2 人日)
// 判据:一次性投入 < 3 个月的机器差价 → 先修图

7. 边界:什么情况"全量"更省事

场景 为什么 做法
小工程(< 5 模块) 全量只要 1 分钟 全量
发布到生产(要干净) 增量可能"带脏产物" 全量
依赖图"一时拆不开" 改造成本高 全量 + 加机器
CI 有"干净环境" 无本地缓存 全量 + 远程缓存

最后一条与"CI"有关的边界:"CI 的干净环境"不等于"必须全量":开"远程构建缓存",把"上游模块的产物"缓存住——于是"干净环境"也能"只编下游",而"缓存命中率"成了新的可观测指标.
判断口径一句话:
"第二次构建"与"第一次"差多少**。几乎一样,就说明"增量完全没生效"——此时"修图/去 clean"的收益"远大于加机器";差得明显(如 12 分钟 vs 2 分钟),说明"增量在工作",剩下的优化"收益有限".

最后一个"团队"现实:"拆环"是"架构动作",而"构建慢"被当成"运维问题"——于是"修的人"往往没有"改模块结构"的权限,只能"加机器/开缓存"。而"拆环"的最小切口其实是"测试用具模块":它不涉及业务代码,只要"挪文件 + 改依赖声明",一天能做完,收益是"整个仓库的增量构建"。

而"一天"这个量级,正是"运维提出、架构同意"最容易发生的规模:它不要求"重构",只要求"把一个模块挪出去"——之后"增量构建"才第一次真正工作,而"构建慢"的抱怨会一次性消失.

而"一次性消失"这件事,会让"加机器"的预算自然腾给别处。

这就是"修根因"与"堆资源"的差别:前者一次,后者每月。

而"一次"这个成本,通常被高估了。

它往往只需要"一个人两天",而不是"一个季度"。

8. 自测

  1. "全量重编"的三种成因里,哪一种最常见?它为什么让"增量"从未启用?
  2. 三类"依赖边"里,哪一类是"环"的高发区?为什么 Maven 能容忍而增量构建不能?
  3. "有环"的三种代价里,哪一项最容易被忽略?
  4. "拆环"三种做法里,哪一种最见效?它把什么挪走了?
  5. "增量是否生效"最简单的观察是什么?它常被误认为什么?
  6. 判断"该不该优化增量"的那个问题是什么?
S06-A25 · 构建的依赖图:为什么"改了 A 却重编了整个仓库"
https://www.clxhxhhr.top/posts/6689/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。