《Spring Boot 实战》2.1 依赖冲突排查

本节用图书借阅服务 book-loan 的五模块工程讲透 Maven 依赖冲突排查:从 NoSuchMethodError 等症状反查依赖树,读懂 dependency:tree 的 omitted 与 conflict 标记,掌握最短路径优先与先声明优先两条调解规则,并给出 exclusions、dependencyManagement 与 dependency:analyze 的用法。

本节目标:学会把「运行时才炸」的依赖冲突,还原成一条可读的依赖树路径;掌握 mvn dependency:tree 的标记含义、Maven 的两条版本调解规则,以及 <exclusions>、dependencyManagement、dependency:analyze 三种修复手段各自的适用边界。
适用版本:Spring Boot 4.1.x(Java 21)

2.1 依赖冲突排查

入门卷讲过 pom.xml 的骨架和 starter 怎么读,本节解决的是它留下的那个坑:依赖声明对了、编译过了、启动也没报错,但一调某个接口就抛 NoSuchMethodError。这类问题不来自你的代码,而来自 Maven 在背后替你「选」了一个你没声明的版本。

本节统一用书中的示例工程「图书借阅管理服务」(下称 book-loan),它是一个五模块的 Maven 工程,领域模型只有三个实体:Book(图书)、Member(读者)、Loan(借阅记录)。后面两节(2.2 版本对齐、2.3 构建加速)都围绕同一套 POM 展开。

先看症状:三种运行时异常都指向依赖树

依赖冲突的报错信息几乎不会提「依赖」两个字,它只告诉你某个类出问题了。先把症状归成三类,再看它们各自指向什么。

运行时异常字面含义首要怀疑
NoSuchMethodError编译期存在的类/方法,运行期那份 jar 里没有版本被「调解」成了更低的版本
ClassNotFoundException / NoClassDefFoundError类整个不在 classpath 上被 <exclusions> 误排、或 scope 不对
AbstractMethodError / IncompatibleClassChangeError接口与实现来自不同版本框架升级后新旧版本混在一条 classpath 上

一段真实的失败长这样:

java.lang.NoSuchMethodError: 'java.util.stream.Collector com.google.common.collect.ImmutableList.toImmutableList()'
    at com.birdor.bookloan.application.LoanQueryService.byMember(LoanQueryService.java:41)
    at com.birdor.bookloan.web.LoanController.listByMember(LoanController.java:58)

LoanQueryService 里写了 stream.collect(ImmutableList.toImmutableList()),IDE 里能跳转、编译也通过,说明编译期那份 Guava 有这个静态方法;运行时却没有。toImmutableList() 是 Guava 21.0 才引入的 API,所以运行时用的必然是更老的版本。问题不在代码,在依赖树。

book-loan 的模块结构

先明确工程长什么样,因为「冲突」是多个模块的依赖在运行期合并后才出现的。

book-loan/
├── pom.xml                     book-loan-parent(packaging=pom,只管版本与插件)
├── book-loan-domain/           Book / Member / Loan 实体与领域接口
├── book-loan-application/      LoanQueryService 等用例编排
├── book-loan-infrastructure/   JPA 仓储实现
├── book-loan-web/              REST 控制器
└── book-loan-boot/             启动类 + spring-boot-maven-plugin 打包

关键认知:每个模块单独看都是「版本正确」的,冲突发生在合并后的运行期 classpath 上。所以定位冲突的第一步不是改 POM,而是问「这个坐标是被哪个模块、哪条路径拉进来的」。book-loan-application 依赖了 book-loan-domain,同时又引了两个内部 SDK:com.birdor:isbn-metadata-sdk(ISBN 元数据)和 com.birdor:loan-report-sdk(借阅报表)。冲突就藏在后两者里。

读懂 mvn dependency:tree

先只打印出问题那个模块的依赖树:

mvn -pl book-loan-application dependency:tree

-pl(--projects)把输出限定在指定模块,避免五棵树的噪声。默认输出如下(示例输出,版本号随你实际依赖变化):

[INFO] --- maven-dependency-plugin:3.8.1:tree (default-cli) @ book-loan-application ---
[INFO] com.birdor.bookloan:book-loan-application:jar:1.0.0-SNAPSHOT
[INFO] +- org.springframework.boot:spring-boot-starter-webmvc:jar:4.1.1:compile
[INFO] |  +- org.springframework.boot:spring-boot-starter:jar:4.1.1:compile
[INFO] |  \- org.springframework:spring-webmvc:jar:7.0.9:compile
[INFO] +- com.birdor:isbn-metadata-sdk:jar:2.3.0:compile
[INFO] |  \- com.google.guava:guava:jar:19.0:compile
[INFO] +- com.birdor:loan-report-sdk:jar:1.4.0:compile
[INFO] \- org.springframework.boot:spring-boot-starter-test:jar:4.1.1:test

每行的格式是 groupId:artifactId:packaging:version:scope,前面的 +- / \- 和缩进表示层级(深度)。

