ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MyBatisPlus分页插件失效排查:maxLimit与overflow机制详解

MyBatisPlus分页插件失效排查:maxLimit与overflow机制详解 MyBatisPlus 的分页插件是 Java 后端项目里用得最多的组件之一但也是莫名奇妙失效的重灾区。最近我在排查一个导出工单时就撞上了热搜里那个经典的单页 500 条限制前端传了 size10000接口却只吐回 500 条查了半天才发现是分页拦截器的 maxLimit 被写死成了 500。这种问题说难不难但如果不清楚分页插件的内部机制排查过程会非常折磨人。这篇文章不聊 MyBatisPlus 的基础 CURD那些官方文档写得很全。我重点拆解分页插件的工作链路、maxLimit 和 overflow 这些冷门参数的作用以及我总结的 6 个分页假失效复盘案例。无论你是刚接手老项目的新人还是被报表导出卡在 pageSize 上的老手按文中的配置和排查步骤走一遍基本能解决 90% 的分页问题。1. 分页插件到底在背后做了什么1.1 MybatisPlusInterceptor 与 PaginationInnerInterceptor 的关系先纠正一个很容易误解的点MyBatisPlus 3.4.0 之后分页功能不是靠一个简单的注解或 starter 自动开启的而是要显式注册MybatisPlusInterceptor并且在它的内部链路上挂上一个PaginationInnerInterceptor。很多老项目从 3.3.x 升级上来之后分页突然失效就是因为旧版还在用PaginationInterceptor这个 bean升级后它被标记废弃行为也不再一致。MybatisPlusInterceptor 本身是一个 MyBatis 拦截器它的作用是把多个功能插件分页、多租户、乐观锁、防全表更新等串成一个责任链。每个 InnerInterceptor 在 SQL 执行前和后分别有机会改写参数和 SQL。分页插件关注的就是beforeQuery这个阶段它在 SQL 真正发给数据库之前把查询改写成带LIMIT的形式同时生成一条COUNT查询。1.2 分页生效的三个前提条件在实际项目里我总结分页插件生效必须同时满足三个条件缺一个都会出现传了 Page 但没分页的诡异现象项目里确实注册了 MybatisPlusInterceptor且里面挂了 PaginationInnerInterceptorMapper 方法的入参里有一个IPage类型的参数或者返回IPage并且调用时传入了IPage插件能识别当前数据库方言一般通过DbType.MYSQL这类构造参数显式指定。有意思的是这三个条件只要不满足插件都是静默跳过不会报错。所以很多配置了分页其实是我觉得配置了去看一眼 Bean 定义也许就发现自己把旧版拦截器和新版拦截器同时注册了。1.3 它生成的 SQL 长什么样假设业务 SQL 是SELECT id, name, age FROM user WHERE age 18 ORDER BY id DESC分页插件拿到 pageNo1、pageSize10 之后会改写为两条 SQL 交给执行器第一条是 countSELECT COUNT(*) FROM user WHERE age 18第二条是取数SELECT id, name, age FROM user WHERE age 18 ORDER BY id DESC LIMIT 10注意这个改写发生在 MyBatis 的 Executor 层面所以在你的 Mapper 方法里看不到LIMIT字符串只有打开 SQL 日志才能确认插件到底有没有介入。这也是排查分页问题时最重要的一步先去日志里找LIMIT。2. 单页 500 条限制是怎么来的maxLimit 和 overflow 全解析2.1 maxLimit 的默认值不是 500先说结论MyBatisPlus 官方版本的PaginationInnerInterceptor默认maxLimit是很大的可以理解为不限制你遇到单页 500 条限制绝大多数情况是项目里某位同事在某处写了setMaxLimit(500L)。也就是说500 这条限制往往是项目骨架带给你的不是 MyBatisPlus 原生行为。常见的来源有几个公司内部 starter 里预置的分页配置、开源后台管理模板里抄来的统一配置、或者某个老同事为了防全表扫描刻意加的业务保护。2.2 当 pageSize 撞上 maxLimit 会发生什么PaginationInnerInterceptor 在执行分页改写前会做一次大小检查伪逻辑是if (maxLimit 0 pageSize maxLimit) { page.setSize(maxLimit); pageSize maxLimit; }这个检查是静默修改的不会抛异常也不会在你调用的Page对象里留下明显提示。于是你前端明明传了 10000SQL 日志里却只看到LIMIT 500接口返回的 records 也刚好是 500 条。不清楚这一点的人很容易把锅甩给前端或者怀疑是不是缓存导致的数据不对。2.3 解除 500 条限制的三个层面想解除这个限制至少有三个层面可以动改全局配置paginationInnerInterceptor.setMaxLimit(-1L)彻底不限制最省事但也意味着任何一次分页请求都可能拖出全表调大上限比如改成 5000 或 10000给业务留出余量同时保留防全表扫描的最低保护隔离处理正常列表分页继续沿用原来的 500 上限另开一个不带 IPage 参数的 Mapper 方法给导出、批处理使用彻底绕开分页插件。我的建议是先别急着把 maxLimit 调成 -1。后面我会专门讲大 pageSize 场景的替代方案。这里先把限制从哪来、怎么解除讲透让你排查时有方向。2.4 overflow页面溢出时到底该返回哪一页除了 maxLimit另一个容易被误认为是分页失效的参数是overflow。它的作用非常直白当用户请求的页码超过了总页数时要不要自动回到最后一页。默认行为是overflowfalse比如总共 30 条数据每页 10 条总共 3 页前端却请求 pageNo5此时接口返回空 recordstotal 仍然是 30。很多前端同学看到页码存在、数据却空第一反应就是后端分页坏了。把overflow设为true之后pageNo5 会被自动纠正成 pageNo3返回最后一页的数据体验上更符合直觉。要注意的是overflowtrue 依赖 count 查询结果所以必须让分页插件先执行 count不要随意设置searchCount(false)否则它没法判断总页数。这三个参数放一起记会更清楚参数默认值作用典型误用maxLimit不限制单页条数上限以为没设置就没限制其实是模板设了 500overflowfalse页码溢出时是否跳回最后一页请求超出总页数时空数据被当成分页失效optimizeJointruecount 是否优化 JOINGROUP BY/DISTINCT 场景下 count 不准3. 可复制的配置与代码从拦截器到 Mapper 一条龙3.1 拦截器配置类直接上我在实际项目里最常用的一套配置Spring Boot MyBatisPlus 3.5.xConfiguration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); // 单页上限-1 表示不限制保守一点可以设 5000 pagination.setMaxLimit(-1L); // 页码溢出时自动跳回最后一页 pagination.setOverflow(true); // 优化 count 查询默认就是 true可以显式写出来 pagination.setOptimizeJoin(true); interceptor.addInnerInterceptor(pagination); return interceptor; } }如果你用的是 Spring Boot 3.x 和对应的mybatis-plus-spring-boot3-starter这段代码主体不变只是 starter 依赖要换掉。另外注意如果项目里同时存在多数据源每个 SqlSessionFactory 都要挂上这个拦截器否则某个数据源的分页还是会失效。3.2 Mapper 方法的几种写法对比分页参数的核心要求是方法入参里得有一个IPage而且这个IPage不必和业务参数混在一起用Param标注插件会自动扫描参数列表。写自定义 XML SQL 时最常见的正确姿势是public interface UserMapper extends BaseMapperUser { IPageUserVO selectUserDetailPage(Page? page, Param(deptId) Long deptId); }对应 XMLselect idselectUserDetailPage resultTypecom.example.vo.UserVO SELECT u.id, u.name, u.age, d.dept_name FROM user u LEFT JOIN dept d ON u.dept_id d.id where if testdeptId ! null AND u.dept_id #{deptId} /if /where ORDER BY u.id DESC /select这里有三个容易踩的细节IPage参数不要写进 SQL 的#{}引用里它只是给插件看的分页开关MyBatis 不会把它当作 SQL 绑定参数返回类型建议用IPageUserVO这样 records 类型明确分页信息完整如果实际上只需要 List 而不需要 total可以让 Mapper 方法返回ListUserVO但仍传入Page参数分页照样生效只是少了 count 信息。这种写法适合只要前 N 条的查询。四种常见写法可以这样选写法适用场景注意事项BaseMapper.selectPage单表简单分页直接可用复杂查询别硬拼 Wrapper自定义 XML IPage 返回多表联查、需要结果映射IPage 不要写进 #{}返回类型别用错List 返回 Page 参数只取前 N 条不需要 total分页仍生效但拿不到 total不带 IPage 的普通查询导出、批处理、绕开上限插件完全不参与需要自己控制 LIMIT3.3 Service 层的调用与结果封装Service 里调用分页非常简单public IPageUserVO listUserByDept(Long deptId, long current, long size) { PageUserVO page new Page(current, size); return userMapper.selectUserDetailPage(page, deptId); }如果查询很重、total 不需要每次都算可以PageUserVO page new Page(current, size); page.setSearchCount(false); ListUserVO records userMapper.selectSomeList(page, condition);searchCount(false)的意思是跳过自动 count 查询只返回当前页数据。对高并发列表接口或者纯上一页/下一页的滚动场景很有用代价是page.getTotal()会是 0不能拿去做总页数展示。4. 六个分页假失效排查实录排查分页问题我建议先按下面这张表走一遍再决定深入哪一步排查步骤看什么结论SQL 日志有没有 LIMIT没有 LIMIT → 拦截器没触发SQL 日志LIMIT 后面的数字数字正好等于 500 → 撞 maxLimit返回结果total 是否为 0count 查询被优化错或 searchCount(false)返回记录前后页数据是否重复ORDER BY 不稳定或 PageHelper 混用4.1 升级版本后拦截器没跟上现象老项目从 3.3.x 升到 3.5.x之前好好的分页突然不生效SQL 日志里没有任何LIMIT。原因3.4.0 把分页拦截器从PaginationInterceptor换成了MybatisPlusInterceptor如果项目里还在注册旧 bean新版本下它不会对你自定义 Mapper 生效。这种问题在结构复杂的大项目里特别隐蔽因为 starter 的自动配置也可能在背后注册了一个拦截器和你手工写的 bean 互相冲突。解决删掉旧 bean统一换成MybatisPlusInterceptor并且确认全局只保留一个 MybatisPlusInterceptor 实例。4.2 方法参数里根本没有 IPage现象Mapper 方法是ListUser selectListByDeptId(Long deptId)Service 里自己 new 了一个 Page 对象然后在代码里subList切分。原因这不是分页插件失效而是压根就没让插件入场。方法入参没有 IPage插件在beforeQuery阶段会直接 return不会对 SQL 做任何改写。你看到的分页是内存分页数据量一大就 OOM 或接口超时。解决把 Page 加进方法参数让 LIMIT 下沉到数据库执行。4.3 复杂 SQL 的 count 查询翻车现象联表 GROUP BY DISTINCT 的自定义 SQL分页查出来的 total 是 0或者 total 和实际记录数对不上最后一页数据读不出来。原因插件默认会对 count 做优化比如尝试去掉不影响行数的 JOIN。但对部分写法尤其是带 GROUP BY、DISTINCT、UNION 的 SQL优化器猜错场景生成的 count 语句不等于原始 SQL 的结果集大小。解决先试着pagination.setOptimizeJoin(false)关闭 join 优化如果还不对就手动写 count 查询用page.setSearchCount(false)关掉自动 count自己在 Service 里查一遍总数再塞回 Page。4.4 pageSize 超过 maxLimit 被静默截断现象前端要求一页 2000 条接口只返回 500 条total 却正常。原因就是前面讲的maxLimit检查。500 这个数字出现在 SQL 日志的 LIMIT 里非常典型。解决先全局搜maxLimit和setMaxLimit确认是谁设的再根据业务决定是调大上限、还是给导出另开通道。4.5 分页数据重复或漏数据现象翻页时第一页出现的记录在第二页又出现或者某些记录永远查不出来。原因绝大多数情况是 SELECT 语句里没有稳定的ORDER BY。数据库不保证无排序时的返回顺序分页之后自然会出现偏移和重复。另一个常见原因是用了深分页比如 pageNo10000、pageSize20数据库要扫描OFFSET 200000行才能返回结果虽然功能没错但性能和稳定性已经崩了。解决业务字段里挑一个唯一且稳定的列主键最理想加到 ORDER BY 里深分页用 keyset 方式迭代后面我会给代码。4.6 混用了多个分页插件现象SQL 日志里出现了LIMIT但实际返回条数还是不对或者干脆报语法错误。原因项目里同时引用了 MyBatisPlus 的分页插件和 PageHelper两个插件同时改写 SQL导致 LIMIT 被拼接了两次。这个在依赖管理混乱的老项目里不少见。解决只保留一个分页方案。如果团队习惯 MyBatisPlus就把 PageHelper 相关依赖和配置全部清掉。核心排查口诀可以记成三句话先看日志有没有 LIMIT再看 total 数值对不对最后检查方法参数里 IPage 在不在。按这个顺序走下去大部分问题十分钟内能定位。5. 大分页与大导出单页超过 500 条的正确姿势5.1 先问自己真的需要一页 5000 条吗在解除 500 条限制之前我建议你先冷静评估一下业务场景。前端一次渲染 5000 行 DOM性能大概率扛不住用户也不可能有耐心在一张表格里翻几千行。真正需要一次拿很多条的场景基本只有三种数据导出、批处理/定时任务、以及某些报表模块要把数据一次性交给前端图表库。如果是普通列表正确解法是减小 pageSize配合滚动加载或虚拟列表如果是导出或批处理应该走专门的大批量通道而不是让分页列表接口去迁就。5.2 方案一手工 LIMIT 不用 IPage给导出单独定义一个不带 IPage 参数的 Mapper 方法然后在 SQL 或 Wrapper 里手工控制条数ListUser batch userMapper.selectList( new LambdaQueryWrapperUser() .gt(User::getId, lastId) .orderByAsc(User::getId) .last(LIMIT 10000) );这种写法完全绕开分页拦截器maxLimit 管不到它前提是你的selectList没有被其他 InnerInterceptor 破坏。注意.last()里的内容是拼接进 SQL 的绝不能把前端传的字符串直接塞进去否则就是注入口子。5.3 方案二keyset 分段拉取避免深分页面对几十万上百万的数据最稳的迭代方式是记录上一批的最大主键而不是用页码。long lastId 0L; int batchSize 10000; ListUser batch; do { batch userMapper.selectList( new LambdaQueryWrapperUser() .gt(User::getId, lastId) .orderByAsc(User::getId) .last(LIMIT batchSize) ); // 处理这一批数据... if (!batch.isEmpty()) { lastId batch.get(batch.size() - 1).getId(); } } while (batch.size() batchSize);每批只扫主键索引往后 1 万行性能非常稳定不受总数据量和页码深度影响。对导出、全量同步、数据清洗这类任务来说这是我认为最实用的模式。5.4 方案三MyBatis 流式查询配合 POI 边查边写如果你要导出的数据量很大比如几十万行 Excel上面 keyset 还需要分批次组织内存。更彻底的做法是用 MyBatis 原生流式查询每从数据库取回一行就立刻写进 Excel内存里始终只有极少数记录。Mapper 方法ListUser scanAll(ResultHandlerUser handler);XMLselect idscanAll resultTypecom.example.entity.User resultSetTypeFORWARD_ONLY fetchSize-2147483648 SELECT id, name, age FROM user ORDER BY id /selectService 里配合 POI 的 SXSSF 使用SXSSFWorkbook workbook new SXSSFWorkbook(); Sheet sheet workbook.createSheet(用户); mapper.scanAll(resultContext - { User user resultContext.getResultObject(); // 每来一行写一行 Excel Row row sheet.createRow(sheet.getLastRowNum() 1); row.createCell(0).setCellValue(user.getId()); // ... }); workbook.write(outputStream);MySQL 里把fetchSize设为-2147483648Integer.MIN_VALUE会启用流式读取这样不会一次性把整个结果集灌进内存。这个模式写出来的导出程序导 50 万行数据内存占用也稳定具体数据量取决于你的堆和行宽度但远好于一次 pageSize5000 的做法。5.5 什么时候可以把 maxLimit 调大如果业务确实需要单页超过 500 条而且你评估过数据量、字段宽度、DB 性能那调大 maxLimit 也是合理选择。我一般建议列表页保持一个小上限比如 100 或 200防止有人误传大 size 把库拖垮管理端报表可以设到 5000再往上就不推荐了因为网络传输和 JSON 序列化的成本也会涨导出、批处理不要走分页插件走 5.2 到 5.4 的方案。大页面本身不是银弹任何超过 5000 条的单次返回都值得你重新审视设计。我在实际项目里踩过最多次的坑其实不是 maxLimit而是只检查了代码、没看日志就改配置。分页插件有个非常友好的特性它对没有 IPage 参数的查询完全无感所以只要方法签名没变怎么调拦截器都没用。后来我给自己定了个习惯凡是接到分页相关的工单第一件事永远是去线上日志里搜最近一次 SQL 有没有 LIMIT、LIMIT 后面的数字是多少然后再决定改代码还是改配置。这个习惯帮我省了很多无意义的排查时间也推荐你试试。
返回列表