如果你的项目里用了 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 的人划重点,我会说第二天阶段不用急着学代码生成器、多租户插件这些东西,先把下面四块吃透:
- BaseMapper 通用方法:知道
selectPage、selectList、selectById等方法的签名,知道哪个方法默认支持分页,哪个不支持。 - Wrapper 条件构造器:多条件查询、模糊查询、排序、
in、between,这些动态条件的拼法要熟练。 - IService/ServiceImpl 体系:写业务代码时能省下很多样板代码,但也得了解
saveOrUpdate、lambdaQuery这类链式方法背后的逻辑。 - 分页插件:理解分页插件的注册方式、拦截原理,以及它有什么默认行为限制。
这四个点里面,最容易出问题的就是分页。它不像普通 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 里自己手写了 LIMIT | MP 不会覆盖你手动拼接的 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 实际返回 500 | maxLimit 默认限制 | 调大 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 排查分页问题的实操套路
我把自己后来固定的排查流程写一下,照着做基本不会漏:
- 先看依赖,确定 MybatisPlus 版本,核对 Spring Boot 版本是否匹配。
- 看配置类里有没有
MybatisPlusInterceptorBean,有没有分页插件被 add 进去。 - 开 MyBatis SQL 日志,定位到具体查询语句,看有没有
LIMIT,有的话看限制值是否正确。 - 对比
page.getTotal()和page.getRecords().size(),如果 total 是 0 但 records 是对的,就是方言或拦截器问题;如果 total 对但 records 一直是 500,就是 maxLimit 撞上了。 - 如果是自定义 Mapper 方法,检查方法签名,确认参数列表第一个是 IPage。顺便看一眼返回类型是否也是 IPage。
- 最后再确认排序字段是否有索引。这一步经常被忽略,
ORDER BY字段如果没索引,深分页的慢会让你误以为是分页插件的问题。
这个流程下来,大多数分页问题 10 分钟内都能定位。
5.3 两个值得记住的心得
第一,MybatisPlus 的分页是“单表神器”,不是“复杂查询神器”。如果查询语句涉及多表 join、子查询、聚合统计,我一般会直接在 XML 里手写分页 SQL,利用 IPage 参数让拦截器帮忙生成 LIMIT,但查询主体的控制权完全留给自己。不要硬把复杂的统计需求塞进 Wrapper 里,那只会让你写出难以维护的代码。
第二,setMaxLimit(-1L)这个开关,我建议在本地开发环境随便开,但生产环境一定要谨慎。日常开发中你确实会遇到“分页条数被限制到 500 导致导出数据不完整”的情况,这时候正确的做法是在导出任务里做分批循环,每次取 500 条追加写入,而不是给分页接口设置一个巨大的 size。把“列表展示”和“数据导出”这两种场景分开对待,你的代码质量和接口稳定性都会好很多。
最后分享一个小技巧:如果你怀疑某个自定义 Mapper 方法的分页没生效,不要盯着 XML 一直看,最快的验证方式是在方法参数里加一个Page,然后在日志里搜LIMIT。有LIMIT说明拦截器参与了;没有LIMIT,大概率是参数签名的问题。这个验证方法我用了很久,至今仍然是最直接的定位手段。