ARTICLE DETAIL

资讯详情

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

MyBatisPlus分页失效排查指南:从插件原理到单页500条限制

MyBatisPlus分页失效排查指南:从插件原理到单页500条限制 但凡用 MyBatisPlus 做分页十有八九都遇到过这样的诡异现象代码里明明写了selectPage数据库却把全表数据都拉了出来或者列表页一次返回几百上千条数据前端翻页按钮完全失效再或者查询总数一直为 0页面上的“共 X 条”永远不显示。很多人照着网上的教程抄了一遍配置仍然不行。今天我把 MyBatisPlus 分页失效的坑以及最近热搜里经常被问到的“单页500条限制”问题结合我自己在真实项目里的排查过程一次性讲透。这篇文章适合刚接触 Spring Boot MyBatisPlus 的同学也适合有一定经验但是被分页问题困扰过的人内容不绕弯直接讲能落地的配置、排查步骤和避坑经验。1. 先搞懂分页插件的工作原理1.1 为什么必须用插件而不是手写 LIMIT很多初学者会问MyBatis 本身不支持分页吗其实 MyBatis 作为持久层框架只负责 SQL 映射和执行它并不具备物理分页能力。你可以在 XML 里手动写LIMIT #{start}, #{size}但这样做有几个问题第一LIMIT是 MySQL 的方言语法换到 PostgreSQL、Oracle 或 SQL Server 就得改成LIMIT ... OFFSET ...、ROWNUM或OFFSET ... FETCH NEXT项目一旦跨数据库所有分页 SQL 都要重写第二手工拼分页参数很容易出错比如忘记计算 offset或者传入负数第三分页往往还要带上一个 COUNT 查询手工写两遍 SQL 不但啰嗦而且后续维护成本高。MyBatisPlus 的分页插件本质上是利用 MyBatis 的 Interceptor 机制在 Executor 执行 SQL 之前做一层拦截。它会解析已经映射好的 SQL 语句根据当前使用的数据库方言自动改写成分页 SQL并且额外生成一条 COUNT 查询。我们写业务代码的时候只需要把PageT对象作为参数传进去插件会自动把当前页码、每页条数提取出来生成类似SELECT ... LIMIT 0,10的语句再把 count 结果填充到 Page 对象的total属性上。这样一来业务代码里完全不需要关心数据库方言差异也不需要手工维护分页逻辑这也是 MyBatisPlus 分页插件最核心的价值。1.2 分页插件的正确配置方式分页插件本身不是一个 Spring Bean它必须被装进MybatisPlusInterceptor这个总拦截器里才能生效。在 Spring Boot 项目中最常见的配置方式是这样Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(DbType.MYSQL); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }这里有一个非常容易被忽略的点DbType必须和实际数据库类型匹配。比如你连的是 PostgreSQL但这里写成了DbType.MYSQL分页插件生成的语法虽然可能也能跑但在某些边界场景下会出问题比如 count 查询的写法、limit 参数的顺序不同方言处理起来有细微差别。更麻烦的是如果不匹配插件可能直接抛异常或者生成错误的 SQL 导致查询结果不对。所以不管你用的是 MySQL、PostgreSQL、Oracle 还是达梦都记得把DbType改成对应类型。另外如果你的项目里有多个拦截器组件比如乐观锁插件OptimisticLockerInnerInterceptor、防全表更新插件BlockAttackInnerInterceptor它们的添加顺序是有讲究的。一般来说分页插件要放在最前面或者至少保证分页插件不是被覆盖掉的那一个。我见过一些项目在多个配置类里分别定义了不同的MybatisPlusInterceptorBean结果后定义的 Bean 把前一个覆盖了分页插件根本没进到拦截器链里分页自然就失效了。这个细节后面还会展开讲。2. 分页失效的常见场景与排查实录2.1 配置了拦截器但分页仍返回全部数据这是被吐槽最多的一种“失效”写好了PageUser page new Page(1, 10);调用了userMapper.selectPage(page, null)但日志里打印的 SQL 居然没有 LIMIT直接SELECT * FROM user把全表查了出来。遇到这种情况第一反应应该是检查拦截器到底有没有被 Spring 容器管理。排查步骤很简单先在配置类里打个断点或者加一行System.out.println(paginationInnerInterceptor)看应用启动时是否真的执行到了这段代码。如果没有执行到说明配置类没有被 Spring 扫描到或者Configuration注解所在的包不在主启动类的扫描路径下。如果有执行到但分页依然失效那就要考虑是不是存在多个MybatisPlusInterceptorBean。Spring 容器里如果同时存在两个同类型的 Bean通常后面定义的那个会起作用而不是两个都生效。如果你在ConfigA里定义了一个不带分页插件的MybatisPlusInterceptor又在ConfigB里定义了一个带分页插件的运行时究竟哪个生效完全取决于 Bean 的加载顺序。这就可能导致分页时灵时不灵。还有一种情况是项目里额外引入了别人封装的分页实现比如某些二次开发的 MyBatis 增强包它可能也注册了一个拦截器把你的分页插件给挤掉了。排查这类问题最靠谱的办法是启动后通过ApplicationContext把所有MybatisPlusInterceptor的实例都打印出来Autowired private ApplicationContext applicationContext; PostConstruct public void check() { MapString, MybatisPlusInterceptor beans applicationContext.getBeansOfType(MybatisPlusInterceptor.class); beans.forEach((name, bean) - System.out.println(name - bean)); }如果打印出来只有一个 bean而且里面包含分页插件那基本可以排除拦截器未生效的问题。2.2 自定义 SQL 里分页参数放在非第一位导致失效MyBatisPlus 官方文档明确规定使用分页插件时IPage参数必须放在方法参数列表的第一个位置。很多同学在写自定义 SQL 时习惯把查询条件放在前面比如ListUser selectUserList(String name, PageUser page);这种情况下分页插件虽然能识别到Page参数但在解析参数时可能会出现错乱尤其是当你用了Param注解以后MyBatis 的参数处理逻辑会更复杂。我自己实际测试过在某些版本下这样写分页不会报错但 SQL 中被拼接的分页条件会丢失导致 LIMIT 没有生效。最稳妥的写法是把Page放在第一位或者至少让Page不依赖Param注解IPageUser selectUserList(PageUser page, Param(name) String name);对应的 XML 里不需要任何关于分页的特殊处理MyBatisPlus 插件会自动在 SQL 末尾拼接 LIMITselect idselectUserList resultTypecom.example.entity.User SELECT * FROM user WHERE name LIKE CONCAT(%, #{name}, %) /select注意返回值类型如果你只需要当前页的数据不需要总数返回值可以写成ListUser这样分页插件也会执行物理分页只是不会执行 count 查询。如果你需要总数来渲染前端分页组件返回值必须写成IPageUser或者PageUser这样 MyBatisPlus 才会额外执行 count 并把总数填充到page.getTotal()。还有一个容易踩的坑在 XML 里手工写了LIMIT同时又用了分页插件。这会导致插件在原有 LIMIT 的基础上再次拼接一个 LIMIT生成类似LIMIT 10 LIMIT 10的 SQL直接报语法错误。所以自定义分页 SQL 里一定不要自己写 LIMIT把分页完全交给插件。2.3 多表关联查询导致 COUNT 结果不对分页插件会自动生成一条 count 查询原理上它会把原 SQL 包一层SELECT COUNT(1) FROM (原SQL) AS total。遇到单表查询没问题但一旦你的 SQL 里有JOIN、DISTINCT、GROUP BY这条自动生成的 count SQL 可能性能很差甚至统计结果不对。最常见的场景是 left join 一对多查询比如一个用户有多张订单左连接后数据行数会变多count 出来的总数跟着变多导致前端分页显示的总条数和实际数据对不上。遇到这种情况有两种处理方式。第一种是手动写 count SQL通过自定义方法重写总数。MyBatisPlus 的Page对象允许先执行 list 查询后再手动设置 total但更规范的做法是利用optimizeCountSql。分页插件默认会尝试优化 count SQL它会去掉ORDER BY把SELECT后面的列替换成COUNT(1)但这个优化只对简单 SQL 有效。如果你发现 count 还是慢或者不准可以在查完 list 之后显式注入一个专门的 count 查询public IPageUserVO selectUserPage(PageUserVO page, Param(name) String name) { ListUserVO records customMapper.selectUserList(page, name); Long total customMapper.selectUserCount(name); page.setTotal(total); page.setRecords(records); return page; }这里需要注意的是如果customMapper.selectUserList也接收了page参数它会触发分页插件执行一次 count然后你又手动 setTotal就会覆盖掉原本的 total行为是可预期的。但如果不想让插件自动查 count可以调用page.setSearchCount(false)这样插件只拼 LIMIT不执行 count 查询。2.4 返回类型不对导致的“假分页”还有一种情况被很多人误认为是分页失效方法返回类型是ListUser调用后确实拿到了一页数据但前端分页组件需要total结果total一直是 0或者拿不到总数。其实这不是物理分页失效而是List返回值不会触发 count 查询。MyBatisPlus 的拦截器只会在方法返回值类型是IPage时才额外执行 count 并填充到 Page 对象里。我之前排查过一个线上问题列表接口第一次打开要等 3 秒后面翻页只要 10 毫秒。后来发现是因为返回类型是List而且 Page 对象在 Service 层被直接忽略了selectList查询实际是全表查询只是拿到前端以后做了内存分页。这属于典型的假分页数据量小的时候看不出来数据量一大就容易 OOM。所以建议所有分页接口统一返回IPage千万别偷懒返回 List。如果你是从selectPage这种内置方法改成自定义方法还要注意内置方法中Page对象是作为第一个参数传入的自定义方法也一样。有些同学在 Service 层用BeanUtils把PageUser转换成PageUserVO时忘记把total、current、size等属性一并拷贝导致前端拿到的 Page 对象里只有 records其他字段全是默认值。转换的时候可以用 MyBatisPlus 提供的convert方法PageUserVO voPage new Page(page.getCurrent(), page.getSize(), page.getTotal()); voPage.setRecords(page.getRecords().stream().map(UserVO::new).collect(Collectors.toList()));3. “单页500条限制”究竟是怎么回事3.1 限制的源头不在 MyBatisPlus 默认行为里最近网上很多人搜“接触 MyBatisPlus 单页 500 条限制”其实 MyBatisPlus 本身默认并没有限制单页条数。如果你在一个列表接口里传size10000插件是允许的数据库也会真的给你查一万条。那这个“500条限制”是从哪来的根据我在各种项目里的排查经验限制的源头通常是以下四种情况之一第一种项目里有人显式设置了分页插件的maxLimit500。这是最直接的原因。PaginationInnerInterceptor从 3.4.0 版本开始支持maxLimit配置作用非常粗暴当请求的page.getSize()大于设置的值时强制把size改成这个值。也就是说你传 size1000实际查询只会执行LIMIT 500。第二种公司内部框架在更上层做了封装。有些团队会在 Service 基类里统一限制单页最大值比如传入的pageSize超过 500 就直接改成 500或者直接抛业务异常。第三种数据库驱动或连接池的配置。比如 MySQL 的maxAllowedPacket参数限制的是单条 SQL 最大字节数如果你查出来的数据量太大会报PacketTooBigException而不是静默截断到 500 条。所以这条和真正的“单页500条限制”不太一样但很多人会把报错和限制混为一谈。第四种前端表格组件的虚拟滚动或渲染性能兜底。比如某些管理后台的前端框架默认每页最多渲染 500 条数据超过以后页面卡顿产品经理就要求后端限制了。这种限制纯粹是业务需求跟 MyBatisPlus 无关。3.2 分页插件 maxLimit 的配置与解除如果你的项目确实在分页插件上设置了maxLimit解除或调整的方法非常简单。先找到创建PaginationInnerInterceptor的地方看看有没有这样一行代码PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L);这行代码一旦存在所有分页请求的单页上限就会变成 500。要解除限制有两种办法一是直接把setMaxLimit这一行注释掉因为默认值是null表示不限制二是把参数值调大比如setMaxLimit(1000L)。但这里要特别说一句maxLimit本身是一个很好的保护机制。很多接口被爬虫恶意遍历或者前端不小心传了一个size999999如果没有限制数据库可能会被一次查询拖垮。合理的做法是保留maxLimit但把它设置成一个符合业务预期的值比如 1000 或 2000而不是默认的 500。同时配合自定义校验在 Controller 层就拦截掉超大的分页参数而不是让数据库去硬扛。如果项目里搜索不到setMaxLimit那“500条限制”大概率来自上面提到的第二种或第四种原因。你可以打开浏览器开发者工具看接口返回的 JSON 里records数组长度是不是 500也可以在后端打印最终执行的 SQL看LIMIT后面的值是多少。如果 SQL 里的 LIMIT 是 500而代码里没有 maxLimit那就要检查是不是有另外一个PaginationInnerInterceptor被复制到了其他地方。3.3 如何验证 maxLimit 是否真的生效我常用的验证方法很简单在数据库日志里打印 MyBatis 执行的 SQL。在application.yml里配置 SQL 日志输出logging: level: com.example.mapper: debug然后请求一个size1000的分页接口查看控制台输出的 SQL。正常情况下插件生成的 SQL 应该是LIMIT 1000如果看到的是LIMIT 500那就是有地方强制设置了maxLimit500。还有一种可能性是你在 Service 层使用Page对象时没有把从前端传入的size赋值到 Page 对象上而是写死了PageUser page new Page(1, 500);这种情况下不管前端传多少后端都只是查 500 条看起来就像是“单页限制 500”。这也是个很常见的人为失误排查时容易忽略。4. 分页性能优化与高级玩法4.1 COUNT 查询性能优化使用 MyBatisPlus 分页插件时每次分页查询都会额外执行一条 COUNT SQL数据量大、SQL 复杂时这条 COUNT 可能比数据查询还慢。插件默认会尝试优化 COUNT把ORDER BY去掉把查询列替换成COUNT(1)。但在一些复杂 SQL 下优化效果有限。有几个思路可以结合使用一是对非常复杂的报表统计类 SQL关闭自动 count自己写专门的 count 查询。关闭自动 count 的方式是调用page.setSearchCount(false)或者自定义方法返回List类型。然后单独写一个轻量的 count SQL只统计主表数量避免 JOIN。二是如果业务场景是“下拉加载更多”而不是页码跳转根本不需要 total这时完全可以不执行 count 查询。前端用“是否还有下一页”来替代总页数也就是每次查询 size1 条如果查出 size1 条说明还有更多保存其中 size 条返回。这种方式在移动端动态列表里应用很广对数据库压力小很多。三是针对JOIN过多导致的 count 性能问题可以尝试使用MPJ或者手写专门的 count 语句。对于一对多关系的分页还有一种操作是把 JOIN 放到子查询中先分页主表再关联子表数据避免把关联后的行数统计错。4.2 深分页问题与常见替代方案分页插件用的仍然是LIMIT offset, size这种物理分页方式。当页码很大时比如第 10000 页SQL 会变成LIMIT 100000, 20。MySQL 执行这样的语句需要扫描并丢弃前 100000 行性能非常差而且页码越深越慢。这个问题不在 MyBatisPlus 本身而是传统分页方式固有的缺陷。针对深分页比较常用的优化是“基于游标的分页”。核心思路是记住上一页最后一条数据的唯一键下一页查询时加上WHERE id 上次ID条件配合LIMIT size。这样数据库只需要从指定位置开始扫描而不是从 0 扫到 100000。注意使用这个方案时排序字段必须是唯一的最好用主键id否则可能会出现重复或漏数据。如果业务上必须使用传统页码分页也可以利用子查询优化 SQL。比如原 SQL 是SELECT * FROM user ORDER BY id LIMIT 100000, 20;改成SELECT * FROM user WHERE id (SELECT id FROM user ORDER BY id LIMIT 100000, 1) ORDER BY id LIMIT 20;子查询只查询一个主键比查询整行数据要快得多外层再根据主键范围取数。MyBatisPlus 里可以通过Wrapper的apply方法拼接PageUser page new Page(10001, 20); LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.ge(User::getId, lastId); wrapper.orderByAsc(User::getId); userMapper.selectPage(page, wrapper);注意这种方式需要你提前计算好上一次的 lastId本质上变成了游标分页但配合插件仍然能用。4.3 分页对象在不同层级的传递与转换很多团队的分层比较严格Controller 不直接接收 EntityService 返回 DTO/VO。MyBatisPlus 的Page泛型支持任意类型所以你可以直接在 Mapper 层查询时就把结果映射成 VO或者先在 Mapper 层查到PageUser再到 Service 层转换成PageUserVO。第一种方式在 XML 里写自定义查询时直接指定resultType为 VO 类型字段映射可以通过as别名对齐。这样 Mapper 返回的就是IPageUserVO不需要额外转换性能也好。第二种方式适合实体和 VO 字段差异比较大、需要做一些字段计算的情况。转换时建议把total、current、size一起带过去否则前端会丢失分页信息。可以用 MyBatisPlus 提供的IPage方法PageUserVO voPage new Page(page.getCurrent(), page.getSize(), page.getTotal()); voPage.setRecords(page.getRecords().stream().map(UserVO::new).collect(Collectors.toList()));如果是基于 Spring Boot 3 MyBatisPlus 3.5.x 的项目要注意新版MybatisPlusInterceptor和旧版PaginationInterceptor不能混用。旧版PaginationInterceptor在 3.4.0 之后已经废弃如果你引用了旧代码同时又把推荐的新配置写进去可能会出现分页插件注册了两套的情况最终表现就是有时分页正常、有时不正常。我建议统一使用新版MybatisPlusInterceptor并且只保留一个配置类。5. 常见问题速查表为了让大家在排查时能直接“照着做”我把实际项目中遇到过的分页相关问题和解决方案整理成了下面这张速查表基本覆盖了绝大部分非业务逻辑导致的分页“假失效”或“真失效”。现象可能原因解决办法SQL 中没有 LIMIT返回全表数据分页插件未注册或配置类未被扫描多个 MybatisPlusInterceptor Bean 互相覆盖检查 Spring 扫描路径统一在同一个配置类中注册分页插件通过 ApplicationContext 打印拦截器实例排查执行了 LIMIT但 total 始终为 0方法返回类型为 List而非 IPagePage 对象没有填充 total返回值改为 IPage确保查询方法第一个参数为 Page 对象并使用 selectPage 或自定义 IPage 返回自定义 SQL 分页不生效Page 参数不在第一位XML 里手动写了 LIMIT参数未使用 Param调整参数顺序确保 Page 为第一个参数删除 XML 中的手工 LIMIT统一参数注解多表 JOIN 查询总数不准自动生成的 count SQL 没有正确处理 JOIN 去重关闭自动 count使用手动 count或改写为子查询分页先分页主表再关联单页最大条数被限制为 500PaginationInnerInterceptor 的 maxLimit 被设置Service 层写死了 size搜索 setMaxLimit 并注释或调大检查分页参数是否被硬编码深分页越来越慢LIMIT offset 过大数据库扫描大量无用行改用游标分页基于上次 ID或使用子查询先查主键再关联分页数据出现重复或缺失ORDER BY 字段不唯一翻页时数据顺序发生变化在排序条件后增加唯一键如 ORDER BY create_time DESC, id DESC分页接口偶发报错查看日志发现 LIMIT 重复拼接XML 中手动写了 LIMIT插件又自动追加去掉 XML 中的 LIMIT把分页完全交给插件处理这张表不能百分之百涵盖所有环境下的问题但排查思路是通用的先看 SQL 输出再确认拦截器接着排查分页参数最后考虑数据库层面。思路清晰很多“玄学”问题都能找到根源。6. 我的个人排查经验写到最后分享两个我在实际项目中踩过的最深的坑。第一个是分页插件被覆盖的问题。早期为了项目结构清晰我在MybatisPlusConfig里定义了一个MybatisPlusInterceptor在SecurityConfig里又定义了一个两个都叫同一个方法名mybatisPlusInterceptor()。结果后加载的 Bean 把先加载的覆盖了分页插件彻底失效但代码看上去完全没有问题。后来我把项目里所有MybatisPlusInterceptor的 Bean 统一收敛到一个配置类并且给每个方法起了不同的名字问题再没出现过。所以排查分页问题时别只盯着 SQL先看看 Spring 容器里到底注册了几个拦截器执行过程到底走了哪一个。第二个是关于“单页500条限制”。有一次项目上线后运营反馈列表最多只能显示 500 条我排查了半天数据库、网关、前端最后发现是另一位同事在一个公共配置类里加了一行pagination.setMaxLimit(500L)用来防止别人恶意拉取数据但没有在需求文档里说明。这提醒我任何性能保护型配置都要在代码注释里写清楚最好放在独立的常量配置里否则后人接手时真的会排查到崩溃。最后再补一句很实际的话分页这种事不要过度依赖框架。MyBatisPlus 的分页插件解决的是“通用分页”问题遇到深分页、大数据量、跨库迁移这些场景还是要结合业务自己设计合理的方案。多打印 SQL、多实测不同方言比纯靠经验靠谱得多。
返回列表