开门见山说个事:现在Java后端做持久层,你要是还在用纯粹的MyBatis手写SQL,CRUD接口一个表写一堆XML,人效真的跟不上。MyBatisPlus在MyBatis基础上把单表CRUD、条件构造器、分页插件、代码生成器全给封装好了,项目里彻底告别重复的增删改查模板代码。这篇文章我把自己在实际项目中用MyBatisPlus这几年踩过的坑、调优过的配置、解决的问题,包括最近特别多人问的“分页失效”和“单页500条限制”,一次性理清楚。不管你是在老项目里引入MP,还是新项目选型,这篇都值得看完再动手。
1. 为什么团队都在迁移到 MyBatisPlus
1.1 它到底解决了什么痛点
先别急着看代码,想清楚一个问题:我们以前用MyBatis写单表操作,最烦的是什么?是每个实体类对应一套Mapper接口加一套XML,里面全是insert、delete、update、selectById这种几乎一模一样的SQL。表一多,XML文件几十个,改个字段名要全局搜替换,眼都快花了。
MyBatisPlus做的事情很简单:单表CRUD不用写SQL,BaseMapper把常用的方法全给你预置好了,你继承一下接口就有十几个现成方法可以用。条件查询不用拼字符串,用LambdaQueryWrapper构造查询条件,类型安全、可读性好、不怕字段名拼错。分页不用再手写LIMIT,配一个分页插件,传个Page对象就能拿到总数和当前页数据。代码生成器一键把实体、Mapper、Service、Controller全部生成出来,新模块落地速度可以说是碾压手写。
为什么在众多增强框架里MP脱颖而出?它没有重新发明轮子,BaseMapper底层还是MyBatis那一套,SQL还是你自己能控制的。MP更像是一个“脚手架”,把重复劳动包下来,复杂查询你随时可以退回原生SQL,灵活性和便捷性兼顾。这个定位非常讨巧,团队上手成本极低,从一个老MyBatis项目迁移过来,几乎不需要培训。
1.2 适合什么项目,什么情况下别用它
从项目类型来看,MP最适合业务系统、管理后台、中台服务这类以单表CRUD为主、查询条件比较灵活的项目。如果一个项目80%以上的数据库操作是单表增删改查,MP能帮你省掉大量的模板代码。尤其是新项目快速迭代的阶段,代码生成器加MP,一个模块从建表到接口跑通可能就半天时间。
但是别神话它。如果你的系统是报表分析型项目,SQL动不动就是三四张表join加子查询加窗口函数,这种场景MP帮不上太大忙,复杂SQL还是得老老实实写在XML里。还有一种是极端追求SQL性能优化的团队,每一句SQL都要手工调执行计划,MP自动生成的SQL虽然不差,但可能会让你觉得不够“精准”。我个人的建议是:MP作为基础CRUD层没问题,复杂查询继续用XML,两者完全可以共存,这也是MP官方推荐的使用方式。
2. 核心功能的落地姿势:CRUD、条件构造器和分页
2.1 内置CRUD接口怎么用才不踩坑
引入MP之后,实体类上记得加几个关键注解,否则会有一些隐藏问题。@TableName指定表名,如果表名和实体名不一致不指定就会报错;@TableId指定主键字段,主键如果叫id而实体里叫userId,不指定也会出问题;@TableField指定非主键字段名,特别要注意字段名是keyword、order、desc这种数据库关键字时,必须用@TableField标注。还有事务版本的实体最好加@Version实现乐观锁,逻辑删除字段加@TableLogic。
继承BaseMapper就能直接用这些方法:
public interface UserMapper extends BaseMapper<User> { }selectById、deleteById、insert、updateById、selectBatchIds这些方法都有。但有个点很多人会忽略:updateById这个方法,默认只会更新非NULL字段。如果你想把某个字段更新为NULL,直接传一个null进去,这个方法不会帮你更新。解决办法是使用LambdaUpdateWrapper,显式调用set方法:
userMapper.update(null, new LambdaUpdateWrapper<User>() .eq(User::getId, 1001) .set(User::getRemark, null));这个坑我当年踩过,线上有个需求要把备注清空,结果updateById传null字段进去,静默失败,数据完全没变,排查了很久。所以记住:updateById适合整行覆盖更新,局部字段更新特别是置空操作,一定要走UpdateWrapper。
2.2 条件构造器的正确打开方式
条件构造器是MP的灵魂所在。QueryWrapper、LambdaQueryWrapper、UpdateWrapper、LambdaUpdateWrapper这四件套要搞清楚各自的使用场景。QueryWrapper用法是列名写字符串,好处是动态性高,坏处是字段名写错编译器不报错,运行时报错;LambdaQueryWrapper直接传实体方法的引用User::getName,编译期就能发现问题。能写Lambda就用Lambda,代码重构的时候好处尤其明显。
动态条件判断是日常开发最常用的功能,比如前端传过来的筛选条件可能为空,我们以前要么写一堆if判断拼接SQL,要么用MyBatis的 标签。MP支持condition参数,写法非常精炼:
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(name), User::getName, name) .eq(StringUtils.hasText(status), User::getStatus, status) .ge(ageMin != null, User::getAge, ageMin);第一个参数是boolean,为false时这个条件自动忽略,整个查询条件链看起来非常干净。eq、ne、like、between、in、orderByDesc这些方法都支持condition重载。还有个要注意的点,like查询在MySQL里走的是%关键字%这种模糊匹配,如果数据量大,LIKE '%xxx'这个前缀%会导致索引失效,这是SQL优化问题,不是MP问题,但使用时要心里有数。
2.3 分页插件必须这样配才对
分页是MP的招牌功能,但很多人配错。先看标准配置:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个配置是必须的,少了它分页就不生效,这一个问题能解释网上90%的“MP分页失效”求助帖。DbType一定要指定准确,因为MP要根据数据库类型生成不同的分页方言。接着在Mapper里这样写:
Page<User> page = new Page<>(current, size); Page<User> result = userMapper.selectPage(page, new LambdaQueryWrapper<User>().eq(User::getStatus, 1)); List<User> records = result.getRecords(); long total = result.getTotal();Page对象有两个关键点。第一,Page构造方法第一个参数是页码,从1开始;第二个是每页大小。第二个点,MP的分页查询是执行两条SQL,一条带LIMIT查数据,一条不带LIMIT查COUNT,所以在分页SQL里别再去掉旧的count查询,插件会自动处理。total是long类型,前端如果按int接收,数据量大的时候会溢出,这个注意一下。
3. 分页失效:5个必查原因和现场还原
3.1 原因一:插件没配,等于白写
我刚才说的那句“分页插件必须配”,真不是危言耸听。很多项目是半路接的MP,只引入了依赖,把Mapper改成继承BaseMapper,然后就开开心心写Page查询了,结果list结果出来,一数条数不对,发现limit完全没生效。翻配置文件,翻代码,排查了半天,最后发现MybatisPlusConfig这个类根本不存在。
这个问题的本质是:PaginationInnerInterceptor是一个拦截器,它要注册到MyBatis的拦截器链里才会对SQL做改写。你没配置,SQL就不会被改写,分页自然不生效。我自己给团队做代码审查的时候,第一件事就是看有没有这个配置类。如果是Spring Boot项目,确保配置类被扫描到,别把配置类写在Spring扫描路径之外。
3.2 原因二:多表联查时count翻车
分页插件在遇到多表关联的SQL时,自动生成的count SQL偶尔会有问题。比如SQL里有distinct,有group by,有复杂的子查询,插件生成的count SQL可能是错误的,报语法错误,或者count结果不对。分页插件看到count失败,可能就不执行分页SQL了,直接返回全量数据,表现出来就是分页失效。
解决办法其实很简单:手动控制count查询。MP的Page对象可以设置一个AnsiSqlParserInfo或者直接传入分页SQL,但更常见的做法是自定义一个count SQL。如果你的mapper方法是用@Select注解写分页SQL的,可以这样:
@Select("select * from blog b left join author a on b.author_id = a.id where b.title like #{name}") IPage<BlogVO> selectBlogPage(Page<?> page, String name);Page参数只要放在方法参数里,分页插件会自动拦截改写这条SQL。如果count不对,或者性能差,你可以单独写一个count查询方法,或者在XML里定义两条SQL语句,一条查询列表,一条查询总数,然后把总数通过自定义方案传回去。实战里,大部分联表分页问题只要保证主表是驱动表、条件字段有索引,自动count还是能扛得住的。
3.3 原因三:自定义SQL与Wrapper参数脱节
这个是新手的重灾区。我见过有人这样写Mapper方法:
List<User> selectUserPage(Page<User> page, @Param("name") String name);然后在XML里:
<select id="selectUserPage" resultType="..."> select * from user where name like concat('%', #{name}, '%') </select>分页插件要生效,Page必须是第一个参数吗?其实不是必须的,但IPage参数必须被识别到。关键坑在于:如果你在Service里创建了Page对象,但没传给Mapper方法,或者Mapper方法的Page参数没配上,插件无法识别分页意图,就不会做分页。还有一点,如果你的自定义SQL里,参数是用@Param("ew")传进来的Wrapper,那么在XML里要写${ew.customSqlSegment}才能把条件拼接进去。写漏了,条件丢失,看起来也是“分页没生效”,因为数据量变多了。
正确姿势是:
IPage<User> selectUserPage(Page<User> page, @Param(Constants.WRAPPER) Wrapper<User> wrapper);XML里:
<select id="selectUserPage" resultType="..."> select * from user ${ew.customSqlSegment} </select>3.4 原因四:物理分页SQL和插件叠buff
这个问题隐蔽性很高。有的项目之前没有分页插件,开发人员在SQL里手写了LIMIT:
<select id="selectPage" resultType="..."> select * from user limit #{offset}, #{size} </select>引入MP之后,这段SQL又被分页插件拦截改写,结果生成了limit limit这种畸形SQL,或者插件改写后参数错乱,直接报语法错误。我当时遇到一个现场,pageSize传10,结果返回了20条数据,追查下去发现就是原生limit和分页插件互相叠加了。
排查方法:在XML里搜一下有没有手写limit,#{offset}这种关键字。有的话,要么删掉原生limit,全交给插件处理;要么这个查询不走Page参数,直接用List接收,保留原生SQL。记住一个原则:同一句SQL里,物理分页只能有一个实现方式,别混用。
3.5 原因五:返回类型与IPage使用姿势错误
分页方法可以返回IPage,也可以返回List,但两种方式分页插件的处理逻辑有区别。如果你用List接收返回结果,Page对象还是照样有效,但插件的分页SQL只负责查询数据,总条数不会自动查。很多人用List接收后,发现total里没有值,或者压根没生成count查询,就以为是分页失效。
这里给大家一个统一的规范:分页查询方法的返回值一律用IPage。这样MP会自动执行count,同时把records封装进IPage对象里,total、current、size全都有了。用List接收,等于自己把count信息丢掉了,还容易出现“看起来分页没生效”的误会。如果在自定义DTO查询里不想创建实体类,也可以用IPage<Map<String, Object>>,没毛病。
3.6 排查思路总结
把分页失效问题整理成了一张速查表,遇到问题直接对照排查,比从头查快很多:
| 现象 | 优先级 | 排查方向 |
|---|---|---|
| SQL没有LIMIT | 高 | 分页插件是否注册、能否被扫描到 |
| 返回条数对不上 | 高 | 手写limit与插件叠加;count结果错误 |
| 总条数total为0 | 中 | 返回类型是否用了List,没用IPage |
| SQL语法错误 | 中 | count SQL重写失败,检查复杂SQL |
| 条件丢失 | 中 | 自定义SQL里${ew.customSqlSegment}是否拼接 |
| 多数据源场景 | 中 | 每个数据源都要注册分页插件 |
4. 单页500条限制:新版本分页插件的一个大坑
4.1 这个限制是从哪来的
最近很多群友问:项目明明配置了分页,为什么pageSize传600就报错了,说超过单页最大限制。这个问题从MyBatisPlus 3.5.9版本开始出现,分页插件里新增了一个MaxLimitHandler机制,默认单页查询最大行数限制为500条。这个设计本意是防止有人恶意查询超大分页,比如pageSize传100万,直接把数据库拖垮。
之前没有限制的年代,分页插件对pageSize大小没有任何约束,传多少就limit多少。加这个默认限制后,pageSize超过500直接抛异常。很多老项目一升级MP版本就突然冒出这个报错,就是这个原因。
4.2 典型报错现场
报错信息长什么样,大家先认一下:
Error querying database. Cause: com.baomidou.mybatisplus.core.exceptions.MybatisPlusException: single page maximum limit is 500或者类似“single page maximum limit is xxx”的信息。看到这个关键字,第一反应就是命中MaxLimitHandler了。这里我要先给一个判断标准:你的业务真的需要单页超过500条吗?如果是前端表格展示、分页器翻页,单页500条已经属于比较大的容量了。但如果是内部系统导出数据、接口对接批次拉取,或者某些管理后台的“查看全部”功能,500条限制确实会让业务卡住。
4.3 解决办法一:@InterceptorIgnore快逃
MP提供了一个注解,可以在Mapper方法上跳过部分拦截器处理。针对分页,可以在方法上标注:
@InterceptorIgnore(maxLimit = "0") IPage<Order> selectOrderPage(Page<Order> page, @Param(Constants.WRAPPER) Wrapper<Order> queryWrapper);maxLimit = "0"表示这条SQL不参与单页限制拦截,pageSize想传多少传多少。这个注解的原理是只对该方法生效,其他方法仍然保留默认限制。个人建议在确实需要超大分页的接口上单独使用,不要全局放开。因为全局放开等于把这个安全机制架空了,万一有人恶意传超大分页,数据库压力会直接爆炸。
4.4 解决办法二:自定义MaxLimitHandler
如果你用的版本已经支持自定义的它,可以在配置分页插件时传入自定义限制值:
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L);这里setMaxLimit(500L)设置的是单页最大限制,注意默认就是500。如果你觉得默认值不够,调大到1000或者2000,都可以接受。但要考虑性能,MySQL的limit偏移量一大,全表扫描的代价是很高的。比如pageSize=2000,currentPage=10,实际数据库要扫描offset=18000行再返回2000行,这性能能好到哪里去?所以更好的姿势不是无限调大限制,而是业务上避免超大分页。
4.5 解决办法三:全局配置与新旧版本差异
全局配置方式是通过配置类设置PaginationInnerInterceptor的maxLimit,相当于所有分页查询都受影响。但是这里有个版本差异要注意:MyBatisPlus 3.5.9以前的版本,PaginationInnerInterceptor没有maxLimit这个概念;3.5.9及以后版本才默认限制500。所以如果你没升级版本,项目分页一直都好好的,同样代码升级到3.5.9+,突然就报单页500限制错误,多半是你之前pageSize传过很大数值的接口在报警。
升级之后想全局关闭限制,最简单的处理是做一个自定义限制的Handler,把限制值调成一个业务上不可能到达的巨大值,比如Long.MAX_VALUE。但强烈不建议这么做。更好的做法是:先审计一遍所有接口的pageSize调用,把不合理的分页参数修正,再针对导出、批量拉取这种确有大分页需求的接口单独@InterceptorIgnore。这套组合拳下来,既能保证系统稳定,又不受默认限制影响。
5. 真实项目的其他高频配置和优化经验
5.1 逻辑删除和填充器的坑
逻辑删除是标配,实体类字段加@TableLogic,删除操作自动变成update,查询自动追加deleted=0。但有几个坑得注意。第一,逻辑删除后,唯一索引字段容易冲突,比如用户名是唯一索引,用户删了记录还在,下次注册同名用户就冲突。解决办法一般是把逻辑删除字段纳入联合唯一索引,或者用时间戳处理。第二,3.5.9之前版本逻辑删除字段如果没在全局配置里指定logic-delete-field,每张表都要在实体类上注解,漏掉一张表,删除就变物理删除了,这个相当危险。
填充器是另一个容易被忽略的功能。@TableField(fill = FieldFill.INSERT)配合MetaObjectHandler,可以自动填充createTime、updateTime、creator这些字段:
@Component 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和strictUpdateFill的用法,字段类型必须写正确,否则填充不生效。一旦填充分配好,新建和修改数据的接口再也不用手动set时间字段了,减少漏填的几率。
5.2 多租户插件和分页的联动
如果你的项目要做SaaS多租户隔离,TenantLineInnerInterceptor会自动在SQL后面拼上tenant_id条件。这个插件和分页插件一起配合,要注意插件的注册顺序。TenantLineInnerInterceptor要在PaginationInnerInterceptor之前注册,因为SQL先加好租户条件,再走分页改写,这样才能保证count统计也是租户隔离后的数据。
如果某些表不需要租户隔离,比如字典表、全局配置表,要单独配置TenantLineHandler忽略这些表。现实中我遇到过一个坑:联表查询里只给主表加了租户条件,从表没加,导致租户A查到了租户B的数据。这其实是SQL层面条件遗漏问题,使用多租户插件时,要仔细检查生成的SQL是否在所有业务表上都加了租户条件。在代码里想办法打印实际执行SQL,多看一眼,能省掉线上事故。
5.3 查询性能优化的几个实测技巧
MP生成SQL的能力没问题,但性能还是得靠使用者本身把控。这里分享几个实测过的优化方向。
第一个是避免SELECT *。BaseMapper的selectById、selectList默认查询所有字段,但很多场景只需要两三个字段,全字段查询会浪费IO和带宽。可以用select方法指定字段。LambdaQueryWrapper里用select指定列,或者写自定义SQL,都有助于减少不必要的数据传输。
第二个是超大查询避免IN特别长的集合。比如一个批处理任务查询几千个id,IN条件里塞几千个参数,数据库执行计划很容易出问题。建议分批处理,每批500个id,效率高很多。
第三个是用好数据库索引。eq、like前模糊匹配这类检索条件,如果没有对应索引,数据量大起来MP再方便也是白搭。建议直接利用执行计划EXPLAIN分析一下MP生成的SQL,确认走的索引是不是最合理的。
5.4 代码生成器:新模块开发提速的关键
最后说下代码生成器。MyBatisPlus的AutoGenerator可以基于数据库表生成实体、Mapper、Service、ServiceImpl、Controller、XML全套文件。很多人觉得生成器会生成冗余代码,但实际用起来,尤其是在中后台管理系统里,生成的Controller和Service能直接跑通一套基础CRUD,再改改查询条件和业务逻辑就行,新手也能快速上手。
新版本配置代码稍微多一点,但核心逻辑不变。强调一个使用体会:生成完之后,务必要自己再检查一遍实体类字段和数据库字段的映射。生成器是根据表结构反向生成的,如果表里字段注释写得不全,生成的注释也没啥参考价值,还是得自己补上业务含义。一个字段一个坑,字段注释写清楚,后面接手的同事会感谢你。
6. 常见问题速查表
做一个速查表,把上面所有经验浓缩到这里,方便随时查阅。
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 分页完全没生效,没有LIMIT | 缺少分页插件配置 | 添加MybatisPlusInterceptor,注册PaginationInnerInterceptor |
| 分页条数不对 | 手写LIMIT与插件叠加 | 删除原生limit,保留插件分页 |
| total为0 | 方法返回类型用了List | 改成返回IPage |
| count SQL报错 | 复杂SQL导致自动count失败 | 手动指定count或改写SQL结构 |
| 单页超过500条报错 | 3.5.9+版本默认500限制 | @InterceptorIgnore(maxLimit="0")或调大maxLimit |
| 局部字段更新为NULL失败 | updateById忽略null字段 | 改用LambdaUpdateWrapper的set方法 |
| 逻辑删除字段失效 | 实体未加@TableLogic | 补上注解并检查全局配置 |
| 多租户数据串号 | 从表未加租户条件 | 检查各表的租户拦截配置 |
| 查询全字段性能差 | 默认SELECT * | 使用select指定字段,优化索引 |
特别提示:分页是数据库访问的高频动作,一旦出现性能问题,优先打开慢SQL日志定位具体SQL。MP生成的SQL看起来简单,但join多、条件多时同样可能执行计划崩坏,不能用“框架帮我搞定”的心态忽视SQL本身的调优。
做后端开发这些年,我越发觉得框架只是工具,真正决定系统质量的还是使用者的理解深度。MyBatisPlus给我最大的帮助不是省了多少行代码,而是把重复劳动压缩到极致,让我把精力放在真正的业务逻辑和SQL性能优化上。也希望这篇稿子能帮你在分页、限制、逻辑删除、多租户这些关键点上少走弯路,直接抄作业。