免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

MyBatis-Plus 实战避坑:逻辑删除、分页与乐观锁的常见陷阱

MyBatis-Plus 实战避坑:逻辑删除、分页与乐观锁的常见陷阱 1. 前言这些坑你大概率踩过一半做 Java 后端的这两年MyBatis-Plus 几乎成了项目里的标配。单表 CRUD 不用写 SQL分页、逻辑删除、自动填充、乐观锁都给你封装好了确实爽。但“爽”是有代价的——我见过太多团队代码跑起来一切正常等业务逻辑复杂一点、数据量上来一点各种“灵异事件”就冒出来了。标题说“90% 的人都踩过这些坑”这个数字可能略夸张但绝不夸张的是你在网上随便搜一个 MyBatis-Plus 的 issue很多问题翻来覆去就是那么几类。依赖引错了、Mapper 扫描不到、逻辑删除和唯一索引冲突、自动填充不生效、or 条件拼接错乱、分页深翻页卡死……这些坑不是偶然而是框架设计和使用习惯之间的错位造成的。这篇文章不打算讲 MyBatis-Plus 的基础用法那文档里都有。我想把我在多个项目里真正踩过、排过、修过的坑拿出来每个坑都配上反例和正确姿势。如果你是刚用 MP 的新人可以当避坑手册用如果你已经写了不少 MP 代码建议对照着查一遍自己的项目——说不定你正在埋雷。2. 依赖与多模块代码还没写坑先埋好了2.1 版本选不对拦截器直接失效MyBatis-Plus 的版本演进有一个非常容易踩的断档3.4.0 之前的版本用的是PaginationInterceptor、OptimisticLockerInterceptor这类单独的拦截器从 3.4.0 开始官方把拦截器统一收口到了MybatisPlusInterceptor通过InnerInterceptor组合来扩展。很多老项目从旧版本升级时网上搜到的新写法是// 新写法正确 Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }结果还是有人按照旧文档注册PaginationInterceptor在 3.5.x 版本下这个类已经废弃分页功能要么不生效要么直接启动报错。这类问题在依赖升级后特别高频建议升级前先看一眼当前版本的官方文档中“拦截器”这一节别被博客里的旧代码带偏。2.2 Lombok 和实体类的“无参构造”暗雷说一句不少人都被坑过的事Lombok 的Builder和 MyBatis-Plus 放在一起容易出事。MP 在查询结果映射回实体时底层用的是反射它优先找无参构造方法。如果你的实体类写成这样// 反例 Data Builder public class User { private Long id; private String name; }那么Builder会生成一个全参构造方法。虽然Data默认也有无参构造但一旦你同时显式写了有参构造或者用了AllArgsConstructor无参构造就没了。MP 在反射创建对象时直接报错报错信息经常是NoSuchMethodException: com.example.User.init()不仔细查根本想不到是 Lombok 和 MP 协作的问题。解决办法很粗暴Builder和 MP 实体类共存的情况下手动加一个NoArgsConstructorData Builder NoArgsConstructor AllArgsConstructor public class User { private Long id; private String name; }这也是若依这类老框架里集成 MP 时很常见的坑——若依的实体类习惯用Data加EqualsAndHashCode(callSuper true)有人为了链式赋值加上Builder结果整个模块的查询全部报错。记住一条原则凡是被 MP 当成实体映射的类无参构造必须保证存在。2.3 多模块下 Mapper 扫描不到、分页插件重复注册多模块工程里另一个高频问题是MapperScan。很多项目把 MyBatis-Plus 的依赖放在公共模块各个业务模块里放 mapper 包启动类上写SpringBootApplication MapperScan(com.example.business.mapper) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }问题在于如果你的所有 mapper 分散在不同模块中比如user模块的 mapper 在com.example.user.mapperorder模块的 mapper 在com.example.order.mapper启动类里一个MapperScan根本扫不全。正确做法是扫描公共前缀MapperScan(com.example)或者更稳妥一点在每个模块自己的配置类上加MapperScan避免启动类扫描范围过大或者跨模块包名不统一的问题。还有一个隐藏比较深的雷多模块里如果多个模块都定义了分页插件 Bean而且 Bean 方法名相同会出现BeanDefinitionOverrideException或者更严重的是两个分页插件同时注册SQL 执行时 count 被优化两遍性能明显异常。排查建议是分页插件这种全局配置只放在启动工程里别在公共依赖模块里自动装配。若依项目整合 MP 时它自带的 PageHelper 和 MP 分页插件一起出现也是同样的道理——两种分页机制同时存在系统会把 SQL 截断两次你看到的结果就完全不对了。3. 逻辑删除的真实翻车现场删除之后怎么就无法新增了3.1 场景复现唯一索引 TableLogic逻辑删除本身用法很简单实体上给个TableLogic注解然后配置全局逻辑值mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0查询时 MP 会自动帮你加AND deleted 0删除时自动转成UPDATE ... SET deleted 1这个体验确实不错。但问题往往出在“唯一索引”上。举个真实案例用户表的phone字段上有唯一索引。某用户注销走后管理员想把同一个手机号重新录入到新用户上结果插入直接报Duplicate entry 138xxxx for key uk_phone。原因很简单逻辑删除并没有真正删除数据被删的那一行phone138xxxx还在表里唯一索引照样生效新插入的自然进不去。这大概是逻辑删除最经典的反模式。只要你的业务存在“删除后需要重新使用唯一键”的场景单纯靠TableLogic 单字段唯一索引是行不通的。3.2 正确姿势把 deleted 字段变成“每次删除都不同的值”既然 0 和 1 这种固定值会占用唯一索引一个常见的变通方案是删除时把deleted字段设置为主键id的值。这样唯一索引从uk_phone改成uk_phone_deleted(phone, deleted)每次删除后deleted都不同唯一索引之间不会互相冲突新插入的deleted 0也不会撞上旧数据。建表时这样改-- 反例 ALTER TABLE t_user ADD UNIQUE KEY uk_phone (phone); -- 正确 ALTER TABLE t_user DROP INDEX uk_phone; ALTER TABLE t_user ADD UNIQUE KEY uk_phone_deleted (phone, deleted);但这里有个坑MP 默认的deleteById逻辑删除时生成的 SQL 是固定的SET deleted 1并不会自动把主键值写进去。所以不能直接依赖 MP 内置方法而是要在 mapper 里自定义一条逻辑删除 SQLUpdate(UPDATE t_user SET deleted id WHERE id #{id} AND deleted 0) int logicDeleteByCustomId(Param(id) Long id);这样删除后deleted字段的值就是当前主键旧数据和新数据在(phone, deleted)联合唯一索引下井水不犯河水。3.3 实际操作中的注意事项这个方案虽然好用但也要注意几点查询时如果用了selectByIdMP 会自动追加AND deleted 0不受影响。如果某些统计 SQL 需要包含已删除数据就不能依赖 MP 的自动拼接需要单独写原生 SQL。deleted字段类型用bigint别再用tinyint否则存不下主键值。别担心浪费空间逻辑删除本来就要为历史数据留位置。如果团队里有同事习惯用deleteById而不是自定义方法逻辑删除字段的赋值会回到deleted 1唯一索引冲突问题会再次出现。建议在项目编码规范里写清楚“所有逻辑删除必须走自定义 SQL 或统一封装方法”。提示还有一种做法是删除时写当前时间戳而不是主键值效果类似时间戳的缺点是无法直接看出是哪条记录被删了而存主键值可以追溯。我更推荐存主键值。4. 自动填充与乐观锁明明配置了为什么还是不生效4.1 MetaObjectHandler 不生效的 90% 原因MyBatis-Plus 的自动填充createTime、updateTime、创建人、更新人等用起来很方便但经常有人配置了之后发现字段根本没被填充。我排查过不少这种问题统计下来 90% 是下面三个原因第一MetaObjectHandler实现类没有被 Spring 扫描到。多模块工程里你把这个类放在公共模块但启动类扫描的包路径和公共模块不在同一个前缀下整个 Bean 根本不存在。常见的表现是本地测试时字段有值一部署到多模块环境就全空了。第二实体字段上少了TableField(fill FieldFill.INSERT)或FieldFill.INSERT_UPDATE。很多人以为加了 handler 就万事大吉但 MP 判断哪些字段需要自动填充依据的是实体字段上的fill属性。不标属性handler 里的strictInsertFill就找不到填充目标。第三插入时手动给填充字段赋值了。strictInsertFill的逻辑是“如果字段为空才填充”如果你在业务代码里写了user.setCreateTime(new Date()); userMapper.insert(user);MP 看到这个字段已经有值就不会覆盖。这在逻辑上其实是合理的但确实让很多人误会成“自动填充失效”。正确配置应该是这样的Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, createBy, Long.class, getCurrentUserId()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); this.strictUpdateFill(metaObject, updateBy, Long.class, getCurrentUserId()); } }实体字段上要写全TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;4.2 Version 乐观锁悄悄失效的三种情况乐观锁的配置不复杂实体上加Version然后注册OptimisticLockerInnerInterceptor。但我发现很多人配置了之后乐观锁并没有起到预期作用而且代码不报错很难发现。最容易踩的是第一种注册的拦截器不对。3.4.0 之后还在用OptimisticLockerInterceptor整个拦截器不生效Version变成摆设。这个光看代码很难察觉因为 CRUD 照常跑只有出现并发覆盖时才会发现问题。第二种更隐蔽update(entity, wrapper)时实体里的version是 null。比如你从前端传入一个 DTO只 set 了几个业务字段version 字段根本是空的然后调用userMapper.update(user, new LambdaUpdateWrapperUser() .eq(User::getId, user.getId()));MP 乐观锁判断 version 条件时发现实体里的 version 为 null就不会给你带上WHERE version ?的条件等于乐观锁没参与。正确做法是先从数据库查出当前version再赋值到实体上或者用UpdateWrapper时也把 version 条件显式写出来。第三种是 version 字段没有默认值。插入一条新记录时如果version字段在数据库里没有默认值实体也没赋值插入后 version 就是 null。后续更新时SET version null然后WHERE version null永远不会匹配更新行数永远为 0。你可以猜猜这是多常见的情况——新同事建表时少看了字段开了个允许 NULL。我的习惯是version字段数据库默认值为 0实体初始化也赋值为 0Version private Integer version 0;这样插入、更新、删除全链路都稳。4.3 配置正确后怎么验证真的生效了配置完成后建议写一个简单单元测试来验证。开两个事务模拟并发修改或者更直接一点打开 SQL 日志看 update 语句末尾有没有带AND version ?UPDATE t_user SET name ?, version 2 WHERE id ? AND version 1SQL 里能看到AND version ?说明乐观锁生效看不到就要回 4.2 里检查拦截器注册和实体赋值。5. 条件构造器 and/or你以为的 SQL 不是真正的 SQL5.1 反例or 一放开条件全乱套MyBatis-Plus 的LambdaQueryWrapper用起来是真方便但它的条件拼接方式坑人也真是一流。尤其是or()方法很多人用着用着就发现查出来的数据比预期多得多。举个例子你想查“状态为 1 的用户或者名字叫张三且年龄为 18 的用户”第一反应可能这么写// 反例 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1) .or() .eq(User::getName, 张三) .eq(User::getAge, 18);看一眼生成的 SQLWHERE status 1 OR name 张三 AND age 18表面上看起来没毛病因为 SQL 里AND的优先级本来就高于OR所以实际语义是status 1 OR (name 张三 AND age 18)确实符合需求。但问题是如果你想让OR两边都是“复合条件”——比如“状态为 1 且会员为 true 的用户或者名字叫张三且年龄为 18 的用户”——很多人继续往上堆// 反例 wrapper.eq(User::getStatus, 1) .eq(User::getVip, true) .or() .eq(User::getName, 张三) .eq(User::getAge, 18);生成的 SQL 是WHERE status 1 AND vip true OR name 张三 AND age 18按照AND优先这个 SQL 等价于(status 1 AND vip true) OR (name 张三 AND age 18)咦好像也没错。那真正的坑在哪坑在于继续追加条件的时候。比如再想加一个“性别为男”的全局条件// 反例 wrapper.eq(User::getStatus, 1) .eq(User::getVip, true) .or() .eq(User::getName, 张三) .eq(User::getAge, 18) .eq(User::getGender, 男);生成的 SQL 变成WHERE status 1 AND vip true OR name 张三 AND age 18 AND gender 男问题来了gender 男这个条件你认为它是对整个查询生效的全局条件但由于AND优先级更高它实际只挂在了后半段也就是name 张三 AND age 18 AND gender 男而前半段status 1 AND vip true根本不受性别限制。这类 bug 在复杂查询里极难发现查半天看不到问题。5.2 正确姿势用 and(consumer) 把分组包起来解决思路是用and(...)或or(...)方法把条件分组显式包起来别让它裸奔。对于上面这个场景正确写法是// 正确姿势 wrapper.and(w - w.eq(User::getStatus, 1).eq(User::getVip, true)) .or(w - w.eq(User::getName, 张三).eq(User::getAge, 18)) .eq(User::getGender, 男);生成的 SQLWHERE (status 1 AND vip true) OR (name 张三 AND age 18) AND gender 男你看 SQL 生成还是一样的逻辑AND 优先但因为or()和后面追加的eq(gender)之间没有括号gender 仍然是挂在最后一个 or 分组上。如果想让 gender 对整个查询生效需要更靠前的结构wrapper.eq(User::getGender, 男) .and(w - w.eq(User::getStatus, 1).eq(User::getVip, true) .or(w2 - w2.eq(User::getName, 张三).eq(User::getAge, 18)));不过在实际项目里我更建议这种复杂条件直接写自定义 SQL或者拆成多个简单查询在代码层合并别硬拼 Wrapper。条件构造器适合简单的动态查询复杂业务逻辑硬套 Wrapper 只会让代码越来越难读、越来越难调。6. 分页查询从小数据量到大数据量可能就差一个插件版本6.1 分页插件没注册selectPage 返回全表很多第一次用 MP 分页的同事都会有这个经历调用selectPage方法返回的records是全部数据total也对不上。翻以前博客有人说是版本问题有人说是配置问题折腾半天才发现是分页拦截器没有注册。// 反例只调了方法没注册分页插件 IPageUser page userMapper.selectPage(new Page(1, 10), null);在 3.4.0 之后没有PaginationInnerInterceptor的情况下selectPage并不会报错而是直接把所有数据查出来内存里帮你“分页”。数据量小的时候看不出问题等表里几百万条记录时一次分页查询直接 OOM 也不奇怪。正确姿势在 2.1 里已经写了这里再强调一遍分页插件必须显式注册并且最好指定数据库类型因为不同数据库方言不同默认自动识别有时会在特殊数据源下出错。6.2 深翻页LIMIT 100000, 10 的恶梦注册了分页插件之后另一个躲不掉的坑是深翻页。当用户翻到第 10001 页时MP 生成的 SQL 是SELECT * FROM t_order WHERE deleted 0 ORDER BY create_time DESC LIMIT 100000, 10;这个 SQL 在 MySQL 里会扫描前 100010 行然后丢弃前 100000 行再返回 10 行。数据量越大offset 越大查询越慢。这个问题的根源是 SQL 的 LIMIT 语法本身不是 MP 的锅但 MP 把这个操作简化到了“无感”反而让很多团队根本意识不到深翻页的代价。实际业务中不建议支持无限深翻页。产品上限制最多翻 100 页或者用“上一页/下一页”模式。如果必须跳页可以改成基于主键的游标方式SELECT * FROM t_order WHERE deleted 0 AND id 100000 ORDER BY id ASC LIMIT 10;业务侧记住上一页最后一条记录的 id下一页带着这个 id 继续查。这种方式的响应时间基本恒定不会因为页码增大而线性恶化。MP 的Page对象不直接支持这种模式但可以用 LambdaQueryWrapper 加一个gt(id, 上一页最大id)来实现代码不复杂效果立竿见影。6.3 count 查询优化别让分页把简单查询变成复杂统计分页插件会对每个分页查询自动执行一次count然后在 count 结果上计算总页数。问题在于如果原 SQL 本身带了 left join 或者 order bycount 语句可能被拼接得异常笨重。好在PaginationInnerInterceptor默认有个optimizeCountSql参数会尝试去掉 order by 等无关部分来优化 count但 join 场景下并不总是能优化到理想状态。如果你发现某个分页接口的 count 特别慢可以直接用 MP 的手写 count 方式替代或者拆成两条 SQL一条查数据一条单独查总数。很多时候 join 只是想在列表里展示冗余字段完全可以用子查询或者IN来替代性能提升反而更明显。提示分页插件生成的 count SQL 不一定是你预期的最优 count。遇到 count 慢先EXPLAIN一把看走了哪个索引再决定是加索引、拆 SQL 还是关闭自动 count。7. 高频问题排查速查表和我的几个“土办法”7.1 高频问题速查表我把这些年遇到过的 MP 相关频率较高的问题收敛成一张表你排查的时候直接对着找现象常见原因处理方式分页不生效返回全部记录分页插件未注册或注册了旧的PaginationInterceptor确认使用MybatisPlusInterceptor并加入PaginationInnerInterceptor逻辑删除后同数据无法插入单字段唯一索引和逻辑删除冲突建(业务字段, deleted)联合唯一索引删除时deleted id自动填充字段为空handler 未被扫描、实体没有fill属性、手动赋值导致跳过检查 Bean 扫描、补充TableField(fill ...)乐观锁不生效拦截器未注册、实体 version 为 null、数据库字段无默认值注册OptimisticLockerInnerInterceptorversion 默认 0条件查询结果莫名多/少or()未分组与后续条件优先级错乱用and(w - ...)/or(w - ...)显式分组NoSuchMethodException实体对象没有无参构造LombokBuilder与AllArgsConstructor掩盖了无参构造添加NoArgsConstructor多模块下 mapper 注入失败MapperScan扫描范围没有覆盖所有模块扫描公共包前缀或各模块单独配置7.2 我常用的排查套路以上是具体的坑最后分享一下我的常规排查套路。遇到 MyBatis-Plus 相关异常我不会先去翻代码而是先把mybatis-plus的 SQL 日志打开看它执行时究竟拼了什么样的 SQL。MP 的问题绝大多数都能在 SQL 层面看出来——自动填充没生效看 INSERT 里没字段乐观锁失效看 UPDATE 里没有 version 条件or 条件错乱看 WHERE 里的括号结构。另外写代码时我会刻意“假设框架是有状态的”。也就是说不要假设deleteById一定是物理删除不要假设selectPage一定做了分页不要假设Version字段一定参与了更新条件。每接到一个历史项目第一步是把所有TableName、TableLogic、Version、TableField(fill ...)注解列一遍弄清楚语义再谈业务开发。最后再分享一个小技巧如果你用的是多模块工程建议把 MP 相关的配置类单独放在一个模块并通过 Spring Boot 的spring.factories或AutoConfiguration统一装配不要把配置散落在各个业务模块。这样后续升级版本、排查问题时只需要看一个地方项目里的“雷”会少很多。
返回列表