做Java后端这些年,MyBatisPlus几乎是每个新项目的标配,最近团队在重建内部框架模块,正好轮到06号Module,借着这个机会我把MyBatisPlus里最容易被问爆的两个问题——分页失效、单页条数限制——重新撸了一遍。这篇内容不是官方文档的复读,是我们在实际项目中踩过坑、翻过源码、最后沉淀下来的一套可落地方案,希望能帮到正在被这两个问题折磨的同学。
1. 为什么项目里绕不开MyBatisPlus
1.1 框架定位决定了它的价值
MyBatisPlus的口号是“只做增强不做改变”,这句话听起来温和,实际用起来非常关键。它没有颠覆MyBatis,而是在MyBatis基础上提供了一堆现成的能力:单表CRUD不用写SQL、Wrapper条件构造器解决动态查询、分页插件自动拼LIMIT、逻辑删除注解一键生效、自动填充处理创建时间更新时间。这些能力对应的恰恰是后端开发中最枯燥、最重复、最容易出bug的日常编码场景。
我见过不少团队纠结要不要引入MyBatisPlus,担心“框架绑架业务”。这里我的观点很直接:如果你的项目以单表操作和简单关联查询为主,MyBatisPlus能把开发效率提升一个档次;如果你的业务以大宽表Join、复杂子查询为主,那MyBatisPlus也不拦着你写XML,它不会像重ORM那样强迫你改变SQL习惯。这种“用得深能省事、用得浅不碍事”的弹性,是我推荐它的核心原因。
1.2 版本差异是第一个坑
很多人把MyBatisPlus的依赖坐标背下来就完事了,但版本不同,API差异会让你一脸懵。3.4.0之后分页插件统一收拢到了MybatisPlusInterceptor里,老版本的PaginationInterceptor已经废弃;到了3.5.x,拦截器顺序管理更严格,注册方式也变了。还有Spring Boot 3的用户只能引入mybatis-plus-spring-boot3-starter,用旧坐标会直接启动报错。
我们框架模块选型时定的是3.5.x分支,原因很务实:官方对3.5.x的维护力度强,社区反馈问题修复快,而且分页插件的maxLimit参数在3.4.3.4之后实现更稳定。如果你是老项目升级,要注意新版拦截器的配置方式,这个稍后会有完整示例。
1.3 常用能力清单先拉一遍
理解一个框架最快的方式,是把它的能力清单对应到具体业务场景。我在团队内部培训时常画这样一张对照表:
| 能力 | 解决什么场景 | 需要额外配置 |
|---|---|---|
| BaseMapper通用CRUD | 单表增删改查,不用写XML | 无 |
| Wrapper/QueryWrapper | 动态条件查询,多条件组拼 | 注意SQL注入风险 |
| 分页插件 | 列表接口自动分页 | 必须注册拦截器 |
| 乐观锁 | 防止并发更新覆盖 | 实体加@Version注解 |
| 逻辑删除 | 不物理删除,保留历史数据 | 实体加@TableLogic |
| 自动填充 | 统一处理创建时间/更新时间/操作人 | 自定义MetaObjectHandler |
| 代码生成器 | 快速生成实体、Mapper、Service | 需单独引入生成器依赖 |
表格看完你会发现,分页是其中唯一一个“必须主动注册插件才会生效”的能力。这个特性直接引出了后文最常见的坑:为什么我的分页没效果?因为你根本还没把分页插件注册进去。
2. 分页插件原理与分页失效的根源
2.1 分页插件是怎么偷偷改SQL的
理解分页失效,先得知道分页插件做了什么。MyBatisPlus的分页能力不是靠你在Mapper里写LIMIT实现的,而是依赖MyBatis的拦截器机制,在SQL执行前动态改写SQL。
核心类是PaginationInnerInterceptor,它挂在MybatisPlusInterceptor这个总拦截器上。执行查询时,拦截器拿到原始的BoundSql,做两件事:第一,把原始SQL去掉ORDER BY外层包装,生成一条SELECT COUNT(1) FROM (原SQL) total统计总数的查询;第二,根据数据库方言生成带LIMIT的分页SQL,MySQL就是LIMIT offset, size,PostgreSQL就是LIMIT size OFFSET offset,Oracle就是ROWNUM那套逻辑。执行完之后,把结果塞进Page对象的records、total、pages字段。
这段机制说明一个关键点:只有你传入了Page对象,拦截器才会触发改写。你如果只是写了一个普通查询方法,返回一个List,分页插件是爱莫能助的。
2.2 分页失效的八种典型场景
我把实际业务中遇到过的分页失效场景汇总排序,按出现频率从高到低:
场景一:压根没注册分页插件。
这是最常见的情况,没有之一。你依赖引了、实体建了、Mapper写好了,一查发现selectPage返回的数据是全量,total也是0或者不正确。原因就是MybatisPlusInterceptor这个Bean没有配置到Spring容器里。解决方案很简单,注册Bean即可,后文会有完整代码。
场景二:自定义SQL方法里Page对象不是第一个参数。
自定义分页查询有个铁律:Page必须放在Mapper方法参数列表的第一个位置,不能被@Param包装,否则拦截器无法识别分页参数。很多人习惯性写selectUserPage(@Param("name") String name, @Param("page") Page page),结果分页完全失效。正确写法是selectUserPage(Page page, @Param("name") String name)。
场景三:Mapper方法返回类型不是IPage。
分页插件的SQL改写条件是方法返回值为IPage或Page,如果你把返回类型写成了List<User>,那SQL改写时虽然可能生成了LIMIT,但结果没法回填到Page对象里。最典型的写法应该是IPage<UserVO> selectUserPage(Page<?> page, ...).
场景四:自定义SQL里手动加了LIMIT。
这个坑特别隐蔽。有些同学写XML时习惯性带一句LIMIT #{offset}, #{size},本意是双保险,结果分页插件识别到SQL里已经有LIMIT,要么报错,要么拼接后语法不对,要么数据错乱。记住:用了MyBatisPlus分页插件,XML里就不要再写任何分页语句,把分页完全交给拦截器。
场景五:多数据源场景下插件只配置了一个。
微服务里常配多个数据源,有人只在主数据源里注册了分页插件,切到从库查询时发现分页失效/分页不生效。排查时先确认分页插件配置在哪个数据源,如果用的是dynamic-datasource这种动态数据源框架,分页插件的注册方式和单数据源又有区别。
场景六:SQL太复杂,拦截器改写错位。
当你使用了UNION、多层嵌套子查询、或者Window函数时,PaginationInnerInterceptor的SQL改写逻辑可能无法正确识别主查询边界,生成的COUNT SQL和LIMIT SQL不符合预期。常见的表现是total统计不对,或者LIMIT加在了内层子查询上。这不是什么Bug,是因为框架对复杂SQL的解析能力有限。处理方案有两个:要么把复杂SQL拆成简单查询在应用层做二次处理,要么手动提供COUNT SQL(通过Page的optimizeCountSql和XML里的<count>标签自定义)。
场景七:多个拦截器执行顺序不对。
MyBatisPlus官方建议拦截器的顺序是:多租户、动态表名 → 分页 → 乐观锁 → 防全表更新。如果你把分页插件放到了多租户插件的前面,SQL改写顺序异常,会导致分页参数和租户条件错位。特别是老版本里拦截器之间没有强制顺序控制,必须自己保证注入顺序。
场景八:MyBatis版本和MyBatisPlus不兼容。
这个属于版本管理问题。MyBatisPlus对MyBatis版本有对应关系表,版本差距过大会出现拦截器不执行或执行报错。建议直接用mybatis-plus-boot-starter传递下来的MyBatis版本,不要手动覆盖,除非你真的知道自己在做什么。
2.3 分页失效问题定位的五步法
遇到分页不生效,别上来就怀疑框架Bug,按我下面这个顺序排查,90%能在两分钟内定位问题。
第一步,开SQL日志,看控制台输出的SQL语句末尾有没有LIMIT。如果LIMIT字样压根没出现,说明拦截器没生效,直接跳到第二步;如果出现LIMIT但数据不对,问题在SQL改写逻辑,看原SQL是否太复杂。第二步,检查分页插件有没有注册。全局搜一下PaginationInnerInterceptor关键词,没有的话就是注册缺失。第三步,检查Mapper方法定义,确认第一个参数是不是Page、返回类型是不是IPage。第四步,检查是不是多数据源场景,分页插件是否只配置在一个数据源上。第五步,关闭其他自定义拦截器,看分页是否恢复,用来验证拦截器顺序问题。
这套排查流程在我们团队已经固化成一份文档,前端接口一反馈“分页没效果”,后端同学先走一遍流程,基本挡掉了八成问题工单。
3. 单页500条限制的真相与应对
3.1 500这个数字到底是谁定的
“MyBatisPlus单页500条限制”是个流传很广的说法,但严格讲,MyBatisPlus框架本身并没有硬编码“一页最多500条”。这个数字来自项目里的三层设置,很多人把它们混为一谈了。
第一层是PaginationInnerInterceptor的maxLimit参数。这是MyBatisPlus官方提供的拦截器级限制,创建分页插件时如果调用了setMaxLimit(500),那么当请求的单页size超过500时,分页插件会直接拦截拦截(不同版本行为略有差异,有的是抛异常提示超过上限,有的是按最大值执行)。这算最接近“MyBatisPlus单页500条限制”的技术根源。
第二层是业务代码里的参数校验。不少团队会在PageQuery对象里写一个@Max(value = 500)注解,或者手动判断page.getSize() > 500时抛业务异常。
第三层是网关/前端组件层的默认约束。管理后台的表格组件如el-pagination默认每页10条/20条/50条,下拉里最大选项可能就是500。用户如果手动改URL参数传了1000,后端没限制,前端也不一定会展示那1000条。
所以遇到“单页500条限制”,先查是哪个环节挡住了。我见过最典型的误会:后端同学压根没配maxLimit,但前端表格组件的分页大小下拉最多只有500,前端产品经理就一口咬定“后端限制500条”——其实是前端组件限制死了。
3.2 maxLimit参数的正确玩法
如果你的项目确实需要在拦截器层面设置单页上限,代码很简单:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setMaxLimit(500L); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }这段配置生效后,当业务代码请求new Page<>(1, 1000)时,分页插件会识别到size=1000超过maxLimit=500,按版本不同要么抛异常,要么将单页条数压回500。这个限制的本质是保护数据库,防止接口被人恶意循环调用大pageSize拖垮数据库,也可以防止一次查询返回几十万行把内存打满。
但我要特别提醒:maxLimit不是用来限制导出功能的。很多同学遇到导出报表需要查10万条数据,为了绕过500条限制直接把maxLimit改成了Long.MAX_VALUE。这等于拆了保险丝。导出类场景的正确出路不是走分页查询,而是走异步任务 + 流式查询/分批查询。
3.3 大数据量查询的正确姿势
被“单页500条限制”挡住高频场景有两个:一个是前端列表要一次看全量数据,一个是导出功能需要全量结果。
前端列表要全量数据,这是伪需求,真正该做的是筛选条件和排序,而不是一次渲染5000行。和产品经理聊清楚交互边界,限制单页条数是合理的产品逻辑。
导出功能需要全量数据,这里给一个安全的做法:用分页循环把数据分批捞出来,每批不要贪多,1000条以内比较稳妥。
public void exportAllUsers(UserQuery query) { long batchSize = 1000L; long current = 1L; Page<User> page; do { Page<User> pageParam = new Page<>(current, batchSize); page = userMapper.selectPage(pageParam, buildWrapper(query)); // 每批数据写入Excel/CSV writeToFile(page.getRecords()); current++; } while (current <= page.getPages()); }这段代码巧妙地绕开了条数限制,同时每批数据占用内存有限,适合几十万级别导出的场景。如果单表几百万行,就得考虑游标分页或直接用数据库流式读取。
4. Spring Boot整合MyBatisPlus分页的完整实操
4.1 环境准备与依赖配置
我以Spring Boot 2.7 + MyBatisPlus 3.5.3.1为例,先把pom依赖列出来:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>Spring Boot 3项目把starter换掉:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.3.1</version> </dependency>配置文件的重点是打开SQL日志,方便排查分页问题。application.yml里加上:
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0map-underscore-to-camel-case保证数据库下划线字段自动映射成驼峰属性,这是MyBatis的老配置,很多人会忘。logic-delete配置项是按需的,有逻辑删除字段才需要。
4.2 注册分页插件的两种写法
当前主流写法是Java配置类:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setMaxLimit(500L); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }老项目如果还在用mybatis-config.xml,也可以把拦截器配进XML里,但Spring Boot项目不推荐,配置类和代码放在一起更容易维护。
这里有个细节值得说明:DbType.MYSQL要和你实际的数据库类型一致。如果你用的PostgreSQL却配了MYSQL,分页SQL会按MySQL语法拼接LIMIT,轻则语法报错,重则数据错乱。多环境部署时,这块建议从配置中心读取。
4.3 Service层标准分页写法
实体类和Mapper基类的写法就不赘述了,直接看最常用的两种分页查询。
第一种,BaseMapper自带的selectPage,适合单表条件分页:
public IPage<UserVO> pageUsers(UserQuery query) { Page<User> page = new Page<>(query.getCurrent(), query.getSize()); LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.hasText(query.getStatus()), User::getStatus, query.getStatus()) .like(StringUtils.hasText(query.getName()), User::getName, query.getName()) .orderByDesc(User::getCreateTime); IPage<User> result = userMapper.selectPage(page, wrapper); // 实体转VO的逻辑在这里,组装全局返回结构 return result.convert(user -> convertToVO(user)); }第二种,自定义SQL分页,适合带Join的多表查询。先定义Mapper接口方法:
public interface UserMapper extends BaseMapper<User> { IPage<UserDeptVO> selectUserWithDeptPage(Page<?> page, @Param("query") UserQuery query); }再写XML:
<select id="selectUserWithDeptPage" resultType="com.example.vo.UserDeptVO"> SELECT u.id, u.name, u.status, d.dept_name FROM user u LEFT JOIN dept d ON u.dept_id = d.id <where> <if test="query.name != null and query.name != ''"> AND u.name LIKE CONCAT('%', #{query.name}, '%') </if> <if test="query.status != null"> AND u.status = #{query.status} </if> </where> ORDER BY u.create_time DESC </select>这块有个重点必须反复强调:XML里不要写LIMIT。这条SQL不会自己分页,会分页的是外部传入的Page参数。只要第一个参数是Page、返回类型是IPage,分页插件就会自动把这条SQL改写成带LIMIT的查询,并自动执行COUNT统计。
4.4 分页执行流程与参数计算逻辑
把执行流程掰开揉碎讲一遍,你以后看日志就能心里有数。假设请求参数current=2、size=10,业务代码执行selectUserWithDeptPage(page, query)。
第一步,分页插件识别到Page对象,先计算偏移量offset = (current - 1) * size = 10。第二步,执行COUNT查询,生成SELECT COUNT(1) FROM user u LEFT JOIN dept d ON u.dept_id = d.id WHERE ...,拿到total。第三步,改写主查询为SELECT ... LIMIT 10, 10,执行后把数据封装到Page的records里。第四步,把total、current、size回填,如果之前没算过pages,会按pages = (total + size - 1) / size向上取整处理。
日志里你会看到两条SQL前后执行,第一条是COUNT,第二条是带LIMIT的查询。如果你发现只有一条查询SQL没有COUNT,那说明searchCount被关了或者分页插件没生效。
补充一个性能细节:MySQL的LIMIT 100000, 10虽然也能执行,但MySQL需要先扫描前面100010行再丢掉,页数越深越慢。因此分页查询在数据量大时必须要限制深度,这块下一节展开讲。
5. 常见问题速查表与团队避坑经验
5.1 分页常见问题速查表
把我在一线答疑上遇到过的问题整理成一张速查表,方便你遇到现象时快速对照:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 查询结果没有分页,全部返回 | 分页插件未注册 | 注册MybatisPlusInterceptor和PaginationInnerInterceptor |
| total始终为0 | COUNT SQL执行失败或searchCount=false | 检查SQL日志中的COUNT语句,确认分页插件已生效 |
| total值不对 | 复杂SQL的COUNT改写错误 | 拆SQL或手动指定COUNT |
| 传了Page但返回List | Mapper返回类型不是IPage | 换成IPage/Page返回类型 |
| 翻到第2页数据还是第1页 | Page参数放错位置或被@Param包装 | Page放第一个参数,不套@Param |
| LIMIT语句拼接报错 | SQL里手写了LIMIT | 删掉XML里的LIMIT,交给插件处理 |
| 单页超过500被拦截 | PaginationInnerInterceptor设置了maxLimit | 调整maxLimit或走分批查询逻辑 |
| 多数据源其中一分页失效 | 分页插件没同步到所有数据源 | 在对应的SqlSessionFactory上补配拦截器 |
| 分页在某个事务方法里失效 | 内部调用绕过代理/拦截器 | 确保SQL走的是MyBatis的Executor链路 |
这张表适合打印出来贴在工位。
5.2 深分页问题的替代方案
数据量超过百万后,LIMIT offset, size的性能问题会浮出水面。就算不涉及MyBatisPlus本身,这也是所有分页方案的共性难题。除了团队里硬性限制页数之外,更优雅的做法是游标分页。
游标分页的核心思想是:不翻页,只翻“下一页”,以上一次查询到的最后一条记录ID作为下一次查询的起点:
public IPage<User> pageUsersByCursor(Long lastId, int size) { Page<User> page = new Page<>(1, size); LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.gt(User::getId, lastId) // 只查比上次最大ID大的记录 .orderByAsc(User::getId) .last("LIMIT " + size); return userMapper.selectPage(page, wrapper); }注意这里的last("LIMIT " + size)和分页插件是冲突的,如果用了这段思路,就不适合再套用分页插件,而是把Page当成普通的参数载体。项目里两种方式混用时,务必想清楚当前接口走的是哪条链路。实践下来,游标分页在“下拉加载更多”类的Feed流场景非常好用,但在传统后台表格分页场景不适用,因为用户可能想直接跳到第50页。
5.3 团队落地时的规范建议
最后分享几条我们团队经历过阵痛后沉淀的分页规范,不是理论,全是教训。
分页参数要统一封装成PageQuery对象,放在Controller层解析,Service层只接收标准对象,禁止散装传current和size参数,这样能保证参数校验(比如size上限)只写一次。所有自定义分页SQL的Mapper方法,第一个参数必须是Page,返回类型必须是IPage,把这个约定写进团队代码规范检查项里,CR的时候重点看。COUNT查询很慢的大表,可以适当使用Page对象的searchCount开关,在明确不需要total的接口里关掉自动统计,能省掉一次全表COUNT的开销。
还有一个点很容易被忽略:分页插件里的maxLimit不要随意放宽。如果业务真的需要单页500条以上,先和前端聊清楚交互,是不是筛选条件没做全;再和后端确认查询性能,EXPLAIN一下SQL语句看看扫描行数。多数情况下500这个阈值对于人类阅读已经是极限了,放宽限制只是在给数据库埋雷。
根据我个人经验,在项目里落地这套规范后,分页相关的线上问题工单至少降了八成,剩下的两成基本是业务需求变动导致的。MyBatisPlus本身不难,难的是把规范融入团队的日常开发习惯,这篇文字如果能让你的团队少走几个弯路,就没白写。