
如果你最近在写Spring Boot项目大概率已经被重复的增删改查折磨得够呛建一张表要写实体类、写Mapper接口、写XML里的SQL、再写Service层包装一遍光一个单表就需要四五个文件大部分代码还是CtrlC、CtrlV。我第一次把MyBatis-Plus引入项目的时候其实是有疑虑的——框架帮你把活都干完了万一遇到复杂业务怎么办但一个迭代做完之后我承认它在单表CRUD这块的效率和开发体验确实比我之前手写MyBatis要好上一大截。这篇文章就从一个实际项目搭建的角度把Spring Boot整合MyBatis-Plus的全过程拆开讲清楚包括版本兼容、依赖配置、BaseMapper用法、条件构造器选型、分页插件和几个我踩过的坑。内容基本覆盖了从零搭项目到日常增删改查的所有环节适合刚入门Spring Boot、准备使用MyBatis-Plus的读者也适合已经在项目里用了MyBatis、想换个更高效写法的同学。1. 为什么我最终在项目里选了MyBatis-Plus三种ORM方案的取舍先聊一个可能在你脑子里盘旋过的问题既然框架这么多为什么偏要选MyBatis-PlusJava后端做持久化市面上主流的就那几条路MyBatis配合XML手写SQL、Spring Data JPA基于Hibernate自动映射、再就是MyBatis-Plus这种站在MyBatis肩膀上的增强工具。我前几个项目分别用过了原生MyBatis和JPA说实话各有各的难受。原生MyBatis配置繁琐单表CRUD也得写一堆标签很多SQL其实没有任何业务价值JPA虽然面向对象做得舒服但一旦遇到多表关联、动态排序、复杂统计写JPQL或者派生方法名简直让人头皮发麻。MyBatis-Plus的定位比较特殊它不替代MyBatis而是“只做增强、不做改变”。也就是说你原来写的XML、自定义Mapper方法、ResultMap统统还能用它只是在MyBatis之上加了一层通用能力。最直接的表现是继承BaseMapper之后单表的insert、delete、update、select方法就全部自带不写一行XML也能跑。对于大部分业务系统来说单表CRUD能占到数据操作里相当大的比例省掉这一块的模板代码开发效率的提升是立竿见影的。方案单表CRUD效率复杂SQL灵活性学习成本对已有代码的侵入性原生MyBatis XML低高中低Spring Data JPA中中中高较高MyBatis-Plus高中高低低当然没有银弹。如果你的系统里绝大多数SQL都是多表联查、报表统计、复杂嵌套子查询那MyBatis-Plus的场景优势就没那么大了这种时候反而原生MyBatis的XML更合适。我现在项目里的做法是两者共存——单表操作用MP报表类SQL走XML之后再讲怎么把它们放在同一个工程里互不干扰。2. 初始化Spring Boot工程JDK、IDEA、Maven仓库这三件事先处理明白不管你是自己敲代码练手还是接手公司的项目第一步都是把一个可以跑起来的Spring Boot工程立起来。我这边以IDEA为例说明因为绝大多数Java开发者日常都在用这个IDE操作路径比较统一。2.1 JDK版本确认这一步看起来简单但很多人一开始就在版本上栽跟头。Spring Boot 2.x建议用JDK 8或者11Spring Boot 3.x则强制要求JDK 17以上。如果你准备拿新项目练手我建议直接用JDK 17配Spring Boot 3.x或者2.7都行如果公司环境是老项目比较多大概率还是JDK 8配Spring Boot 2.x。下面整合MyBatis-Plus时我会专门讲版本如何对应这里先保证JDK能装上、环境变量能配上。在IDEA里可以通过File → Project Structure → SDKs查看当前JDK版本也可以在命令行敲java -version确认。装JDK时注意别漏了JAVA_HOME环境变量Maven编译的时候找不到JDK的话项目根本跑不起来。2.2 通过Spring Initializr创建工程打开IDEA点击File → New → Project左侧选择Spring Initializr。这里有几个关键配置项Group一般填公司域名倒序比如com.exampleArtifact项目名比如demoType选Maven别选Gradle后续教程和依赖坐标都以Maven为例Java版本根据你前面装的JDK选8或者17Dependencies先勾选Spring Web和MySQL Driver其他都后面再加IDEA在创建过程中会联网从Spring Initializr拉取项目模板如果网络慢可以改用https://start.spring.io的镜像地址或者直接手动创建一个空Maven项目再补pom.xml。后者其实更可控我早期学习时都是这么干的——空项目反而能让我看清每个依赖是怎么加进去的。2.3 配置Maven阿里云镜像Maven默认中央仓库在国外国内网络拉依赖经常卡得让人抓狂这里建议改一下settings.xml里的mirror用阿里云仓库。具体是找到Maven安装目录下conf/settings.xml在mirrors节点中加入mirror idaliyunmaven/id mirrorOfcentral/mirrorOf name阿里云公共仓库/name urlhttps://maven.aliyun.com/repository/public/url /mirror如果是用IDEA自带的Maven也可以在File → Settings → Build Tools → Maven里设置User settings file指向你自己配置的settings.xml。这一步做完之后后续引入任何依赖的速度都会快很多属于一次配置、长期受益的操作。创建完成后IDEA会自动生成一个标准的Maven项目结构核心目录就这几个src/main/java放Java代码src/main/resources放配置文件、静态资源、模板src/test/java放测试代码。后面我们写的Mapper接口、service类都会放在java目录下对应的包里application.yml则放在resources下。3. 整合MyBatis-Plus的关键步骤版本匹配、依赖引入、数据源配置与启动类扫描这一章是整个整合过程的核心也是新手最容易卡住的地方。我见过太多人依赖一加、启动一跑报错一堆最后发现是版本或者配置不对。我们把整个整合过程拆成三块来讲。3.1 版本匹配Spring Boot 2和3的选择整合过程中最大的坑就是版本匹配。MyBatis-Plus官方对Spring Boot 3的支持出了一个单独的startermybatis-plus-spring-boot3-starter。你如果在Spring Boot 3项目里用老的mybatis-plus-boot-starter启动时大概率报ClassNotFoundException或者NoSuchMethodError。当初我从Spring Boot 2.7升到3.x的时候光这个坑就折腾了一个多小时。Spring Boot版本MyBatis-Plus依赖JDK要求2.xmybatis-plus-boot-starter3.5.3.1JDK 83.xmybatis-plus-spring-boot3-starter3.5.3.1JDK 17版本号我写3.5.3.1起步是因为这个版本之后对Spring Boot 3的支持才稳定。当前的最新版本可能已经更高了去Maven中央仓库搜一下就能看到。如果项目已经存在可以用mvn dependency:tree命令查看当前依赖树确认没有同时引入两个不同的starter导致冲突。3.2 依赖引入与pom.xml配置这里给出一个完整的pom.xml依赖片段同时包含Web、MySQL驱动和MyBatis-Plus三部分。注意看注释Spring Boot 2和3的MyBatis-Plus依赖坐标是完全不同的dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- Spring Boot 2.x 用这个 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency !-- Spring Boot 3.x 请改用这个 -- !-- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.3.1/version /dependency -- /dependencies如果你用Spring Boot 2.7以上版本还需要在properties里确认一下mysql-connector-j的版本兼容性。新版MySQL驱动类名已经从com.mysql.jdbc.Driver改成了com.mysql.cj.jdbc.Driver这个在配置数据源时直接写全类名即可不要用旧的。3.3 数据源配置与启动类扫描在src/main/resources/application.yml中配置数据源和MyBatis-Plus的相关属性spring: datasource: url: jdbc:mysql://localhost:3306/test_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: assign_idurl里的serverTimezone记得配成Asia/Shanghai否则本地MySQL默认时区和驱动默认时区不一致经常会在建连接时报错。log-impl配置成StdOutImpl后控制台会直接打印MP执行的真实SQL调试阶段建议开着上线前再关掉避免日志量太大影响性能。启动类上记得加MapperScan注解SpringBootApplication MapperScan(com.example.demo.mapper) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }MapperScan的作用是把指定包下的接口扫描成MyBatis的Mapper这样不用在每个Mapper上重复加Mapper注解。如果你的Mapper接口分散在多个包也可以写多个MapperScan或者直接扫描它们的共同父包。4. BaseMapper实测零SQL完成单表CRUD是怎么做到的环境搭好之后我们写一个最常规的单表CRUD例子让你直观感受一下MP的效率。这里以t_user表为例。4.1 实体类的注解TableName(t_user) public class User { TableId(type IdType.AUTO) private Long id; private String username; private Integer age; private String email; // getter setter 省略 }TableName指定实体类对应的表名如果实体类名和表名完全一致可以省略但在实际项目中表名经常带前缀建议显式写上。TableId用于声明主键IdType.AUTO表示数据库自增。如果不指定id-typeMP默认会使用雪花算法生成一个分布式ID这一点在公司数据库管理比较严格的时候需要注意——DBA可能会要求所有表都用自增主键或者统一格式的业务主键。4.2 Mapper接口与基础CRUDMapper接口是MP整个机制的核心继承BaseMapperT后单表操作的方法就全都就位了public interface UserMapper extends BaseMapperUser { }没错就这一行。接下来在测试类里直接注入使用SpringBootTest class CurdTests { Autowired private UserMapper userMapper; Test void testInsert() { User user new User(); user.setUsername(zhangsan); user.setAge(28); user.setEmail(zhangsanexample.com); int rows userMapper.insert(user); System.out.println(影响行数 rows); // 插入后会自动回填主键id System.out.println(生成的主键 user.getId()); } }跑一下这个测试控制台会打印MP自动生成的INSERT语句你会发现它比你自己写的还要规范。主键回填这个特性特别实用你不需要再手动查一次自增ID。BaseMapper内置方法中日常最常碰到的就这几类方法作用insert(T entity)插入一条记录deleteById(Serializable id)按主键删除delete(WrapperT wrapper)按条件删除updateById(T entity)按主键更新selectById(Serializable id)按主键查询selectList(WrapperT wrapper)按条件查询列表selectPage(PageT page, WrapperT wrapper)分页查询selectCount(WrapperT wrapper)查询总条数4.3 Service层的封装在真实业务项目里Controller通常不直接调Mapper而是通过Service层中转。MP对Service层也做了对应封装配合起来非常顺滑public interface UserService extends IServiceUser { } Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { }ServiceImpl已经把BaseMapper的方法再封装了一层Controller里直接RestController RequestMapping(/user) public class UserController { Autowired private UserService userService; GetMapping(/list) public ListUser list() { return userService.list(); } }这种写法省得每层都转来转去业务很轻的时候Controller甚至不需要额外写任何方法体。5. 条件构造器怎么选QueryWrapper、LambdaQueryWrapper与链式API的实际体验BaseMapper解决了单表CRUD但实际业务查询条件五花八门。MP的做法是提供Wrapper条件构造器来动态拼SQL。常见的有两种写法QueryWrapper和LambdaQueryWrapper。先看QueryWrapperQueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(username, zhangsan) .ge(age, 18) .orderByDesc(id); ListUser users userMapper.selectList(wrapper);再看LambdaQueryWrapperLambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getUsername, zhangsan) .ge(User::getAge, 18) .orderByDesc(User::getId); ListUser users userMapper.selectList(wrapper);这两段代码功能完全一样但差异很微妙。QueryWrapper里的username是字符串编译期不会检查字段名写错了也不会报错只有运行时才会暴露。更麻烦的是一旦数据库字段做过一次重命名或者表结构调整所有字符串就得全文搜索去改。LambdaQueryWrapper用User::getUsername这样的方法引用字段名在编译期就会被检查IDE里重构字段时也会自动同步这个优势在项目规模变大之后非常明显。常用条件方法基本都覆盖了日常场景eq等于、ne不等于、gt大于、ge大于等于、lt小于、le小于等于、like模糊、in包含、isNull为空、between区间、groupBy分组、orderByDesc倒序、last拼接自定义SQL片段。有一个正则匹配的场景也常用到apply不过谨慎使用因为它拼接的是裸SQL片段容易引入注入风险。如果你用了ServiceImpl封装还可以直接用更简洁的链式APIListUser list userService.lambdaQuery() .eq(User::getUsername, zhangsan) .ge(User::getAge, 18) .list();这段代码在Controller里直接就能写连Service的过渡方法都省了小模块快速开发时特别好用。需要注意的是链式调用每次都会实例化一个Wrapper对象如果循环里频繁调用会有些微开销但绝大多数业务场景感知不到。6. 分页插件、逻辑删除与自动填充三个要动手配置才能生效的功能这三个功能属于MP里“你装了才知道好”的能力但它们有个共同点不是默认开启需要手动配置。当初我在分页插件上栽过跟头这里重点讲一下。6.1 分页插件不注册就失效先给结论分页插件在MyBatis-Plus里不是一个默认开启的配置。如果你不注册MybatisPlusInterceptor那么Page参数会失效selectPage查出来其实是全量数据再内存分页一旦数据量大就直接OutOfMemory。这个坑我亲眼见过同事线上项目踩过一张表几百万数据前端分页请求直接拖垮应用。注册方式很简单新建一个配置类Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }然后分页查询就可以这样写PageUser page new Page(1, 10); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.orderByDesc(User::getId); PageUser userPage userMapper.selectPage(page, wrapper); System.out.println(总条数 userPage.getTotal()); System.out.println(当前页数据 userPage.getRecords());Page对象的getTotal()会自动执行一条COUNT查询返回的数据封装在getRecords()里。如果你的数据库是其他类型记得修改DbType不同数据库的分页方言不一样。6.2 逻辑删除配置一次全局生效逻辑删除在业务里很常见MP对它做了全局支持。先在配置文件里声明mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0然后实体类对应字段加上注解TableLogic private Integer deleted;配置后调用deleteById时会自动转成UPDATE t_user SET deleted 1 WHERE id ?selectList会自动加上deleted 0条件。你的业务代码里几乎不需要关心“查的时候过滤已删除数据”这件事。但要注意逻辑删除字段和数据库唯一索引存在冲突——比如用户表用户名唯一逻辑删除后再插入相同用户名的记录唯一索引仍然会报错。解决方案一般是把用户名改成“被删除的用户名_时间戳”或者干脆改用物理删除。这个点很容易被忽略等线上出问题排查的时候会很痛苦。6.3 自动填充时间字段再也不用手动set很多表都有create_time、update_time这类字段以前每次插入和更新都得手动set。MP的自动填充可以把这个操作自动化。实现一个MetaObjectHandlerComponent 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;这一套配完之后插入和更新时根本不用手动set这些时间字段数据库层面甚至不用写默认值CURRENT_TIMESTAMP全部交给MP来填。你写代码的时候会明显感觉少了特别多重复劳动。strictInsertFill和strictUpdateFill的区别在于当字段已经有值时strict系列不会强制覆盖这个设计很贴心。顺便提一句乐观锁如果你需要处理并发更新可以在实体类加Version注解的version字段然后注册OptimisticLockerInnerInterceptor更新时会自动带上版本校验。MP把这个经典问题也内置了只是需要额外配置一下。7. 避坑实录版本冲突、字段映射和LocalDateTime这些坑的完整排查链路这里记三个我自己实际踩过、也帮人排查过的典型问题每个都说清楚现象、排查链路和最终解法你可以直接对照自己的项目。7.1 版本冲突Spring Boot 3 错误starter的启动崩溃现象Spring Boot 3.x项目引入mybatis-plus-boot-starter后启动时直接抛ClassNotFoundException错误信息里既有mybatis-plus相关的类也有Spring的类。第一次遇到你会觉得莫名其妙因为它不是固定的某个类缺失而是各种类都开始找不到。排查链路看启动日志的异常类型定位是Spring容器加载哪个Bean时崩溃用mvn dependency:tree查看整个依赖树确认有没有多个mybatis-plus相关坐标混在一起检查starter是否和Spring Boot主版本匹配——这一步是关键你会发现用的mybatis-plus-boot-starter其实是给Spring Boot 2用的换成mybatis-plus-spring-boot3-starter后重启验证这个问题的本质是Spring Boot 3的包名和模块结构调整较大旧starter里的自动配置类和条件注解完全对不上。换坐标是唯一出路不要试图在旧starter上打补丁。7.2 字段映射列名和下划线命名法不一致现象查询User列表其他字段正常username总是null但数据库里有值。这种情况最容易在新接手代码或者改表结构之后出现。原因MySQL里列名叫user_nameJava属性叫usernameMP默认开启的驼峰映射map-underscore-to-camel-case能把user_name映射成userName但映射不成username。差一个字母映射关系就断了。解决办法有两个一个是把数据库列名改成user_name属性命名为userName这样符合驼峰映射规则另一个是用TableField(user_name)显式指定字段映射关系。后面这个方案更灵活尤其当数据库列名不规范、带前导缩写时显式注解最靠谱。排查这种问题时先看控制台打印的SQL确认查询的列名是什么再用结果对象反射看一下字段最后对比实体类字段和实体类的数据库列名基本就能锁定。7.3 LocalDateTime序列化JSON里的时间变成了数组现象接口返回的JSON里LocalDateTime字段变成了一串数字数组year、month、day之类的或者格式和前端约定不一致。这其实是Jackson对Java 8时间类型的默认处理导致的它会把LocalDateTime序列化成对象结构而不是我们期望的yyyy-MM-dd HH:mm:ss字符串。最简单的解法是在配置文件里统一全局格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8如果项目里用到了Redis做缓存还需要注意Redis序列化器对Java 8时间类型的支持必要时给LocalDateTime单独配一个类型转换器。这块梳理清楚之后前后端联调时关于时间格式的沟通成本会少非常多。8. 我在实际项目中的使用习惯与扩展建议最后聊点我自己的一些习惯算是给你少走点弯路的参考。第一不要因为用了MP就抛弃XML。像统计类SQL、多表联查这种场景MP的Wrapper写起来并不比SQL直观反而XML里的自定义SQL更清晰。MP允许你在Mapper接口里继续写原生方法XML照常放在resources/mapper目录下两者完全不冲突我现在的项目就是这样混合着用。具体做法是在Mapper接口中声明方法然后在src/main/resources/mapper下写同名的XML文件并在application.yml里配置mybatis-plus.mapper-locations指向那个目录。第二理解BaseMapper和ServiceImpl的分工。简单场景Controller直接调IService的方法复杂业务在Service实现类里封装不要把查询逻辑全部堆在Controller里。MP的链式API确实方便但用多了会让Controller越来越大后期维护时定位逻辑比较痛苦。第三如果项目是团队协作建议把MP的全局配置提前统一好比如主键类型、逻辑删除字段、分页插件是否启用等每个人各配一套合并代码时冲突不断。上线前务必看一眼MP打印的SQL日志确认是否走了预期索引——这个习惯能帮你提前发现很多性能隐患。大致就这些。Spring Boot整合MyBatis-Plus本身不算难真正影响体验的是版本和配置细节把这两块理清楚剩下的就是熟练度的问题了。希望这篇保姆级教程能帮你把工程一次性跑起来后面在项目里写出更顺手的代码。