老炮踩坑录 · 番外篇 · 翻车现场系列
基于「企业融合评估平台」真实 pom 与打包产物,复盘 Maven 冲突时怎么决定用哪个版本
关键词:依赖冲突 · 传递依赖 · nearest-wins · dependencyManagement · NoSuchMethodError
👋 欢迎阅读
🏠个人主页:知守观
📘我的专栏:老炮踩坑录
💻当前内容:Maven 依赖冲突
文章目录
- 前言
- 案发现场:pom 里写的和 lib 里跑的不一致
- 版本是谁定的:Maven 的三条选择规则
- 规则一:dependencyManagement 说了算
- 规则二:路径最近者胜(nearest-wins)
- 规则三:同深度,先声明的胜;重复声明,只打一行警告
- 为什么没炸,以及为什么难发现
- 正确写法:四条铁律
- 铁律一:看依赖树,看打包产物,别只看 pom
- 铁律二:关键版本,用 dependencyManagement 锁定
- 铁律三:把检查交给构建工具,别交给人的眼睛
- 铁律四:同族依赖,版本对齐
- 自查清单
- 老炮点评
- 下期预告
前言
离职后复盘这个项目,有一天我解压了当年打的 war 包,想核对几个 jar 的版本。
翻到WEB-INF/lib目录,一个文件名让我停住了:
jsqlparser-1.0.jar1.0?项目里的 mybatis-plus 是 3.3.0,它那一版的发布说明里白纸黑字写着 “jsqlparser up 3.1”。也就是说 mybatis-plus 要带进来的是 jsqlparser 3.1。
lib 目录里只有一个 jsqlparser,是 1.0,3.1 不见了。
谁换的?没人换呀。pom.xml 里从头到尾没出现过 jsqlparser 这个词——它是传递依赖,两个框架各自依赖了不同的版本,Maven 按规则保留了其中一个。
选择的结果写在 lib 目录里,只是从来没人去看。
案发现场:pom 里写的和 lib 里跑的不一致
pom.xml 里的两段声明(真实行号):
<!-- pom.xml L94 --><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.3.0</version></dependency><!-- pom.xml L102 --><dependency><groupId>com.github.pagehelper</groupId><artifactId>pagehelper-spring-boot-starter</artifactId><version>1.2.5</version></dependency>两个都是分页相关的框架,同时存在于一个项目里。它们各自拖着一条传递依赖链,终点是同一个 artifact:同一个
com.github.jsqlparser:jsqlparser,一个依赖 1.0,一个依赖 3.1。Maven 的处理方式是自己选一个,不报错——选一个保留,另一个不打进 classpath,也不给任何提示。
最终保留下来的,是 pagehelper 那条链上的 1.0。
注意一个细节:pom 里 mybatis-plus 声明在第 94 行,pagehelper 在第 102 行,mybatis-plus 靠前。很多人以为"先声明的赢",mybatis-plus 明明先声明,还是输了。因为 Maven 比的是依赖路径的深度,3 比 4 小,pagehelper 那条链更近,声明顺序在这里不起作用。
版本是谁定的:Maven 的三条选择规则
这次复盘我把 pom.xml 和 lib 目录从头到尾对了一遍,版本不一致的情况一共三类,对应 Maven 的三条规则。
规则一:dependencyManagement 说了算
pom.xml 里这个依赖,一个版本号都没写:
<dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><scope>runtime</scope></dependency>lib 里躺的是mysql-connector-java-8.0.13.jar。版本号从哪来的?从 parent 来——spring-boot-starter-parent 2.1.0背后的spring-boot-dependencies这份 BOM,把几百个常用依赖的版本全管了。
再看配置文件里的痕迹:com.mysql.cj.jdbc.Driver是 8.x 驱动才有的类名,serverTimezone是 8.x 驱动要求的参数。这个项目跑的是 8.0.13 的驱动,而决定这件事的人,是写 spring-boot-dependencies 的人。
不写版本本身没错,BOM 就是干这个的。但要清楚一点:把 parent 从 2.1.0 升到 2.7.18,pom 一个字不用改,驱动、Jackson、Netty、Logback 的版本全跟着变。升级 parent 从来不只是升一个框架,是换掉整张依赖清单。
规则二:路径最近者胜(nearest-wins)
jsqlparser 这一局就是这条规则的实例。两条链,深度 3 的赢,深度 4 的被丢弃。
被丢弃的那个不是消失了,Maven 只是不打进 classpath,在依赖树里会留一行记录。这是排查的关键证据,后面讲怎么查到这条记录。
规则三:同深度,先声明的胜;重复声明,只打一行警告
这份 pom 里,commons-lang:commons-lang:2.6声明了两遍(L243 和 L265),一个字不差的两遍。commons-codec也声明了两遍:第一次不写版本(被 BOM 管成 1.11),第二次显式写 1.11,注释写着"华为云使用"。
Maven 对重复声明的处理是:用其中一个,然后在构建日志里打一行[WARNING] duplicate declaration。
一行 warning。构建日志几千行,没人看。这两处重复声明从进仓库那天起就一直在,到项目停更也没人发现。
为什么没炸,以及为什么难发现
先说没炸的原因,如实讲。
我搜了全项目,分页代码 10 个文件,清一色PageHelper.startPage:
PageHelper.startPage(pageNum,pageSize);mybatis-plus 在这个项目里只被当 CRUD 框架用——没配PaginationInterceptor,没有任何地方调selectPage,IPage一个引用都没有。jsqlparser 3.1 的 API 主要被 mybatis-plus 的分页 count 优化用到,而那段代码在这个项目里从来没被执行过。
pagehelper 自己做分页用 1.0,正好 classpath 上就是 1.0,严丝合缝。需要 3.1 的代码路径,一步都没踩上去。
网上搜 “The following method did not exist: net.sf.jsqlparser”,能搜到一大把 pagehelper 和 mybatis-plus 共存的事故记录,症状都差不多:日常功能全好,某天一条复杂 SQL 触发分页 count 优化,抛NoSuchMethodError。具体是哪个方法不存在,取决于那条 SQL 解析出来什么结构。
这类问题难发现,原因有四个:
- 编译期不校验。编译只看你 import 的类在不在,不检查运行时会加载哪个版本。jsqlparser 1.0 和 3.1 包名一样,编译两边都过。
- 启动期不校验。Spring 容器起来,一切正常。版本不对不会阻止启动,只会在执行到那行代码时炸。
- 测试走不到。测试环境的 SQL 简单,分页 count 不走优化分支,或者干脆没测那条路径。
- 没人看产物。所有人核对依赖都看 pom.xml,没人解压 war 包看
WEB-INF/lib。而 pom 里写的,本来就不是最终结果。
正确写法:四条铁律
铁律一:看依赖树,看打包产物,别只看 pom
mvn dependency:tree-Dverbose-Dincludes=com.github.jsqlparser-Dverbose是关键,被丢弃的版本会显示出来:omitted for conflict with 1.0——谁要它、输给了谁、赢的是哪个版本,一行全交代了。每一行 omitted 背后都是一次版本冲突的选择,挨个搞清楚原因,是接手老项目的第一课。
war 包项目再加一步:解压产物,直接翻WEB-INF/lib。那是上线时类路径上的全部真相。
铁律二:关键版本,用 dependencyManagement 锁定
<dependencyManagement><dependencies><dependency><groupId>com.github.jsqlparser</groupId><artifactId>jsqlparser</artifactId><version>3.1</version></dependency></dependencies></dependencyManagement>dependencyManagement 优先级高于传递依赖的选择结果,写进去,所有路径上的 jsqlparser 统一成 3.1。
但锁定之前先回答一个问题:两个使用者都兼容你锁的那个版本吗?jsqlparser 1.0 到 3.1 跨了大版本,pagehelper 5.1.4 配 3.1 能不能正常工作,我没有验证过,不敢替它打包票。锁完版本,两个框架的分页功能都要回归一遍。
更根本的问题其实是:一个项目为什么同时需要 mybatis、mybatis-plus、pagehelper 三个持久层相关的 starter?这是选型层面的事,我在 Java 技术选型三条铁律 文章里写过,这里不展开。
有个细节我没完全确认:文章里我提到,把
jsqlparser强行锁定到 3.1 后,pagehelper 5.1.4能不能完美兼容,我没有实际测过。有没有兄弟在项目里踩过这俩组合的坑?或者你们干脆把pagehelper换成mybatis-plus自带的分页了?评论区指点一下。
铁律三:把检查交给构建工具,别交给人的眼睛
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-enforcer-plugin</artifactId><executions><execution><goals><goal>enforce</goal></goals><configuration><rules><dependencyConvergence/><banDuplicatePomDependencyVersions/></rules></configuration></execution></executions></plugin>dependencyConvergence是最狠的一条:同一个 artifact 从多条链进来,版本必须收敛成一个,否则构建直接失败。banDuplicatePomDependencyVersions治的是 commons-lang 写两遍那种问题,重复声明从 warning 升级成 error。
丑话说在前面:这份 pom 加上dependencyConvergence,第一次构建肯定过不了,jsqlparser、httpclient 那一串 omitted 全会变成报错。这正是目的——把运行时才暴露的版本问题,提前到构建期暴露。修的方式就是铁律二,在dependencyManagement里逐个锁定。
铁律四:同族依赖,版本对齐
pom.xml L131 还有一处:
<dependency><groupId>com.fasterxml.jackson.datatype</groupId><artifactId>jackson-datatype-guava</artifactId><version>2.9.8</version></dependency>Jackson 这一族,Boot 2.1.0 的 BOM 统一管在 2.9.7。这里单独给家族里的一个成员指定了 2.9.8。lib 目录里现在的局面是:jackson-databind-2.9.7.jar和jackson-datatype-guava-2.9.8.jar并存,一个家族混着两个补丁版本。
如实说,这次混版本没造成过已知的事故,Jackson 2.9.x 补丁版本之间基本兼容。但写法本身就是隐患——下次有人照这个思路给 databind 也单独指个版本,撞上一组真不兼容的组合,就是另一个 jsqlparser 故事。
同族依赖的处理只有两种:整个家族交给 BOM 管,一个版本都别写;或者整个家族显式指定同一个版本。只给家族里一个成员开小灶,是最差的选择。
自查清单
自查打卡:在你的项目里跑一下这个命令:
mvn dependency:tree -Dverbose | grep "omitted for conflict"
看看有多少个被 Maven 静默丢弃的版本?打在评论区,我看看谁的“地雷”最多。
老炮点评
pom.xml 里写的版本号,只是 Maven 选择版本时的输入之一,不是最终结果。真正决定类路径的是那三条规则:BOM、路径深度、声明顺序。
编译通过、启动成功,什么都证明不了。类路径上躺的是哪个版本,只有打包产物和依赖树说了算。
这个项目的 jsqlparser 没出事,靠的是那段代码从来没被执行。ThreadLocal + @Async 导致用户数据串号 文章里那句话放到这里一样成立:你依赖的是碰巧没触发,而不是代码本身是安全的。
最后问一句:你们平时排查
NoSuchMethodError或依赖冲突,是老老实实跑mvn dependency:tree捋,还是等线上真炸了,才被迫去解压 jar 包看版本?
评论区聊聊,我看看有多少项目是“盲盒式”上线。
下期预告
核对版本的过程中我还发现了另一件事:这个 pom 声明了 60 多个依赖,lib 目录里却躺了 150 多个 jar。
多出来的那些是哪来的?翻了一下,一个叫 swagger2markup 的依赖贡献了一大串——rhino、pegdown、json-schema-validator、libphonenumber,全跟着进了WEB-INF/lib。一个只在本地生成离线文档的工具,凭什么上生产?
下期讲:《一个生成文档的依赖,让生产 war 包多了 30 个 jar》。
如果本文对你有帮助,欢迎:
👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言
你的每一次互动都是我继续更新的动力,我们下一篇见!🚀
我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。