
Spring Boot 3 刚出来那一阵我在一个旧项目上做升级验证碰到的问题大多都出在持久层老的 MyBatis-Plus starter 完全起不来换成适配包之后又是一堆 API 变动。直到 3.5.9 正式版推出我才敢把几个新服务直接搭在 Spring Boot 3 MyBatis-Plus 3.5.9 这套组合上。这篇文章不抄官方文档而是把从依赖配置、分页插件、通用 CRUD、代码生成器到问题排查完整走了一遍之后的实操记录适合正在做 Boot 3 升级或者新项目选型想直接用新版本的同学参考。先说结论Spring Boot 3 不是换个版本号那么简单它把javax.*迁移到了jakarta.*所以 MyBatis-Plus 必须用专门的mybatis-plus-spring-boot3-starter。如果你拿着老的mybatis-plus-boot-starter硬凑启动时直接就会遇到 ClassNotFound 一类的错误根本不给你慢慢调的机会。下面我按项目落地的顺序把每一步都拆开说明白。1. 依赖环境和启动配置先把地基打牢1.1 为什么不能用老版本的 starterSpring Boot 3.0 发布时影响最大的不是版本号而是 Java EE 命名空间的迁移。以前写的javax.sql、javax.annotation等包在 Boot 3 里面都换成了jakarta.*。MyBatis-Plus 官方为此单独维护了一套适配 starter坐标是mybatis-plus-spring-boot3-starter版本号跟随主版本走比如 3.5.9。我见过不少人直接把老项目的依赖改了个版本号就往 Boot 3 上搬结果mybatis-plus-boot-starter里面的自动装配类还是继续引用javax.annotation.Resource、javax.sql.DataSource这些老接口。Spring Boot 3 的类加载环境下根本找不到这些类轻则启动日志飘红重则直接NoClassDefFoundError。正确的依赖写法长这样dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.9/version /dependency如果你的项目是分层单体应用通常还需要引入spring-boot-starter-jdbc或数据库驱动依赖比如 MySQLdependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependencymysql-connector-j是 Boot 3 时代的新坐标老项目里常用的mysql-connector-java已经停止维护。用 Boot 3.2.x 的话版本号由 spring-boot-dependencies BOM 统一管理你不用特意写版本除非你为了修复某个数据库驱动 bug 才需要手动覆盖。1.2 JDK 版本和 Spring Boot 版本的匹配表Spring Boot 3 的基线是 Java 17MyBatis-Plus 3.5.9 也是按照 Java 17 编译验证过的。如果你现在公司的生产环境还是 Java 8最好先别急着升 Boot 3Java 17 和 Java 8 的字节码版本差距不是靠换几个依赖就能抹平的。我自己在几个虚拟项目里验证过的组合如下Spring Boot 版本Java 版本MyBatis-Plus 版本备注3.2.5173.5.9推荐组合用得最多3.3.x17 / 213.5.9也可以拦截器兼容性正常3.1.x173.5.9如果项目被锁定在 3.1也能用如果新项目没有历史包袱我建议直接用 Spring Boot 3.2.x 或 3.3.x。因为 3.1 和 3.0 的某些自动装配方式在后续版本有调整而 MyBatis-Plus 3.5.9 并没有针对旧版本做特判用新 Boot 反而省心。1.3 最小可用配置以下是我新建项目时常用的application.yml片段spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/example_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: root mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: banner: false db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath*:mapper/**/*.xml这里面三个点值得说log-impl配成StdOutImpl后MyBatis 会把 SQL 打印到控制台排查问题最直接。但生产环境一定要去掉否则日志文件会被 SQL 撑爆。map-underscore-to-camel-case打开后数据库字段user_name能自动映射到实体属性userName。MyBatis-Plus 默认已经开了这里写全是为了防止有人误改。mapper-locations用classpath*而不是classpath是因为如果你的 mapper XML 分散在多个 jar 包或模块里普通路径扫描不到。实体类上的TableName、TableId注解建议写全虽然 MyBatis-Plus 有默认的驼峰转下划线规则但一旦表名和实体名对不上排查起来很费时间。2. 拦截器链分页、多租户、乐观锁、防全表更新2.1 分页插件为什么必须单独注册一个 BeanMyBatis-Plus 3.5.9 里分页功能依赖PaginationInnerInterceptor而且必须注册到MybatisPlusInterceptor里。第一次用的时候很容易漏掉这一步结果调用selectPage一看日志SQL 里根本没有 LIMIT把整张表查出来了。配置类写法如下Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setMaxLimit(500L); paginationInterceptor.setOverflow(false); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }这里我重点解释两个参数。setMaxLimit(500L)是单页最大行数限制。比如前端传入 pageSize100000如果不拦截MyBatis-Plus 会老老实实拼上LIMIT 100000本质还是全表扫描。加上maxLimit后超过 500 的部分会被强制改写成 500至少能把接口响应时间压住。setOverflow(false)是指当查询页码超出总页数时返回空列表而不是继续查最后一页。比如一共 10 页前端传了 pageNum999overflowfalse时直接返回空集合overflowtrue时则会查询第 10 页。管理后台我建议用false避免用户手动改 URL 参数后拿到意料之外的数据。2.2 多个拦截器的注册顺序如果你同时使用多租户插件、分页插件、乐观锁插件顺序不能乱。MyBatis-Plus 官方推荐的顺序是多租户插件先注册分页插件第二乐观锁插件最后。为什么多租户插件需要在 SQL 解析阶段就把tenant_id条件拼进 WHERE 里如果分页插件先执行那么分页插件解析到的可能还是未改写租户条件的原始 SQL最后生成的总数查询和分页查询就会有偏差严重的会跨租户查到别人的数据。乐观锁插件处理的是UPDATE语句的版本号条件把它放在最后可以保证前面的过滤条件、分页改写都已经完成再统一追加version ?条件。具体配置可以参考下面的顺序Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); TenantLineInnerInterceptor tenantLineInterceptor new TenantLineInnerInterceptor(); tenantLineInterceptor.setTenantLineHandler(new TenantLineHandler() { Override public Expression getTenantId() { return new LongValue(1001L); } Override public String getTenantIdColumn() { return tenant_id; } Override public boolean ignoreTable(String tableName) { return sys_config.equals(tableName); } }); interceptor.addInnerInterceptor(tenantLineInterceptor); interceptor.addInnerInterceptor(paginationInterceptor); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; }ignoreTable里可以过滤掉不需要加租户条件的表比如全局配置表。2.3 防全表更新与删除的兜底机制线上出过特别多因为写代码时少加 WHERE 条件导致的误删全表事故MyBatis-Plus 提供BlockAttackInnerInterceptor就是为了挡住这种低级错误。把它注册进拦截器链后如果你执行delete(null)或update(entity, null)MyBatis-Plus 会直接抛出异常阻止 SQL 执行。interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());这个拦截器不影响正常的deleteById(id)、updateById(entity)操作只拦截那些没有 where 条件的更新删除。我自己习惯把BlockAttackInnerInterceptor放在分页插件后面因为这类操作一般不需要和租户条件叠加放在最后反而能避免一些自定义 SQL 的误判。注意BlockAttackInnerInterceptor对通过Wrapper构造的delete(wrapper)是放行的因为 Wrapper 里带了条件。它只是拦截“无条件的全表操作”不要指望它帮你全盘兜底最终还是要靠代码 review。3. 通用 CRUD、Wrapper 与自动填充的操作细节3.1 BaseMapper 与 IService 的使用边界BaseMapperT提供的是单表通用 CRUDIServiceT则是更上层的批量操作封装。很多项目里直接让 Controller 调IService业务上确实省事但你会发现ServiceImpl里被塞满了各种业务判断最后变成一个巨型类。我的习惯是简单的单表增删改查走IService比如配置管理、字典管理复杂的统计查询、多表关联查询全部手写 XML写在Mapper接口里。不要为了少写几行代码把ServiceImpl. lambdaQuery()拼出一个上百行的条件链那比 SQL 还难读。批量插入时IService.saveBatch会按默认批次大小 1000 分批执行内部拼接的是多条 INSERT 语句。如果你的数据库是 Oracle默认分批逻辑可能和 MySQL 不同建议看下官方文档或自己重写saveBatch。MySQL 用户则要注意rewriteBatchedStatementstrue这个 JDBC 参数它能让批量插入真正变成一条复合 INSERT性能提升非常明显。我实测过没有这个参数时插入 1 万条数据要 3 秒多加上后能压到 1 秒以内。3.2 LambdaQueryWrapper 的常用技巧条件构造器是 MyBatis-Plus 最常用的查询入口。推荐用LambdaQueryWrapper不要用老的QueryWrapper。因为 lambda 写法可以编译期校验字段名不会出现字符串拼接问题。一个业务里很常见的动态查询示例public IPageUserVO pageUsers(PageUserVO page, UserQuery query) { LambdaQueryWrapperUser wrapper Wrappers.UserlambdaQuery() .eq(query.getStatus() ! null, User::getStatus, query.getStatus()) .like(StringUtils.hasText(query.getName()), User::getName, query.getName()) .ge(query.getStartTime() ! null, User::getCreateTime, query.getStartTime()) .le(query.getEndTime() ! null, User::getCreateTime, query.getEndTime()) .orderByDesc(User::getCreateTime); PageUser result userMapper.selectPage(page, wrapper); // 转 VO手工分页组装 }这段代码里有几个细节eq(condition, column, value)的第一个参数是布尔条件只有条件成立时才会拼接 SQL。这里可别漏掉否则前端没传参数时你会得到一个WHERE status null这条 SQL 会匹配不到任何数据。StringUtils.hasText是 Spring 自带的工具类能同时排除null、空串、纯空白字符串。也可以通过import com.baomidou.mybatisplus.core.toolkit.StringUtils那个也可以。orderByDesc(User::getCreateTime)里如果字段允许为空排序时要注意数据库默认值否则可能会出现 NULL 值排在最前面的情况。需要控制 NULL 排序时得用 XML 里的ORDER BY create_time DESC NULLS LAST。3.3 自动填充 createTime 和 updateTime很多表都有create_time、update_time这两个公共字段。用 MyBatis-Plus 的自动填充功能可以省掉在每个 insert 和 update 方法里手动赋值。实体字段这样定义TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.UPDATE) private LocalDateTime updateTime;然后实现MetaObjectHandlerComponent 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()); } }有个坑必须提醒strictInsertFill里的字段名要和实体属性名完全一致类型参数LocalDateTime.class也要和实体属性的真实类型匹配。如果实体字段是java.util.Date你还在 handler 里写LocalDateTime.class填充就不会生效日志里也没有明显报错只能靠看 SQL 里的 value 是 null 发现问题。另外如果你用insert方法传入的实体已经给createTime赋值了strictInsertFill会判断已经有值而不覆盖。如果你希望“强制覆盖”可以改成setFieldValByName(createTime, LocalDateTime.now(), metaObject)但大部分业务场景用默认行为就够了。4. 代码生成器3.5.9 的 FastAutoGenerator 实战4.1 快速生成一套三层代码MyBatis-Plus 的代码生成器在 3.5.9 里已经收归到mybatis-plus-generator模块。它的主推方式是FastAutoGeneratorAPI 比老版AutoGenerator简洁很多不需要写一堆 TemplateConfig 和 InjectionConfig 的样板代码。先用两个依赖dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-generator/artifactId version3.5.9/version scopetest/scope /dependency dependency groupIdorg.apache.velocity/groupId artifactIdvelocity-engine-core/artifactId version2.3/version /dependency然后在测试模块里写一个简单的生成入口public class Generator { public static void main(String[] args) { FastAutoGenerator.create(jdbc:mysql://localhost:3306/example_db, root, root) .globalConfig(builder - builder .author(your-name) .outputDir(System.getProperty(user.dir) /src/main/java)) .packageConfig(builder - builder .parent(com.example.demo) .moduleName(system) .entity(entity) .mapper(mapper) .service(service) .serviceImpl(service.impl) .controller(controller)) .strategyConfig(builder - builder .addInclude(sys_user, sys_role) .addTablePrefix(sys_) .entityBuilder() .enableLombok() .enableTableFieldAnnotation() .controllerBuilder() .enableRestStyle()) .execute(); } }生成完后你会得到entity、mapper、service、serviceImpl、controller一套文件。这里我建议把outputDir指向System.getProperty(user.dir) /src/main/java而不是写死成绝对路径这样工程目录换位置后还能反复执行。addTablePrefix(sys_)的意思是数据库表sys_user生成的实体类名是User不是SysUser如果不想自动去掉前缀把这个方法删掉就行。4.2 XML 文件生成到哪去了FastAutoGenerator 默认的 mapper XML 模板会生成到src/main/resources/mapper/下。如果你执行完 generator 之后发现 resources 目录里没有 XML先刷新一下 Maven 工程看看是不是被 IDE 缓存挡住了。如果想让 XML 生成到指定路径可以在globalConfig里用.xmlDir()指定.globalConfig(builder - builder .author(your-name) .outputDir(System.getProperty(user.dir) /src/main/java) .xmlDir(System.getProperty(user.dir) /src/main/resources/mapper))很多团队不直接使用生成的ServiceImpl因为里面只有空壳实现业务代码还是要自己写。我的建议是生成器只负责把实体、 Mapper 接口、基础 service 一次性铺好业务逻辑不要依赖生成的 Controller尤其是不要直接用生成的ServiceImpl里的公共方法去拼复杂查询。4.3 生成器常见报错模板引擎和数据库类型FastAutoGenerator 默认使用 Velocity 模板。如果你项目里已经引入了其他模板引擎像 FreeMarker生成器只会在默认模板缺失时才去找对应依赖所以通常不会冲突。但如果你在templateConfig里自定义模板时把模板文件名写错运行时会直接抛“TemplateException: Error loading template”。这个错误我遇到过好多次最简单的处理是用 IDE 打开模板文件确认路径或者在 resources/templates 下放置官方默认模板。数据库类型也要注意。FastAutoGenerator.create只接收 jdbc url 和账号密码它内部会根据 JDBC 元数据判断数据库类型。如果你用的数据库连接字符串包含奇怪参数比如 PostgreSQL 的 schema 配置可能会导致 DbType 识别错误后续生成的分页方言就乱了。建议在strategyConfig里手动指定.strategyConfig(builder - builder .dbType(DbType.POSTGRE_SQL) .addInclude(public.sys_user))这样生成 SQL 时就知道该用LIMIT ? OFFSET ?还是ROWNUM了。5. 运行期常见问题与排查实录5.1 启动时 NoClassDefFoundError: jakarta/annotation/Resource这类问题的根源基本是 jar 包依赖不足。Spring Boot 3 把Resource从javax.annotation挪到了jakarta.annotation。如果你项目里没有显式引入jakarta.annotation-api某些第三方组件的类在反射时就会因为这个注解缺失而报错。解决办法是显式添加依赖dependency groupIdjakarta.annotation/groupId artifactIdjakarta.annotation-api/artifactId /dependency如果项目里没有 Web 依赖或者你使用的是一个精简骨架这依赖很有可能没有传进来。加上后重新加载 Maven 即可。如果还报错就要看是不是有别的库把老旧的javax.annotation-api传进来了用mvn dependency:tree查一下依赖树排除掉老版本。5.2 分页不生效SQL 没有 LIMIT这是 MyBatis-Plus 使用频率最高的一个问题。分页功能不是写上selectPage就能生效的必须同时满足三个条件一是有MybatisPlusInterceptorBean二是注册了PaginationInnerInterceptor三是 Mapper 方法的参数里真的带了IPage。我一个虚拟项目里曾经写过这样的自定义方法ListUser selectPage(PageUser page, Param(name) String name);看上去没错但 XML 里忘了写IPage参数只写了nameselect idselectPage resultTypecom.example.User SELECT * FROM user WHERE name #{name} /select这样执行时MyBatis 的Page对象根本没有进入 SQL 上下文分页拦截器拿不到它自然就不会拼 LIMIT最后把全表数据返给前端。正确写法是在 XML 里使用IPage参数select idselectPage resultTypecom.example.User SELECT * FROM user WHERE name #{name} /select实际上只要方法签名里出现了IPage类型的参数MyBatis 就会把它当作分页对象处理XML 里不用显式去引用它拦截器会自己补充 LIMIT。但前提是 XML 里不能漏掉这个参数名。如果你没在注解里写Param(page)XML 里最好别直接用#{page}否则会报参数绑定错误。5.3 Invalid bound statement (not found)这个错误一般是 Mapper 接口能找到但 XML 找不到。排查思路固定看target/classes或target/classes/mapper目录下有没有对应的 XML 文件。没有就说明 Maven 没有把 resources 里的 xml 当资源打包。此时需要确认src/main/resources/mapper里的 XML 名称是否正确是否被 IDE 设置为 excluded。检查application.yml里的mapper-locations。如果配的是classpath:mapper/*.xml而 XML 在多个模块里要改成classpath*:mapper/**/*.xml。检查 XML 的namespace是否和 Mapper 接口全限定名完全一致。比如接口是com.example.system.mapper.UserMapperXML 的 namespace 必须是同一个字符串少一个包名都不行。检查 Mapper 接口是否被MapperScan扫描。MapperScan可以放在启动类上也可以放在配置类上但要保证扫描的包路径覆盖到了 Mapper 接口所在位置。这个错误我见过最多的是第三种因为代码生成器改名后有人手动调整了包结构但是忘了同步改 XML 里的 namespace。5.4 多数据源配置下拦截器失效如果你的项目用了两个数据源比如一个主库一个从库手动配置两个SqlSessionFactory时MybatisPlusInterceptor并不会自动作用到两个工厂上。因为拦截器本质上是往Configuration里追加的只有注册到某个SqlSessionFactory对应的Configuration中才会生效。最常见的现象是主数据源上分页没问题切到从数据源后分页查询又把全表拉出来了。解决方式是在创建每个SqlSessionFactory时都通过factoryBean.setPlugins(interceptor)把同一个拦截器实例注入进去而不是只依赖 Spring Boot 的自动装配。Bean Primary public SqlSessionFactory primarySqlSessionFactory(Qualifier(primaryDataSource) DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean factoryBean new MybatisSqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setPlugins(mybatisPlusInterceptor()); return factoryBean.getObject(); }如果你的多数据源是通过常见动态数据源框架来实现的通常只要保证 Bean 方法返回类型正确拦截器会自动适配。但单靠 Spring Boot 自动配置是不行的很多人在这里绕了很久。5.5 逻辑删除和唯一索引冲突配置了逻辑删除后MyBatis-Plus 会在所有 SELECT、UPDATE、DELETE 语句中自动附加deleted 0条件。但数据库表结构的唯一索引不会因为逻辑删除而放过重复数据。比如用户表有uk_username唯一索引用户 A 被逻辑删除后deleted 字段变成 1再插入一个同名用户 B 时数据库仍然会报唯一索引冲突因为这个唯一索引同时把 deleted1 也算进去了。解决办法没有通用解要看业务怎么定。最常用的方案是把唯一索引改成联合索引(username, deleted)这样逻辑删除的记录不会阻塞新记录插入。但要注意如果你允许同一用户名同时存在多条逻辑删除记录联合索引依然可能冲突。所以设计表时就得想清楚唯一约束到底要不要包含 deleted 字段。这个坑属于“功能正常但到线上流量一大就出事”的类型建议在表结构评审阶段就检查所有唯一索引对应的字段。5.6 SQL 日志打不出来开发时想打印 SQL配置如下mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl如果加了还是看不到 SQL先确认你的配置文件前缀写对了。很多人顺手写成mybatis.configuration.log-impl那是 MyBatis 原生配置MyBatis-Plus 不会读。另外如果 mapper 接口方法加了Mapper注解但配置的是mybatis-plus.mapper-locations还是正常可以找到的重点还是看配置前缀。生产环境一定要把log-impl去掉或者改成org.apache.ibatis.logging.slf4j.Slf4jImpl然后配合日志级别控制不要用标准输出直接打在控制台否则日志会非常难管理。5.7 主键策略雪花 ID 和数据库自增3.5.9 默认的主键策略是ASSIGN_ID也就是雪花算法生成 19 位 Long 型 ID。如果你用的是AUTO数据库自增必须在实体主键上显式标注TableId(type IdType.AUTO) private Long id;否则 MyBatis-Plus 会默认用雪花 ID写入时和数据库自增策略不一致填进去的 ID 和库里序列对不上。这个问题在旧表迁移时特别常见旧表的 ID 分布是连续的新代码写进去却是随机大数对分库分表友好的场景倒是无所谓但对一些老系统ID 连续性会影响很多周边逻辑。实际用下来的一些体会这套组合稳定下来之后我最满意的不是 CRUD 变快了而是MybatisPlusInterceptor把散落各处的通用逻辑收敛进了基础设施。多租户过滤、逻辑删除、防全表更新这些横切关注点以前要在每个 Service 里手写一遍现在全部由拦截器统一处理新来的同事也不需要一遍遍背规则只要约定不写裸 SQL 就能避免很多事故。最后分享一个小技巧如果你在 Spring Boot 3 项目里碰到某个第三方库和 MyBatis-Plus 同时操作 mapper 接口并且出现奇怪的空指针先检查MapperScan和第三方库的扫描路径是不是重叠了。比如某权限框架也在扫描*Mapper两边同时注册同一个接口静态代理和字节码代理会互相干扰。解决办法是让 MyBatis-Plus 只扫描自己的 mapper 包第三方框架用它的专属注解去标记资源不要把两个工程的 mapper 放在同一个 package 里。整体看Spring Boot 3 MyBatis-Plus 3.5.9 确实成熟了。只要依赖坐标选对、拦截器配置齐全、XML 路径写对剩下的就是常规业务开发。如果你正在升级旧项目建议先拿一个非核心服务做试点把分页、逻辑删除、代码生成器这三块跑通再逐步铺开。