MybatisPlus分页插件原理与实战:解决失效、500限制及深分页优化 分页这功能说简单是真简单LIMIT一拼就完事说麻烦也是真麻烦慢查询、COUNT 不准、深分页卡死哪个都能让你在大半夜接到告警电话。我这几年做后端用 MybatisPlus 的地方不少分页插件几乎成了标配但每次带新人或者帮别人排障都会发现不少人对它既熟悉又陌生——用是会用的可真出了问题比如热搜里那个“分页失效”“单页 500 条限制”往往一脸懵。这篇文章我不打算复述官方文档而是以我实际踩坑、排障的经验为线索把这几年用 MybatisPlus 分页插件积累下来的细节、原理、坑、优化手段一次性讲透。不管是刚入门的新人还是想搞清楚“分页为什么失效”“500 条限制怎么破”的运维老手都应该能从里面找到你想要的东西。1. 分页插件到底帮我干了什么先说个容易被忽略的点分页插件不是一个业务组件它是一个 SQL 层面的拦截器。它的存在是为了让我们在写业务代码的时候彻底忘掉“分页 SQL 怎么写”这件事把精力放在条件构造上。1.1 没有插件的时候事情有多烦回想一下最早手写分页的日子。每写一个列表查询就得手动拼一条带LIMIT的 SQL还要配套写一条COUNT(*)的统计 SQL。单表还好一旦遇到多表关联、条件一多两条 SQL 的WHERE条件稍不一致页面上显示的总数就会对不上数据错乱得莫名其妙。而且手写分页还容易踩另一个坑分页参数不合法。前端传个current -1或者size 99999你要是没做校验SQL 能给你拼出一个把整个表拖垮的变态分页。这种问题在早期的不少项目里是真实发生过的。1.2 插件在底层做了三件事MybatisPlus 的分页插件核心是一个PaginationInnerInterceptor它挂在 Mybatis 的拦截器链路上会在 SQL 执行之前拦截Executor的 query 方法然后做三件事第一解析原始 SQL。它通过 JSqlParser 之类的 SQL 解析器把我们的查询语句解析成抽象语法树定位到SELECT、FROM、WHERE、ORDER BY等部分。第二生成 COUNT 查询。插件会复制一份原始 SQL把查询列改写为COUNT(*)并去掉ORDER BY排序在 COUNT 时毫无意义还白费性能然后先执行这条 COUNT SQL拿到总数。第三拼接分页方言。它会根据数据库类型比如 MySQL 的LIMIT ?, ?、Oracle 的ROWNUM、PostgreSQL 的LIMIT ? OFFSET ?拼出真正的分页 SQL把原 SQL 包裹进去执行。换句话说我们在业务里传一个Page对象进去插件负责把一切 SQL 层面的脏活累活干完最后把结果塞回Page对象里包括total、pages这些属性。1.3 选型对比为什么不是 PageHelper这里顺便提一个很多新人会纠结的问题Mybatis 系还有个著名的分页插件 PageHelper它和 MybatisPlus 的分页插件有什么本质区别PageHelper 的用法是“静态方法 物理分页上下文”也就是你在查询前调用PageHelper.startPage(pageNum, pageSize)它会把分页参数塞进一个 ThreadLocal然后在下一次查询时自动拦截拼接。这个机制有个天然隐患如果startPage之后紧接着执行的 SQL 不是你想要分页的那条比如你先查了一个别的查询或者方法提前 return 了ThreadLocal 里的参数没有得到清理就会串到别的 SQL 上造成莫名其妙的“分页污染”。MybatisPlus 的分页方式则是“显式传参”selectPage(page, wrapper)分页参数是跟着方法签名走的不存在隐藏上下文理论上更可控。所以只要项目已经用了 MybatisPlus我的建议是直接用它的分页插件别再去重复引入 PageHelper 了。2. 从 0 到 1正确的接入姿势明白了原理咱们直接进入实操。这里的每一步我都标注了“为什么”因为配置这东西照抄容易翻车理解了才能应变。2.1 依赖引入与拦截器注册MybatisPlus 从 3.4.0 版本之后分页插件和租户插件、乐观锁插件一样统一挂在MybatisPlusInterceptor下面。具体注册方式如下Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); // 单页最大条数限制默认 500 条超过会报错 pagination.setMaxLimit(500L); // 超出数量后是否返回所有数据默认 false pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); return interceptor; } }这里有两个关键点。第一DbType必须匹配你实际的数据库。如果你用的是 MySQL 却配成了DbType.ORACLE分页 SQL 会按 Oracle 的方言生成那基本就是直接报错或者查不出来。我在项目里见过有人把配置复制粘贴忘了改排查了半天才发现是数据库类型对不上。第二setMaxLimit(500L)就是热词里那个“单页 500 条限制”的源头。这是 MybatisPlus 提供的一个保护机制当请求的size大于 500 时插件会直接抛异常而不是默默执行一条可能拖垮数据库的超大分页查询。这个设计本身是为了保护数据库但很多人不知道它、不理解它导致“明明配置了分页一页超过 500 条就报错”的现象。后面的章节我会专门展开讲怎么合理处理这个限制。2.2 Page 对象与 Mapper 方法的正确用法配置完拦截器接下来是业务代码。最标准的分页写法是让 Mapper 方法接收一个IPage类型的参数并且返回值也是IPageIPageUserVO page new Page(current, size); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1) .orderByDesc(User::getCreateTime); IPageUserVO result userMapper.selectUserPage(page, wrapper);注意selectUserPage这个方法不会被 MybatisPlus 自动生成需要你自己写在 Mapper 接口里比如这样public interface UserMapper extends BaseMapperUser { IPageUserVO selectUserPage(IPageUser page, Param(ew) WrapperUser wrapper); }对应的 XML 也不复杂select idselectUserPage resultTypecom.example.vo.UserVO SELECT u.id, u.name, u.status FROM user u ${ew.customSqlSegment} /select这里有个很多新人容易忽略的细节第一个参数必须是IPageMybatisPlus 才会触发分页拦截。如果你把IPage放在第二个参数或者用了ListUser作为返回值分页拦截器会直接跳过结果是查出了全量数据。这就是“分页失效”最常见、最隐蔽的原因之一。2.3 条件构造在分页里的“隐形坑”用 LambdaQueryWrapper 构造条件的时候如果你在 service 层把查询条件放在wrapper里XML 中又用${ew.customSqlSegment}引入那么 MybatisPlus 会帮你自动拼上WHERE和条件参数。这里有个细节条件构造器里的参数是预编译的#{}占位符安全且高效但如果有人图省事在 XML 里写${}做字符串拼接那不仅分页可能出问题SQL 注入风险也会直线上升。我自己的习惯是单表简单查询直接调BaseMapper.selectPage连 XML 都不用写多表查询才写自定义 SQL而且这个 SQL 务必保证外层结构完整让分页插件能正确解析。换句话说别在 XML 里手写LIMIT了也别写什么FOR UPDATE之类的特殊子句和分页共存解析器碰到这些复杂情况容易“懵”。3. 分页失效的根源排查方法论对照热搜词“mybatisplus 分页失效”这是很多人都会遇到的大坑。我这里整理一下我排障以来遇到的各种失效原因按出现频率从高到低排个序。3.1 拦截器根本没生效最基础、也最容易被忽略的原因就是配置了分页插件但实际上没生效。这种情况常见于项目里同时存在多个 Mybatis 配置类或者Configuration没被扫描到。排查方法很简单打开 SQL 日志看打印出来的 SQL 里有没有LIMIT。如果查询语句里压根没有LIMIT那说明拦截器就没进来。还有一种情况是项目里同时引入了多个版本的 MybatisPlus 依赖导致用了旧版本的PaginationInterceptor老 API而新代码用的是MybatisPlusInterceptor。两条链路并存不一定谁生效表现就是时好时坏、分页偶尔失效。处理这种问题先去mvn dependency:tree看一下依赖树把重复的依赖排除掉确保全局只用一套版本。3.2 自定义 SQL 写法触了解析器盲区分页插件依赖 JSqlParser 解析 SQL如果 SQL 写得过于“自由”解析器解析不了它就不会做分页改写。举几个常见的例子在SELECT后面写了DISTINCT且配合了奇怪的函数导致 COUNT 改写不准确。SQL 里带UNION插件对 UNION 的 COUNT 改写策略比较特殊容易直接失效。在子查询里写了ORDER BY或者主查询里带了FOR UPDATE。用了 MySQL 的GROUP_CONCAT之类的聚合函数同时还在外面分页。遇到这种问题时我的建议是分页的 SQL 尽量保持标准结构别把复杂的聚合逻辑塞进同一个分页查询里。比如需要聚合统计的可以拆成两步先查条件符合条件的 ID 集合再根据 ID 集合去分页取明细。这样既能稳定分页又方便优化性能。3.3 多表 JOIN 分页的经典性能问题JOIN 分页的“失效”不是指报错而是指结果不对或性能爆炸。为什么因为分页插件只是在你的 SQL 后面拼接LIMIT它并不会帮你优化 JOIN 的执行计划。举个例子订单表和订单明细表 JOIN一个订单可能对应多条明细分页时LIMIT作用在 JOIN 后的结果集上很容易造成一个订单的数据被拆到两页里导致前端展示的订单列表出现重复和缺失。而且如果大表 JOIN 后再分页MySQL 需要先把 JOIN 的结果全部算出来再丢弃 offset 之前的数据深分页时效率极低。针对这种场景我的做法是改造 SQL 结构先分页查出主表的 ID 集合再根据 ID 去关联明细表。比如select idselectOrderPage resultTypecom.example.vo.OrderVO SELECT ... FROM orders o LEFT JOIN order_item i ON o.id i.order_id WHERE o.id IN ( SELECT id FROM ( SELECT id FROM orders WHERE ${ew.sqlSegment} ORDER BY create_time DESC LIMIT #{offset}, #{size} ) tmp ) ORDER BY o.create_time DESC /select这种方式虽然 SQL 看起来复杂但避免了 JOIN 后的全量运算深分页时性能提升非常明显。4. 单页 500 条限制它是保护不是 bug接着聊热搜里的第二个关键词“解除 mybatisplus 单页 500 条限制”。很多人在遇到“单页超过 500 条就报错”时第一反应是这个插件有 bug想直接把它改成不限制。但我要先给这个限制正名这个 500 条默认限制是 MybatisPlus 有意加上的安全阀。4.1 限制的运作机制PaginationInnerInterceptor里有一个maxLimit属性默认值是 500。每次执行分页查询时插件会校验page.getSize()如果当前页的 size 超过了maxLimit它会直接抛一个异常比如MybatisPlusException: size 1000 exceeds maxLimit 500。这个异常不是 SQL 层的报错所以你在 SQL 日志里看不到任何痕迹只知道接口 500 了。很多新人在排障时压根想不到是配置项在拦截排查半天才发现是这个“隐藏规则”。提示网上很多“破解 500 条限制”的文章说的是把maxLimit设置成一个很大的值比如999999L。我个人不建议这么干除非你确认自己的场景必须一次性拉取大量数据且已经做过性能评估。4.2 正确“解除”限制的姿势如果你的业务确实需要一页返回超过 500 条数据比如导出、批处理有几种相对合理的方案方案一在配置里调大maxLimit比如setMaxLimit(5000L)。注意这是全局生效的会影响所有分页接口所以调大前最好先评估一下最大的数据量和接口被恶意调用时的风险。方案二如果只是极少数接口需要大页就不要全局放开。可以在这些特殊接口里手动创建一个没有限制的Page子类然后在调用前做一层参数校验确保只有白名单接口能访问。方案三把“大结果集”从“分页查询”里剥离出去。比如导出功能不要走前端点击“下一页 500 条”再导出的方式而是用异步任务去后台分批刷数据。前端的翻页保持在正常范围不受影响后端的批量获取用游标或者分段取值来自行控制。4.3 应用层再补一道锁即便你不改maxLimit我也建议在应用层再对分页参数做一次校验。因为 MybatisPlus 的maxLimit只防住了插件层如果某条 SQL 没走分页插件比如自定义查询直接写死了 LIMIT那就没保护了。我会在 Controller 入口处统一做一层校验类似这样if (size ! null (size 1 || size 200)) { throw new BizException(分页大小必须在1~200之间); } if (current null || current 1) { current 1; }这层校验的目的不是为了覆盖 MybatisPlus 的maxLimit而是让异常信息更友好、更早暴露。你可以在校验参数时就拦截掉非法请求而不是等 SQL 执行到一半才发现问题。5. 数据量大了之后从 LIMIT 走向游标很多项目初期用分页插件用得挺爽但表里到了几百万、几千万行之后深分页的慢查询就藏不住了。某一天运营反馈“后台翻到第 100 页就半天加载不出来”DBA 甩过来一条慢 SQL一看就是LIMIT 990000, 10这种写法。5.1 深分页为什么天生就慢LIMIT 990000, 10在 MySQL 里的执行逻辑是先扫描出 990010 行再丢弃掉前 990000 行最后返回 10 行。前面扫的那些行全部是无效计算。更麻烦的是如果ORDER BY的字段没有合适的索引MySQL 还得先把这些行排序代价成倍增加。所以深分页的“深”才是性能问题的根源不是分页插件本身的问题。分页插件只是忠实地执行了你的分页需求。5.2 优化手段一覆盖索引 子查询针对深分页我最常用也最推荐的一种优化手段是“查 ID 再回表”。思路是先用覆盖索引把满足条件的主键 ID 查出来只走索引不碰行数据拿到 ID 后再去回表查完整行。SELECT * FROM user WHERE id IN ( SELECT id FROM ( SELECT id FROM user WHERE status 1 ORDER BY create_time LIMIT 990000, 10 ) tmp ) ORDER BY create_time;这里最内层的查询只查id字段如果status和create_time上有合适的联合索引这个内层查询可以完全在索引里完成速度会快很多。外层再根据 ID 集合回表由于只需查 10 行代价非常小。我自己在做订单列表分页时就是先把订单 ID 集合算出来再关联订单明细等子表。实测下来在百万级数据量的表上第 100 页的查询时间从原来的 2 秒多降到了 300 毫秒以内。5.3 优化手段二游标分页Keyset Pagination覆盖索引子查询能解决部分深分页问题但页码越深内层扫描的行仍然越多本质上只是减慢了变慢的速度。真正的解法是游标分页——不依赖页码而是依赖上一次查询返回的最后一个值。比如按照id分页SELECT * FROM user WHERE status 1 AND id #{lastId} ORDER BY id LIMIT 10;这种写法天然只扫描目标范围内的数据无论翻到多深性能都稳定。不过它有一个明显的限制用户不能随意点击“跳到第 N 页”只能一页一页翻。所以它适合资讯流、消息流、日志列表这类场景而不适合传统意义上的“第 N 页跳转”。如果你一定要保留“跳页”的能力又不想被深分页拖垮那么折中的方案是在总数据量超过某个阈值比如 10 万条时前端改为“加载更多”的交互后端就走游标分页数据量小的时候继续用传统页码分页。这个阈值可以根据你的数据库性能和表数据量自定义。5.4 结合分页插件的游标实现有的同学会问分页插件不支持游标分页怎么办其实不需要把游标分页硬塞进插件里。针对游标场景我的做法是单独写一个 Mapper 方法IPageUser selectUserPageByCursor(IPageUser page, Param(lastId) Long lastId, Param(size) Long size);XML 里直接写游标 SQL查询条件里带上lastId。分页插件依然可以在这种 SQL 上拼接LIMIT此时LIMIT的 offset 始终是 0所以性能没有任何问题。换句话说分页插件和游标方案并不冲突。你可以在日常列表里用插件做常规分页在“加载更多”这种场景里把current固定为 1用lastId来构造条件一样能复用插件的分页结果封装能力。6. 常见问题速查与避坑经验最后我把这几年实践中遇到的分页相关高频问题汇总成一张速查表方便以后遇到问题时直接对照排查。问题现象可能原因处理方案分页查询返回全量数据拦截器未注册或未生效检查 SQL 日志是否含 LIMIT检查配置类扫描路径单页超过 500 条就报错maxLimit默认限制调大maxLimit或走异步/游标方案分页到了深页数奇慢无比深分页 offset 过大扫描行数多覆盖索引 子查询或改游标分页自写 SQL 分页结果不对SQL 含 UNION / DISTINCT 等复杂结构改标准 SQL或拆分子查询提前过滤多表 JOIN 后分页数据重复JOIN 结果集有重复行LIMIT 切分错乱先查主表 ID 再关联明细返回 List 而不是 IPage方法签名不符合插件要求Mapper 方法第一个参数必须是 IPage返回值用 IPage分页条件丢失查出了别的数据wrapper条件没有正确传递XML 里使用${ew.customSqlSegment}分页插件和别的拦截器冲突多个插件顺序不对确认MybatisPlusInterceptor只有一个且分页插件在最内层分页时 ORDER BY 没有生效LIMIT 和 ORDER BY 顺序问题检查 SQL 拼接确保 ORDER BY 在分页前6.1 两个容易被忽略的小细节除了上面表格里的内容还有两个细节值得单独拿出来说。第一个是分页总数total的准确性。插件默认会执行 COUNT 查询但如果你传的Page里已经手动设置了total比如你之前自己查过总数插件会尊重你设置的值不会重复执行 COUNT。这个特性有时会带来困扰如果你在循环里复用了同一个Page对象第二次查询时total还是旧值总数就不会更新。解决方法是确保每次查询都new Page()或者先把total重置。第二个是searchCount属性的意义。有些场景下列表接口只需要数据不需要总数比如下拉加载更多。这时你可以设置page.setSearchCount(false)让插件跳过 COUNT 查询。这个优化在高频接口上能省掉一次额外的数据库查询对性能提升很有帮助。6.2 排障顺序和工具心得排障时我的固定思路是这样的先看 SQL 日志确认LIMIT有没有拼上去看到LIMIT但没有生效再往上检查方法签名方法签名没问题再看 SQL 结构是否足够标准结构没问题最后再怀疑插件版本和依赖冲突。SQL 日志我这里多说一句MybatisPlus 打印 SQL 时默认会打印两条一条是执行前的原始 SQL带占位符一条是执行时的参数列表。很多人只看第一条看不到实际执行的 SQL。建议配合p6spy这类工具它能打印出真正发送到数据库的完整 SQL排查分页问题时会方便很多。写在最后分页插件之外多想想业务最后分享一点我个人的体会。分页插件是一个工具它解决的是“把 SQL 分页这件事封装起来”的问题但它解决不了“你的分页需求是否合理”的问题。很多人一遇到分页性能差就怪插件其实真正该做的是审视业务场景是不是真的需要深分页是不是真的允许用户一次取 500 条、1000 条如果一个后台管理列表翻到 100 页的请求占比不足 1%你完全可以通过产品交互来规避这个需求而不是让数据库去硬扛。这几年我用分页插件最大的收获其实是养成了“先理解机制再调整参数先分析场景再选择方案”的思维习惯。maxLimit不是坑深分页也不是病盲目改配置才是。希望我这篇文章能帮到正在被这些问th题困扰的人。