2433 字
约 8 分钟
16
S06-A19 · 依赖版本冲突:最后谁赢,为什么你说了不算

一个项目显式声明了 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. 自测

  1. 三种仲裁规则分别是什么?为什么"最短路径优先"在"多个直接依赖"时会失效?
  2. "版本最高优先"看起来更安全,它的反面风险是什么?
  3. 第四种冲突表现为什么最难查?它与前三种的区别是什么?
  4. 为什么 dependency:tree 普通模式看不到冲突的另一方?-verbose 解决了什么?
  5. BOM 与锁文件分别治理什么?为什么"锁文件"能防住"别人加库"?
  6. 判断"是否需要治理"的那个问题是什么?
S06-A19 · 依赖版本冲突:最后谁赢,为什么你说了不算
https://www.clxhxhhr.top/posts/6554/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。