资讯详情

Spring Boot 3升级MyBatis-Plus 3.5.9实战:踩坑记录与依赖配置指南

📅 2026/10/15 10:41:13 | 华诺云谱 👁 阅读
Spring Boot 3升级MyBatis-Plus 3.5.9实战:踩坑记录与依赖配置指南
前几天把一套内部后台管理系统从 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 的配置。我之前见过不少项目配置的是source1.8/source升级 Spring Boot 3 后编译报错就是因为编译器还在按 Java 8 语法处理代码。建议统一改成properties java.version17/java.version /properties plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration release17/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 groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.9/version /dependency如果还需要 jsqlparser 的 SQL 解析能力再补一个可选依赖dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-jsqlparser/artifactId version3.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-web3.2.x 或 3.3.x与内嵌容器版本对齐MyBatis-Plus 核心mybatis-plus-spring-boot3-starter3.5.9替代旧 boot-starterSQL 解析mybatis-plus-jsqlparser3.5.9分页 count/join 优化需要编译增强lombok1.18.30低于此版本在 JDK 17 会编译失败编译插件maven-compiler-plugin3.11.0release 参数设为 172.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 factoryBeanObjectTypemybatis-spring 版本过低换 boot3 starter排除旧依赖NoSuchBeanDefinitionException: SqlSessionFactory数据源/SqlSessionFactory 多候选指定 Primary 或排除自动配置Mapper 注入为 nullMapperScan 路径不匹配放到启动类扫描 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写起来是这么写的QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(name, 张三);这写法有个致命问题字段名是字符串一旦实体改字段名这里编译期完全不提示运行期才发现 SQL 报错。我更建议无脑用LambdaQueryWrapperLambdaQueryWrapperProduct 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 IServiceProduct { }Service public class ProductServiceImpl extends ServiceImplProductMapper, 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实体里偶尔会有ListString、MapString, Object这类 JSON 字段。MyBatis-Plus 3.5.9 支持在字段级别配置JacksonTypeHandlerTableName(value member, autoResultMap true) public class Member { TableField(typeHandler JacksonTypeHandler.class) private ListString 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 用拦截器统计慢 SQLMyBatis 本身没有内置慢 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 坐标全部对齐再处理分页和自动填充的配置最后才是不急不慢的代码层面优化。这套顺序走完基本不会在启动阶段折戟。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