MyBatis-Plus 从零配置到进阶实战:分页、逻辑删除、自动填充与避坑指南

📅 发布时间:2026/10/14 6:16:16
MyBatis-Plus 从零配置到进阶实战:分页、逻辑删除、自动填充与避坑指南
最近在整理项目基础代码的时候发现不少团队已经把 MyBatis-Plus 作为默认的持久层框架。说实话早几年我还在手写 XML 里的 resultMap、动态 SQL 的时候根本没想过 CRUD 能省到这种程度。这篇笔记不是官方文档的复述而是我从零配置 MyBatis-Plus 到跑通分页、逻辑删除、自动填充整个过程留下的记录和踩坑整理。适合刚接触 MP、准备在 Spring Boot 项目里引入它的同学参考也适合那些已经在用但只停留在“能用”阶段、想搞清楚配置细节的开发者。1. 为什么选择 MyBatis-Plus核心价值与适用场景1.1 从传统 MyBatis 的痛点说起先聊聊我最早用 MyBatis 的感受。单表 CRUD 在业务里其实占了一大半但传统 MyBatis 要求每个方法都要写 SQL哪怕是一个简单的selectById也得在 Mapper 接口里声明再去 XML 里写对应的 SELECT 语句。更麻烦的是联表查询时的 resultMap 映射字段一多XML 文件就变得又臭又长review 代码的时候大部分时间都在看重复的映射配置。那时候我就在想如果能把单表 CRUD 的模板方法直接生成好、把条件查询抽象成链式 API我只需要关心那些真正复杂的连表查询和业务 SQL开发效率会提升很多。这也是 MyBatis-Plus 最初吸引我的点。它不是一个全新的 ORM而是在 MyBatis 基础上做增强保留了 MyBatis 原有的灵活性和 SQL 控制力同时把单表操作、分页、逻辑删除这些高频场景从“手写”变成了“配置”。1.2 MyBatis-Plus 的核心特性速览我把它和 JPA、通用 Mapper 做过对比。JPA 在复杂查询时容易陷入 JPQL 和原生 SQL 之间的拧巴通用 Mapper 虽然早期解决了单表 CRUD但在条件构造的灵活性和更新策略上不够顺手。MyBatis-Plus 的核心特性可以归纳成这几块通用 CRUD继承BaseMapperT之后insert、deleteById、updateById、selectById、selectList这些方法开箱即用不需要写一行 SQL。条件构造器QueryWrapper、UpdateWrapper、LambdaQueryWrapper这套封装能构造出链式的动态条件不用再去 XML 里拼if标签。分页插件内置物理分页基于数据库方言自动生成 LIMIT 语句避免内存分页假象。逻辑删除通过全局配置和字段注解把删除操作自动转成update实现数据不物理删除。自动填充用于统一处理创建时间、更新时间、操作人等字段自己不用每次手动 set。乐观锁插件配合版本号字段解决并发更新覆盖问题。这些特性基本都是“约定优于配置”默认行为很聪明但一旦需要踩刹车它也给了足够多的自定义接口。1.3 什么项目适合引入 MyBatis-Plus不是所有项目都适合。如果项目里全是复杂的多表 join 和存储过程那 MyBatis-Plus 的帮助有限反倒因为多了自动生成的规则和插件增加了理解成本。但如果你做的是常规业务系统比如后台管理、会员中心、订单系统单表 CRUD 占了七八成那 MP 就能直接帮你把大量样板代码省掉。剩下那两三成复杂 SQL仍然可以用 MyBatis 原生的Select注解或者 XML 去写MP 完全不会干涉。我个人的建议是新项目如果用 Spring Boot MySQL 这套组合可以直接引入 MP只要注意不要让团队过度依赖它的“一把梭”能力复杂查询该写 SQL 还是要写 SQL。老项目想迁移也不是不行但要看现有 Mapper 里有没有大量依赖 XML 的复杂映射如果有改造成本会高于收益建议先拿一个独立模块做试点。2. 环境准备与基础配置2.1 依赖引入与版本选择这里以 Spring Boot 2.x 项目为例。我目前用的是 MyBatis-Plus 3.5.x 和 Spring Boot 2.7.x版本兼容性比较稳定。如果你用的 Spring Boot 3.x需要注意 MP 3.5.3 以上才有对应的 starter且包路径略有变化。引入依赖时直接使用官方 starter 即可dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency如果是多模块工程建议在父 pom 的dependencyManagement里统一管理版本避免子模块各自指定导致版本冲突。还要注意一点如果你之前单独引入了mybatis和mybatis-spring再引入 MP starter 可能会产生依赖冲突。MP starter 本身包含了 MyBatis 的依赖所以通常只需要保留 MP 的 starter不需要额外重复引用。2.2 Spring Boot 配置文件详解MP 提供了大量配置项但真正项目里常用的就那几个。我在application.yml里的典型配置如下spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml type-aliases-package: com.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: auto table-prefix: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0mapper-locations用来指定 XML 文件位置如果没有 XML 可以不加。map-underscore-to-camel-case必须设为 true这样数据库字段user_name才能自动映射到实体属性userName不用每个字段都写TableField。log-impl设为 StdOutImpl 是方便开发时在控制台看 SQL生产环境可以去掉或换成别的日志实现。id-type: auto表示主键使用数据库自增。如果你的表主键是自定义分配比如雪花 ID那可以改成assign_id。MP 默认的策略是ASSIGN_ID也就是雪花 ID这个细节非常容易踩坑很多人把表设置成自增主键结果插入后 entity 的主键变成了一长串雪花 ID就是因为没有显式配置。2.3 启动类扫描与 Mapper 接口定义Spring Boot 启动类上需要加MapperScan否则 Mapper 接口不会注册成 Bean。当然也可以在每个 Mapper 接口上加Mapper但项目里 Mapper 一多逐个加注解太啰嗦我一般统一在启动类上扫描SpringBootApplication MapperScan(com.demo.mapper) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }Mapper 接口本身没什么特殊要求直接继承BaseMapper就行泛型指向对应的实体类public interface UserMapper extends BaseMapperUser { // 除了继承的方法之外有需要可以自己声明方法 }实体类则建议和表名、字段名严格对应。如果表名和类名不一致用TableName(t_user)指定如果某个字段不想映射用TableField(exist false)标记。这里我个人的习惯是实体用 Lombok 的Data再加TableName明示表名避免团队里有人改类名导致映射错误。3. 核心功能我这样用CRUD、条件构造器与分页3.1 BaseMapper 的通用 CRUDBaseMapper 提供的方法足够应付单表操作。我用的最多的是这几个insert(entity)插入一条记录回填主键。selectById(id)根据主键查一条记录返回 null 表示不存在。selectBatchIds(ids)根据主键集合批量查询。updateById(entity)根据主键更新非 null 字段。selectList(wrapper)查询列表wrapper 可以传 null。deleteById(id)逻辑删除开启后底层会变成 update 语句。delete(wrapper)按条件删除同样受逻辑删除配置影响。有一个特别容易忽略的点updateById默认只更新非 null 字段。如果你想把某个字段更新成 null直接传 entity 是不生效的需要改用UpdateWrapper并且用.set(field, null)显式设置。这是很多测试新人第一次疑惑的地方“我明明把字段置空了为什么数据库没变”原因就在这里。3.2 条件构造器 QueryWrapper / LambdaQueryWrapper条件构造器是 MP 使用频率最高的能力。我最早用QueryWrapper后来改成LambdaQueryWrapper原因是 Lambda 方式能把字段名写错的问题从运行时提前到编译期。比如要查所有状态为启用的用户按创建时间倒序ListUser users userMapper.selectList( new LambdaQueryWrapperUser() .eq(User::getStatus, 1) .orderByDesc(User::getCreateTime) );如果还要支持多个条件条件构造器里可以继续链式追加LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(name), User::getName, name) .ge(beginTime ! null, User::getCreateTime, beginTime) .le(endTime ! null, User::getCreateTime, endTime) .eq(User::getStatus, 1) .orderByDesc(User::getCreateTime); ListUser list userMapper.selectList(wrapper);这里用like(StringUtils.hasText(name), ...)这种重载方法是 MP 非常贴心的设计第一个 boolean 条件为 true 时才拼接后面的查询条件相当于把原来 XML 里的if判断搬到 Java 里代码清爽很多。不过要注意QueryWrapper如果使用了实体类属性名对应的数据库字段字符串写错不会立刻报错而是 SQL 执行时才出错所以我建议团队统一使用LambdaQueryWrapper。3.3 分页插件配置与使用分页插件需要先配置一个拦截器。我在配置类里写的Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(100L); interceptor.addInnerInterceptor(pagination); return interceptor; } }分页插件是比较早的 com.baomidou 提供的分页器我之前用过一个叫PaginationInterceptor在 MP 3.5.x 里已经改成PaginationInnerInterceptor要特别注意版本差异。setMaxLimit是可选配置作用是对单次分页查询的最大条数做限制防止有人一次把整张表读出来。使用分页的方式是调用selectPagePageUser page new Page(current, size); // 可以设置排序比如 page.addOrder(OrderItem.desc(create_time)) PageUser result userMapper.selectPage(page, wrapper); ListUser records result.getRecords(); long total result.getTotal();千万不要在 service 层循环调用分页查询正确做法是先查出总数再查数据。MP 的selectPage会自动执行一个 count 查询和一个分页查询result.getTotal()已经帮你算好总数了。分页插件对复杂 SQL 的 count 优化不够好如果 SQL 里有多个 left join生成的 count 可能偏慢。遇到这种情况可以单独写一个 count SQL或者在分页前用optimizeJoin之类的插件做优化。虽然复杂场景不多但真遇到时排查方向要正确。4. 进阶功能配置与避坑逻辑删除、自动填充、乐观锁4.1 逻辑删除的全局配置业务系统里“删除”往往是假删数据不能真的从库里消失。MP 的逻辑删除做得很简单声明一个字段代表逻辑删除标识然后在配置里定义删除值和非删除值。实体类示例Data TableName(user) public class User { TableId(type IdType.AUTO) private Long id; private String name; TableLogic private Integer deleted; }配置里的logic-delete-field: deleted是全局指定逻辑删除字段logic-delete-value: 1和logic-not-delete-value: 0是指定删除标识值。实体字段上的TableLogic其实可以用全局字段名替代但建议两者配合避免个别表字段名不一致导致漏配置。逻辑删除开启后MP 会自动改写 SQLdeleteById(id)变成UPDATE user SET deleted 1 WHERE id ? AND deleted 0selectById(id)变成SELECT ... FROM user WHERE id ? AND deleted 0selectList(wrapper)自动追加AND deleted 0这个自动改写是透明的SQL 日志里能看到。但要注意如果表里没有deleted字段而全局配置了逻辑删除那所有 SQL 都会报列不存在的错。如果某些查询需要查已删除数据不能直接用 BaseMapper 方法要写原生 SQL 绕过 MP 的自动逻辑。唯一索引字段在逻辑删除后可能有问题。比如用户名有唯一索引删掉一个用户后同名记录还能插入吗通常会因为deleted字段也参与唯一约束而冲突这已经不属于 MP 的问题而是设计层面的取舍。4.2 字段自动填充实现创建时间、更新时间、操作人这类字段业务代码里经常要重复 set容易漏。MP 的自动填充需要两步第一步实体字段上加TableField(fill FieldFill.INSERT)或UPDATETableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;第二步实现MetaObjectHandler接口注册成 BeanComponent 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()); } }这里有个很隐蔽的坑如果你用strictInsertFill它只会填充当前值为 null 的字段。也就是说如果业务代码显式给createTime设置了一个值MP 不会覆盖它。这其实是好事可以应对某些需要回填旧数据的场景。另一个常见问题数据库字段类型是datetime实体字段是LocalDateTime自动填充没问题如果实体用了Date则是java.util.Date和LocalDateTime的转换问题容易序列化出错。建议统一使用LocalDateTime并且在 Jackson 配置里定义好格式化避免接口返回的时间格式变成数组。4.3 乐观锁插件配置与注意点并发更新时最简单安全的做法是乐观锁。MP 的乐观锁实现方式是通过版本号字段必须在插件列表里加OptimisticLockerInnerInterceptorinterceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());注意插件顺序。分页插件和乐观锁插件可以并存但如果是逻辑删除插件需要在分页之前。MP 3.5.x 的插件顺序很重要顺序不对可能导致 SQL 改写不生效。实体类里加上版本字段Version private Integer version;乐观锁生效的条件有严格限制不能使用UpdateWrapper设置版本字段为 null 或固定值。updateById时必须把带 version 的实体传进去MP 执行的 SQL 会带上WHERE version 旧值。更新完成后M 不会自动回填新的 version 到实体里你需要从之前读取的实体或重新查询来获取。实际使用中我踩过一个坑批量更新时比如update(entity, wrapper)如果 wrapper 没显式携带 version 条件乐观锁可能不生效。后来查文档发现update(entity, wrapper)形式的更新不会像updateById那样自动拼接 version需要自己控制或者避免在这种场景下用乐观锁。5. 实际操作中的常见问题与排查5.1 常见异常速查表我把实际项目里遇到的、以及社区里常见的问题整理成了表格方便排查异常现象常见原因解决思路启动报Invalid bound statement (not found)Mapper XML 没有扫到检查mapper-locations路径和 XML namespace 是否匹配控制台打印 SQL 报Unknown column xxx实体属性名和数据库字段映射不一致开启 camel-case 映射或使用TableField显式指定insert后主键是一串雪花 ID数据库自增但配置没有设置为auto在id-type中改为auto或实体TableId(type IdType.AUTO)逻辑删除后查询还出现已删数据没加TableLogic或全局配置没生效检查实体字段注解和global-config.db-config.logic-delete-field分页 total 为 0分页插件未正确配置检查MybatisPlusInterceptor是否注入版本是否匹配updateById字段更新为 null 不生效MP 默认只更新非空字段使用UpdateWrapper.set(字段, null)乐观锁并发更新总是成功插件未加载或更新方法不满足条件确认插件加入并使用updateById且实体中带 version 字段多次修改实体属性后selectById缓存了旧值一级缓存重复使用关闭一级缓存或使用新SqlSession一般不会影响业务但要注意5.2 我个人踩过的坑与解决思路第一个坑是版本号问题。当时我从网上抄了一份 MyBatis-Plus 3.4.2 的配置项目用的 Spring Boot 2.5结果启动就报 class not found。排查到最后发现是 mybatis-plus 和 mybatis-plus-generator 的版本不一致一个 3.4.2 一个 3.5.0maven 依赖调解后内部类版本乱了。后来我把所有 MP 相关的依赖都统一到mybatis-plus-boot-starter上由它传递依赖问题迎刃而解。所以版本管理这件事不能只盯着核心包starter、generator、extension 必须一致。第二个坑是分页插件顺序。某次加逻辑删除后deleteById没有自动改写log 里打印的还是 delete SQL。后来发现我只加了PaginationInnerInterceptor和OptimisticLockerInnerInterceptor漏了BlockAttackInnerInterceptor不对是逻辑删除其实不需要单独插件靠的是TableLogic。那次问题出在TableLogic的字段名全局配置覆盖掉了实体注解导致字段识别错了。把global-config.db-config.logic-delete-field和实体字段对齐后就好了。第三个坑是字段映射。有一个表字段叫is_deleted实体属性叫deleted我没加TableField默认驼峰映射把它映射成了isDeleted结果所有查询都报字段不存在。后来我给实体字段加了TableField(is_deleted)明确指定数据库列名才算彻底解决。业务里碰到is_开头的字段真的建议显式注解不要依赖默认映射。第四个坑其实是设计层面的逻辑删除加唯一索引的问题。用户表用户名有唯一索引我先删了一个用户再插入一个同名用户因为逻辑删除只是把 deleted 置为 1唯一索引仍然存在导致插入失败。最后我调整了表结构把唯一索引改成“用户名 deleted”的组合索引同时业务层在逻辑删除时把 deleted 改成主键 ID 的大数字这样既保留唯一性又能支持重名插入。这个方案不一定适合所有场景但如果你也有逻辑删除加唯一索引的需求可以参考。写在最后的实际操作体会这篇笔记写到这我回想了一下MyBatis-Plus 的“简单配置”其实分两层第一层是启动即用的默认配置第二层是业务化定制比如逻辑删除、乐观锁、自动填充。很多人只停留在第一层觉得 MP 就是一个少写 SQL 的工具遇到奇怪问题就束手无策。实际上把配置背后的改写逻辑搞明白比死记 API 有用得多。我自己的经验是遇到 MP 的诡异行为第一件事不是查 API而是把配置项和 SQL 日志对照起来看。比如逻辑删除有没有生效看打印的 SQL 里有没有WHERE ... AND deleted 0分页有没有生效看 SQL 里有没有 LIMIT自动填充有没有生效看 INSERT 语句里有没有带上创建时间。只要愿意看 SQL绝大多数问题都能快速定位。最后再分享一个小技巧学习 MP 的时候不要直接用官方生成器自动生成所有代码建议先手动写一遍实体类、Mapper、配置和简单的 CRUD。这个“笨办法”会逼你搞清楚每个配置的含义以后再遇到复杂项目就能很快判断哪些地方需要改配置、哪些地方需要写原生 SQL。很多人用工具生成完代码一脸懵遇到问题完全不知道从哪下手就是少了这一步亲手配置的过程。希望这篇笔记能帮你在 MyBatis-Plus 的使用上少走一些弯路。