注意 com.birdor:loan-report-sdk 这一行下面什么都没有。它其实也传递依赖了 Guava,但默认输出把它藏起来了——这正是最危险的地方:你照着默认输出排查,会得出「只有一个 Guava,版本是 19.0」的结论,而看不到冲突本身。

加 -Dverbose 才会把被剪掉的分支打出来:

mvn -pl book-loan-application dependency:tree -Dverbose
[INFO] +- com.birdor:isbn-metadata-sdk:jar:2.3.0:compile
[INFO] |  \- com.google.guava:guava:jar:19.0:compile
[INFO] +- com.birdor:loan-report-sdk:jar:1.4.0:compile
[INFO] |  \- (com.google.guava:guava:jar:32.0.1-jre:compile - omitted for conflict with 19.0)

结论:排查冲突时一律加 -Dverbose,默认输出会掩盖问题。 常用标记的含义如下:

标记含义
- omitted for duplicate同一坐标已在本模块更早位置出现过,Maven 剪掉这一份
- omitted for conflict with X与已选定的版本 X 冲突,被调解剪枝(只有 -Dverbose 显示)
(version managed from A by B)版本被 <dependencyManagement> 从 A 改成了 B
(optional)该依赖是 optional,不会向下传递

两条调解规则

Maven 遇到同一个坐标有多个版本时不会报错,它会静默「选一个」。规则只有两条,按顺序判断,理解这两条就能预判结果。

规则一:最短路径优先(nearest definition wins)

「路径长度」按从当前模块数起的节点数算。

book-loan-application
+- com.google.guava:guava:32.0.1-jre        <- 直接声明,深度 1
\- com.birdor:isbn-metadata-sdk:2.3.0
   \- com.google.guava:guava:19.0           <- 传递依赖,深度 2

深度 1 战胜深度 2,最终选 32.0.1-jre。这条规则解释了为什么「直接声明一个依赖」往往能压住冲突——它把版本提到了最短路径上。

规则二:同深度先声明优先(first declaration wins)

两条路径深度相同,比的是在 POM 里谁写在前面:

book-loan-application
+- com.birdor:isbn-metadata-sdk:2.3.0       <- 先声明
|  \- com.google.guava:guava:19.0           <- 深度 2
\- com.birdor:loan-report-sdk:1.4.0         <- 后声明
   \- com.google.guava:guava:32.0.1-jre     <- 深度 2,被剪枝

两个候选都在深度 2,isbn-metadata-sdk 写在前面,于是 19.0 胜出,loan-report-sdk 里的 32.0.1-jre 被剪掉。运行期调用 ImmutableList.toImmutableList() 就抛出了本节开头那个 NoSuchMethodError。

这条规则有个反直觉的推论:POM 里两行依赖的先后顺序会改变运行时行为。它也解释了那个经典困惑——「我只加了一行看起来无关的依赖,服务就崩了」:新依赖插在了前面,悄悄改变了同深度竞争的胜负。所以多模块项目里依赖顺序应当被当成代码一样审查。

定位:从异常里的类名反查

有了规则,定位就是三步机械操作:

  1. 从异常消息里取出类的全限定名(这里是 com.google.common.collect.ImmutableList),定位到它的坐标 com.google.guava:guava;
  2. 用 -Dincludes 只打印与该坐标相关的路径,避免在几百行树里翻找;
  3. 看被调解选中的版本,与编译期使用的版本比对。
mvn -pl book-loan-application dependency:tree -Dverbose -Dincludes=com.google.guava:guava
[INFO] +- com.birdor:isbn-metadata-sdk:jar:2.3.0:compile
[INFO] |  \- com.google.guava:guava:jar:19.0:compile
[INFO] \- com.birdor:loan-report-sdk:jar:1.4.0:compile
[INFO]    \- (com.google.guava:guava:jar:32.0.1-jre:compile - omitted for conflict with 19.0)

-Dincludes 的值格式是 groupId:artifactId,也支持通配(如 *:guava)。它只是过滤显示,不改变调解结果——别指望用它「修」冲突。

修复一:用 摘掉错误来源

既然 19.0 是从 isbn-metadata-sdk 来的,最直接的修法是把这条路径上的 Guava 摘掉:

<dependency>
  <groupId>com.birdor</groupId>
  <artifactId>isbn-metadata-sdk</artifactId>
  <version>2.3.0</version>
  <exclusions>
    <exclusion>
      <groupId>com.google.guava</groupId>
      <artifactId>guava</artifactId>
    </exclusion>
  </exclusions>
</dependency>

三个必须记住的要点:

  • exclusion 只写 groupId 和 artifactId,不写版本——它排除的是该坐标的所有版本,无法只排 19.0 而保留 32.0.1。
  • 它只作用于当前这条 <dependency> 的下游,不会影响其它路径引入的同名依赖。
  • 排除之后必须确认还有别的路径能提供这个类。如果 loan-report-sdk 那条路径也不存在,结果就是从「版本错」升级成「类不存在」,你会看到 ClassNotFoundException——问题没解决,只是换了个报错。

isbn-metadata-sdk 内部其实只用了 Guava 的 Preconditions.checkArgument,而这个 API 从 Guava 1.0 就有,所以在这里排除是安全的;如果它用了新版才有的 API,就不能靠排除解决,得推动上游 SDK 升级。

