
分页这件事在业务系统里属于“平时没人夸、坏了马上炸”的典型功能。我见过太多项目明明MyBatis Plus都引进去了结果查列表还是手写limit拼参数问就是一句“分页插件不生效啊”。说实话我帮人排查这类问题没有十次也有八次了最后发现绝大多数都不是插件本身坏了而是配置没到位、依赖没引全、或者版本升级踩了坑。这篇就把我从踩坑到填坑的完整过程写出来从原理到配置、从迁移到排查一条龙讲清楚适合刚接触MyBatis Plus的初学者也适合项目里正在被分页问题折磨的运维和开发同学。1. 先搞清楚分页插件失效的三种典型表现分页插件不生效这件事表面症状看着都一样但背后的病根完全不同。先学会“望闻问切”比盲目百度有用得多。我根据实际排查经验把最常见的现象归成三类你可以对照一下自己属于哪种。第一类是查全表。接口返回的数据不按pageSize截断直接把整张表的数据全部丢回来。这种情况通常不是插件白配置了而是查询方法压根没走插件拦截的路径。比如你用了list()或者自定义Mapper方法但方法入参里没有Page对象插件找不到分页参数自然就老老实实帮你把全表捞出来了。第二类是分页参数被当成普通查询条件。比如传入pageNum和pageSize后SQL日志里出现WHERE page_size ?这种写法说明你没用MyBatis Plus的IPage或Page对象接收参数而是自己定义了两个普通字段插件当然不会去识别这种“野生分页参数”。第三类是能分页但count不对。列表数据只有一页但返回的total却是0或者total是全部数据的条数。这类问题最隐蔽它不报错、不崩溃就是数字不对。我之前排查过一个项目用户列表分页一切正常但total永远是0查到最后发现是count查询的SQL被某个自定义拦截器干扰了。记住这三种表现后面排查的时候按图索骥会快非常多。2. 一行配置背后的逻辑拦截器注册方式变迁很多教程会告诉你“加一行Bean配置就行”但没告诉你这一行配置在不同版本里写法完全不一样。我见过最惨的案例是项目从MyBatis Plus 3.4.x升级到3.5.x原有分页突然全部失效原因就是官方把分页拦截器的注册方式改了旧代码不动的话插件压根不会被加载。2.1 老版本PaginationInterceptor时代如果是基于MyBatis Plus 3.4.x及更早版本配置是这么写的Configuration public class MybatisPlusConfig { Bean public PaginationInterceptor paginationInterceptor() { return new PaginationInterceptor(); } }这个PaginationInterceptor是早期的分页拦截器继承自MybatisPlusInterceptor的前身Interceptor体系。特点是简单粗暴一个拦截器包打天下注册进去就能用。但缺点也很明显后续官方需要支持更多插件能力比如乐观锁、防止全表更新等不可能把功能都往这一个拦截器里堆于是才有了新的插件体系。2.2 新版本MybatisPlusInterceptor加PaginationInnerInterceptor从3.4.0开始官方逐步推荐使用新的多插件体系。3.5.1之后PaginationInterceptor已经被标记为过时如果你还在用老写法虽然短期内不报错但升级过程中很容易被“误伤”。现在标准配置是这样Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(DbType.MYSQL); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }注意DbType.MYSQL这个参数很多人会漏掉。它告诉插件当前数据库类型插件才能根据对应方言生成正确的分页SQL。如果你配的是MySQL却用了默认的DbType.OTHER某些场景下生成的limit语法可能不对甚至触发行人limit优化都失效。2.3 为什么官方要改成多插件体系简单类比一下老版本像一个人身兼数职既管分页又管乐观锁还管防全表更新耦合度太高任何一个功能出问题都可能互相影响。新版本像一家公司按岗位分工每个拦截器只干一件事由MybatisPlusInterceptor这个“项目经理”统一调度保证顺序和职责清晰。所以当你升级MyBatis Plus版本后发现分页失效第一反应应该是查一下官方版本升级文档确认拦截器注册API是否变了。我见过很多次所谓的“Bug”其实就是新旧API混用导致的。3. 分页插件的工作原理从SQL改写说起配置写完之后有人可能觉得“这就完了一行配置这么神奇”是的神奇的地方在于MyBatis Plus分页插件本质上是一个SQL改写器它在MyBatis执行SQL之前悄悄把原生SQL换成了带limit的翻版。理解这个过程能帮你避免很多奇怪的问题。3.1 MyBatis的Interceptor机制分页插件在MyBatis里的正式身份是Interceptor即拦截器。MyBatis允许我们在SQL执行的关键节点插入自定义逻辑分页插件拦截的就是Executor的query方法。当Mapper方法被调用时整个执行链路大致是Mapper方法 - SqlSession - Executor - StatementHandler - PreparedStatement而Executor.query是入口之一在这里动手脚最合适。分页插件会检查查询参数里有没有IPage类型的对象有的话就说明“这次要分页”于是开始SQL改写。这一步是自动发生的不需要你改任何Mapper XML或注解SQL。3.2 limit语句是怎么拼接的原生SQL改成limit的过程核心就是字符串解析和重组。以MySQL为例插件拿到原始SQL后会先解析出SELECT后面的字段列表、FROM后面的表、WHERE条件、ORDER BY等结构然后在SQL末尾拼上LIMIT offset, pageSize。比如你写的SQL是SELECT * FROM user WHERE age 18 ORDER BY create_time DESC插件在Page参数存在时会自动改写成SELECT * FROM user WHERE age 18 ORDER BY create_time DESC LIMIT 0, 10但这里有个关键细节如果原始SQL里已经有LIMIT插件就不会再追加了因为MyBatis Plus分页插件不处理“嵌套分页”这种情况。所以你自己手写的Mapper SQL千万不能再带limit否则会冲突。3.3 count查询的自动生成与性能陷阱插件执行分页时会额外生成一条count查询用来计算总条数。默认规则很粗暴——把SELECT后的字段列表替换成COUNT(1)然后去掉ORDER BY保留WHERE条件。这对于大部分场景都是正确的但有两个坑第一当SQL里有GROUP BY时简单的COUNT(1)结果会被误解。插件检测到GROUP BY后会自动改用SELECT COUNT(1) FROM (原始SQL) AS total这种嵌套子查询的方式保证分组统计的正确性。第二如果原始SQL本身特别复杂比如关联了四五张表、有大量子查询count查询也会跟着变复杂性能会明显下降。这时候我建议手动优化count SQL而不是依赖插件默认生成的。3.4 关于“只查一页不查count”的优化插件有个内置优化逻辑当pageSize大于总数据量时它不会执行count查询而是直接把结果当成全部数据。这个逻辑在拦截器内部叫optimizeCountSql默认是开启的。但如果你在配置PaginationInnerInterceptor的时候手贱把构造参数改了这个优化可能会失效。配置代码里能看到setOptimizeJoin等方法建议保持默认开启除非你有非常明确的自定义需求。理解原理之后你会发现自己排查问题时的“手感”完全不一样了——不再对着报错瞎猜而是看一眼运行日志就知道是哪个环节出了问题。4. 实操从手写limit到分页插件的完整迁移理论知识聊完直接进入实操环节。我拿一个典型的用户管理模块举例演示怎么把手写limit的老代码迁到MyBatis Plus分页插件上同时把自定义SQL联表分页也一起搞定。4.1 手写limit的痛点老代码长这样我相信很多项目里现在还躺着类似的写法public ListUserVO queryUserList(int pageNum, int pageSize, String keyword) { int offset (pageNum - 1) * pageSize; return userMapper.selectUserListByPage(offset, pageSize, keyword); }对应的Mapper XMLselect idselectUserListByPage resultTypecom.example.vo.UserVO SELECT id, name, age, create_time FROM user WHERE name LIKE CONCAT(%, #{keyword}, %) ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /select这段代码最大的问题不是“能用”而是“不能复用”。一旦你需要同时返回总条数就得再写一个count查询方法两个SQL必须手动保持一致。后面如果查询条件加了筛选你得同时维护两条SQL漏改一条就是灭顶之灾。我见过一个项目的列表页total和list数据对不上查了三天最后发现是count SQL漏了一个status 1的过滤条件。4.2 改造ServiceImpl的page方法用MyBatis Plus分页插件改造后代码简洁一截public IPageUserVO queryUserList(int pageNum, int pageSize, String keyword) { PageUserVO page new Page(pageNum, pageSize); LambdaQueryWrapperUser wrapper Wrappers.UserlambdaQuery() .like(StringUtils.isNotBlank(keyword), User::getName, keyword) .orderByDesc(User::getCreateTime); return userMapper.selectPage(page, wrapper); }如果是单表查询selectPage就够了分页插件会在内部完成SQL改写和count查询。返回的IPage对象里records是当前页数据total是总条数current是当前页size是每页条数前端需要的分页数据全都在这里面。4.3 自定义SQL联表分页业务里单表查询是少数多表联查才是常态。MyBatis Plus分页插件同样支持自定义SQL关键是把Page对象作为Mapper方法的第一个参数传进去。Mapper接口写法IPageUserVO selectUserRolePage(Page? page, Param(keyword) String keyword);Mapper XML写法select idselectUserRolePage resultTypecom.example.vo.UserVO SELECT u.id, u.name, u.age, r.role_name FROM user u LEFT JOIN user_role ur ON u.id ur.user_id LEFT JOIN role r ON ur.role_id r.id WHERE u.status 1 if testkeyword ! null and keyword ! AND u.name LIKE CONCAT(%, #{keyword}, %) /if ORDER BY u.create_time DESC /select注意这里XML里不需要手写LIMIT插件会自动拼接。调用方式也没什么特别的把Page对象传进去就行PageUserVO page new Page(1, 10); IPageUserVO result userMapper.selectUserRolePage(page, 张三);数据正常返回之后你可以在日志里看到插件自动生成了两条SQL——一条count一条limit。4.4 前端传参与分页建模规范迁移过程中我发现前后端对“页码”的定义经常不一致。有的前端传page1表示第一页有的传offset0表示偏移量。MyBatis Plus的Page构造器里current就是页码从1开始size是每页条数。我建议项目里统一用pageNum和pageSize这两个参数名对接前端既符合MyBatis Plus的习惯也避免了offset换算的麻烦。这里再补充一个小建议分页返回对象建议统一封装成PageResultT结构包含records、total、current、size四个字段前端不管是Vue还是React项目都能直接复用不用每次都重新写解析逻辑。5. 避坑指南高频问题与排查速查下面这些坑都是我实际项目里踩过或者帮人排查过的按出现频率排序建议你收藏起来当速查表用。5.1 total一直为0有段时间我手下的开发小哥跑过来说“分页返回total是0”我过去一看他确实配了拦截器也确实传了Page但问题出在他的Maven依赖只引了mybatis-plus-core没引mybatis-plus-extension。分页插件核心类PaginationInnerInterceptor在扩展包里core里压根没有他那个Bean方法根本编不过去但IDE有时候抽风不报错运行期才炸。注意MyBatis Plus分页插件依赖mybatis-plus-extension模块确保依赖完整最低也要引入mybatis-plus-boot-starter。5.2 数据正常但没走分页这种情况一般是Mapper方法的参数列表里没有IPage类型。插件是根据入参类型判断是否执行改写逻辑的如果方法签名是ListUser selectList(MapString, Object params)里面放pageNum和pageSize插件根本不管。解决办法是改造Mapper方法把Page参数加进去。5.3 多个拦截器顺序不对新版多插件体系里如果同时用了分页、乐观锁、防止全表更新等多个拦截器注册顺序会影响执行效果。比如防全表更新拦截器如果排分页前面执行时发现LIMIT还没拼上整表操作风险大增。建议把分页拦截器放在比较靠前的位置。MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); interceptor.addInnerInterceptor(pagination); // 其他拦截器依次添加 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());5.4 版本兼容的暗坑mybatis-plus、mybatis-spring、spring-boot三者版本要匹配。我用过一个组合是Spring Boot 2.7.x配MyBatis Plus 3.5.3一切正常后来有人升级到Spring Boot 3.0.x没同步升级MyBatis Plus启动直接报错。Spring Boot 3从javax迁移到了jakarta命名空间MyBatis Plus必须有对应的3.5.3版本适配这一关不过所有配置都是白搭。5.5 与逻辑删除的组合坑MyBatis Plus的逻辑删除功能是内置的但逻辑删除SQL的拼接发生在分页SQL改写的哪个阶段不同版本有细微差别。我在3.5.1版本上遇到过一个问题分页数据查出来了但逻辑删除的过滤条件只出现在count SQL里没有出现在limit SQL里结果列表第一页永远包含已删除数据。排查半天最终升级到3.5.2才解决。遇到这类组合问题不要死磕代码先检查版本。5.6 深分页性能问题LIMIT 100000, 10这种深分页查询数据库需要先扫描10万行再丢弃性能会逐渐劣化。这是数据库层面的问题插件本身并不背这个锅。业务里如果确实需要翻上百页考虑用游标分页基于排序字段的值做条件比如WHERE id lastId LIMIT 10或者延迟关联先查主键再回表查详情来优化。MyBatis Plus本身不直接支持游标分页需要自己改造SQL但改造方式很简单就是去掉OFFSET改成基于唯一键的WHERE条件。6. 一个排查实例日志里的蛛丝马迹理论知识再多不如动手查一次。分享一个真实的排查过程你可以跟着走一遍。6.1 打开SQL日志MyBatis Plus的SQL日志打印配置很简单在application.yml里加一行mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl配置好之后访问分页接口控制台会输出完整的SQL执行日志。这是排查分页问题的第一利器效率远超一行行debug。6.2 识别SQL是否被改写看日志的时候重点找两个标志出现LIMIT ?字样说明插件已经生效。出现SELECT COUNT(1) AS total或者类似的count语句说明插件在正常执行总数统计。如果日志里只有原始查询SQL没有任何LIMIT说明插件压根没参与执行问题出在拦截器注册或参数传递上。我之前帮人排查过一个问题控制台日志里能看到LIMIT有人手写进去了但他明明没有配置分页插件。查代码发现是Mapper XML里直接写了万能LIMIT #{pageSize}而且pageSize参数被写死了等于功能是自己用参数拼出来的。这种问题一旦业务改动非常容易踩雷。6.3 常用排查Checklist如果日志都看了还是没定位到问题按下面这个清单逐项打勾基本能覆盖90%的故障[ ] 是否引入了mybatis-plus-boot-starter且项目里没有冲突的mybatis-spring坐标[ ] Spring Boot启动类上是否有MapperScan扫描到Mapper接口所在包[ ] 配置类里的MybatisPlusInterceptor是否被Spring容器加载可以在Bean里加断点确认[ ] 查询方法入参是否包含Page对象[ ] Mapper XML里是否手写了LIMIT与插件自动拼接冲突[ ] 数据库方言类型是否配置正确[ ] MyBatis Plus版本与Spring Boot版本是否兼容我写这个清单的时候特意把“是否手写LIMIT”放在靠前的位置因为这是实际项目里最容易被忽略的一点也是最容易造成双份分页上演“叠加态”事故的源头XML里写一次插件再拼一次最后SQL变成LIMIT 0, 10 LIMIT 0, 10直接报语法错误。6.4 拦截器未加载的终极验证有时候配置类写了但Spring就是没加载一个隐蔽原因是项目里有多个Configuration类类名或包名扫描路径不正确。这时不要犹豫直接在配置类里加一个构造函数的System.out.println启动时看控制台有没有打印。没有打印就说明配置类本身都没被扫到跟分页插件没有半毛钱关系。写在最后的一个小经验分页插件这个问题我前前后后折腾过不少回最大的体会是配置类问题最难的不是“怎么写”而是“为什么这么写”。很多人照着教程复制粘贴版本一换就翻车。我现在的习惯是启动任何新项目第一件事就把MyBatis Plus版本钉死在某个已知稳定版本上并且把分页拦截器的配置单独放到一个配置类里注释写明适配的版本号。另外建议你本地开发环境一定打开SQL日志哪怕分页功能已经稳定了也要偶尔看一眼实际执行的SQL是否符合预期。配置这东西短期看是“一次搞定”长期看是“持续维护”别偷懒分页插件才能一直好好干活。