前几天把一套内部后台管理系统从 Spring Boot 2.7 往 3.2 上迁,MyBatis-Plus 也顺手从 3.5.1 升到 3.5.9。本来以为这种小版本升级应该没什么波澜,结果应用启动到一半直接抛了Invalid value type for attribute 'factoryBeanObjectType',后面还跟着一串 SqlSessionFactory 相关报错。查了一圈,问题根本不在业务代码,而是依赖坐标里混进了旧版 mybatis-spring。
这篇不打算做官方文档的复读机,只把我这次升级踩过的坑、验证过的依赖组合、分页/自动填充/逻辑删除的配置,以及日常 CRUD 里几个趁手的写法整理出来。适合三类人看:一是正在把老项目从 Spring Boot 2.x 往 3.x 迁的,二是新项目想直接用 Spring Boot 3 + MyBatis-Plus 3.5.9 做底座的后端,三是启动报错但找不到排错方向的开发者。先把结论放在前面:Spring Boot 3 不需要什么魔法,大多数集成问题无非是命名空间、依赖版本、拦截器注册这三件事没对齐。
1. Spring Boot 3 升级的本质变化:不只是一次版本号跳动
很多人会把 Spring Boot 2.7 到 3.x 当成普通大步进升级,但它其实是一次技术栈换代。MyBatis-Plus 3.5.9 之所以能和 Spring Boot 3 配合,靠的不是版本号数字够新,而是它专门做了底层适配。要理解后面那些报错,得先看明白三件事。
1.1 Jakarta 命名空间迁移
Spring Boot 3 是建立在 Spring Framework 6 之上的,最直接的变化就是 Java EE 的包名从javax.*全面换成了jakarta.*。比如过去写javax.servlet.http.HttpServletRequest,现在必须写jakarta.servlet.http.HttpServletRequest。不只是 Servlet,包括 Validation、Resource 注解这些基础 API,命名空间全换了。
这一刀切下来,影响面非常大。所有第三方框架如果还是按旧规范编译,在 Spring Boot 3 的容器里就可能出现ClassNotFoundException或者NoClassDefFoundError。MyBatis-Plus 老版本里的 boot-starter 依赖的是 mybatis-spring 2.x,而 mybatis-spring 2.x 的类还是面向javax体系设计的。它在 Spring Boot 3 环境里虽然能勉强加载,但因为 Spring 容器对 Bean 定义的类型推断方式变了,直接导致启动阶段就崩。
MyBatis-Plus 3.5.9 之所以能行,就是因为它内部把 mybatis-spring 拉到了 3.0 系列,并且提供了独立的mybatis-plus-spring-boot3-starter坐标。换句话说,不是 Spring Boot 3 变简单了,而是适配 Spring Boot 3 的版本终于出来了。
1.2 Java 17 基线带来的连带改动
Spring Boot 3 要求 JDK 17 起步,这本身是个硬门槛。如果你的项目还在 JDK 8 上跑,那第一个要改的不是 MyBatis-Plus,而是编译环境。
但这里有个容易被忽略的连带问题:Lombok。旧版 Lombok 对 Java 17 的支持很差,编译时经常报错,甚至直接生成错误的字节码。我这次升级就把 Lombok 从 1.18.24 提到了 1.18.30 以上,问题才消失。
还有 maven-compiler-plugin 的配置。我之前见过不少项目配置的是<source>1.8</source>,升级 Spring Boot 3 后编译报错,就是因为编译器还在按 Java 8 语法处理代码。建议统一改成:
<properties> <java.version>17</java.version> </properties> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <release>17</release> </configuration> </plugin>这里特意用release而不是source/target,是因为source/target只控制语法版本,release会连 JDK 内部 API 一起限制,能提前发现那些用了高版本独有 API 的代码。我自己在本地跑 JDK 21 时,也还是把 release 降到 17,避免把字节码级别推到太新。
1.3 MyBatis-Plus 3.5.9 为 Spring Boot 3 做了哪些适配
3.5.9 这个版本最关键的适配点有两块。
第一块是 Mapper 注册机制。Spring Boot 3 的自动配置对 FactoryBean 的泛型判断更严格,旧版 mybatis-spring 的MapperFactoryBean已经不适合新容器。3.5.9 换成与新容器匹配的 mybatis-spring 3.x 后,@MapperScan扫描到的每个 Mapper 接口才能正常生成代理对象。
第二块是 SQL 解析依赖被拆分了。3.5.x 系列把 jsqlparser 单独做成可选依赖,默认 starter 不会传递引入。这不是坏事,而是为了减重。但要注意,分页插件在做 count 优化、join 优化的时候需要用到 SQL 解析能力。如果你用的是复杂分页场景,最好显式补上mybatis-plus-jsqlparser依赖,否则某些环境里分页执行到一半会报 SQL 解析异常,而且报错信息并不直观。
我自己某一次在独立环境里复现这个问题时,启动完全正常,一执行SELECT分页就炸,日志指向 jsqlparser 相关类找不到。当时一度以为是分页插件配置错了,最后 Maven 依赖树一查,才知道是少了一个可选依赖。
2. 依赖坐标与版本矩阵:把 pom.xml 写成能一次跑通的样子
依赖问题占了 Spring Boot 3 集成 MyBatis-Plus 3.5.9 报错的七成以上。这一节直接把我认为最稳的组合写出来,照着抄能省很多时间。
2.1 用 mybatis-plus-spring-boot3-starter,而不是旧 boot-starter
很多人按着老记忆去搜依赖坐标,会搜到mybatis-plus-boot-starter,这个东西在 Spring Boot 3 下已经不能直接用了。3.5.9 对应的正确坐标是:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.9</version> </dependency>如果还需要 jsqlparser 的 SQL 解析能力,再补一个可选依赖:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-jsqlparser</artifactId> <version>3.5.9</version> </dependency>如果你项目里之前用了mybatis-plus-boot-starter,升级时不能只把版本号改成 3.5.9,必须把 artifactId 整个换掉。我这次就是在新模块里图省事直接写了旧坐标,结果启动报错时根本没想到是坐标不对。后来在 Maven 依赖树里看到传递进来的 mybatis-spring 版本是 2.x,才反应过来。
2.2 几种常见依赖搭配与 Maven 传递冲突
除了坐标本身,还要注意传递依赖的冲突。最典型的是spring-boot-starter-jdbc。
MyBatis 相关的 starter 本身就会传递引入数据源支持,正常情况下不需要再手动加spring-boot-starter-jdbc。但有些项目里因为要用 JdbcTemplate,额外引了一份,这时候 Maven 的版本仲裁就可能把 mybatis-spring 拉低到旧版本,于是又回到第一节说的启动崩溃。
我自己现在遇到的项目里有相当多是 Spring Boot 3.2 + HikariCP 默认连接池 + MyBatis-Plus 3.5.9 的组合,这个组合其实最干净,什么都不用额外配。除非确认有特殊需求,否则没必要再引入额外的连接池 starter。如果实在要引入 JdbcTemplate,建议在依赖里排除掉旧 mybatis-spring,或者干脆不用 Spring Boot 的自动配置,单独创建 JdbcTemplate Bean。
下表是我常用的依赖对照,方便排查时快速确认:
| 用途 | 依赖 | 推荐版本 | 说明 |
|---|---|---|---|
| Spring Boot 基础 | spring-boot-starter-web | 3.2.x 或 3.3.x | 与内嵌容器版本对齐 |
| MyBatis-Plus 核心 | mybatis-plus-spring-boot3-starter | 3.5.9 | 替代旧 boot-starter |
| SQL 解析 | mybatis-plus-jsqlparser | 3.5.9 | 分页 count/join 优化需要 |
| 编译增强 | lombok | 1.18.30+ | 低于此版本在 JDK 17 会编译失败 |
| 编译插件 | maven-compiler-plugin | 3.11.0+ | release 参数设为 17 |
2.3 一套可复制的配置模板
pom 依赖选对之后,application.yml 里的 MyBatis-Plus 配置也有几个值得固定的默认项。我的建议是能少写就少写,把该开的开、该关的关。
mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml type-aliases-package: com.example.demo.entity 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这里说明两个点。
map-underscore-to-camel-case在 MyBatis-Plus 里默认就是 true,但写出来能让后面接手的人一眼看清意图。log-impl配成StdOutImpl是开发期的做法,SQL 会直接打到控制台,定位问题非常直观,但生产环境一定不要开,否则日志量会翻好几倍。我自己的习惯是:开发环境打印,测试环境开慢 SQL 统计,生产环境把日志级别统一关掉。
另外id-type: assign_id是 MyBatis-Plus 的雪花算法主键,对应实体主键用Long,数据库字段用BIGINT。如果你数据库主键用int,雪花算法生成的 ID 会溢出,这是很多人忽略的坑。
3. 启动阶段几类高频报错及完整排错链路
配置完成之后,真正跑项目时才是战斗开始的地方。以下三个问题是我在升级过程中真实见过的,按出现频率排序,从根因到解法一起说清楚。
3.1 Invalid value type for attribute 'factoryBeanObjectType'
报错信息大致长这样:
Invalid value type for attribute 'factoryBeanObjectType': java.lang.String这个报错的本质是 Spring Boot 3 在扫描 Mapper Bean 时,需要MapperFactoryBean提供明确的泛型类型,而旧版 mybatis-spring 的类结构没法满足新容器的要求。常见触发原因有两个:一是用了旧的mybatis-plus-boot-starter,二是手动引入的某个依赖把 mybatis-spring 拉回了 2.x。
排错操作顺序:
- 执行
mvn dependency:tree看 mybatis-spring 的实际版本。 - 如果显示 2.x,找出是哪个依赖传递进来的。
- 把项目里的 MyBatis-Plus starter 换成
mybatis-plus-spring-boot3-starter3.5.9。 - 重新
mvn clean compile后启动。
这一步最关键的地方是不要急着改代码。业务代码连动都还没动,问题只在依赖装配层面,先把版本树捋清楚。
3.2 Mapper 注入为 null 与 SqlSessionFactory 初始化失败
另一种常见情况是应用能启动,但一到业务层调用某个 Mapper 时就空指针,或者报NoSuchBeanDefinitionException。定位卡住的人通常会怀疑是不是 Bean 没扫描到,其实大部分时候是@MapperScan的位置不正确。
Spring Boot 3 的组件扫描基于主启动类所在的包根部往下扫。如果你把@MapperScan写在了某个子包下,而 Mapper 接口和启动类不在同一个祖先包范围内,生成的 Mapper 代理就没有被注册进容器。
我的建议是:@MapperScan放在启动类上,路径直接指到包含全部 Mapper 接口的包,比如:
@SpringBootApplication @MapperScan("com.example.demo.mapper") public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }这里不要偷懒去扫整个主包,只扫 Mapper 专门的包,避免把非 Mapper 接口误注册。如果项目是多数据源场景,还会多一层 SqlSessionFactory 创建的复杂度,建议先把单数据源跑通再上多数据源。
3.3 一次从日志到依赖树的排错示范
有一次我在一个模拟项目里重现问题,日志显示:
APPLICATION FAILED TO START Description: Parameter 0 of method sqlSessionFactory in com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration required a single bean, but 2 were found:这个就属于 SqlSessionFactory 存在多个候选,Spring 不知道用哪个。当时顺着日志往上翻,发现是某个测试模块里配置了两个数据源,而且两个数据源都触发了自动配置。解决办法很简单:在其中一个数据源配置类上排除掉DataSourceAutoConfiguration,或者指定@Primary。
排错思路可以固化成一套:先看启动失败的具体阶段,再看日志里缺的是 Bean、还是类、还是依赖,最后再用dependency:tree验证版本。别一上来就试网上的各种配置,配置基础不对,试一百遍也一样。
我整理了一个速查表,方便对照:
| 日志关键字 | 根因 | 处置 |
|---|---|---|
| Invalid value type for attribute 'factoryBeanObjectType' | mybatis-spring 版本过低 | 换 boot3 starter,排除旧依赖 |
| NoSuchBeanDefinitionException: SqlSessionFactory | 数据源/SqlSessionFactory 多候选 | 指定 @Primary 或排除自动配置 |
| Mapper 注入为 null | @MapperScan 路径不匹配 | 放到启动类,扫描 Mapper 专用包 |
| SQL 解析异常 / jsqlparser 相关类缺失 | 缺 mybatis-plus-jsqlparser | 补可选依赖 |
4. 分页、自动填充、逻辑删除在 3.5.9 里的配置实践
这三个能力是 MyBatis-Plus 在开发中最常用的功能,也是在 Spring Boot 3 下比较容易失效的地方。失效的原因往往不是框架坏了,而是配置方式还停在旧版本的习惯上。
4.1 分页拦截器的正确注册与参数保护
3.5.x 已经废弃了原来的PaginationInterceptor,正确做法是注册MybatisPlusInterceptor并把PaginationInnerInterceptor加进去:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); pagination.setOptimizeJoin(true); interceptor.addInnerInterceptor(pagination); return interceptor; } }这里有两个容易被忽略的动作。
第一,DbType.MYSQL要匹配实际数据库。如果你用 PostgreSQL 却写DbType.MYSQL,生成的分页 SQL 会出现语法错误。第二,setMaxLimit(500L)是个保护性参数。生产环境最怕有人前端口误传一个超大页码,直接把全表刷出来,加了上限之后超过 500 条会自动限制,避免接口被打爆。
分页插件的返回值里,total字段的准确性取决于 count 查询是否正确。3.5.9 在 join 场景下默认会尝试优化 count 语句,如果关联表条件复杂,可以关闭optimizeJoin让 count 走原始 SQL,更保险。
4.2 MetaObjectHandler 自动填充的隐蔽坑
自动填充是 MyBatis-Plus 的一个标志性能力,但 Spring Boot 3 下它有个隐蔽问题:填充方法里使用的 Java 类型和实体字段类型不一致时,填充会静默失效。
先看正确配置:
@Component public class AuditMetaObjectHandler 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;坑在于:如果你的createTime字段声明成Date,而strictInsertFill里传的是LocalDateTime.class,填充不会报错,只是不生效,create_time 保持 null。排查这类问题最直接的办法是看 insert 之后控制台打印的 SQL,字段有没有出现在 set 列表里。
另外,如果项目里同时注册了多个MetaObjectHandler实现,它们的执行顺序由 Bean 的名字或@Order决定。处理同名字段时,后执行的会覆盖先执行的。我一般每个业务模块只保留一个全局填充类,避免自己跟自己打架。
4.3 逻辑删除的全局配置与多表查询注意事项
逻辑删除可以用注解@TableLogic,也可以走全局配置。我建议两个都用,注解表达业务含义,全局配置兜底。
mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0实体里对应加一个字段:
@TableLogic private Integer deleted;全局配置的好处是:所有实体在生成 SQL 时都会自动带上WHERE deleted = 0,你不用每个表都去补条件。
但要注意,这个自动过滤只对单表操作生效。多表 join 时,如果副表用了逻辑删除,MyBatis-Plus 生成的 SQL 不会自动给副表加过滤条件。你得自己在 SQL 里手写t2.deleted = 0,否则查出来的数据会混入已删除记录。我遇到过最隐蔽的一次是报表统计对不上数,排查了半天才发现是逻辑删除字段没有作用到关联子查询。这是一个非常容易踩、又很难发现的坑。
5. 条件构造器与 Service 层封装:日常 CRUD 的三种高效写法
框架集成跑通只是开始,日常开发里真正影响效率的是条件构造器和 Service 层怎么用。3.5.9 把链式 API 做得很顺,但用错了照样难受。
5.1 LambdaQueryWrapper:避免魔法字段名的关键
很多老项目还在用QueryWrapper,写起来是这么写的:
QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq("name", "张三");这写法有个致命问题:字段名是字符串,一旦实体改字段名,这里编译期完全不提示,运行期才发现 SQL 报错。我更建议无脑用LambdaQueryWrapper:
LambdaQueryWrapper<Product> wrapper = Wrappers.lambdaQuery(); wrapper.eq(Product::getStatus, 1) .like(StringUtils.hasText(keyword), Product::getName, keyword) .and(w -> w.gt(Product::getPrice, 100).or().isNull(Product::getPrice)) .orderByDesc(Product::getCreateTime) .last("LIMIT 10");这个写法有几个细节值得说。
用Product::getStatus而不是字符串,从源头杜绝字段名漂移。like方法第一个参数是 boolean 条件,keyword 为空时不拼接这个条件,省掉一长串 if-else。and(w -> ...)这种嵌套 lambda 用于处理 or 和 and 的优先级,生成出来的 SQL 会带括号,避免出现经典的where a or b and c语义错乱问题。
.last("LIMIT 10")这个操作要节制。它会在 SQL 末尾追加内容,如果拼接了用户可控的字符串,等于把 SQL 注入风险打开。我通常只在固定常量场景里用它,绝不把外部参数传进去。
5.2 ServiceImpl 链式调用与 saveOrUpdate 的底层逻辑
MyBatis-Plus 的IService接口自带不少封装方法,继承之后开发速度能快不少:
public interface ProductService extends IService<Product> { }@Service public class ProductServiceImpl extends ServiceImpl<ProductMapper, Product> implements ProductService { }然后业务代码就能写:
productService.lambdaUpdate() .eq(Product::getId, id) .set(Product::getStock, stock - 1) .update(); productService.lambdaQuery() .eq(Product::getCategoryId, categoryId) .list();这个链式语法读起来非常接近自然语言,逻辑一目了然。
但saveOrUpdate要想清楚再用来处理批量场景。它的内部逻辑是:先根据主键查一次数据,存在就 update,不存在就 insert。单条调用还好,循环里调用saveOrUpdate就会出现 N 次查询加 N 次写入,性能损耗容易被忽略。我在实际项目里见过一段循环调用saveOrUpdate的代码,处理 1000 条数据执行耗时翻了五倍。
建议是:确定是新增的场景用saveBatch批量插入,确定是更新用updateBatchById,只有确实需要"存在更新、不存在插入"的业务才用saveOrUpdate。
5.3 特殊字段处理:JSON 映射与 TypeHandler
实体里偶尔会有List<String>、Map<String, Object>这类 JSON 字段。MyBatis-Plus 3.5.9 支持在字段级别配置JacksonTypeHandler:
@TableName(value = "member", autoResultMap = true) public class Member { @TableField(typeHandler = JacksonTypeHandler.class) private List<String> tags; }这里有个硬性条件:实体类必须开启autoResultMap = true,否则查询结果回填时 TypeHandler 不生效。我见过有人只加了@TableField(typeHandler = JacksonTypeHandler.class),结果插入正常、查询出来的 tags 都是 null,就是忽略了autoResultMap。
数据库字段类型建议用json、text或varchar都行,但要从一开始定好规范。用varchar时要注意长度,JSON 字符串很容易超出默认 255 的长度限制,插入时直接报 Data truncation,排查起来又看不出明显痕迹。
6. SQL 日志与慢查询治理:让 MyBatis-Plus 跑得更透明
框架用顺手之后,真正决定线上体验的是 SQL 可观测性。Spring Boot 3 + MyBatis-Plus 3.5.9 的组合里,如果只靠默认输出,排查问题是很难受的。我会把常用的两个手段放一起说。
6.1 日志输出该怎么配置
开发环境最直接的做法是把 MyBatis 日志打出来,在 application.yml 里加:
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样控制台会打印完整的 SQL、参数和返回行数,排查数据对不上、字段为 null 的问题都非常有用。
但 StdOutImpl 有个缺点:SQL 和参数是分开打印的,参数占位符和实际值在日志里离得比较远,读起来费劲。你可以自己写一个简单的BaseLog引导让 MyBatis 走默认日志体系,或者用接口层面统一打印。我有个习惯是在 Mapper 接口上拉一个切面,专门记录入参和耗时,配合 StdOutImpl 能很快定位是哪条 SQL 慢。
6.2 用拦截器统计慢 SQL
MyBatis 本身没有内置慢 SQL 日志,但可以靠拦截器实现。我用的方案是在 MyBatis 的Executor层面加一个拦截器,统计每个方法执行的耗时。
@Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}) }) @Component public class SqlCostInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { long start = System.currentTimeMillis(); try { return invocation.proceed(); } finally { long cost = System.currentTimeMillis() - start; if (cost > 500) { MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; System.out.println("[慢SQL] " + ms.getId() + " 耗时 " + cost + "ms"); } } } }这个方案的好处是不需要引入额外的追踪组件,代码量也小。500ms 阈值可以按项目实际情况调,接口平均耗时就 20ms 的项目,阈值设成 200ms 更合适。拦截器的扫描顺序会对拦截效果有影响,建议放在 MybatisPlusInterceptor 之后注册,避免和分页插件产生顺序干扰。
6.3 二级缓存为什么我默认不开启
MyBatis-Plus 支持通过@CacheNamespace开启二级缓存,实体实现Serializable之后就能跨 SqlSession 共享缓存数据。听起来很美好,但我在实际项目里基本默认不开启。
原因有两个。第一,如果你的项目是分布式多实例部署,二级缓存是本地缓存,一个实例更新了数据,其他实例的缓存还是脏的。第二,MyBatis-Plus 的逻辑删除、自动填充、分页 count 这些操作跟二级缓存叠加时,很容易返回过期的总数或结果,而且缓存变化的触发条件并不直观。
我现在的习惯是:单机小项目能用,分布式项目一律关掉。如果要做缓存,建议在更上层做,比如 Service 层用 Spring Cache,搭配 Redis 这类集中式缓存。把 MyBatis 的二级缓存留给那些对一致性要求极低的只读配置表,并且要主动控制缓存的失效时机。
最后再分享一个我自己升级时的判断标准。不要为了追赶框架版本而升级,Spring Boot 3 + MyBatis-Plus 3.5.9 的收益更多体现在长期维护和 Java 17 带来的性能潜力上。如果项目还在 JDK 8 且运行稳定,优先升 Java 17,再谈框架版本,否则一次改动叠加大问题排查会非常痛苦。而如果已经决定升级,先把 pom 坐标全部对齐,再处理分页和自动填充的配置,最后才是不急不慢的代码层面优化。这套顺序走完,基本不会在启动阶段折戟。