Spring Boot 3与MyBatis Plus整合:版本适配、配置与排障实战
简介Spring Boot 3与MyBatisPlus整合示例工程面向需要快速搭建Java Web数据访问层的开发者目标是解决传统MyBatis配置繁琐、CRUD模板代码多的问题。资源采用Maven标准结构组织压缩包约85KB共24个文件包含Java源码、编译后的class文件、YAML配置、pom依赖与Maven wrapper等可直观查看完整项目骨架和依赖版本。示例工程覆盖数据源配置、实体类注解、BaseMapper继承、条件构造器使用以及Service、Controller分层调用可帮助读者理解如何利用MyBatisPlus的自动填充、通用CRUD与Lambda查询来减少重复代码。压缩包内src目录按主代码与测试代码分层可直接导入IDE运行调试便于边看边练。工程结构简洁依赖与配置集中便于直接迁移到个人项目中二次开发。配套的YAML配置和Mapper接口示例尤其适合对照学习初学者可按步骤搭建中级开发者也能快速提取整合方案。目前已有1435人学习资源体积小巧、内容聚焦适合作为Spring Boot 3技术栈的入门实践参考。1. Spring Boot 3 MyBatis Plus 整合先把版本适配这关过了再谈开发效率Spring Boot 3 一上来就把基础运行环境抬到了 JDK 17同时把 javax 命名空间整体换成了 jakarta不少人从 2.x 老项目照搬依赖坐标应用根本起不来。MyBatis Plus 这边starter 也改成了针对 Boot 3 的专用坐标依赖引错就是启动报错、Mapper 扫描不到、分页全部失效的三连坑。这篇文章把 Spring Boot 3 与 MyBatis Plus 整合的完整路径拆开讲清楚选型、依赖、配置、拦截器、代码生成器、条件构造器再到真实排障记录适合正在做 Boot 3 升级、或新项目直接落在 Boot 3 上的后端开发者按部就班复现。2. 先把版本矩阵定下来Spring Boot 3 与 MyBatis Plus 的兼容性边界2.1 JDK 17 与 Jakarta 命名空间两个硬约束困住老项目先从最常见的老项目改造说起。很多团队在 Spring Boot 2.x 时代用的是 JDK 8代码里到处都是javax.annotation.Resource、javax.validation.*这类包名。Spring Boot 3 奠基在 Spring Framework 6 之上Java 版本底线是 17命名空间整体迁移到 Jakarta EE 9这意味着两条线同时断掉编译期javax头文件直接飘红运行时如果还在用旧版切面或字节码增强工具ClassNotFound 会一层一层往外冒。MyBatis Plus 在适配这条路上也经历了一轮改造。它的 Spring Boot 3 适配版本把内部对数据源、Servlet 相关的引用全部切换成jakarta.*所以你不能把 2.x 时代的旧模块硬塞进 Boot 3 工程里。这个阶段最容易出的是UnsatisfiedDependencyException报错信息指向某个与 MyBatis 相关的 Bean 创建失败但底层根因其实是依赖版本没对齐。我一般会在动手前先跑一次依赖树盘点看清楚自己手里到底是什么坐标mvn dependency:tree -Dincludescom.baomidou这段命令会把com.baomidou相关的所有间接依赖拉出来。如果看到的是mybatis-plus-boot-starter而不是mybatis-plus-spring-boot3-starter依赖就没换对。参数上-Dincludes后面的值用的是 groupId 加冒号过滤可以只查某个库也可以查出全部依赖后肉眼过滤。再比如 Spring Boot 3 里已经没有了spring.factories自动装配那套旧机制改用AutoConfiguration.imports。这直接导致一些老版本的第三方 starter 在 Boot 3 里静默失效表面不报错但功能全无。所以整合这件事依赖坐标只是第一步版本矩阵对了后续的拦截器和配置才有意义。2.2 依赖坐标Boot 3 专属 starter 与版本策略正确的依赖长这样放在pom.xml的 dependencies 里dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version你本地仓库能拉到的 3.5.x 最新版/version /dependency关于 groupId、artifactId、version 三个参数有两点必须说清第一artifactId 必须是mybatis-plus-spring-boot3-starter中间多了spring-boot3字样这就是为 Boot 3 准备的专用坐标。如果你用了普通的mybatis-plus-boot-starter类路径里加载的依然是指向 Boot 2 时代的自动配置类启动时大概率直接抛ClassNotFoundException: jakarta.annotation.Resource。第二版本不要在 pom 里写死一个远古版本。3.5.x 系列里只有 3.5.3 之后才提供对 Spring Boot 3 的完整适配我习惯直接选当前仓库最新的 3.5.x 小版本既规避了早期适配不完整的细节问题也能兼容后续 JDK 小版本升级。另外一个特别容易被忽略点如果你项目里还引用了单独的mybatis-plus-extension或mybatis-plus-core依赖版本必须和 starter 配套。混搭不同小版本会导致AbstractMethodError这种错误报在方法执行时报错位置还很偏排查起来很折磨人。2.3 最小可运行工程启动一遍再写业务我强烈建议先把最小工程搭起来跑通一个查询再进行业务开发。这个最小工程由四部分组成启动类、实体类、Mapper 接口、一个测试方法。SpringBootApplication MapperScan(com.demo.mapper) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }启动类上MapperScan的包路径必须覆盖 Mapper 接口所在目录否则运行时所有 Mapper 都不会注册。Data TableName(t_user) public class User { TableId(type IdType.ASSIGN_ID) private Long id; private String name; private Integer status; private LocalDateTime createTime; }实体类用TableName显式绑定表名TableId指定主键策略。ASSIGN_ID是雪花算法生成 ID适合分布式场景如果是自增主键改成IdType.AUTO。Mapper public interface UserMapper extends BaseMapperUser { }Mapper 接口继承BaseMapperUser后单表的 insert、delete、update、select 方法全都有了不需要写任何 SQL。SpringBootTest class UserMapperTest { Autowired private UserMapper userMapper; Test void testSelectById() { User user userMapper.selectById(1L); System.out.println(user); } }这个测试能跑通说明数据源、Mapper 扫描、实体映射全部通了。如果这一步走不通后面的代码生成器、分页、乐观锁全是空谈。检查排查顺序是先看启动日志有没有数据源初始化失败再看控制台 SQL 有没有打印出来最后确认返回对象是不是 null。3. 配置与插件基建分页、乐观锁、自动填充一次配齐3.1 application.yml 中四组核心参数的语义解释src/main/resources/application.yml里需要配置四大块内容spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/demo_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: false db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0driver-class-name用的是 MySQL 8 的驱动类Class.forName 加载时不会显式触发但配错了会在启动阶段报Failed to load driver class。url里的serverTimezoneAsia/Shanghai必须带上否则 MySQL 8 与 JVM 时区不一致查询时间字段会差 8 小时。map-underscore-to-camel-case: true这个开关建议显式打开。Spring Boot 2.x 时代不少项目默认就是开的但在 Spring Boot 3 MyBatis Plus 3.5.x 组合下如果不写这一项并不会因为你引了 MP 就默认开启。你会在查询时遇到一种很隐蔽的现象SQL 执行不报错但 userName 字段永远是 null就是因为user_name列没有自动映射到userName属性。log-impl配成StdOutImpl是开发期最直接的 SQL 输出方案每条 SQL、预编译参数、影响行数全部打到控制台。它有一个坏处就是刷屏生产环境记得换掉。# 生产环境推荐的 log-impl 配置 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImplglobal-config.db-config里的逻辑删除三项配置是配合TableLogic注解用的。logic-delete-field: deleted声明实体里哪个字段是逻辑删除标志logic-delete-value: 1和logic-not-delete-value: 0分别表示删除和未删除时字段落库的值。MP 会在执行 deleteById 时自动转成 update 语句把 deleted 置为 1。3.2 MybatisPlusInterceptor拦截器注册顺序决定行为分页和乐观锁都要依靠 MP 的拦截器链这个链的注册方式是一个配置类Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }PaginationInnerInterceptor负责把物理分页语句拼到原 SQL 上。DbType.MYSQL告诉它按 MySQL 方言生成LIMIT如果这里不传或者传错部分数据库会生成不符合语法的 SQL查询直接抛异常。这个枚举值一共有几十种不要图省事写个DbType.OTHER那等价于没有分页。OptimisticLockerInnerInterceptor依赖实体字段上的Version注解。注册之后执行 MP 内置的 updateById 时会自动带上where version ?的条件并在更新成功后把 version 自增。这里的两个坑看起来很小但实际很要命第一拦截器顺序必须分页在前、乐观锁在后。顺序颠倒后某些查询场景下分页的 count 语句会被乐观锁逻辑干扰导致 count 结果不准。第二乐观锁只包装 MP 内置的 update 方法如果你走的是自定义 SQL乐观锁不会生效必须在 SQL 里手动判断 version。实体字段上的Version注解用法如下Version private Integer version;3.3 自动填充处理器与逻辑删除的配合自动填充用来解决 create_time 和 update_time 这类字段的自动赋值问题。最常用的写法是自定义一个 MetaObjectHandler 实现类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()); } }对应的实体字段必须加注解否则填充永远不生效TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;FieldFill.INSERT表示只在 insert 时填充FieldFill.INSERT_UPDATE表示 insert 和 update 都填充。这里有一个不对称的坑如果 updateTime 字段标的是FieldFill.INSERT那更新时strictUpdateFill会被跳过你会发现所有 update 操作之后 update_time 都是旧值排查时先对注解和 fill 枚举。strictInsertFill与strictUpdateFill的区别在于前者只填充 null 字段后者只填充已有值且字段注解匹配的字段。它们保证了不会覆盖你已经手动赋值的数据。逻辑删除那边需要在实体上加TableLogic注解TableLogic private Integer deleted;字段名和logic-delete-field声明一致时全局配置就生效。我实际用下来的感受是逻辑删除字段统一叫deleted类型统一用 Integer别一会叫 delete_flag 一会叫 is_deleted不然配置不生效你还得跟全局配置较劲。4. 从代码生成器到条件构造器把重复 CRUD 的编码时间省下来4.1 代码生成器一次生成实体、Mapper、Service、ControllerMyBatis Plus 的代码生成器可以按表反向生成整套数据访问代码。我习惯用链式配置的 FastAutoGenerator写法比老式 AutoGenerator 简洁很多FastAutoGenerator.create( jdbc:mysql://127.0.0.1:3306/demo_db?serverTimezoneAsia/Shanghai, root, root) .globalConfig(builder - builder .author(demo) .outputDir(src/main/java) .enableSwagger()) .packageConfig(builder - builder .parent(com.demo) .entity(entity) .mapper(mapper) .service(service) .serviceImpl(service.impl) .controller(controller)) .strategyConfig(builder - builder .addInclude(t_user) .addTablePrefix(t_) .entityBuilder() .enableLombok() .versionColumnName(version) .logicDeleteColumnName(deleted) .controllerBuilder() .enableRestStyle()) .execute();参数说明.addInclude(t_user)限定只生成一张表不写就是全库生成.addTablePrefix(t_)会把表名前缀剥掉t_user生成的是 User 实体。.versionColumnName(version)和.logicDeleteColumnName(deleted)会让生成的实体自带Version与TableLogic注解省去手磨实体类的功夫。生成完成后我一般会先删掉 controller 和 service因为真实项目里 Service 的方法基本都是二次加工的留着默认模板反而碍事。生成器不是拿来直接用而是拿来当起点这个心态要摆正。4.2 LambdaQueryWrapper 与 QueryWrapper怎么选、怎么用在业务代码里MP 的 Wrapper 类绕不开。新代码我推荐 LambdaQueryWrapper它在编译期就能校验字段名是否真实存在而 QueryWrapper 用字符串写列名错一个字母只能运行期炸出来。// 查询状态为1且名字包含张的用户按创建时间倒序 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1) .like(User::getName, 张) .orderByDesc(User::getCreateTime); ListUser users userMapper.selectList(wrapper);eq是等值条件like是模糊匹配orderByDesc是倒序排列。这里面的User::getStatus是方法引用MP 会解析成实际列名 status。它比写status安全重构实体字段名时 IDE 能直接报错不用等到线上环境来教育你。批量更新场景用 LambdaUpdateWrapper 更多LambdaUpdateWrapperUser updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(User::getStatus, 0) .set(User::getName, 已禁用) .set(User::getUpdateTime, LocalDateTime.now()); userMapper.update(null, updateWrapper);这里有一个边界要记住.set(User::getUpdateTime, ...)手动 set 后自动填充的updateFill不会介入。自动填充机制只对实体上有TableField(fill ...)注解的字段生效Wrapper 上的 set 是纯 SQL 拼接行为。所以走 Wrapper 更新时时间字段也一律用 set 显式写不要指望填充器帮你补。如果 updateTime 字段在实体上标了INSERT_UPDATE同时你又在 Wrapper 里 set 了同一个字段会出现一次更新发两条 set 的情况行为容易被搞混。4.3 自定义 SQL 与分页联查别被 BaseMapper 限制住BaseMapper 只能处理单表 CRUD多表联查必须走自定义 SQL。常见做法是 Mapper 接口里定义方法XML 文件写 SQLMapper public interface UserMapper extends BaseMapperUser { IPageUserVo selectUserPage(IPageUser page, Param(status) Integer status); }select idselectUserPage resultTypecom.demo.vo.UserVo SELECT u.id, u.name, u.status, o.order_no FROM t_user u LEFT JOIN t_order o ON u.id o.user_id WHERE u.status #{status} /select注意IPage参数放在方法第一个位置MP 的PaginationInnerInterceptor会从方法参数里识别出 IPage然后自动改写 SQL把查询变成带 LIMIT 的物理分页。如果你把 IPage 放到了第二位或者没传拦截器认不出分页意图结果就是查全表。resultType 这里用的是 UserVo联表查询回来的列名如果和属性对不上可以在 VO 里加别名或者直接改用 resultMap 显式映射。经验上凡是联表查询我一律用 resultMap因为 resultType 依赖驼峰映射的默认行为写错一个列名就是一个 null 字段报错还没有等到前端页面展示时才暴露那已经晚了。resultMap iduserVoMap typecom.demo.vo.UserVo id propertyid columnid / result propertyname columnname / result propertyorderNo columnorder_no / /resultMap select idselectUserPage resultMapuserVoMap SELECT u.id, u.name, o.order_no FROM t_user u LEFT JOIN t_order o ON u.id o.user_id WHERE u.status #{status} /select5. 避坑手记Spring Boot 3 MyBatis Plus 整合中的五次真实排障5.1 分页查询不生效total 为 0 或返回全表现象前端传了 pageNum2、pageSize10接口返回 total0 或者直接把整张表全量吐出来。原因大概率没注册PaginationInnerInterceptor或者注册了但 DbType 写错。第二个常见原因是 Controller 里接收分页参数后没有把 IPage 传进 Service 层只在 Service 内部 new 了一个默认 Page 对象前端参数全丢每次查的都是第一页。解决先检查配置类里有没有MybatisPlusInterceptor且包含PaginationInnerInterceptor再检查 Service 实现里有没有把 Controller 传入的 Page 原样传给 Mapper 方法。我见过 Service 里写死new Page(1, 10)的代码这种写法在联调阶段一定是第一个被骂。提示分页参数在 Spring MVC 里接收时可以加 MP 提供的Page对象作为参数也可以自定义一个 PageQuery 再转成 Page。5.2 插入记录后创建时间为 null现象insert 一条数据库里 create_time 为 NULL。原因实体字段上没有TableField(fill FieldFill.INSERT)注解或者 MetaObjectHandler 没有被 Spring 扫描到。我遇到过一次字段明明加了注解还是 null 的情况最后定位到是实体类里用了LocalDateTime但处理器里 new 出来的是Date对象类型对不上直接跳过。解决确认处理器类加了Component确认实体字段注解的 fill 类型是FieldFill.INSERT或FieldFill.INSERT_UPDATE并把处理器里赋值的类型和实体字段类型保持一致。全部改完后重启应用再看控制台输出的 insert SQL正常情况会出现create_time2024-...的参数。5.3 枚举字段映射失败反直觉的 ERROR现象实体里有个枚举类型字段查询报Cant find enum value或返回的枚举不是期望值。原因MP 默认按枚举name()来映射。如果你的库里存的是 code比如数据库写 0、1枚举类是 SUCCESS、FAIL默认映射就会在反序列化时找不到对应项。解决在枚举类中加EnumValue注解指向 code 字段并让枚举持有 code 属性和一个静态解析方法public enum StatusEnum { SUCCESS(0, 成功), FAIL(1, 失败); EnumValue private final Integer code; StatusEnum(Integer code, String desc) { this.code code; } }有一个务实建议是把全局配置里的default-enum-type-handler也显式指到 MP 的MybatisEnumTypeHandler避免多版本 MyBatis 共存时 pick 错处理器。5.4 自定义 SQL 里 resultType 一直返回空字段现象联查 SQL 执行成功但返回的 VO 里有一半字段是 null。原因resultType 走自动映射列名必须是属性名或者符合map-underscore-to-camel-case的命名如果不是匹配就有盲区。解决联查结果直接用 resultMap在 XML 里显式把 column 和 property 对应起来。一个实务点是resultMap 不参与驼峰自动映射写 resultMap 时列名要完整光写一个 id 映射就丢在那里后续新增字段又会出现空值。改成 resultMap 后字段映射关系一目了然新同事接手时也能直接从 XML 里读懂。5.5 启动日志里出现反射访问告警现象项目启动日志里出现Unable to make field ... accessible一类告警业务还能跑但看着发怵。原因Java 17 对强封装默认开启MyBatis 里某些反射访问路径被模块系统挡住。MP 3.5.x 的新版本基本都规避了但如果引入了很老的 mybatis 核心包或者其他反射工具就会触发。解决优先升级 MP 到 3.5.x 最新小版本。如果告警来自其他第三方包可以在启动参数里加--add-opens java.base/java.langALL-UNNAMED作为临时方案但长期还是得把旧组件升级掉。我在一个老项目里正是靠升级 MP 小版本把这组告警压下去的基本没动 JVM 参数。6. 验证整合成果SQL 日志、映射正确性与启动基线检查6.1 先看三类典型 SQL 是否正常整合完成后我习惯把log-impl配成 StdOutImpl做一组快速验证。启动应用后用 Postman 或直接写测试代码连续做三次操作selectById、带条件 selectList、insert 一条。回到控制台看三类输出。selectById 的日志应当是SELECT id,name,status,create_time,update_time FROM t_user WHERE id?参数是1(Long)。selectList 的日志应当包含 where 条件和排序字段。insert 的日志应当包含 create_time 和 update_time 的赋值参数。如果 selectById 出来的是SELECT *说明映射配置没走到位如果 insert 没有 create_time 参数自动填充没生效。这三步能快速辨别整合行为是否符合预期。6.2 验证一级缓存的边界MyBatis 一级缓存是 SqlSession 级别的在 Spring 管理下每次 Mapper 方法调用对应不同的 SqlSession所以一级缓存在无事务场景下是基本不生效的User user1 userMapper.selectById(1L); User user2 userMapper.selectById(1L); System.out.println(user1 user2); // false两条独立 SQL如果在一个事务里执行两次相同查询第二次会命中一级缓存打印出的 SQL 只有一个 select。理解这个边界你才不会在整合后期遇到莫名的数据不一致时怀疑 MP 帮了倒忙。从那以后我每次在 Spring Boot 3 上搭 MyBatis Plus都会先跑一遍这套检查流程再写业务逻辑。先确认依赖坐标再验证分页拦截器和自动填充最后过一遍 SQL 日志。这三个环节把 80% 的整合期问题挡在了业务编码之前希望帮到你。本文还有配套的精品资源点击获取