本地跑得好好的,上线就炸?我掉进了 Maven 和 JVM 设的三重陷阱

90 阅读6分钟

诡异的线上故障

你有没有遇到过这种情况?

代码在本地 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 后的行为完全不同:

111.jpg

对比表格:

场景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

错误发生过程:

  1. 加载阶段:JVM 成功加载 PaginationInnerInterceptor.class

  2. 链接阶段:解析该类的静态初始化块

    // PaginationInnerInterceptor 的静态初始化(伪代码)
    static {
        // 这里期望的是 4.6 的 API 结构
        SelectExpressionItem item = ...; 
    }
    
  3. 问题出现

    • JVM 尝试加载 SelectExpressionItem
    • 找到了 jsqlparser-4.9.jar 中的这个类
    • 但 4.9 的 SelectExpressionItem 内部结构与 4.6 不同
    • 类签名验证失败 → 链接失败
    • 抛出 NoClassDefFoundError

为什么类存在还会链接失败?

关键在于 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.53.5.9
JSQLParser 版本4.94.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。

参考资料