☰
MybatisPlus分页插件配置与深分页优化:分页失效与500条限制实战
2026/9/26 20:18:41 网站建设 项目流程

如果你的项目里用了 MybatisPlus,并且已经写过头几个 CRUD 接口,那你迟早会在分页这件事上踩坑。我接触 MybatisPlus 的第二天,就撞上了两个很典型的问题:列表接口第一屏数据正常,翻到后面 limit 参数完全不生效,甚至一次性把整张表的数据全查了出来;后来把数据量加到几千条,又发现单页条数被框架悄悄砍到 500,翻页翻到怀疑人生。

这篇文章就是我围绕这两个坑做的实操记录,顺便把 MybatisPlus 分页插件的配置方式、底层拦截原理、深分页优化以及我排查问题时的完整思路一次说清楚。适合刚入手 MybatisPlus 的 Java 后端开发,也适合那些已经被“分页失效”和“单页 500 条限制”折磨过、想彻底搞明白来龙去脉的同学。

1. 接触 MybatisPlus 的第二天:先搞清楚它到底帮我省了什么事

1.1 为什么 MybatisPlus 能成为持久层“标配”

在接触 MybatisPlus 之前,我经历过很长一段纯手写 MyBatis XML 的日子。单表 CRUD 的每一个 insert、update、selectById、deleteById 都要在 XML 里写一遍,表一多,这种重复劳动会非常消耗耐心。后来项目里逐渐换到 MybatisPlus,核心舒适区在于:单表 CRUD 完全不需要写 SQL 了,你的 Mapper 接口只需要继承BaseMapper<T>,十几条常用方法直接就有。

它背后的设计思路其实就一条:把“单表操作”这种 80% 的重复场景收敛成约定好的通用方法,把变化的部分交给条件构造器(Wrapper)。你用LambdaQueryWrapper、LambdaUpdateWrapper去拼接动态查询条件,不需要再去拼字符串 SQL。再加上 MybatisPlus 的IService<T>/ServiceImpl<M, T>体系,Service 层写起来也很薄,事务、批量操作、链式查询都给你封装好了。

但这同时也带来一个问题:很多同学只看到了“封装带来的便利”,却忽略了 MybatisPlus 本质上还是运行在 MyBatis 之上。分页插件不是生来就有的,需要显式注册到 MyBatis 拦截器链里,很多人分页失效,根因就在这里——框架帮你把该做的都做了,但前提是你得按它的规则把该配的配好。

1.2 第二天阶段最该掌握的四个核心点

如果让我给刚接触 MybatisPlus 的人划重点,我会说第二天阶段不用急着学代码生成器、多租户插件这些东西,先把下面四块吃透:

  1. BaseMapper 通用方法:知道selectPage、selectList、selectById等方法的签名,知道哪个方法默认支持分页,哪个不支持。
  2. Wrapper 条件构造器:多条件查询、模糊查询、排序、in、between,这些动态条件的拼法要熟练。
  3. IService/ServiceImpl 体系:写业务代码时能省下很多样板代码,但也得了解saveOrUpdate、lambdaQuery这类链式方法背后的逻辑。
  4. 分页插件:理解分页插件的注册方式、拦截原理,以及它有什么默认行为限制。

这四个点里面,最容易出问题的就是分页。它不像普通 CRUD 那样“继承一下就能跑”,分页依赖一个独立的拦截器,配置漏一步,或者环境稍微差一点,表现出来的症状就是“分页失效”。

2. 分页插件配置:分页失效的大部分原因,都藏在这里

2.1 新版分页拦截器的正确配置模板

MybatisPlus 在 3.4.0 版本之后,把原来用于分页的PaginationInterceptor替换成了新的MybatisPlusInterceptor+PaginationInnerInterceptor组合。新版设计把所有扩展能力都收敛到了MybatisPlusInterceptor这一个拦截器里,分页、乐观锁、防全表更新、多租户等都是通过添加不同的InnerInterceptor来实现。