修复二:用 dependencyManagement 锁版本

更稳的做法不是删掉某条路径,而是统一规定这个坐标用哪个版本:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.google.guava</groupId>
      <artifactId>guava</artifactId>
      <version>32.0.1-jre</version>
    </dependency>
  </dependencies>
</dependencyManagement>

放进 dependencyManagement 后,无论 Guava 从哪条路径进来,版本都会被强制改成 32.0.1-jre。它和 <exclusions> 的本质区别在于是否改变依赖的存在性:

手段作用是否改变存在性影响范围
<exclusions>从某条路径上摘掉一个坐标是(移除)只影响该 <dependency> 的下游
<dependencyManagement>强制统一版本否(只改版本)该模块内所有路径
直接 <dependency> 带 <version>声明为直接依赖是(引入)提升到深度 1,压过所有传递路径

实践中的优先级:能用 dependencyManagement 统一,就不要用 <exclusions> 逐个打补丁。排除是外科手术,每加一个都要单独解释为什么;版本统一是一条规则,能覆盖整个模块。上例里最干净的解法其实是把 Guava 写进父 POM 的 dependencyManagement(下一节 2.2 会讲怎么把它放进自建 BOM),子模块从此不写版本。

找无用依赖:dependency:analyze

冲突的另一面是「声明了却没用」和「用了却没声明」,两者都是隐患。Maven 提供了专门的检查:

mvn -pl book-loan-application dependency:analyze
[INFO] --- maven-dependency-plugin:3.8.1:analyze (default-cli) @ book-loan-application ---
[WARNING] Used undeclared dependencies found:
[WARNING]    org.springframework:spring-core:jar:7.0.9:compile
[WARNING] Unused declared dependencies found:
[WARNING]    org.apache.commons:commons-lang3:jar:3.20.0:compile

两个警告区的含义完全不同:

  • Used undeclared:字节码里直接引用了某个类,但 POM 里没声明它,纯靠传递依赖「蹭」来的。这是最隐蔽的一类问题——上游哪天不再传递这个库,你的代码就编译不过了。修法是把它显式声明出来(版本交给 dependencyManagement 管)。
  • Unused declared:声明了但字节码没直接引用。不要盲删:JDBC 驱动、spring-boot-starter-actuator、注解处理器这类依赖是运行时才起作用或只在配置里出现,analyze 看不到引用,删了会出问题。

想让它成为 CI 的硬门槛,可以绑定到 verify 阶段并开启失败即中断:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-dependency-plugin</artifactId>
  <executions>
    <execution>
      <id>analyze</id>
      <goals>
        <goal>analyze-only</goal>
      </goals>
      <configuration>
        <failOnWarning>true</failOnWarning>
        <ignoredUnusedDeclaredDependencies>
          <ignoredUnusedDeclaredDependency>org.postgresql:postgresql</ignoredUnusedDeclaredDependency>
        </ignoredUnusedDeclaredDependencies>
      </configuration>
    </execution>
  </executions>
</plugin>

ignoredUnusedDeclaredDependencies 就是给上面那类「运行时才用」的依赖开的豁免口。先让它以警告模式跑一段时间,把误报清理干净,再打开 failOnWarning。

常见坑

坑表现处理
只看默认 dependency:tree冲突分支被隐藏,误判「没有冲突」排查时一律加 -Dverbose
用 <exclusions> 一劳永逸排掉后变成 ClassNotFoundException排除前确认仍有其它路径提供该类
直接声明版本「压住」冲突版本对了,但引入了不兼容的组合优先用 dependencyManagement 统一,改动面更小
各模块各写版本运行期合并后互相覆盖版本只在父 POM / BOM 里写一次
靠传递依赖蹭 spring-core上游升级后类消失analyze 的 Used undeclared 区就是它
排除用 *:* 通配整棵子树被排掉,后续难以排查只排具体坐标,逐个写明原因

小结

  • 依赖冲突的症状是运行时异常(NoSuchMethodError / ClassNotFoundException / AbstractMethodError),根因在合并后的运行期 classpath,不在代码。
  • mvn dependency:tree -Dverbose 是排查的主力工具;默认输出会隐藏被剪掉的分支,-Dincludes=groupId:artifactId 用来聚焦单个坐标。
  • 两条调解规则决定胜负:最短路径优先、同深度先声明优先。后者意味着 POM 里依赖的书写顺序会影响运行时行为。
  • <exclusions> 改变依赖的存在性,<dependencyManagement> 只改版本;能用后者统一就不要用前者打补丁。
  • dependency:analyze 分「Used undeclared」与「Unused declared」两区,前者必须修,后者要人工判断,可配 failOnWarning 纳入 CI。

本节解决的是「冲突已经发生怎么修」。更根本的做法是让冲突不发生——把所有版本收拢到一处统一管理,这正是下一节 2.2 版本对齐与 BOM 的主题。

阅读导航:上一节:1.3 分层与包结构约定 · 下一节:2.2 版本对齐与 BOM 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计