2270 字
约 7 分钟
13
S06-X07 · Maven 依赖冲突与传递性治理

编译、单测、打包全绿,上线第一次调用就抛 NoSuchMethodError,堆栈指向的 API 在源码里找不到调用点——根因通常在依赖树。

Maven 的解析是"先建图、再剪枝":同一个 groupId:artifactId 在图中出现多次只保留一个版本,其余丢弃。决定保留谁的是最短路径优先与先声明优先,后者意味着 pom 里两行 <dependency> 的顺序能改变生产环境的运行结果。


1. 两条调解规则

规则 触发条件 判定 后果
最短路径优先 候选版本深度不同 深度小的胜 直接依赖 > 二级传递 > 三级传递
先声明优先 深度相同 依赖树构建顺序中先出现的胜 交换 pom 中两段声明即可换版本
版本锁定(dependencyManagement) 任意深度 管理版本强制生效 优先级高于前两条,但不覆盖已显式声明版本的直接依赖

先把 c3p0 声明在前、还是把 legacy-dao 声明在前,解析结果不同:

<!-- A:c3p0 先声明,胜出 0.9.5.5 -->
<dependency>
  <groupId>com.mchange</groupId><artifactId>c3p0</artifactId><version>0.9.5.5</version>
</dependency>
<dependency>
  <groupId>com.example</groupId><artifactId>legacy-dao</artifactId><version>2.1.0</version>
  <!-- 内部依赖 c3p0:0.9.1.2 -->
</dependency>

把这两段声明对调,legacy-dao 先入图,它内部要求的 c3p0:0.9.1.2 先占位,低版本胜出。代码一行未改,classpath 已换——这是不可维护的隐式契约,IDE 排序就能改回去。正确做法:用 dependencyManagement 或 exclusions 显式钉住。


2. dependency:tree 的读法