以 Spring Boot 3.x + MybatisPlus 3.5.x 为例,正确的分页配置模板如下:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); // 设置单页最大条数,默认是500,-1表示不限制,我这里先写-1方便演示 paginationInnerInterceptor.setMaxLimit(-1L); // 溢出总页数后是否进行处理,true回到首页 paginationInnerInterceptor.setOverflow(false); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }

这里有两个容易踩的细节。第一,DbType一定要正确指定,分页插件要根据数据库方言生成不同的分页 SQL。比如 MySQL 是LIMIT ?,PostgreSQL 是LIMIT ? OFFSET ?,SQL Server 又是另一种写法。你用的是 MySQL 却没有传DbType.MYSQL,插件在方言识别上就可能出问题。第二,拦截器是@Bean交给 Spring 管理的,如果项目里有多个地方定义MybatisPlusInterceptorBean,也会造成冲突或覆盖。

2.2 分页失效的六种典型症状与排查顺序

我把实际排查过程中遇到过的分页失效场景整理了一张表,症状不同,根因往往也不同。

症状常见原因处理方式
total 为 0,records 却返回全部数据分页拦截器没有注册,或注册的旧版 PaginationInterceptor 已失效确认 MybatisPlusInterceptor Bean 存在,且在 Spring 容器中生效
自定义 Mapper 方法分页返回全部数据方法参数里没有 IPage,或者 IPage 不在参数第一位自定义分页方法第一个参数必须是IPage<T>,返回值用IPage<T>
老项目从 Spring Boot 1/2 升级到 3 后分页失效依赖用错了,Spring Boot 3 不支持mybatis-plus-boot-starter改成mybatis-plus-spring-boot3-starter
分页参数明明传了 size=100,查回来还是 500命中 PaginationInnerInterceptor 的 maxLimit 限制调整 maxLimit 或自定义 LimitHandler
分页 SQL 里自己手写了 LIMITMP 不会覆盖你手动拼接的 LIMIT去掉手写 LIMIT,让分页插件来追加
多数据源中部分数据源分页失效PaginationInnerInterceptor 固定了单一方言为不同数据源配置对应的方言或使用多拦截器策略

排查分页问题我建议按照一个固定的顺序来,不要焦虑地乱试。

第一步,看日志。把 MyBatis 的 SQL 日志开关打开,看打印出来的 SQL 到底有没有LIMIT关键字。如果有LIMIT且正确,说明分页插件在工作,问题在业务层;如果完全没有LIMIT,那就是拦截器没生效,方向直接奔着配置去。

第二步,检查依赖和版本。MybatisPlus 和 Spring Boot 版本不匹配是升级场景里特别常见的坑,旧拦击器类被移除之后,项目可能连启动都不会报错,但分页就是静默失效。

第三步,检查自定义 Mapper 方法的签名。在写自定义分页 SQL 时,方法参数列表里如果没有IPage,MybatisPlus 是无法知道你要分页的。

第四步,确认 Page 对象有没有真的传给查询方法。有的人在 Service 层 new 了一个 Page,却调用selectList(wrapper)而不是selectPage(page, wrapper),Page 白建了。

3. 单页 500 条限制:是保护也是坑

3.1 500 条限制到底是谁加上的

查询单页 500 条限制,是 MybatisPlus 分页插件自带的一个“保险丝”。PaginationInnerInterceptor在构造时会默认把maxLimit设置为 500L,意思就是:不管你前端传的size是多少,只要大于 500,分页插件就会把它强制改成 500。

这个限制并不是数据库加上的。MySQL 的LIMIT子句本身没有“最多只能 500 条”这种说法,只要 offset 和 row count 范围合法,一次查出 10 万条也是可以的。所以这是 MybatisPlus 在设计上的有意为之,核心目的就是防呆:防止某些开发同学在页面上写了个巨大的size,直接把数据库拖垮,或者一次性把整张表的数据都拉出来。

