Spring Boot 3.3整合Mybatis-Plus 3.5.9实战:依赖配置与踩坑指南

发布时间:2026/10/6 8:26:23
Spring Boot 3.3整合Mybatis-Plus 3.5.9实战:依赖配置与踩坑指南 最近把一个老项目从 Spring Boot 2.7 直接升到 3.3.6Mybatis-Plus 从 3.5.3 换到 3.5.9中间踩了不少雷。如果你正准备在 Spring Boot 3.3.x 上整合 Mybatis-Plus或者想把旧项目往上迁这篇文章基本能把前期搭建流程和高频坑都覆盖到 —— 从依赖版本怎么选、分页插件为什么多一个包到 BaseMapper 的 CRUD 落地、通用的无状态增删改查封装最后是我实际排查过的几个报错链条。代码都是可以直接复制去用的那种。1. 从升级视角看 Spring Boot 3.3.x为什么很多老项目卡在这里先说个背景。Spring Boot 3 对整个 Java 技术栈来说是道分水岭哪怕你代码写得一模一样它底层的要求变了第三方库不跟着改就直接跑不起来。你根本绕不过去。1.1 Spring Boot 3 客观上带来的三个硬性变化第一个是 JDK 版本最低要求 17。这个影响其实不大真正在项目里折腾人的是后面两个。第二个是命名空间的迁移原来的javax.servlet、javax.annotation这些包名全部换成了jakarta.*Spring Boot 3.0 官方文档里写得很清楚这是为了跟 Jakarta EE 对齐。对你来说项目里所有依赖老javax包的第三方库在 Spring Boot 3 下面都会编译不过去或者直接 ClassNotFound。第三个是 Spring Framework 6 对依赖管理的收紧很多以前靠“自动配置 约定俗成”就能跑起来的东西现在需要你显式声明。比如 Mybatis-Plus 在 3.5.3 之前一直是走mybatis-plus-boot-starter当时它针对的是 Spring Boot 2 的自动配置机制到了 Spring Boot 3官方单独拆了一个mybatis-plus-spring-boot3-starter出来你不换 artifactId光升级版本号是没用的。1.2 老项目升级时最先蹦出来的三类报错我用过时的依赖直接跑旧项目刚启动就挂了报错主要集中在三处Caused by: java.lang.ClassNotFoundException: javax.servlet.ServletContext这个大概率是某个旧中间件或者旧版 Mybatis-Plus 的依赖里还盯着 javax。Error creating bean with name sqlSessionFactory点进去看根因基本都是 Mybatis-Plus 自带的 MybatisSqlSessionFactoryBean 在初始化时跟 Spring Boot 3 的自动配置冲突。Invalid value type for attribute factoryBeanObjectType: java.lang.String这个往往是 Mybatis 版本太老对 Spring 6 的新 Bean 定义模型不兼容。这些报错不用一个个硬啃版本对了它们自己就消失了。所以我建议升级的第一件事不是改代码而是先看一眼你的依赖组合表。1.3 一个被很多人忽略的坑Starter 命名已经变了很多人升级时习惯性搜索 Mybatis-Plus 最新版本然后把旧mybatis-plus-boot-starter的版本号改一下完事。但在 Spring Boot 3 里必须换成mybatis-plus-spring-boot3-starter这个 starter 是 MP 官方为 Spring Boot 3 单独维护的。我第一次升级的时候没注意启动直接报找不到 MybatisSqlSessionFactoryBean 的候选 bean排查了十分钟才发现是坐标写错了。这种问题文档里其实有写但你不踩一次真的容易忽略。2. 依赖引入与版本组合mybatis-plus-spring-boot3-starter 是关键既然要整合第一件事就是把依赖配准。先给结论再解释为什么。2.1 Spring Boot 3.3.x 对应 Mybatis-Plus 版本推荐Spring Boot 版本JDK 要求Mybatis-Plus 推荐版本说明3.3.0 ~ 3.3.6173.5.5 及以上低版本也能跑但分页插件版本要匹配3.3.x当前最新173.5.9推荐直接用这个修了一堆 JSqlParser 兼容问题3.2.x173.5.5同理尽量别低于 3.5.62.7.x83.5.3 及以下老组合用mybatis-plus-boot-starter我的建议是Spring Boot 3.3.x 用 Mybatis-Plus 3.5.9这是当前最稳的组合之一。另一个选择是 3.5.7不过它在个别 Count 查询下会有 SQL 生成的小问题3.5.9 已经处理掉了。2.2 pom.xml 完整依赖示例parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.3.6/version relativePath/ /parent properties java.version17/java.version mybatis-plus.version3.5.9/mybatis-plus.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version${mybatis-plus.version}/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies注意看我特意加了mybatis-plus-spring-boot3-starter而不是老的mybatis-plus-boot-starter。这就是前一节说的坐标变化。另外 MySQL 驱动从 Spring Boot 2 的mysql-connector-java换成了mysql-connector-jgroupId 和 artifactId 都变了别照抄旧 pom。2.3 一个很多人踩过的依赖陷阱mybatis-plus-jsqlparser如果你用的是 Mybatis-Plus 3.5.6 及以上版本并且要配分页插件还需要额外加一个包dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-jsqlparser/artifactId version${mybatis-plus.version}/version /dependency这是 Mybatis-Plus 3.5.6 之后的拆分动作。官方把 JSqlParser 相关的依赖抽出来了分页插件PaginationInnerInterceptor底层依赖它。不引入会怎么样启动不报错但是一旦执行分页查询它会抛一个ClassNotFoundException: com.github.jsqlparser.parser.CCJSqlParserUtil之类的错误。这个坑很隐蔽因为报错不是出现在项目启动阶段而是你第一次调分页接口的时候。3. 最小可运行骨架配置类、数据源与分页插件依赖配好之后接下来就是让整个项目先跑起来。这里我给一套最小配置包含了 yml 配置和配置类直接抄就行。3.1 application.yml 基础配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: your_password mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里面几个点我要单独说一下map-underscore-to-camel-case默认其实也是 true但显式写出来能帮助新同事快速理解而且有些老项目的 PO 字段命名不规范这个配置一旦关掉数据库里user_name映射不到实体的userName查出来的都是 null。log-impl配成StdOutImpl只是开发环境方便看 SQL。生产环境建议去掉因为打印 SQL 在高并发下对性能有影响。id-type: assign_id对应 MP 的雪花 ID 生成策略。分布式环境下这个很有用但如果你的表主键是数据库自增的这里要改成auto否则插入数据的时候主键冲突或者插入的 id 是个超长数字。3.2 MybatisPlusConfig 配置类package com.example.demo.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.mybatis.spring.annotation.MapperScan; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration MapperScan(com.example.demo.mapper) public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }MapperScan放在这里统一管理 Mapper 接口扫描路径比在每个 Mapper 上写Mapper省事很多。分页插件这里只配了一个PaginationInnerInterceptorsetMaxLimit(500L)是给每页最大条数设个上限防止有人直接pageSize传一个亿把数据库拖垮。3.3 分页插件在 Spring Boot 3 中的配置变化很多人是从旧教程里复制这段代码的在 Spring Boot 2 里没问题但在 Spring Boot 3 里有个地方要注意DbType是必须显式指定的。旧版本里PaginationInnerInterceptor不传数据库类型也能跑因为它会自动从数据源推断。到了 Spring Boot 3 配合新版 MP某些场景下自动推断会失败分页语句变成LIMIT ?而不是LIMIT ?,?结果就是分页数据错乱。我自己遇到过二次翻页时重复数据的情况排查到最后就是这个原因。所以现在配置一律写成new PaginationInnerInterceptor(DbType.MYSQL)数据库是 PostgreSQL 就写DbType.POSTGRE_SQL不要偷懒。还有一点如果项目里同时用了 Redis 缓存、多数据源或者自定义拦截器MybatisPlusInterceptor必须作为 bean 注入不能像有些文档里写的那样在自己的拦截器链里手动 new。Spring Boot 3 的自动配置对 bean 顺序很敏感手动 new 容易导致拦截器不生效。4. 从 BaseMapper 到 IServiceCRUD 落地与自动填充、逻辑删除骨架搭好以后真正的编码环节就轻松很多。Mybatis-Plus 的核心价值就是让你少写那些重复的单表 CRUD。4.1 实体类与注解package com.example.demo.entity; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; Data TableName(user) public class User { TableId(type IdType.ASSIGN_ID) private Long id; private String username; private String email; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; TableLogic private Integer deleted; }几个注解各司其职TableName(user)指定表名如果实体类名跟表名能通过驼峰规则对应上比如SysUser对sys_user这个注解可以省略。TableId(type IdType.ASSIGN_ID)对应全局配置里的雪花 ID。注意实体上的注解优先级高于 yml 全局配置。TableField(fill FieldFill.INSERT)和fill FieldFill.INSERT_UPDATE是配合自动填充功能用的。配合后面的MetaObjectHandler插入时自动写create_time和update_time更新时自动刷新update_time——不用每个 Service 里手动 set 时间。4.2 Mapper 接口与服务层写法的推荐姿势package com.example.demo.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.demo.entity.User; public interface UserMapper extends BaseMapperUser { }package com.example.demo.service; import com.baomidou.mybatisplus.extension.service.IService; import com.example.demo.entity.User; public interface UserService extends IServiceUser { }package com.example.demo.service.impl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.example.demo.entity.User; import com.example.demo.mapper.UserMapper; import com.example.demo.service.UserService; import org.springframework.stereotype.Service; Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { }BaseMapper里面已经内置了insert、deleteById、updateById、selectById、selectPage等一堆方法而IServiceServiceImpl又在这个基础上包了一层更面向业务的方法比如save、saveBatch、listByIds。我个人的建议是哪怕你只写单表 CRUD也尽量用IService这一套而不要直接在 Controller 里注入UserMapper。原因有两个一是IService提供的saveBatch批量插入性能明显优于循环单条插入这是我自己测试过的二是你的 Service 迟早会加业务逻辑先搭好这层后面不用返工。4.3 自动填充 MetaObjectHandlerTableField(fill ...)只是一个标记真正干活的还是MetaObjectHandler实现类package com.example.demo.config; import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; import org.apache.ibatis.reflection.MetaObject; import org.springframework.stereotype.Component; import java.time.LocalDateTime; Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }这里有一个细节用strictInsertFill而不是setFieldValByName。strictInsertFill会先判断实体字段是否为 null不为 null 就不覆盖。这意味着你在业务代码里手动给createTime设置了特殊值自动填充不会把你覆盖掉很实用。还有一个注意点字段名填的是实体属性的驼峰名createTime不是数据库列名create_time。写错的话填充不会报错但数据库里那个字段就是空的非常容易漏查。4.4 逻辑删除与乐观锁逻辑删除在上面的application.yml里已经配置了logic-delete-field: deleted实体类上用了TableLogic。这样当你调deleteById时MP 执行的是 UPDATE 语句把deleted置为 1而不是物理 DELETE。好处是数据可追溯坏处是你所有查询都要记得带上deleted 0条件——不过这一点 MP 已经帮你做了selectById、selectList这些内置查询都会自动追加该条件。如果你有“删除后还能查得出来”的需求比如回收站功能那就不能用TableLogic否则查出来的一直是空数据。这个等踩到了再返工就很麻烦因为涉及存量数据。乐观锁也是类似Version private Integer version;配合配置类里加一行import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor; interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());Version会在 UPDATE 时自动拼上WHERE version ?并且把 version 1。这个机制最适合做库存扣减、余额更新这类并发写场景。注意version字段的初始值不能为 null否则条件拼不上。5. 通用 CRUD 服务的无状态设计一个基于 Mybatis-Plus 的实用封装前面讲的都是标准用法。这一节想聊一个更有意思的实践怎么基于 Mybatis-Plus 写一个通用的 CRUD 服务做到“无状态增删改查”。这也是很多人在单表特别多、或者需要给第三方提供通用接口时最常遇到的诉求。5.1 为什么需要“通用”的服务假如你的系统里有 50 张业务表按照传统思路每个实体都要写一个 Mapper、一个 Service、一个 ServiceImpl这种样板代码写到最后纯属体力活。而当你要把某些数据的增删改查能力对外暴露比如给第三方对接方提供按表名操作数据的接口时你不可能为 50 张表写 50 个 Controller。这时候就需要一个“无状态”的通用 CRUD 服务不感知具体业务逻辑只根据传入的实体类型和实体对象直接完成增删改查。这里说的无状态是指服务本身不持有某个业务表的专属逻辑对象的类型是运行时传进来的。我基于 Mybatis-Plus 的BaseMapper做了这样一层封装。5.2 实现一个基于 Mybatis-Plus 的通用 CRUD 服务核心思路利用 Spring 的ApplicationContext按类型获取 Mapper Bean加上 MP 提供的反射工具TableInfoHelper完成通用操作。package com.example.demo.common; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.metadata.TableInfo; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.core.toolkit.ReflectionUtils; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import jakarta.annotation.PostConstruct; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Service; import java.lang.reflect.Field; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; Service public class GenericCrudService { private final ApplicationContext applicationContext; private final MapClass?, BaseMapper? mapperCache new ConcurrentHashMap(); public GenericCrudService(ApplicationContext applicationContext) { this.applicationContext applicationContext; } PostConstruct public void init() { // 初始化时把所有 BaseMapper 类型的 Bean 收集起来 MapString, BaseMapper mappers applicationContext.getBeansOfType(BaseMapper.class); for (BaseMapper? mapper : mappers.values()) { TableInfo tableInfo TableInfoHelper.getTableInfo(mapper.getClass()); if (tableInfo ! null) { mapperCache.put(tableInfo.getEntityType(), mapper); } } } SuppressWarnings(unchecked) public T int save(T entity) { BaseMapperT mapper getMapper((ClassT) entity.getClass()); return mapper.insert(entity); } SuppressWarnings(unchecked) public T int updateById(T entity) { BaseMapperT mapper getMapper((ClassT) entity.getClass()); return mapper.updateById(entity); } SuppressWarnings(unchecked) public T int deleteById(ClassT entityClass, Object id) { BaseMapperT mapper getMapper(entityClass); return mapper.deleteById(id); } SuppressWarnings(unchecked) public T T getById(ClassT entityClass, Object id) { BaseMapperT mapper getMapper(entityClass); return mapper.selectById(id); } SuppressWarnings(unchecked) public T ListT list(ClassT entityClass) { BaseMapperT mapper getMapper(entityClass); return mapper.selectList(Wrappers.emptyWrapper()); } private T BaseMapperT getMapper(ClassT entityClass) { BaseMapper? mapper mapperCache.get(entityClass); if (mapper null) { throw new IllegalArgumentException(No mapper found for entity: entityClass.getName()); } return (BaseMapperT) mapper; } }这个类的核心逻辑在getMapper通过实体 Class 从缓存里找到对应的 BaseMapper。因为是通用的没有任何业务判断所以它是无状态的。调用方式大概是RestController RequestMapping(/generic) public class GenericApiController { private final GenericCrudService genericCrudService; public GenericApiController(GenericCrudService genericCrudService) { this.genericCrudService genericCrudService; } PostMapping(/save) public Result? save(RequestBody MapString, Object body) { Class? entityClass getEntityClass(body.get(entityName)); Object entity JSON.parseObject(JSON.toJSONString(body.get(data)), entityClass); genericCrudService.save(entity); return Result.ok(); } }当然在实际实现时entityName到 Class 的映射可以做一张注册表避免前端传任意类名导致的风险。5.3 实际场景中的取舍与注意这套封装我自己用了大半年体验很好但也想说清楚它的适用边界第一它只适合“无业务规则的纯数据操作”场景。如果你的服务里有“下单后扣库存还要发消息”这种逻辑那一定不能走通用服务业务还是得落到具体的UserService、OrderService里。通用服务只是一个快速通道不是一个替代品。第二必须做好参数校验。因为入口是通用的非法数据一旦入库后续处理很麻烦。最简单的做法是在进入GenericCrudService之前用 hibernate-validator 之类的校验框架对实体做校验或者至少做一次字段级别的白名单过滤。第三在getMapper中使用了TableInfoHelper.getTableInfo要求实体类必须是已经注册到 Mybatis-Plus 元数据里的。如果实体类上缺少TableName注解getTableInfo可能返回 null缓存就收集不到。所以使用前建议给所有实体显式加好TableName。6. 升级与运行阶段的高频踩坑清单最后的这部分我把自己实际遇到或者说排查过的问题按“报错 - 原因 - 解法”整理一下。每一个都是我确认过的不是网上抄来的。6.1 报错 ClassNotFoundException: com.github.jsqlparser.parser.CCJSqlParserUtil这个前面提过。原因就是 Mybatis-Plus 3.5.6 之后把 JSqlParser 拆成了独立模块。如果你的 pom 里只引入mybatis-plus-spring-boot3-starter那分页插件用到的 JSqlParser 是不在依赖里的。我当时的排查链路是这样的启动正常 - 写一个分页接口 - 调一次报类找不到 - 看堆栈发现是jsqlparser的包 - 打开依赖树发现根本没有这个包 - 补上mybatis-plus-jsqlparser解决。建议直接一步到位在 pom 里带上它。6.2 分页查询时数据错乱第二次点击下一页出现重复数据这个问题的特征很典型第一页正常第二页开始数据乱而且不同的页面之间会出现重复记录。跟踪 SQL 会发现生成的语句是LIMIT 10不是LIMIT 10, 10。根因就是我在 3.3 节说的PaginationInnerInterceptor没有指定数据库类型自动推断失败。尤其是项目里配置了多数据源或者使用了自定义路由数据源时MP 会拿不到准确的 DbType。解法也简单new PaginationInnerInterceptor(DbType.MYSQL)显式声明。6.3 LocalDateTime 序列化异常 / 反序列化报错Spring Boot 3 的默认 JSON 序列化用的是 Jackson它默认是不支持 LocalDateTime 直接序列化的。虽然spring-boot-starter-web里已经带了jackson-datatype-jsr310但如果你自己在 ObjectMapper 上做了自定义或者覆盖了默认配置这个模块可能没被加载。我遇到的情况是接口返回的createTime是一长串数字数组前端根本没法用。排查后发现问题出在我自定义的 ObjectMapper 没有注册JavaTimeModule。解法有两个层面spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai这只是管 java.util.Date 的。LocalDateTime 还得靠实体字段上的JsonFormat(pattern yyyy-MM-dd HH:mm:ss)。另一个更省事的办法是全局配置Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder - builder.simpleDateFormat(yyyy-MM-dd HH:mm:ss) .serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))) .deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); }6.4 事务不生效的经典场景Service 自调用这个不算 Mybatis-Plus 的坑但我在用Service接口时几乎每过一阵就会遇到有人问。比如Transactional(rollbackFor Exception.class) public void itIsTransactional(Integer userId) { this.save(user); this.saveOrder(userId); // 内部方法抛异常 }看起来加了Transactional但事务就是不回滚第一条记录还是进去了。原因很简单this.save()调用的是当前对象的自身方法没有经过 Spring 的代理而Transactional是通过代理类拦截的。解决方法是注入自身的代理或者把saveOrder放到另一个 Bean 里调。比如通过AopContext.currentProxy()拿到代理对象再调内部方法。注意还要在启动类上配置EnableAspectJAutoProxy(proxyTargetClass true, exposeProxy true) public class DemoApplication { }6.5 全局异常处理与 MP 异常的框架差异Mybatis-Plus 的某些异常没有走 Spring 的标准异常体系比如MybatisPlusException是继承自RuntimeException的它的处理顺序和捕获逻辑跟传统的DataAccessException不太一样。如果你在ControllerAdvice里只写了ExceptionHandler(Exception.class)那没问题但如果你分开了两个 handler 分别处理业务异常和框架异常要注意顺序防止 MP 的异常被兜底 handler 吞掉后返回一堆无关的堆栈信息给前端。我建议在全局异常里至少加一个针对MybatisPlusException的分支统一包装成友好提示避免前端看到“Internal Server Error”的时候连哪里出错都不知道。在我自己的工作流里Spring Boot 3.3.x 和 Mybatis-Plus 3.5.9 这套组合用了几个月整体比 2.x 时代的体验清爽很多。最大的感受是版本对齐这事千万不能凑合一个坐标写错后面所有的画面都会不一样。如果你们的项目也处在升级或者初始搭建的阶段建议先把这一整套骨架跑通再去填充业务代码。前面 1 到 4 节的内容够你把项目拉起来第 5 节的通用服务算是一个加分项第 6 节的那些报错最好在开发环境提前测出来。