$ mvn -q dependency:tree -Dverbose -Dincludes=org.springframework
[INFO] com.example:clarity-web:jar:1.0.0
[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:3.3.4:compile
[INFO] |  \- org.springframework:spring-webmvc:jar:6.1.13:compile
[INFO] |     \- org.springframework:spring-web:jar:6.1.13:compile
[INFO] +- com.example:legacy-client:jar:2.1.0:compile
[INFO] |  \- (org.springframework:spring-core:jar:5.3.39:compile - omitted for conflict with 6.1.13)
记号 含义 读法
+- 后面还有同级兄弟 该层展开未结束
\- 父节点的最后一个孩子 分支到此为止
omitted for conflict with 6.1.13 本版本被 6.1.13 取代 查 6.1.13 从哪条路径进来
omitted for duplicate 同一 GAV 已出现过 版本一致,通常无害
(version managed from 5.3.39) 版本被管理段改写 源头在 dependencyManagement
:compile / :runtime / :test 传递后的实际 scope 决定是否进最终 lib/

三个要点:加 -Dverbose,否则 conflict/duplicate 标记不打印;加 -Dincludes,全量树几百节点没法读;树是解析后的视图,被丢弃的版本只有 verbose 才看得见;要留证再加 -DoutputFile=/tmp/tree.txt。


3. dependencyManagement 的边界

它是"版本声明池",不是"依赖清单"。生效范围:

场景 是否生效 原因
传递依赖的版本 生效 锁定在剪枝之前应用,优先于两条调解规则
直接依赖未写 <version> 生效,补上版本 主要用途
直接依赖已写 <version> 不生效 显式版本优先
同 GA 在管理段出现两次 取第一次 与依赖顺序规则一致
管理段 GA 拼写不符 静默不生效 无匹配项,无任何警告

"写了却没生效"绝大多数是最后一行:管理段写 spring-boot-starter-web,树里却是 spring-web,两个 artifact,碰不到。

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>3.3.4</version>
      <type>pom</type><scope>import</scope>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.17.2</version>
    </dependency>
  </dependencies>
</dependencyManagement>

同一 GA 取第一次出现,所以单条锁定必须排在 BOM 之后。更稳的做法是改 BOM 暴露的属性(如 jackson-bom.version)——BOM 内部引用属性而非字面量,改一处对所有构件生效。


4. exclusions 与排除过度

exclusions 是唯一能从图上删除节点的手段(管理段只改版本),代价是报错推迟到运行期。

<dependency>
  <groupId>com.example</groupId><artifactId>legacy-client</artifactId><version>2.1.0</version>
  <exclusions>
    <exclusion>
      <groupId>org.springframework</groupId><artifactId>spring-core</artifactId>
  </exclusions>
</dependency>
做法 效果 风险
exclusions 排除冲突版本 图变小、版本唯一 过度排除 → ClassNotFoundException
改为 provided 编译可见、运行期由容器提供 容器无同名 jar 时 NoClassDefFoundError
升级上游依赖 从根上换掉传递版本 需上游兼容,改动面大
管理段改版本 保留节点、换版本 二进制不兼容时 NoSuchMethodError

排除过度的典型症状:启动报 NoClassDefFoundError: org/apache/commons/logging/LogFactory,而 dependency:tree 干干净净——树只反映声明的图,不反映运行期需要的类。先确认该类是否由 JDK 或其他依赖提供(jcl-over-slf4j 就是顶替 commons-logging 的桥接包)。


5. scope 与 optional 改变传递性

直接依赖 scope 传递为 compile 传递为 runtime 传递为 test
compile 是 是 否
provided 否 否 否
runtime 否 是 否
test 否 否 否
optional=true 否 否 否

两个高频误判:provided 切断整条传递链,依赖 A 是 provided,A 依赖的 B 也不进 classpath,哪怕你的代码直接用了 B;optional 是单向下行,对声明方存在、对使用方不存在,适合"部分场景才需要"的扩展,反着想强制别人带上就是错的。


6. BOM 统一版本与 shade 重定位

方式 接管范围 适用
spring-boot-starter-parent 作 parent 依赖版本 + 插件配置 + 资源过滤 单体应用
spring-boot-dependencies 作 BOM 只接管依赖版本 多模块、已有 parent

自建 BOM 的最小结构:packaging=pom + dependencyManagement 列全 GA 与版本,其他工程 import 它。

版本冲突已无法靠换版本解决时(两个库硬依赖同名 API 的不兼容版本),最后一招是 shade 重定位:把其中一个库的包名整体改掉,两份共存。

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-shade-plugin</artifactId>
  <version>3.6.0</version>
  <executions><execution><phase>package</phase><goals><goal>shade</goal></goals>
    <configuration>
      <relocations><relocation>
        <pattern>com.google.common</pattern>
        <shadedPattern>com.example.shaded.guava</shadedPattern>
      </relocation></relocations>
      <filters><filter><artifact>*:*</artifact><excludes>
        <exclude>META-INF/*.SF</exclude>
      </excludes></filter></filters>
    </configuration>
  </execution></executions>
</plugin>

边界很硬:重定位改的是字节码常量池引用,反射按字符串加载的类、ServiceLoader 配置文件、JNI 库路径都不会被改写,需配合 ServicesResourceTransformer 逐项处理。


7. 冲突的三张面孔与定位手段

报错 语义 定位手段
NoSuchMethodError 类在、方法签名不存在 dependency:tree -Dverbose -Dincludes=GA 找胜出版本;javap 看调用点签名
NoClassDefFoundError 编译期有、运行期缺 查 lib/ 是否有该 jar;-verbose:class 看加载来源
LinkageError / IncompatibleClassChangeError 同类被两个加载器加载,或形态不匹配 -verbose:class 搜同一类名出现两次
# 打印每个类的来源,确认到底加载了哪个 jar 里的版本
$ java -verbose:class -jar clarity-web.jar 2>&1 | grep -i "spring-core"
[0.312s][info][class,load] org.springframework.core.SpringVersion
  source: jar:file:/app/lib/spring-core-6.1.13.jar!/org/springframework/core/SpringVersion.class

读法是搜类名、看 source: 后缀。同一类在两行出现且指向不同 jar,问题不在调解而在打包(fat jar 混入 provided 的 jar,或容器自带 jar 与 lib/ 重复)。


8. 排错

现象 根因 处理
调顺序压住冲突,重构后又复现 先声明优先是隐式契约 用 dependencyManagement 钉版本
BOM 导入后版本没变 单条锁定排在 BOM 之前 调到 BOM 之后,或改 BOM 属性
启动缺类但树里没有 排除过度 / provided 断链 恢复节点或显式声明
同一个类出现两份 打包混入重复 jar 查 shade artifactSet 与 scope
dependency:analyze 报 unused 只被反射/SPI 使用 加 ignoredUnusedDeclaredDependencies 白名单

SNAPSHOT 的解析是"每天一次或 -U 时刷新元数据",同一坐标不同时间可能给出不同内容。release 依赖 SNAPSHOT 是构建不可重现的头号来源——树稳定,行为不稳定。


9. 边界

场景 更合适的做法 理由
两库硬依赖不兼容的同名 API 类加载器隔离或 shade 重定位 版本调解无法让两份不兼容实现共存
javax.* 与 jakarta.* 混用 整批迁移到 jakarta 命名空间 包名不同,不是版本问题
只有运行期才暴露的 NoSuchMethodError 集成测试 + -verbose:class 断言 依赖分析证明图,证明不了行为

Maven 能保证"图里只有一个版本",保证不了"这个版本行为兼容":前者靠 -Dverbose 与 dependencyManagement,后者靠测试和 -verbose:class。


10. 自测

  1. 同一 artifact 出现在深度 2 和深度 4,保留哪个?都在深度 3 呢?
  2. omitted for conflict with 6.1.13 与 omitted for duplicate 的区别?哪个需要处理?
  3. 管理段写了版本、直接依赖也写了 <version>,最终用哪个?
  4. 某依赖改成 provided 后,它的传递依赖还进 classpath 吗?
  5. 报 NoSuchMethodError,哪两条命令分别确认"树上保留谁"和"运行时加载谁"?
  6. 为什么靠调整声明顺序解决冲突不可维护?替代方案是什么?
S06-X07 · Maven 依赖冲突与传递性治理
https://www.clxhxhhr.top/posts/5843/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。