一个项目显式声明了 guava:32.1,而某个中间件依赖 guava:19.0。构建时"最短路径优先"让直接依赖胜出,一切正常。半年后有人加了一个工具库,它直接依赖 guava:19.0——于是两个"直接依赖"同级,靠"声明顺序"决胜,而声明顺序在一次重构里被调换了,guava 悄悄降到了 19.0,运行时抛 NoSuchMethodError。
依赖冲突的麻烦不在"哪个赢",而在于仲裁规则是隐式的,而它在"依赖树变化"时会静默改变结果。这个触发方式的可怕之处在于它不在你的改动里:你改了三行业务代码,而"依赖降级"发生在合并别人的 PR 时,于是排查从"我改了什么"开始,方向就错了。
而这次变化通常由"别人加了一个库"触发。
1. 三种仲裁规则
| 规则 | 行为 |
|---|---|
| 最短路径优先 | 直接依赖 > 传递依赖 |
| 先声明优先 | 同级时看声明顺序 |
| 版本最高优先(Gradle 默认) | 取最高版本 |
第一项在 Maven 里是默认:**"我直接写的那个"胜过"别人传进来的那个"**第一项的"直觉"其实是"可控性":我以为我写了版本,就是我控制版本。而它只在"只有我一个直接依赖"的简单情况下成立。
——它符合直觉,但也有例外(多个直接依赖)。
第三项的动机是"避免降级导致的 NoSuchMethodError",而代价是"引入未测的升级"。所以它的本质是"用'可能的静默变化'换'必然的立刻报错'"。
第三项看起来更安全(总是取新),但它有个反面:"没测过的新版本"会自动进来,于是"依赖升级"这件事在无人察觉时发生了。
2. 冲突的四种表现
| 表现 | 类型 |
|---|---|
NoSuchMethodError |
运行时缺失方法(版本低) |
NoClassDefFoundError |
类不存在 |
AbstractMethodError |
接口实现不匹配 |
| 行为变了但不报错 | 同 API 不同实现 |
第四项最难查:两个版本 API 相同,行为不同(如日期处理、序列化默认),于是"功能莫名不对"而没有任何异常。第二项(NoClassDefFoundError)还有个"分包"版本:类被移到了另一个 artifact(如从 guava 移到了 failureaccess),于是"类不存在"而版本是对的。
前三种在"编译期不报、运行期报"这一点上一致:编译用的是 A 版本,运行拿到 B 版本,于是"编译通过但跑不起来"。
3. 五种定位手段
| 手段 | 效果 |
|---|---|
dependency:tree |
看到全树与冲突路径 |
mvn -Dverbose |
显示"被省略的版本" |
dependency:analyze |
发现"声明但未用/用了但未声明" |
运行时打印版本(-verbose:class) |
确认实际加载 |
| 构建锁文件(Gradle/Maven BOM) | 从源头固定 |
第三项(dependency:analyze)能抓另一类问题:"用了但没声明"——它现在能跑,是因为"别人传进来"了;一旦那个传递依赖被移除,编译就失败。
第二项最关键:普通 tree 只显示"选中的那个",-verbose 才显示"另一个被谁引入"。
第四项是"最终裁决":编译期与运行期可能不是同一份 jar第四项还有个"容器"版本:fat jar 里可能同时打进两个版本(不同包路径),而"哪个被加载"取决于类加载顺序——于是"同一个包,本地跑 A、线上跑 B"。
(尤其容器/插件机制),所以要确认"运行时到底加载了哪个"。
// 看冲突与被省略的版本(Maven)
mvn dependency:tree -Dverbose | grep -A2 guava
// 输出示例:
// +- com.foo:middleware:1.2
// | \- (com.google.guava:guava:jar:19.0:compile - omitted for conflict with 32.1)
// 运行时确认:实际加载的类来自哪个 jar
java -verbose:class -jar app.jar | grep -m1 "com.google.common.collect.ImmutableList"
"排除传递依赖"是最直觉的做法,也是最脆的:你排除的是"这个库传进来的那一条",而"它从另一个库传进来"时,排除就不生效了。
<!-- 反例:只排除了 A 传进来的 guava,B 仍会带进来 -->
<dependency>
<groupId>com.foo</groupId><artifactId>A</artifactId>
<exclusions>
<exclusion><groupId>com.google.guava</groupId><artifactId>guava</artifactId></exclusion>
</exclusions>
</dependency>
<!-- 正解:用 BOM 统一版本,而不是逐条排除 -->
4. 三种治理方式
| 方式 | 效果 |
|---|---|
BOM / 平台(dependencyManagement) |
统一版本,显式 |
| 排除传递依赖 | 精确但脆弱 |
| 锁文件(Gradle lockfile) | 完全可复现 |
第一项的隐含收益是"升级集中":升一个版本只需改 BOM 一处,而不是在十个模块里各改一遍(且容易漏)。
第一项最主流:用 BOM 声明"这一组库都用这个版本",于是子模块不需要各自写版本。它把"隐式仲裁"变成"显式声明"。
第三项最彻底:锁文件锁定"解析结果",于是"别人加库"不会改变已有版本——除非有人主动更新锁。
<!-- BOM:一处声明,全局生效 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>
</dependencies>
</dependencyManagement>
<!-- 收益:仲裁规则不再有机会"赢",因为只有一个版本 -->
5. "升级"与"降级"的不对称
| 方向 | 风险 |
|---|---|
| 升到更高版本 | 可能引入行为变化(未测) |
| 降到更低版本 | 可能缺方法/类(立刻报错) |
第二项通常在部署后立刻暴露(NoSuchMethodError),而第一项可能"跑得好好的,某条路径行为变了"。
所以"版本最高优先"的默认策略**偏向"升"**第二项(降级)的不对称还体现在"发现时间":降级导致的缺方法在启动或首个请求就暴露,而升版本导致的行为变化可能要等某条业务路径。前者是"立刻知道",后者是"以后才知道"。
,它把"立刻报错"换成了"可能的静默变化"——而这个交换未必划算。
"版本漂移"应当被当作变更来管理:每次依赖树差异都要能被看见,而不是等它引发故障。一个便宜的做法是"把依赖树快照进仓库,CI 比对差异"。
// 依赖树快照 + 差异(每次 PR 检查)
mvn dependency:tree -DoutputFile=deps.yaml -DappendOutput=false
git diff --exit-code deps.yaml || echo "依赖树变化,请确认"
// 收益:版本漂移从"静默"变成"PR 里可见"
6. 排错:五种"依赖问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
编译过、运行 NoSuchMethodError |
运行时版本更低 | -verbose:class 确认 |
| 加了一个库,旧功能坏了 | 仲裁结果被改变 | 加 BOM 固定 |
| 本地正常、CI 失败 | 本地仓库有旧 jar 残留 | 清本地缓存重试 |
| 单元测试过、集成失败 | 测试用替身吞掉了差异 | 集成用真实依赖 |
| 打包后类冲突 | 多个 jar 含同名类 | 排除冲突依赖 |
第三项的排查动作固定为"删本地缓存后重跑":如果一删就好,说明本地状态与解析结果不一致——而这往往意味着"本地用的版本与 CI 不同"。
第三项很常见:本地 ~/.m2 里残留旧版本,而 CI 从零解析,于是"本地能跑、CI 报错"。
第四项与"升级验证深度"是同一个问题:替身越多,越测不出真实差异。
最后一个"运行时"观测:启动时打印关键库的实际版本。它能把"依赖树说 32.1"与"运行时真的是 32.1"对齐——而这在"fat jar 双版本"场景里是唯一可靠的确认。
// 启动时打印(挑三个关键库)
log.info("libs guava={} jackson={} netty={}",
com.google.common.base.Preconditions.class.getPackage().getImplementationVersion(),
com.fasterxml.jackson.databind.ObjectMapper.class.getPackage().getImplementationVersion(),
io.netty.util.Version.ID);
// 对照依赖树:不一致即为"运行时与解析结果不符"
7. 边界:什么情况不必治理依赖冲突
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单模块、依赖很少 | 冲突概率低 | 用 tree 定期看一眼 |
| 已用成熟 BOM(如 Spring Boot) | 平台已统一 | 跟随平台 |
| 一次性脚本 | 不长期 | 不治理 |
| 内部工具(版本少) | 冲突可见 | 不治理 |
判断口径一句话:依赖树里"同一个 groupId:artifactId 出现多个版本"的情况多不多。最后一条与"排查成本"有关的边界:依赖冲突的排查成本与依赖树大小超线性相关。所以"依赖少的小服务"不必治理,而"几十个中间件的大服务"不治理就是给自己埋雷。
多(微服务、多中间件),必须用 BOM + 锁;少,定期看 tree 就够。
8. 自测
- 三种仲裁规则分别是什么?为什么"最短路径优先"在"多个直接依赖"时会失效?
- "版本最高优先"看起来更安全,它的反面风险是什么?
- 第四种冲突表现为什么最难查?它与前三种的区别是什么?
- 为什么
dependency:tree普通模式看不到冲突的另一方?-verbose解决了什么? - BOM 与锁文件分别治理什么?为什么"锁文件"能防住"别人加库"?
- 判断"是否需要治理"的那个问题是什么?