诡异的线上故障
你有没有遇到过这种情况?
代码在本地 IDEA 里跑得飞起,单元测试全绿,信心满满打个包——结果一部署到测试环境,应用直接启动失败:
java.lang.NoClassDefFoundError: net/sf/jsqlparser/statement/select/SelectExpressionItem
at com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor.<clinit>
Caused by: java.lang.ClassNotFoundException: net.sf.jsqlparser.statement.select.SelectExpressionItem
更魔幻的是——这个类明明在 jar 包里!
别慌,这不是玄学,而是 Maven 依赖仲裁、JVM 类加载与 API 兼容性共同设下的"三重陷阱"。
问题背景
环境信息:
- MyBatis-Plus 版本:3.5.5
- 项目引入了一个自研加密组件,依赖 JSQLParser 4.9
- 错误类型:
NoClassDefFoundError(注意:不是ClassNotFoundException)
测试环境:
- 操作系统:Windows 22H2
- Java 版本:17.0.9
- Maven 版本:3.6+
- 数据库:PostgreSQL
陷阱一:Maven 依赖仲裁的"暗箱操作"
依赖冲突是如何产生的?
项目的依赖关系如下:
graph TD
A[业务应用] --> B[MyBatis-Plus 3.5.5]
A --> C[加密组件]
B --> D[JSQLParser 4.6]
C --> E[JSQLParser 4.9]
F[dependencyManagement] -.强制版本.-> G[JSQLParser 4.9]
style D fill:#f9f,stroke:#333,stroke-width:2px
style E fill:#9f9,stroke:#333,stroke-width:2px
style G fill:#9f9,stroke:#333,stroke-width:4px
同一个 groupId:artifactId(即 com.github.jsqlparser:jsqlparser)出现了两个版本。
Maven 是如何"偷偷"换掉你的依赖的?
Maven 的依赖仲裁遵循以下规则:
graph LR
A[依赖冲突] --> B{dependencyManagement<br/>有声明?}
B -->|是| C[使用 DM 声明的版本]
B -->|否| D{比较路径长度}
D --> E[就近原则:<br/>路径短的获胜]
D --> F[路径相同:<br/>先声明的获胜]
C --> G[最终只保留一个版本]
E --> G
F --> G
关键结论:无论依赖树中有多少个版本声明,Maven 打包时统统只保留一个版本,其他的全部被丢弃。
在本案例中:
dependencyManagement声明了jsqlparser:4.9- 加密组件也直接依赖
jsqlparser:4.9 - MyBatis-Plus 内部期望的
jsqlparser:4.6被彻底覆盖
验证最终版本
# 查看依赖树
mvn dependency:tree -Dincludes=com.github.jsqlparser:jsqlparser
# 实际输出
[INFO] com.example:mybatis-plus-355-error:jar:1.0.0-SNAPSHOT
[INFO] \- com.github.jsqlparser:jsqlparser:jar:4.9:compile
结论:只有 JSQLParser 4.9,MyBatis-Plus 期望的 4.6 已被覆盖。
# 检查打包产物
jar tf target/my-app.jar | grep jsqlparser
# 输出
BOOT-INF/lib/jsqlparser-4.9.jar # 只有 4.9,没有 4.6
陷阱二:为什么本地 IDE 不报错?
IDE 的"宽容"机制
IntelliJ IDEA 和 Eclipse 在运行时不会进行 Maven 依赖仲裁,而是将所有依赖(包括传递依赖)都加到 classpath:
graph TD
A[IDE Classpath] --> B[jsqlparser-4.6.jar<br/>from mybatis-plus]
A --> C[jsqlparser-4.9.jar<br/>from crypto-module]
A --> D[mybatis-plus-extension.jar]
E[JVM ClassLoader] --> F{加载 SelectExpressionItem}
F --> G[可能从 4.6 加载]
F --> H[可能从 4.9 加载]
G --> I[恰好兼容, 不报错]
style G fill:#9f9
关键点:
- IDE 可能优先加载了 4.6 版本(取决于 classpath 顺序)
- 即使加载了 4.9,某些场景下也可能"侥幸"运行
- 这是一种不确定行为——你的同事可能就会遇到问题
打包后的"严格"检查
Spring Boot 打包成 Fat Jar 后的行为完全不同:
对比表格:
| 场景 | IDE 运行 | Fat Jar 部署 |
|---|---|---|
| Classpath | 同时包含 4.6 和 4.9 | 仅包含 4.9 |
| 行为 | 不确定(可能侥幸成功) | 确定(必然失败) |
| 根本原因 | 未执行 Maven 仲裁 | 严格遵循依赖解析结果 |
陷阱三:为什么是 NoClassDefFoundError?
两种"找不到类"的区别
很多人容易混淆这两个异常:
| 异常类型 | 触发时机 | 含义 |
|---|---|---|
| ClassNotFoundException | 动态加载时 | JVM 在 classpath 中找不到 .class 文件 |
| NoClassDefFoundError | 类链接时 | .class 文件存在,但链接失败 |
类加载的三个阶段
graph LR
A[类加载过程] --> B[1. Loading<br/>加载字节码]
B --> C[2. Linking<br/>链接]
C --> D[3. Initialization<br/>初始化]
C --> E[Verification<br/>验证]
C --> F[Preparation<br/>准备]
C --> G[Resolution<br/>解析]
G -.依赖其他类.-> H[加载依赖的类]
H -.链接失败.-> I[NoClassDefFoundError]
style I fill:#f99
实际错误日志分析
关键的错误堆栈:
2025-12-11 18:45:03.668 ERROR 25588 --- [main] o.s.boot.SpringApplication :
Application run failed
...
org.springframework.beans.factory.BeanCreationException:
Error creating bean with name 'mybatisPlusInterceptor'
...
nested exception is java.lang.NoClassDefFoundError:
net/sf/jsqlparser/statement/select/SelectExpressionItem
Caused by: java.lang.NoClassDefFoundError:
net/sf/jsqlparser/statement/select/SelectExpressionItem
at com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor.<clinit>
Caused by: java.lang.ClassNotFoundException:
net.sf.jsqlparser.statement.select.SelectExpressionItem
错误发生过程:
-
加载阶段:JVM 成功加载
PaginationInnerInterceptor.class -
链接阶段:解析该类的静态初始化块
// PaginationInnerInterceptor 的静态初始化(伪代码) static { // 这里期望的是 4.6 的 API 结构 SelectExpressionItem item = ...; } -
问题出现:
- JVM 尝试加载
SelectExpressionItem - 找到了
jsqlparser-4.9.jar中的这个类 - 但 4.9 的
SelectExpressionItem内部结构与 4.6 不同 - 类签名验证失败 → 链接失败
- 抛出
NoClassDefFoundError
- JVM 尝试加载
为什么类存在还会链接失败?
关键在于 API 兼容性。虽然包名和类名相同,但:
// JSQLParser 4.6 中的 SelectExpressionItem
package net.sf.jsqlparser.statement.select;
public class SelectExpressionItem implements SelectItem {
private Expression expression;
public Expression getExpression() { ... }
}
// JSQLParser 4.9 中的 SelectExpressionItem(示例)
package net.sf.jsqlparser.statement.select;
public class SelectExpressionItem<T extends Expression> implements SelectItem<T> {
private T expression;
public T getExpression() { ... } // 泛型签名不同
}
- 泛型签名不同
- 继承关系可能变化
- 某些方法可能被移除或重命名
MyBatis-Plus 3.5.5 编译时使用的是 4.6 的字节码,运行时加载 4.9 的字节码,类的内部契约被打破,导致链接失败。
解决方案:MyBatis-Plus 的适配器模式
为什么不能简单降级?
方案 A(不可行):降级 JSQLParser 到 4.6
<jsqlparser.version>4.6</jsqlparser.version>
问题:如果加密组件必须使用 4.9 的新特性,会导致功能缺失或运行时错误。
正确方案:升级 + 适配器
方案 B(推荐):升级 MyBatis-Plus 到 3.5.9+
从 3.5.9 开始,MyBatis-Plus 引入了适配器模块来支持多版本:
graph TD
A[业务代码] --> B[MyBatis-Plus Core<br/>抽象层]
B --> C{运行时检测}
C -->|发现 4.6 适配器| D[JSqlParserSupport46]
C -->|发现 4.9 适配器| E[JSqlParserSupport49]
D --> F[JSQLParser 4.6 API]
E --> G[JSQLParser 4.9 API]
style B fill:#9cf
style D fill:#f9f
style E fill:#9f9
适配器的实现原理
1. 定义抽象接口
// mybatis-plus-core 模块
package com.baomidou.mybatisplus.core.parser;
public interface JSqlParserSupport {
Select parseSelect(String sql);
void processSelectBody(SelectBody body);
}
2. 提供默认实现(4.6)
// mybatis-plus-extension 模块
package com.baomidou.mybatisplus.extension.parser;
import net.sf.jsqlparser.statement.select.SelectExpressionItem; // 4.6
public class JSqlParserSupport46 implements JSqlParserSupport {
@Override
public void processSelectBody(SelectBody body) {
PlainSelect select = (PlainSelect) body;
for (SelectItem item : select.getSelectItems()) {
if (item instanceof SelectExpressionItem) {
// 使用 4.6 的 API
}
}
}
}
3. 提供 4.9 适配实现
// mybatis-plus-jsqlparser-4.9 模块
package com.baomidou.mybatisplus.extension.parser;
import net.sf.jsqlparser.statement.select.SelectItem; // 4.9 的新类
public class JSqlParserSupport49 implements JSqlParserSupport {
@Override
public void processSelectBody(SelectBody body) {
PlainSelect select = (PlainSelect) body;
// 使用 4.9 的新 API,适配泛型结构
for (SelectItem<?> item : select.getSelectItems()) {
Expression expr = item.getExpression();
}
}
}
4. 运行时动态选择(SPI)
// mybatis-plus-core 的工厂类
public class JSqlParserFactory {
private static JSqlParserSupport instance;
static {
ServiceLoader<JSqlParserSupport> loader =
ServiceLoader.load(JSqlParserSupport.class);
Iterator<JSqlParserSupport> it = loader.iterator();
if (it.hasNext()) {
instance = it.next(); // 优先加载 SPI 注册的实现
} else {
instance = new JSqlParserSupport46(); // 默认实现
}
}
}
SPI 配置文件:
# mybatis-plus-jsqlparser-4.9 模块
# META-INF/services/com.baomidou.mybatisplus.core.parser.JSqlParserSupport
com.baomidou.mybatisplus.extension.parser.JSqlParserSupport49
正确的依赖配置
<properties>
<mybatis-plus.version>3.5.9</mybatis-plus.version>
<jsqlparser.version>4.9</jsqlparser.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- 统一版本 -->
<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
<version>${jsqlparser.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- 关键:手动引入 4.9 适配器 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
</dependencies>
最终的依赖树
graph TD
A[应用] --> B[mybatis-plus-boot-starter:3.5.9]
A --> C[mybatis-plus-jsqlparser-4.9:3.5.9]
A --> D[加密组件]
B --> E[mybatis-plus-extension:3.5.9]
E -.期望.-> F[jsqlparser:4.6]
C --> G[jsqlparser:4.9]
D --> H[jsqlparser:4.9]
I[dependencyManagement] -.强制.-> J[jsqlparser:4.9]
F -.被覆盖.-> J
G --> J
H --> J
K[最终 Classpath] --> L[jsqlparser-4.9.jar]
K --> M[mybatis-plus-jsqlparser-4.9.jar<br/>适配器]
style F fill:#f99,stroke-dasharray: 5 5
style J fill:#9f9
style M fill:#9cf
实战验证:对比测试
当然,光说不练假把式。我们搭建了两个最小化测试工程来验证上述分析:
测试工程 1:错误演示(3.5.5)
配置:
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<jsqlparser.version>4.9</jsqlparser.version>
结果: 启动失败
关键日志:
2025-12-11 18:45:03.668 ERROR 25588 --- [main] o.s.boot.SpringApplication :
Application run failed
...
Caused by: java.lang.NoClassDefFoundError:
net/sf/jsqlparser/statement/select/SelectExpressionItem
at com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor.<clinit>
测试工程 2:正确方案(3.5.9)
配置:
<mybatis-plus.version>3.5.9</mybatis-plus.version>
<jsqlparser.version>4.9</jsqlparser.version>
<!-- 关键:引入适配模块 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
<version>3.5.9</version>
</dependency>
结果: 启动成功
关键日志:
2025-12-11 18:45:43.859 INFO 30676 --- [main]
com.example.demo.CorrectDemoApplication :
Starting CorrectDemoApplication using Java 17.0.9
...
2025-12-11 18:45:46.556 INFO 30676 --- [main]
c.a.d.s.b.a.DruidDataSourceAutoConfigure : Init DruidDataSource
_ _ |_ _ _|_. ___ _ | _
| | |\/|_)(_| | |_\ |_)||_|_\
/ |
3.5.9
...
2025-12-11 18:45:48.781 INFO 30676 --- [main]
com.example.demo.CorrectDemoApplication :
Started CorrectDemoApplication in 5.582 seconds
对比总结
| 对比项 | 错误工程 (3.5.5) | 正确工程 (3.5.9) |
|---|---|---|
| MyBatis-Plus 版本 | 3.5.5 | 3.5.9 |
| JSQLParser 版本 | 4.9 | 4.9 |
| 适配器模块 | 无 | mybatis-plus-jsqlparser-4.9 |
| 启动结果 | 失败 | 成功 |
| 错误类型 | NoClassDefFoundError | 无错误 |
| 启动时间 | - | 5.582 秒 |
下一步你可以做什么?
重要提示: 切勿仅通过 <exclusions> 排除 JSQLParser 4.9 —— 这可能导致加密组件功能异常!正确做法是升级 MyBatis-Plus 并引入适配器。
1. 立即检查你的项目
# 检查 JSQLParser 版本冲突
mvn dependency:tree -Dincludes=com.github.jsqlparser:jsqlparser
# 检查 MyBatis-Plus 版本
mvn dependency:tree -Dincludes=com.baomidou:mybatis-plus
如果发现类似问题,立即升级:
<!-- 升级到 3.5.9+ -->
<mybatis-plus.version>3.5.9</mybatis-plus.version>
<!-- 显式引入适配器 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
<version>3.5.9</version>
</dependency>
2. 在 CI 流程中加入依赖检查
# 在 CI/CD 中增加检查步骤
- name: Check dependencies
run: |
mvn dependency:analyze
mvn dependency:tree > dependency-tree.txt
3. 建立依赖版本管理规范
- 在父 POM 的
dependencyManagement中统一管理关键依赖 - 升级底层库前,必须检查所有使用者的兼容性
- 查阅官方 Release Notes,关注 Breaking Changes
关键技术点总结
1. Maven 依赖仲裁
- 同一坐标最终只保留一个版本
dependencyManagement优先级最高- 本地 IDE 和打包后的行为不同
2. 类加载与链接
ClassNotFoundException:找不到.class文件NoClassDefFoundError:文件存在但链接失败- 链接失败通常因为 API 不兼容
3. 适配器模式 + SPI
- 抽象层隔离版本差异
- 多个适配实现支持不同版本
- SPI 动态选择合适的实现
4. 最佳实践
- 定期检查依赖树
- 基于打包后的 jar 进行测试
- 使用 Maven Enforcer Plugin 强制检查
技术债往往藏在细节里。一次深入的排查,胜过十次临时 workaround。