我第一次遇到这个限制的现场很无厘头:前端同事写了一个批量同步功能,接口参数不小心把每页条数传成了 1000,我在后端看到page.getRecords().size()永远是 500,但page.getTotal()又是对的。排查了半天才发现,不是查询写错了,而是被maxLimit拦截了。

3.2 突破 500 限制的三种做法与适用场景

既然这是框架设定,要突破它其实有三种做法,每种适用场景不一样。

做法一:直接调大或放开全局限制

paginationInnerInterceptor.setMaxLimit(1000L); // 或者完全放开 paginationInnerInterceptor.setMaxLimit(-1L);

优点是最简单,一行配置就能解决。缺点也很明显:这是一个全局规则,一旦放开,项目里所有分页查询都没有了兜底保护。如果团队还有人在写那种“一次查全量”的接口,风险会高很多。生产环境不推荐很随意地setMaxLimit(-1L),除非你有明确的接口压测数据,并且确认每个分页接口的查询量都在可控范围内。

做法二:自定义 LimitHandler,针对不同 Mapper 放行

PaginationInnerInterceptor支持传入一个LimitHandler实现来定制“是否限制”和“限制多少条”。这样就能做到某个列表接口允许 2000 条,其他接口还是默认 500 条。

public class CustomLimitHandler implements LimitHandler { private static final Map<String, Long> LIMIT_MAP = new HashMap<>(); static { // key 是 Mapper 方法的全限定名,value 是允许的最大条数 LIMIT_MAP.put("cc.xa.mapper.UserMapper.selectExportList", 2000L); } @Override public boolean isNeedLimit() { // 根据实际的 Mapper 方法名动态判断 return true; } @Override public long getMaxLimit() { String mapperId = // 这里从上下文拿到当前执行的 Mapper 方法全限定名 return LIMIT_MAP.getOrDefault(mapperId, 500L); } }

不过说实话,这个方案实现起来比较绕,上下文获取当前 Mapper ID 还需要结合 MyBatis 的BoundSql去做,配置成本不低。大多数业务场景我用做法一的“全局限制调大”就够了,自定义 LimitHandler 适合那些需要精细化管理的团队。如果你决定走这个方案,务必保证方法名的 key 和实际运行时的全限定名一致,否则不会命中。

做法三:不改代码,改交互设计

说实话,Web 列表页很少有场景真的需要用户在单页里看 500 条以上的数据。表格单页 20~50 条是体验最好的区间,10 万条数据可以翻 2000 页,但正常人不会这么用。如果你真的面临大列表,更合理的方向是:提供筛选条件、搜索框、导出功能,导出走异步任务,任务内部做分批查询或流式查询,而不是想着突破单页 500 条。

3.3 数据量大时该改的不是限制,而是分页方式

顺着上面说的“单页 500 条”继续延伸,你会发现另一个更重要的问题:当单表数据量到了百万级别,你以为把maxLimit调到 5000 就完事了,实际深分页一样把你打得抬不起头。

比如 MySQL 执行LIMIT 1000000, 20,数据库并不是“从第 1000000 行开始拿 20 条”,而是先扫描 1000020 行,再把前 1000000 行丢掉。这个操作随着页码增大越来越慢,因为扫描的行数一直在膨胀。优化方向不是调大单页条数,而是改变翻页方式:

