公司动态

Spring Boot + MyBatis-Plus 实战避坑指南:从版本选型到事务与性能优化

📅 2026/9/2 2:18:38
Spring Boot + MyBatis-Plus 实战避坑指南:从版本选型到事务与性能优化
简介面向Spring Boot与MyBatis-Plus整合开发的Java后端工程师这份资料包围绕企业级应用常见场景展示从自动配置、起步依赖到增强CRUD、分页、乐观锁、逻辑删除等能力的落地方式适合希望快速搭建数据访问层或参考微服务拆分实践的开发者。压缩包共2000个文件、约60.87MB其中Java源文件最多共1689个覆盖实体、服务、控制器及Mapper接口146个XML文件多用于MyBatis映射与SQL定义99个Shell脚本可辅助启动、打包或环境初始化另有YAML配置、factories配置和少量Markdown文档目录中还可看到公共配置与开发环境等模块化划分。目前已有434人学习通过整套工程结构和典型配置读者可以获得可直接参考的Spring BootMyBatis-Plus基础工程模板理解多环境配置与数据访问层组织的常见手法。同时脚本与文档也能帮助加快本地环境准备和二次开发尤其适合初学者对照学习或团队将其作为内部项目脚手架。 写Spring Boot后端的人十有八九最后都会在数据访问层跟MyBatis-Plus打上照面。这个组合火到什么程度热搜词里从springboot版本太高到事务失效场景从自动装配原理到循环依赖每一个都是实际项目里大家真金白银踩出来的问题。我自己的经验是只要项目里用了Spring Boot MyBatis-Plus前面几个接口写得确实爽但一旦业务复杂起来条件构造器滥用、插件配置没生效、事务莫名回滚各种问题就全来了。这篇东西我不打算写成一个面面俱到的官方文档而是想从选型逻辑、环境版本、核心用法、插件机制、复杂SQL处理再到事务和性能这些真实场景把最容易被卡住的地方全部过一遍。1. 为什么Spring Boot项目里的数据层我首选MyBatis-Plus先说个背景。Spring Boot本身解决的是框架集成和自动配置的问题它帮你把Tomcat、Jackson、数据源这些都收拾利索了但数据访问层到底用哪套方案它不管。于是每个团队都得自己选JPA、原生MyBatis、MyBatis-Plus、jOOQ甚至直接JdbcTemplate。我见过不少团队在这个选项上反复横跳最后又跳回MyBatis-Plus原因其实很朴素——它把单表CRUD里那部分毫无技术含量的重复劳动直接消灭了。如果你写过一个基于原生MyBatis的项目你一定记得那种感觉一张表对应一个实体类、一个Mapper接口、一个XML文件然后里面全是insert、update、deleteById、selectById这种近乎一样的SQL。数据表一多写这些东西纯粹是体力活而且很容易出低级错误比如字段名写错、参数没对应上。MyBatis-Plus做的事情就是把这些通用操作全部内置到BaseMapper里。你的Mapper接口只要继承它单表的增删改查、批量插入、条件查询全都齐了。很多人以为MyBatis-Plus是MyBatis的替代品其实它只是MyBatis的增强插件。它没有改掉MyBatis的任何底层行为SQL会话、参数映射、结果集映射这些依然走的是MyBatis那套机制。所以你在原MyBatis里积累的XML写法、动态SQL经验在这里依然全部有效。这也是它比JPA更容易被Java后端团队接受的深层原因——学习曲线几乎是平的老代码不用推翻重来。还有个我不太想承认但确实是现实的因素招聘市场上会MyBatis-Plus的候选人明显比熟悉JPA的人多。一个新项目交到团队手里用MyBatis-Plus意味着所有人都能快速上手不至于出现这个配置只有某某人看得懂的局面。从项目长期维护的角度看这个选择比技术上的微末优劣更重要。2. 版本与依赖启动前最容易被卡住的一环标题里的热搜词第一个就是springboot版本太高这真不是段子。Spring Boot 3.0发布之后很多新项目直接就是3.x起步然后发现MyBatis-Plus启动报错、Mapper扫描不到折腾半天才意识到是版本不匹配。先说结论如果你用的是Spring Boot 3.x那么JDK必须17以上MyBatis-Plus这边不能用传统那个mybatis-plus-boot-starter而是要引入mybatis-plus-spring-boot3-starter版本号建议在3.5.5以上。如果是Spring Boot 2.7.x这种还在用JDK 8的项目那用mybatis-plus-boot-starter的3.5.x版本就行。这个分水岭非常关键因为两个starter的包名、自动配置类路径都不一样网上那些老教程很多都是针对Spring Boot 2写的直接抄过来在3.x上是跑不起来的。还有一个容易忽略的点就是版本冲突。MyBatis-Plus自带一个mybatis-spring版本它和Spring Boot 3.2以后内置的mybatis-spring版本可能会打架。我遇到过的情况是启动时提示Error creating bean with name sqlSessionFactory排查到最后发现是mybatis-spring的版本被Maven的依赖调解机制给覆盖了。解决办法很直接在pom里显式声明一个跟当前Spring Boot版本匹配的mybatis-spring或者干脆用MyBatis-Plus官方提供的BOM来统一版本。依赖这块我贴一个当前比较稳妥的配置供参考基于Spring Boot 2.7.18加MyBatis-Plus 3.5.7的组合我实测在多个生产项目里都跑得比较稳dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency如果你打算上Spring Boot 3.2以上的版本就把第一行的artifactId换成mybatis-plus-spring-boot3-starter。光看这个名字就知道官方也有点被版本分裂搞烦了索性直接拆成两个starter来维护。所以你的第一件事不是写代码而是确认好你手上的Spring Boot版本和JDK版本再决定用哪个依赖。这一步错了后面全是白忙。3. 从配置到CRUD让第一个接口跑起来版本理清之后实际开发中的第一步就是配置。MyBatis-Plus的配置项不算多但有几个别疏忽。最基础的是数据源配置这没什么好说的就是Spring Boot标准的DataSource配置。然后要留心的是Mapper扫描。Spring Boot主类上要加MapperScan注解否则你的Mapper接口不会被注册成Bean。很多人第一次跑项目报Invalid bound statement (not found)八成就是这里漏了。再说实体类。MyBatis-Plus约定表名和字段名默认采用驼峰转下划线映射比如实体类里的createTime会自动映射到数据库字段create_time这个行为与MyBatis原生配置里的map-underscore-to-camel-case是一致的默认开启的。但主键那条不能偷懒建议显式写上TableId(type IdType.ASSIGN_ID)。我为什么强调这个因为默认的ASSIGN_ID会生成一套雪花ID对于大部分分布式场景是好事但如果你的项目主键是数据库自增的不改成AUTO的话插入之后主键不会回填后面要用到主键做关联操作时就会拿到一个空值或者一个奇怪的字符串。这个坑真的很隐蔽我在代码评审里见过好几次。基础CRUD的写法我就不逐条演示了核心就是你的Mapper接口继承BaseMapperT你的Service接口继承IServiceT实现类继承ServiceImplM, T。这套组合下来单表的基础操作不需要写一行SQL。但我想特别提一下ServiceImpl里的saveOrUpdate方法它的逻辑是先判断主键是否存在存在就更新、不存在就插入。听起来很省事但如果你没做好并发控制在高并发下可能出现重复插入或者覆盖更新的问题。所以这个方法适合用在后台管理这种低并发场景核心交易链路里还是老老实实先查询再决定。还有一件容易被忽略的事就是分页插件。MyBatis-Plus的分页不是开箱即用的你必须手动注册一个PaginationInnerInterceptor。我在项目里见过有人写好了分页代码但怎么都不生效查了半天发现是用户返回了全部数据——因为拦截器没注册。注册方式一般是搞一个MybatisPlusInterceptor的Bean再把分页拦截器加进去Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }DbType根据你的数据库类型来MySQL和PostgreSQL的方言不一样填错了分页SQL生成也不对。这个配置属于一次配置全局受益的类型早配早安心。4. 条件构造器与代码生成单表开发的效率密码CRUD基础能力有了之后真正让开发效率质变的是条件构造器。QueryWrapper和LambdaQueryWrapper几乎是每个项目里出现频率最高的类。它们的核心价值是让你用面向对象的方式拼查询条件不用手写字符串SQL。我自己的习惯是能用Lambda就尽量不用字符串版QueryWrapper。原因很简单——Lambda版在编译期就能检查字段名是否正确比如lambdaQuery().eq(User::getStatus, 1)如果你把getStatus写错了IDE直接给你标红。而字符串版queryWrapper.eq(status, 1)写错了只能等运行时报错。团队协作时这种细微差异能省掉不少沟通成本。条件构造器里我重点说两个容易用错的地方。第一个是and和or的嵌套。很多人写多条件查询时直接eq(...).or().eq(...)然后发现SQL逻辑完全不对。原因在于链式调用天然是一个从左到右的平铺结构没有括号分组概念。如果你要表达的其实是a AND (b OR c)在MyBatis-Plus里不能用简单的连链而是要嵌套LambdaQueryWrapperOrder wrapper Wrappers.lambdaQuery(); wrapper.eq(Order::getUserId, 1001) .and(w - w.eq(Order::getStatus, 1) .or() .eq(Order::getStatus, 2));第二个是条件判断的写法。我见过大量代码里这么写if (StringUtils.isNotBlank(name)) { wrapper.like(User::getName, name); }这种写法没错但代码不简洁。更推荐的做法是使用like方法提供的一个重载wrapper.like(StringUtils.isNotBlank(name), User::getName, name);第一个参数是boolean condition为false时这个条件就不拼接。这样能把if判断全部压缩进链式调用里代码看起来清爽很多也减少了漏写if导致空条件拼接的问题。说完条件构造器就不得不提代码生成器。MyBatis-Plus的AutoGenerator让你连接数据库之后直接把实体类、Mapper、Service、Controller全部生成出来。这个工具挺香但我有个劝告生成完之后一定要手工过一遍生成的代码尤其是Controller和Service实现类——因为它是按固定模板生成的命名规范、业务逻辑跟你的项目实际情况往往有出入。代码生成器适合用来建骨架不适合替代思考。我习惯用它的方式是把生成的代码直接扔进项目里当初始版本然后在此基础上改逻辑而不是指望它一次到位。5. 插件机制实战多租户、乐观锁、逻辑删除MyBatis-Plus能在企业项目里站稳脚跟很大程度靠的是它几个常用插件。我按真实项目里的使用频率排个序逻辑删除、乐观锁、字段自动填充、多租户隔离。这几个插件如果配置得当能省掉大量手写SQL和重复逻辑。逻辑删除是最常被团队启用的插件。只要在实体类的删除标记字段上标TableLogicMyBatis-Plus就会自动把所有delete操作变成update语句把deleted字段置为1。这个机制在数据审计和误删恢复场景下非常好用但要注意两个问题。第一个是全局唯一索引的坑比如user表里有个phone字段设了唯一索引用户删除后再注册一个相同手机号的用户因为旧记录只是打标没真删唯一索引就冲突了。第二个是查询条件所有Mapper的查询都会自动带上deleted0这是好事但如果你写自定义SQL时没用MyBatis-Plus的API而是直接在XML里写了select * from user那这个自动过滤是不会生效的会查出已删除的数据。乐观锁插件也是企业项目里的常客。它通过版本号机制解决并发更新覆盖问题思路很简单更新前检查version是否和当前读取到的一致一致才更新并version1不一致就更新失败。MyBatis-Plus里的做法是给实体类加一个Version字段然后注册OptimisticLockerInnerInterceptor。这里有个我测试过的细节并不是所有update方法都能触发乐观锁。updateById可以但如果你用update(entity, wrapper)这种构造需要确保wrapper里没有把version字段当作更新条件否则可能更新不到。还有update语句里的set部分必须带上version字段才能生效MyBatis-Plus的拦截器实际是在set后面追加了versionversion1并在where里带上旧版本号。字段自动填充是另一个比较实用的插件。创建时间、更新时间这类字段很多表都会有。标准做法是继承MetaObjectHandler实现insertFill和updateFill两个方法在实体类的对应字段上标TableField(fill FieldFill.INSERT)或INSERT_UPDATE。这个功能做好之后你再也不用在每次插入和更新时手动set时间了而且可以顺便把创建人、更新人这些审计信息一起填上对权限审计很有帮助。多租户插件TenantLineInnerInterceptor适合SaaS系统。它的原理是在SQL执行前自动帮你在where后面追加tenant_id 当前租户值。这个插件很强大但也危险——如果配置了全局生效那么一些不需要租户隔离的表比如字典表、配置表也会被强行加上条件导致查不到数据。解决方法是配置ignoreTable列表或者让指定Mapper跳过租户解析。我第一次用这个插件的时候就翻过车一张系统配置表被加了tenant_id条件查出来全是空排查了好久才反应过来。所以启用多租户插件之前务必把表清单拉出来逐个确认哪些表需要隔离。6. 复杂SQL场景MyBatis-Plus与XML的正确协作MyBatis-Plus再强它也解决不了复杂的多表关联查询。我的经验是单表操作用它多表操作直接XML。两者完全可以共存于同一个项目因为我前面提过MyBatis-Plus底层就是MyBatisXML mapper机制原样保留。具体操作起来也很简单。Mapper接口里定义方法然后在同包或resources里的mapper目录写同名XMLnamespace指向Mapper接口全限定名。比如你要做一个订单和用户表的join查询只要在XML里写custom的select语句然后Mapper接口加一个方法就行。MyBatis-Plus的BaseMapper提供的那些方法完全不受影响。但这里有个小问题自定义SQL里怎么用MyBatis-Plus的分页很多人在这卡住。其实非常简单方法签名里直接加一个IPageT类型的第一参数然后在XML里正常写select语句不用手动写limit。分页拦截器会拦截这个方法自动把limit和count语句生成出来。示例IPageOrderVO selectOrderPage(PageOrderVO page, Param(userId) Long userId);select idselectOrderPage resultTypecom.example.vo.OrderVO SELECT o.*, u.name AS userName FROM t_order o LEFT JOIN t_user u ON o.user_id u.id WHERE o.user_id #{userId} /select还有一个容易踩的细节自定义SQL如果涉及逻辑删除字段MyBatis-Plus的TableLogic自动过滤不会生效。比如你写了一条SELECT * FROM t_order WHERE o.user_id #{userId}那么已删除的订单会被查出来。这时候你得在XML里手动加o.deleted 0或者用MyBatis-Plus提供的InterceptorIgnore注解忽略某些自动功能。我的习惯是XML里的自定义SQL全部手工处理删除标记不依赖插件因为插件生成的条件在复杂查询里常常会跟你自己写的where条件产生语义冲突。再补充一个我在代码评审中反复强调的点不要在一个方法里面既用QueryWrapper又用XML去查同一张表。如果有两张同类查询优先合并成一条XML SQL。因为条件构造器太灵活容易让查询逻辑散落在Service代码里一旦SQL需要优化你得把Java代码翻个底朝天才能拼出完整的SQL。XML虽然看起来笨重但所有SQL一览无余DBA做优化时也方便。7. 事务失效与性能优化线上踩坑复盘最后这部分是实打实的经验教训全都是我在项目里遇到并排查过的。先用热搜词里的事务失效场景开头。MyBatis-Plus本身不感知事务它用的是Spring的Transactional。事务失效最常见的几种情况我在这个话题上必须提醒第一种是自调用失效。同一个类里的一个方法调用了另一个带Transactional的方法事务是不生效的。因为Spring的事务是基于AOP动态代理实现的自调用走的是this调用绕过代理事务自然就没挂上。解决办法是把调用拆到另一个Bean里或者用AopContext.currentProxy()。第二种是方法非public。Spring默认只对public方法做事务代理private方法上的Transactional是静默忽略的。这个行为很多新手不知道代码写了一堆事务一个没生效查起问题来非常迷惑。第三种是异常被吞了。Transactional默认只在RuntimeException和Error时回滚受检异常不会触发回滚。如果你在事务方法里catch了异常什么都没往外抛事务会正常提交——try-catch吞异常导致数据不一致就是这么来的。正确做法是让方法把异常抛出去或者指定rollbackFor Exception.class。再说性能问题。MyBatis-Plus方便是真的方便但滥用条件构造器和自动生成的批量方法容易产生慢SQL。我遇到过一个性能事故一个定时任务用insertBatch逐批插入几千条数据每批100条结果一次跑下来要好几分钟。问题不在于MyBatis-Plus的批量方法本身低效而是它的批量插入实际是循环执行单条insert没有走JDBC的rewriteBatchedStatements优化。后来我改成了手写XML的foreach批量插入耗时直接降了一个数量级。还有一个很隐蔽的性能问题是分页count。当你的查询条件比较复杂、涉及多表join时MyBatis-Plus自动帮你生成的count语句可能不是最优的。比如你明明只需要统计主表记录数它却把两个大表join完再count这就会很慢。解决办法是用Page的setOptimizeCountSql或直接在XML里定义Count查询让统计SQL走你指定的路径。另外PaginationInnerInterceptor内部对count查询会尝试去掉不必要的order by但not优化所有场景所以复杂统计还是自己动手写比较靠谱。逻辑删除和性能也有一层关系。当你用TableLogic时所有查询都会带上deleted0条件如果表数据量大这个条件又用不上索引全表扫描就来了。建议在deleted字段上建索引尤其是那些会频繁做逻辑删除和查询的表。最后说一句代码生成器和通用Mapper的度。MyBatis-Plus的强大之处在于让简单的事情变简单但并不意味着所有数据访问都该用它兜底。我的原则是单表、简单查询、CRUD操作用MyBatis-Plus多表join、复杂聚合、需要精细控制SQL的业务一律XML手写。这个边界分清楚之后项目既高效又可控不会出现MyBatis-Plus写的SQL看不懂优化不了的局面。上面的这些坑我基本全都踩过一遍写出来也是希望大家能少走点弯路真遇到了问题也知道往哪个方向查。本文还有配套的精品资源点击获取