
SpringBoot整合Mybatisplus这个话题我在不同团队、不同项目里来回折腾过好几轮。最早用纯MyBatis写XML写到手麻后来换过JPA简单查询确实省事但一涉及到多表关联、复杂动态SQL就有点使不上劲再后来接触MybatisPlus发现它其实解决的是大多数日常CRUD场景下的“手工重复劳动”同时保留了MyBatis原有的灵活度。这篇文章就把我这几年整合MybatisPlus的实际经验整理出来从依赖引入、基础配置、核心API再到常见的坑全部按照动手操作过的路径来讲适合正在搭新项目或者打算把旧项目从纯MyBatis迁移过来的同学参考。1. 为什么绕了一圈我最终还是选了MybatisPlus1.1 从JDBC到MyBatis再到MybatisPlus的“偷懒史”先聊点背景。早年间写Java后台最原始的方式是JDBC每次查询都要自己写Connection、PreparedStatement、ResultSet那一套样板代码稍微复杂一点的SQL光try-catch-finally就能把你绕晕。后来MyBatis出现了用XML或者注解把SQL和Java方法映射起来解决了结果集自动映射的问题这是很大的进步。但MyBatis有一个麻烦——它只是帮你执行SQL基本的单表增删改查还是得你在XML里手动写实体类字段一多insert、update、selectById、deleteById这几段“毫无技术含量”的SQL就占据了大量篇幅。MybatisPlus后面统一叫MP做的事情通俗讲就是把单表CRUD给“包办”了。你只需要定义一个实体类继承一个BaseMapper接口常用的单表方法就全部自动具备省掉了最枯燥的那部分。更关键的是它没有把路走死——复杂的多表查询、存储过程、自定义SQL你依然可以像用原生MyBatis一样去写XML或者注解。用一句话概括我的选型逻辑简单场景交给MP自动完成复杂场景保留MyBatis的全部能力两者配合起来几乎没有盲区。1.2 MP解决的三类核心痛点我在项目里使用MP之后最明显的感受集中在三个方面。第一是启动成本低。新同事接到一个模块不需要先去看一堆XML里每个表的基础方法是什么命名规范BaseMapper自带的那十几个方法足够cover住80%的场景上手速度明显变快。第二是条件构造器带来的查询弹性。以前写一个动态查询用户可能按姓名筛、按时间范围筛、按状态筛组合方式非常多你需要在XML里写大量if标签。MP的QueryWrapper可以用链式方法动态拼条件代码直观多了也不容易漏掉某个条件。第三是内置插件解决了高频通用能力。比如分页以前要么自己写拦截器要么用PageHelper这种第三方工具MP直接提供了分页插件配置一次全局复用。再比如乐观锁、逻辑删除、自动填充时间字段这些在几乎所有业务系统里都会用到的东西MP都做成了注解加插件的极简方案。2. 依赖版本与基础配置连SpringBoot 3.x的坑一起避开2.1 引入依赖时的版本匹配细节如果还在用SpringBoot 2.x依赖选择上基本没太多纠结用mybatis-plus-boot-starter就行版本上我建议直接选3.5.x系列比如3.5.3、3.5.5这些稳定版。这里有一个容易踩的坑MP 3.5.x对MyBatis版本有对应要求如果项目里本来就有自己引入的MyBatis依赖可能会和MP传递进来的版本冲突运行时报一堆奇怪的Mapper绑定错误。如果项目是SpringBoot 3.x情况略有不同。SpringBoot 3基于Jakarta EE原来javax包下的注解都改成了jakarta所以MP官方针对SpringBoot 3提供了单独的starter名字是mybatis-plus-spring-boot3-starter。这个细节特别容易踩坑——我用mybatis-plus-boot-starter在SpringBoot 3项目里试过一次启动直接报找不到SqlSessionFactory相关的类。换成boot3版starter后一切正常。!-- SpringBoot 3.x 使用 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency !-- SpringBoot 2.x 使用 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency2.2 最精简的application.yml配置MP的自动配置做得比较到位其实不写任何配置也能启动但在实际项目中下面几个配置我每次都会加上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: db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0mapper-locations用来指定自定义XML文件的路径如果你的项目里既有MP的BaseMapper方法又有手写的复杂查询XML这项必须配好不然Mapper方法在用到XML时找不到对应的statement。map-underscore-to-camel-case是开启下划线到驼峰的自动映射。数据库字段习惯是user_nameJava属性是userName开启后MP在结果映射时会自动对应上。这个配置在MP中默认是true但还是建议显式写出来团队里一旦有人升级版本或者调整配置时语义更明确。log-impl我习惯在开发环境配成StdOutImpl这样控制台能直接打印出MP生成的SQL和参数排查问题非常方便。生产环境建议换掉或者关掉日志不然输出太密集。2.3 实体类与Mapper接口的“三件套”写法MP的使用模式非常固定我把它总结为三件套。第一建一个实体类用注解标明表名主键策略可以放在全局配置里也可以在字段上单独标注Data TableName(user) public class User { TableId(type IdType.AUTO) private Integer id; private String name; private Integer age; private String email; private LocalDateTime createTime; }第二定义一个Mapper接口继承BaseMapperUser完全不用写方法体public interface UserMapper extends BaseMapperUser { // 自定义方法直接在这里声明比如复杂查询 ListUser selectByCustomCondition(Param(status) Integer status); }第三在启动类上加MapperScan扫描Mapper所在包或者在每个Mapper上标注Mapper注解。我一般用前者mapper包下所有接口一次扫完省事。SpringBootApplication MapperScan(com.example.demo.mapper) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }到这一步一个能跑通基础增删改查的项目就成型了。接下来才是真正体现MP生产力的地方。3. CRUD、条件构造器与分页日常开发用得最多的三块实操3.1 BaseMapper内置方法如何覆盖日常CURD先说结论MP的BaseMapper内置方法覆盖单表CRUD是足够的。insert、deleteById、updateById、selectById、selectList、selectCount这些方法直接用就行。我实际写业务时常用的组合就几类// 新增 userMapper.insert(user); // 根据主键更新实体里有值的字段才更新null字段自动忽略 userMapper.updateById(user); // 根据主键删除 userMapper.deleteById(1); // 查询单个 User user userMapper.selectById(1); // 查询列表配合条件构造器 ListUser list userMapper.selectList(null);特别说一下updateById它默认的更新策略是“非空字段才更新”。什么意思呢如果你把一个User对象从数据库查出来改了其中两个字段再传回去执行updateById那么其他字段即使原本有值只要你在实体上重新赋了值都会被更新覆盖但如果你直接new一个只设置了id和name的User对象调用updateById那么其他字段不会被改动。这个行为在大多数场景下很好用但要注意如果有字段需要显式更新为NULLMP有个TableField(updateStrategy FieldStrategy.IGNORED)或者用UpdateWrapper里的set方法来强制让你指定某个字段更新为null。3.2 条件构造器是MP的灵魂如果说BaseMapper内置方法解决了“有没有”的问题那条件构造器解决的就是“好不好用”的问题。我用了很长时间的QueryWrapper后来推荐团队统一换成了LambdaQueryWrapper。原因很简单Lambda版本直接用方法引用引用实体字段避免了硬编码字符串列名。比如按年龄查询QueryWrapper这么写QueryWrapperUser wrapper new QueryWrapper(); wrapper.gt(age, 18);一旦字段改名这里的age不会跟着变编译期也发现不了错误。换成Lambda写法LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.gt(User::getAge, 18);字段名和实体属性绑定重构时如果改了属性名编译器直接报错安全得多。条件构造器支持的条件非常多日常我常用的有eq等于、ne不等于、gt/ge大于/大于等于、lt/le小于/小于等于、like模糊查询、between区间、in范围、isNull/isNotNull判空、orderByDesc/orderByAsc排序、last追加SQL片段、groupBy分组。举一个实际业务场景分页查询某个范围内的用户且按创建时间倒序还要排除已删除的数据。MP写出来大概是LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.lt(User::getAge, 30) .like(StringUtils.isNotBlank(name), User::getName, name) .orderByDesc(User::getCreateTime); PageUser page new Page(current, size); userMapper.selectPage(page, wrapper);注意这里like方法第一个参数传的是布尔值这个就是MP的“动态条件”特性——条件不成立时这个条件自动跳过省掉了自己写if判断的麻烦。这是我最喜欢的一个细节。3.3 分页插件配置与selectPage的实战MP的分页并不是直接内置的而是通过一个MybatisPlusInterceptor拦截器实现需要手动配置Bean。我之前遇到过一位同事说“MP分页查出来total一直是0”结果一看就是没有配置分页插件。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); // 单页最多限制500条防止有人乱传pageSize把数据库拖垮 pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }配置好之后再调用selectPage(page, wrapper)返回的Page对象里就有records、total、current、size这些信息。需要注意的是DbType一定要按实际数据库类型设置MySQL、PostgreSQL、Oracle的方言不一样设置错了分页SQL生成就会有问题。还有一个容易被忽略的性能细节MP分页查询默认会发一条count语句再加一条limit语句。数据量大时count语句也是要扫全表的如果觉得性能吃紧可以在Page构造时传入一个优化参数。另外MP 3.5.x版本里如果设置了分页查询的同时还做了group bycount的结果可能和你预期不一致这种场景建议自己写count逻辑不要完全依赖自动count。4. 逻辑删除、乐观锁与字段填充不止是加分项4.1 逻辑删除的全局统一配置在很多业务系统里数据不能物理删除必须用一个标志位标记为已删除。以前没有MP时需要自己写update语句把所有delete操作改成update套餐还要记得在每次查询时拼接where deleted 0一旦漏掉一条脏数据就出来了。MP的逻辑删除就是专门解决这个问题的。先在全局配置里声明逻辑删除字段和值global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0然后实体对应字段加上TableLogicTableLogic private Integer deleted;配置完成后调用deleteById时实际执行的SQL就变成了update user set deleted 1 where id ? and deleted 0而不是delete from user。同时所有的selectById、selectList、selectPage都会自动追加deleted 0的条件体现在生成的SQL上就是多了一个条件。这里要注意一个坑逻辑删除生效的前提是SQL经过MP生成的逻辑。如果你手写了自定义XML里面没有主动拼deleted 0条件那逻辑删除的保护是不生效的。我见过线上数据被自定义删除语句物理删掉的事故后来所有自定义删除的XML里都强制要求加上deleted 0条件并且代码评审时重点检查。4.2 乐观锁插件并发更新防覆盖的推荐方案高并发场景下两个人同时读取同一条数据并各自更新后提交的会把先提交的覆盖掉。解决办法有两种悲观锁用数据库行锁乐观锁用版本号字段。MP内置了乐观锁插件用法很简单。实体类加版本字段Version private Integer version;配置加上乐观锁拦截器Bean public MybatisPlusInterceptor optimisticLockerInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; }注意如果同时要用分页插件多个InnerInterceptor的顺序是有讲究的分页插件要放在乐观锁后面。这个顺序在官方文档里也有说明我在实践中确实遇到过顺序颠倒后分页失效的情况。每次更新时MP会自动检查版本号。执行的SQL类似UPDATE user SET name xxx, version version 1 WHERE id ? AND version 旧版本号更新影响行数为0说明版本号已经被别人改过了业务层拿到这个结果就可以提示用户“数据已被其他人修改请刷新重试”。这个机制用起来很顺手但要注意一点乐观锁只对updateById和update方法默认生效如果你用自定义SQL更新不会带版本号条件需要手动写。4.3 字段自动填充create_time、update_time不再靠手工set每个表通常都有create_time和update_time以前每条insert和update都要手动set这两个字段代码冗余不说漏set了就会导致时间字段为NULL。MP的自动填充功能正好解决这个问题。实体字段上做标注TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;然后实现一个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()); } }这样所有通过MP执行的insert和update操作都会自动填时间字段手写的自定义SQL同样不管。另外strictInsertFill这个方法会先判断字段是不是已经有值如果有值就不会覆盖这在批量导入数据、需要保留原本时间字段的场景下非常实用。5. 实战踩坑字段映射、主键策略、批量插入的真实教训5.1 数据库字段与Java属性映射不一致的问题虽然开启了map-underscore-to-camel-case但有几类映射问题仍然会反复出现。第一类是字段名里有特殊单词。比如数据库字段叫user_name对应userName没问题但如果有个字段叫u_class_codeJava属性写成uClassCode也是OK的。真正容易出问题的是数据库字段名与Java属性在语义上不一致的情况比如字段叫org_id属性叫organizationId这种情况下MP自动映射就失效了。解决办法是在实体属性上显式标注TableField(org_id)。第二类是Java属性使用了isXXX这种boolean命名形式。数据库字段叫deletedJava属性如果叫deleted没问题但如果你写成了isDeleted有些反序列化框架拿到字段名时会认为是deleted而MP拿到的是isDeleted映射关系就容易错乱。我的建议是boolean字段属性名直接写成deleted这种不要加is前缀。第三类是数据库字段类型和Java类型不匹配。比如数据库是datetimeJava用LocalDateTime没问题但如果是date类型Java用java.util.Date还好如果是LocalDate也合适。最容易踩的坑是MySQL的bigint unsigned在Java里对应Long类型没问题但某些极端情况下数值超过Long最大范围就会出问题这个需要设计表时就想清楚。5.2 主键策略选择AUTO、ASSIGN_ID还是INPUT主键策略是设计阶段就要想清楚的事情。MP提供几种策略我分别说说使用场景。IdType.AUTO是数据库自增适合MySQL单库场景但对分库分表不友好因为多个库之间的自增ID会冲突。IdType.ASSIGN_ID是MP默认的策略用雪花算法生成一个全局唯一ID适合分布式系统也是我推荐在大多数新项目中使用的策略。雪花ID是一个Long类型的数字整体趋势递增性能也不错。IdType.INPUT是用户自己传入ID适合那种使用业务自定义ID的场景比如用订单号作为主键。还有一种IdType.NONE等于让MP不指定策略跟随全局配置或数据库特性。这里有一个实际使用中的细节用ASSIGN_ID生成的雪花ID在数据库里如果字段类型是bigintJava侧用Long承接没有问题。但前端拿到这个Long类型的ID时如果JavaScript用number表示超过Number.MAX_SAFE_INTEGER约9007199254740991时精度会丢失。雪花ID很容易超过这个范围所以传给前端时建议转成字符串。我在项目里通常会在后端返回给前端的DTO里把ID字段转成字符串避免精度丢失导致的前端数据错误。5.3 批量插入并不一定真的是“批量”MP的saveBatch方法用起来很方便但如果你以为它是一条批处理SQL插入了所有数据那就错了。saveBatch实际默认是分批次执行插入每批1000条但批次内部是循环执行单条insert语句还是真正的批量SQL取决于底层的实现和数据库连接参数。在MySQL下如果JDBC连接字符串没有配置rewriteBatchedStatementstrue即使MP用了批量提交最终发送到数据库的SQL还是逐条执行的性能提升有限。我优化过一个批量导入接口数据量大概5万条原来用saveBatch耗时约18秒在数据库连接串加上rewriteBatchedStatementstrue之后耗时降到了4秒左右。这个参数很多人不知道强烈建议在连接串里加上。另外大批量数据插入时如果要追求极限性能直接拼接一条多values的SQL往往比ORM批量插入更快。以MySQL为例单条insert values可以带几百条记录性能非常可观。MP也支持用自定义SQL实现这种方式只不过SQL大小有限制需要控制单条SQL的数据量通常建议每批500条左右比较安全。5.4 控制台SQL日志与慢SQL定位MP开发环境开启了StdOutImpl后每一条SQL都会打印出来可以看到完整的预处理SQL和参数值这对调试很有用。但它打印的SQL是有占位符的比如WHERE id ?参数会单独打印一行排查时要把两条信息拼在一起看。线上环境建议开启MySQL的慢查询日志或者在应用层配置Druid的慢SQL监控。我遇到过一种情况MP自动生成的查询SQL看起来很简单但因为没有走到索引导致查询极慢。用EXPLAIN分析后发现条件字段上缺少联合索引。这种问题在开发环境数据量小的时候根本察觉不到线上数据一多就爆发了。所以每次上线前最好把核心表的查询SQL都过一遍执行计划重点看type是不是ALL全表扫描。6. 把项目交给团队前代码生成器与使用规范建议6.1 代码生成器批量建实体和Mapper的实践MP官方有代码生成器可以根据数据库表一键生成实体类、Mapper接口、Service和Controller。版本3.5.1前后的生成器API差别很大3.5.1之后改成了FastAutoGenerator写起来简洁很多。FastAutoGenerator.create(jdbc:mysql://localhost:3306/demo, root, password) .globalConfig(builder - builder.author(某开发者).outputDir(/tmp/mp-gen)) .packageConfig(builder - builder.parent(com.example.demo).entity(entity).mapper(mapper)) .strategyConfig(builder - builder.addInclude(user, user_role).entityBuilder().enableLombok()) .execute();生成器适合项目初期批量建表后快速搭骨架但我不建议每次都依赖它。后期表结构变动频繁时重新生成容易覆盖手写改动。我的习惯是生成器只用来生成实体类和Mapper接口这两个最稳定的文件Service和Controller层手写因为业务代码变化大不适合被模板约束。6.2 团队协作中的MP使用红线MP用起来简单但正因为简单容易出现不规范的用法。我在带团队时约定了几条红线效果还不错。第一条查询条件优先使用LambdaQueryWrapper禁止在代码里散落字符串列名。这样做的原因不只是重构安全更重要的是代码评审时能一眼看出查询条件是什么不用去猜列名含义。第二条自定义XML只负责复杂查询不要在XML里写单表CRUD。既然MP已经把单表CRUD做了自定义XML里还写一遍insert和update等于把已经自动化掉的工作又手工做了一遍反而多了一份维护成本。第三条Service层禁止直接返回实体类给前端。实体类里通常有deleted、version这类内部字段直接返回给前端既不安全也不优雅。我要求所有接口返回统一DTO字段按需裁剪这样还能顺便把雪花ID转字符串的问题解决掉。第四条逻辑删除和乐观锁字段必须在建表时就规划好。表设计时统一包含deleted、version、create_time、update_time这四个字段生成实体时也一起带上避免后期补字段时遗漏历史数据的影响。6.3 和MyBatis原生共存时的注意事项MP并不排斥原生MyBatis同一个Mapper接口里可以同时有继承自BaseMapper的方法和你自己写的方法。但有一些细节需要留意。当你的Mapper接口里同时存在“继承的方法”和“XML里定义的方法”时如果方法名相同会产生冲突。比如BaseMapper里有selectList你在XML里也定义了一个id为selectList的查询启动时就会报statement重复的错误。所以自定义方法名一定要避开MP内置方法名。还有一点MP生成的SQL默认是单表操作如果你在实体里用了TableField(exist false)标注的关联字段它不会出现在默认CRUD SQL里。这种变通做法适合轻量级场景但涉及多表复杂查询时还是老老实实写XML更合适不要强行用MP绕。最后再说一下表达式条件里的小细节last方法可以拼接SQL片段比如wrapper.last(limit 10)但这样做会绕过MP分页插件的控制还会破坏SQL的可读性和安全性不适合在复杂业务中使用。我把它归类为“能用但别乱用”的API只在极少数需要临时限制查询条件时才碰它。MP这个框架使用门槛确实低但想把它的能力边界摸清楚还是得在实际项目中趟一遍。我自己的体会是整合阶段真正花费时间的不是配置而是理解“哪些活交给MP、哪些活留给自己”这条分界线。分界线清楚了项目结构自然就清爽了。希望这篇文章能帮你少走一些弯路有整合过程中遇到的其他问题欢迎在评论区一起交流。