  • 方案一:基于主键游标翻页。前端记住上一次最后一条记录的主键,下次查询带上WHERE id < 上一次的id或WHERE id > 上一次的id,配合主键索引,查询速度不会随着页数增加而劣化。
  • 方案二:先查主键再回表。先用LIMIT查出主键集合,再用WHERE id IN (主键集合)去查完整记录,减少回表扫描量。
  • 方案三:限制可用页码。列表页只允许“上一页/下一页”,不支持输入页码跳转,本质上避免了深分页。

在 MybatisPlus 里,如果做游标分页,其实可以不用IPage那一套了,直接在 Wrapper 里加上字段条件和last("LIMIT 20")手动控制。虽然失去了total总条数,但对百万级大列表来说,total本身有两面性:查询总条数也需要全表扫描,你省掉这个count反而能让接口快很多。

4. 分页服务层封装与优化经验

4.1 把 IPage 包装成响应体,别把 MP 类直接暴露给前端

很多项目里会看到 Controller 直接返回IPage对象,这其实不是一个好习惯。IPage自带current、size、records、total、pages等字段,和前端约定的字段名不一定一致;而且一旦返回了 MP 的类,上层业务代码就和 MybatisPlus 绑定死了,后续换框架或者做字段裁剪都很难受。

我习惯在 Service 层把分页结果转换成一个通用的PageResult<T>:

public class PageResult<T> { private List<T> records; private Long pageNum; private Long pageSize; private Long total; private Long pages; public static <T> PageResult<T> of(IPage<T> page) { PageResult<T> result = new PageResult<>(); result.setRecords(page.getRecords()); result.setPageNum(page.getCurrent()); result.setPageSize(page.getSize()); result.setTotal(page.getTotal()); result.setPages(page.getPages()); return result; } }

这样做有几个实际好处。第一,pageNum和pageSize可以按照自己项目的命名规范来,前端和后台的契约稳定;第二,可以把records里查出来的实体批量转成 VO 再塞回去;第三,后续如果某个接口改成游标分页,PageResult的字段可以平滑演进。

4.2 深分页优化:从 limit offset 走向游标和子查询

前面已经提到深分页慢的核心原因,这里展开分享一个我在真实场景里用过的优化经验。

业务背景是这样:某张业务流水表,数据量大概 300 万行,管理后台需要支持按时间倒序查看流水。第一版我直接让前端传页码和每页条数,用 MP 的selectPage查。测到第 200 页的时候,接口耗时已经超过了 3 秒。加索引也没用,因为LIMIT 400000, 20的 offset 本身就是要扫 40 万行。

后来改成游标方案。前端不再传页码,而是传一个lastId,我按主键倒序查询:

LambdaQueryWrapper<BizRecord> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(BizRecord::getStatus, 1) .lt(BizRecord::getId, lastId) // lastId 为上一页最后一条记录的 ID .orderByDesc(BizRecord::getId) .last("LIMIT 20"); List<BizRecord> nextPage = bizRecordMapper.selectList(wrapper);

这样每次查询走的都是主键索引的范围扫描,扫描行数固定在 20 行左右,200 页和 2 亿页的性能是一样的。代价是失去了“跳到任意页”的能力,也不能直接拿到总条数。但管理后台的流水列表,绝大多数用户的真实路径就是“往下一页一页翻”,这个接受度是很高的。

如果你既要页码跳转又要性能,那可以考虑“先查主键”的优化思路,代码大致是这样:

SELECT * FROM biz_record WHERE id IN ( SELECT id FROM biz_record WHERE status = 1 ORDER BY create_time DESC LIMIT 400000, 20 )

在这个写法里,内层查询的覆盖索引只返回主键和排序字段,扫描成本比回表后再丢弃低很多。配合 MybatisPlus 写自定义 SQL 时,把这段话直接写在 XML 里即可。注意内层子查询的ORDER BY字段务必有索引,否则也不会快。

4.3 count 查询的隐性成本

每当我听到“分页查询要返回 total,所以必须 count 一次”时,都会多说一句:这个 count 可能比列表查询本身还费钱。

MybatisPlus 的分页插件在查询时,会默认生成一条 count SQL。它对常见查询能做一定程度的优化,但如果你在查询条件里关联了多张表,或者用了复杂子查询,count 语句可能无法优化到理想的形态。我的习惯是:数据量小无所谓,数据量大了以后要么优化 count 写法,要么干脆不要 count。

有一个很实用的判断标准:如果列表数据本身是一个“无限滚动的信息流”,用户根本不关心总共有多少条,那就没必要返回total。在这种情况下,可以直接用一个不带 count 的分页方式,比如前面的游标方案,或者自定义 SQL 时只查询一页数据。

5. 常见问题排查速查表与实操心得

5.1 分页相关问题的症状、原因与解决办法速查表

症状原因解决办法
翻页超过一定页数后变慢深分页导致 offset 过大改游标分页;先查主键再回表;限制页码跳转
page.getTotal()总是 0拦截器没注册;方言类型不匹配检查 MybatisPlusInterceptor Bean;检查 DbType 配置
page.getRecords()返回全部数据查询方法不是 selectPage;拦截器缺失改用 selectPage;注册分页插件
size传 1000 实际返回 500maxLimit 默认限制调大 maxLimit;使用自定义 LimitHandler
自定义 Mapper 方法分页失败方法参数里没放 IPage,或 IPage 不在第一位第一个参数改成 IPage ,方法返回 IPage
多表 join 分页 total 不准确MP 自动生成的 count 语句不支持复杂 join手写 count SQL,或在 XML 中定制 count 语句
分页后排序字段数据错乱ORDER BY 字段无索引或重复值多主键/业务排序键加索引;补充 secondary order by
获取pages总页数时和业务预期不一致overflow 开关为 false,超出总页数时不处理按需设置 setOverflow(true) 回到首页,或前端兜底

这些场景基本覆盖了我在真实项目里碰到过的绝大多数分页问题。你会发现,真正意义上“Bug级”的问题很少,大量问题都出在“配置没生效”和“默认限制”这两类原因上。所以排查前先把这两类原因过一遍,能够节省大量时间。

5.2 排查分页问题的实操套路

我把自己后来固定的排查流程写一下,照着做基本不会漏:

