一个 40 模块的工程开了 -T 4C(四倍核数并行),构建时间从 12 分钟变成 11 分钟。团队以为是"并行没效果",其实是三个模块"卡在同一个共享资源":同一个本地仓库锁、同一个端口、以及"后置的聚合模块"必须等全部完成。
模块并行的收益,取决于三件事:依赖图能不能排出宽度、有没有"全局串行点"、以及单个模块的耗时是否均匀.
"并行"这件事有个"会计口径"值得先建立:"总耗时 ≈ 关键路径 + 排队时间"——而"加核"只能压"排队时间",压不动"关键路径"。于是"12 分钟变 11 分钟"这类结果,其实在"提示你":你的时间已经"几乎全在关键路径上",而此时"任何"关于并行的调参"都只是"小数点后的优化"。
1. "并行度"实际由什么决定
| 因素 | 影响 |
|---|---|
| 依赖图宽度(同时可跑的模块数) | 决定"能不能并行" |
| 全局串行点(锁/端口/聚合模块) | 决定"并行了多少" |
| 最慢模块(关键路径) | 决定"下限" |
第三项是"硬下限":"一个模块要 4 分钟"时,整条链路"至少 4 分钟"——**并行只能把"其余 36 分钟"压到"4 分钟", 不可能更低.
"关键路径"可以"用日志时间戳算",而不必"读依赖图":
// 三步"算关键路径"(构建日志里就有)
1 每个模块记"开始/结束时间"(Maven/Gradle 都有时间戳)
2 按"结束时间"倒序,取"最后完成的 3 个"
3 看"它们之间是否互相依赖"——是,就是"链";否,就是"并行度不够"
// 判据:这几项"总耗时"占整体的比例 = 你的"收益上限"
2. "三个全局串行点"
| 串行点 | 表现 |
|---|---|
| 共享仓库/缓存锁 | 越并行越"排队" |
| 固定端口(测试容器) | 第二个实例"起不来" |
聚合模块(-am/install) |
尾部"全等" |
第一项最隐蔽:"本地仓库"在并发写时会"加锁"——**于是"4 个线程"实际"排队装依赖".
"共享仓库锁"这条最"反直觉",因为"它看起来是读操作":
// 三种"并发装依赖"的冲突
1 两个模块"同时下载同一个依赖到本地仓库"→ 加锁,排队
2 一个模块"install 到本地仓库"、另一个"读"→ 读到"半成品"
3 并发"写同一个 pom/metadata"→ 文件损坏(偶发)
// 判据:并行后"下载阶段耗时反而上升" → 就是仓库锁
"聚合模块"是"结构性"的串行点,而它"常常被当成"正常结构":**"一个 -am/install 的父工程"最后"把全部模块"聚合起来"——于是"前面 39 个模块再并行,最后都要"等这个聚合"。而它的耗时"往往只有十几秒",却"把前面的并行收益"卡在"尾巴"上——解法是"把聚合拆成"独立的后置步骤",或"用 -DskipTests 让它更轻"。
3. "关键路径"怎么找
| 方法 | 说明 |
|---|---|
| 看"总耗时最长的模块" | 最直接 |
| 看"依赖链最长的那条" | 结构性 |
| 看"最后完成的三个模块" | 实操(日志时间戳) |
第三项是"最省事"的:"每个模块的开始/结束时间"排序,末尾那几个"就是关键路径"——而"给它们减负"比"给整体加核"有效.
"拆最慢模块"是最有效的动作,而它"不需要动依赖图":
// 三步"拆"(按"可独立"的部分切)
1 找到"它为什么慢":代码生成?聚合?测试?
2 把"生成/聚合"拆成"独立模块"(独立输入输出)
3 让"原模块"只依赖"拆出来的产物"
// 判据:拆完后"最慢模块"占比 < 30%,并行才有意义
4. "均匀度"的三种影响
| 情形 | 结果 |
|---|---|
| 均匀(都 30s) | 并行接近线性 |
| 长尾(一个 4 分钟) | 并行"被一个拖住" |
| 双峰(两个都很长) | 取决于"能否同时跑" |
第二项在"真实工程"里最常见:"代码生成模块"或"聚合模块"天然重——于是"拆它"比"加核"有效.
"隐藏依赖"在"并行"时才暴露,而它有两个"高发位置":
// 两处"隐藏依赖"(串行时"恰好没事")
1 输出目录:多个模块写 target/generated-sources(互相覆盖)
2 端口/容器:测试用固定端口(第二个实例 bind 失败)
// 判据:并发跑三次,若有"偶发失败" → 先查这两处
"并发跑三次"这个自检很值:"偶发失败"比"稳定失败"更难定位,而"并发"恰好是"偶发的放大器"——"三次里失败一次"就说明"存在共享资源冲突",此时"加核"只会"让失败更频繁"。而"并行度调小到'不冲突'"这种"绕过去"的做法,是"把并行当成'运气'",而不是"基础设施"。
5. "并行的三个前提"
| 前提 | 检查 |
|---|---|
| 模块间"无隐藏依赖"(文件/端口) | 看"并发失败日志" |
| 每个模块"可独立构建" | 单模块构建一次 |
| 输出"不写同一目录" | 查"产物路径" |
第一项是"并行的真正门槛":"两个模块都写 target/generated"——**于是"并行时互相覆盖",而"串行时恰好没事".
"并行度"不是"越大越好",它有"两个上限":
// 两个"上限"
1 依赖图宽度:同时可跑的模块数(可能只有 3)
2 机器核数:`-T 4C` 在"2 核 CI"上只会"互相抢"
// 判据:并行度 = min(宽度, 核数) —— 超过"宽度"只是"多排队"
6. 排错:五种"并行无效"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 加核没变快 | 关键路径 | 拆"最慢模块" |
| 偶发"文件被占用" | 共享输出目录 | 改"独立路径" |
| 依赖"重复下载" | 仓库锁 | 预下载/镜像 |
| 测试"端口冲突" | 固定端口 | 随机端口 |
| 尾部"全等" | 聚合模块 | 拆聚合/延后 |
7. 边界:什么情况"串行更快"
| 场景 | 为什么 | 做法 |
|---|---|---|
| 模块很少(< 5) | 并行开销占比高 | 串行 |
| 单模块"占 80% 时间" | 并行只压 20% | 先拆模块 |
| 强"共享状态"(IDE/端口/锁) | 并行=排队 | 串行 |
| CI 机器"核少"(2 核) | 并行度=1 | 串行 |
判断口径一句话:"最慢的那个模块,占了多少时间"。占 60% 以上,"并行"的收益上限就是 40%——此时该"拆它",而不是"加核";占 20% 以下,并行才真正"值得调".
8. 自测
- 模块并行的收益由哪三件事决定?为什么"最慢模块"是硬下限?
- 三个"全局串行点"分别是什么?哪一个"越并行越排队"?
- "看最后完成的三个模块"为什么"最省事"?它对应"关键路径"的哪一层?
- "双峰分布"为什么"取决于能否同时跑"?
- "两个模块都写
target/generated"会造成什么?为什么"串行时没事"? - 判断"该并行还是该拆模块"的那个问题是什么?
补一句"决策"经验:"构建时间"的优化顺序应当是"拆关键路径 → 去串行点 → 提并行度 → 加机器"——而现实里"顺序几乎总是反的":先加机器,因为"加机器"只需要"改一个数字"。而"改一个数字"的收益,往往"连一次拆模块的十分之一"都不到;但"拆模块"要"改代码结构",于是"没人愿意先做"。
而"先做难的那一步"这件事,是"构建优化"里唯一"真能省时间"的部分。
而"真能省时间"的判断标准很朴素:"改完之后,最慢模块的占比降下来了没有"。
否则"加了多少核"都只是"安慰剂"。
而"安慰剂"在 CI 账单上是看得见的。
9. 一句话总结
并行的上限由"关键路径"决定,而不由"核数"决定:最慢模块占 60% 时,加核最多只能拿到剩下的 40%。所以"并行没效果"的正确诊断顺序是:先量"最慢模块占比" → 再看"全局串行点" → 最后才看"并行度";而"拆最慢的那个模块",几乎总比"再多给两核"有效。