  1. 先看依赖,确定 MybatisPlus 版本,核对 Spring Boot 版本是否匹配。
  2. 看配置类里有没有MybatisPlusInterceptorBean,有没有分页插件被 add 进去。
  3. 开 MyBatis SQL 日志,定位到具体查询语句,看有没有LIMIT,有的话看限制值是否正确。
  4. 对比page.getTotal()和page.getRecords().size(),如果 total 是 0 但 records 是对的,就是方言或拦截器问题;如果 total 对但 records 一直是 500,就是 maxLimit 撞上了。
  5. 如果是自定义 Mapper 方法,检查方法签名,确认参数列表第一个是 IPage。顺便看一眼返回类型是否也是 IPage。
  6. 最后再确认排序字段是否有索引。这一步经常被忽略,ORDER BY字段如果没索引,深分页的慢会让你误以为是分页插件的问题。

这个流程下来,大多数分页问题 10 分钟内都能定位。

5.3 两个值得记住的心得

第一,MybatisPlus 的分页是“单表神器”,不是“复杂查询神器”。如果查询语句涉及多表 join、子查询、聚合统计,我一般会直接在 XML 里手写分页 SQL,利用 IPage 参数让拦截器帮忙生成 LIMIT,但查询主体的控制权完全留给自己。不要硬把复杂的统计需求塞进 Wrapper 里,那只会让你写出难以维护的代码。

第二,setMaxLimit(-1L)这个开关,我建议在本地开发环境随便开,但生产环境一定要谨慎。日常开发中你确实会遇到“分页条数被限制到 500 导致导出数据不完整”的情况,这时候正确的做法是在导出任务里做分批循环,每次取 500 条追加写入,而不是给分页接口设置一个巨大的 size。把“列表展示”和“数据导出”这两种场景分开对待,你的代码质量和接口稳定性都会好很多。

最后分享一个小技巧:如果你怀疑某个自定义 Mapper 方法的分页没生效,不要盯着 XML 一直看,最快的验证方式是在方法参数里加一个Page,然后在日志里搜LIMIT。有LIMIT说明拦截器参与了;没有LIMIT,大概率是参数签名的问题。这个验证方法我用了很久,至今仍然是最直接的定位手段。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询