MyBatis-Plus分页失效全解析:从拦截器原理到六大核心原因排查
1. 项目概述当分页查询“失灵”时我们在排查什么“分页不生效”这个标题乍一看像是个简单的问题但凡是深度使用过 MyBatis-Plus后面简称 MP的开发者几乎都曾在这个看似基础的功能上栽过跟头。它不像一个全新的、复杂的业务功能那样充满挑战反而更像是一个“暗坑”——你以为配置好了代码写对了但一跑起来返回的数据要么是全量要么分页参数对不上让人瞬间怀疑人生。这个问题之所以值得专门总结是因为它涉及到的层面远比想象中多从框架配置、拦截器原理到 SQL 方言适配、甚至是 Service 层和 Mapper 层的调用方式任何一个环节的疏忽都可能导致分页失效。今天我就结合自己这些年踩过的坑和解决过的案例把 MP 分页不生效的各种原因给你掰开揉碎了讲清楚让你下次遇到时能快速定位而不是在百度里大海捞针。简单来说MP 的分页功能并非魔法它依赖于一个名为PaginationInnerInterceptor的分页拦截器。这个拦截器会在你执行查询 SQL 时动态地根据数据库类型MySQL, Oracle, PostgreSQL等改写你的 SQL添加上LIMIT ?, ?或ROWNUM等分页子句。所谓“不生效”本质上就是这个拦截器没有工作或者工作条件不满足。接下来我们就从最外层到最底层一层层揭开这些原因。2. 分页失效的六大核心原因深度解析MP 的分页机制是一个精巧的拦截器链应用其失效往往源于配置、使用或环境上的细微偏差。下面我将最常见的六大原因进行系统性拆解。2.1 配置层面拦截器未正确装配这是最经典、也最容易被忽略的“第一步”。MP 的分页功能不是默认开启的你必须显式地将分页拦截器配置为一个 Spring Bean。错误示范与正确姿势很多新手会在application.yml里找半天分页配置但 MP 的分页是代码配置。常见的错误是忘记配置或者配置类没有被 Spring 扫描到。Configuration public class MybatisPlusConfig { /** * 核心添加分页拦截器 * 不配置这个 Bean分页功能完全不会启动 */ Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 添加分页拦截器这是关键 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 根据你的数据库类型选择 // 还可以添加其他拦截器如乐观锁拦截器 // interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }注意DbType参数非常重要。如果你用的是 Oracle、PostgreSQL 或达梦等数据库必须传入对应的枚举值。传错类型会导致生成错误的分页 SQL 语法从而失效。例如Oracle 的分页是基于ROWNUM的与 MySQL 的LIMIT完全不同。排查技巧检查配置类确保Configuration注解存在且该类位于 Spring Boot 主应用类SpringBootApplication的组件扫描路径下。检查拦截器实例在应用启动后可以通过调试或打印日志查看SqlSessionFactory中的拦截器链是否包含了PaginationInnerInterceptor。数据库类型匹配确认DbType与项目中实际使用的数据库一致。特别是在多数据源场景下需要为每个数据源单独配置拦截器并指定正确的DbType。2.2 调用层面误用或漏用分页参数即使拦截器配置正确如果你在调用查询方法时没有传入分页参数对象拦截器也无从下手。MP 的分页是“按需”触发的。核心规则只有查询方法的第一个参数是Page类型com.baomidou.mybatisplus.extension.plugins.pagination.Page时分页拦截器才会介入并改写 SQL。常见错误场景在 Service 层调用但未使用page方法// 错误直接调用 baseMapper 的 selectList即使有 Page 对象在别处也不会分页 // public ListUser listUsers() { // return userMapper.selectList(null); // 返回全量数据 // } // 正确使用 IService 的 page 方法 public PageUser listUsersByPage(PageUser page) { return userService.page(page); // 调用 service.page(page) // 或者使用带查询条件的 page(page, queryWrapper) }userService.page(page)内部会处理分页逻辑是推荐的使用方式。在 Mapper 层自定义方法参数顺序错误// UserMapper.java 接口 public interface UserMapper extends BaseMapperUser { // 错误Page 对象不是第一个参数 // ListUser selectCustomPage(Param(name) String name, PageUser page); // 正确Page 对象必须是第一个参数 ListUser selectCustomPage(PageUser page, Param(name) String name); }原理MP 拦截器通过方法签名识别。它只认第一个参数。如果你的方法有多个参数务必把Page放在首位。手动构造了 Page 对象但传入了错误的参数// 创建一个分页对象页码为1每页10条 PageUser page new Page(1, 10); // 执行查询 PageUser result userService.page(page); // 正确情况下result 中的 records 是当前页数据total 是总记录数这里要小心Page的构造器new Page(current, size)current是页码从1开始size是每页条数。如果传入0或负数MP 有默认处理逻辑但可能不符合预期。2.3 SQL 层面自定义 SQL 与分页插件的兼容性问题当你使用Select注解或在 XML 中编写自定义 SQL 时分页拦截器需要对这些 SQL 进行解析和改写。这个过程相对复杂容易出问题。XML 中自定义 SQL这是最常用的方式通常配合Page参数工作良好。!-- UserMapper.xml -- select idselectUserPage resultTypeUser SELECT * FROM user WHERE status 1 !-- 这里不需要手动写 LIMIT拦截器会自动添加 -- /select// Mapper 接口 PageUser selectUserPage(PageUser page, Param(status) Integer status);潜在陷阱复杂的 SQL 语法如果你的 SQL 包含非常复杂的子查询、嵌套查询或特定的数据库函数MP 的 SQL 解析器例如 JSqlParser可能无法正确识别FROM和WHERE部分导致改写失败。表现就是 SQL 执行报语法错误或者分页子句被加在了错误的位置。使用${}进行字符串拼接在 MyBatis 中${}是直接拼接字符串存在 SQL 注入风险同时也会干扰 MP 拦截器的 SQL 解析。强烈建议在分页查询中只使用#{}预编译占位符。Select注解方式Select(SELECT * FROM user WHERE name #{name}) ListUser selectByNamePage(PageUser page, Param(name) String name);这种方式同样要求Page是第一个参数。但需要注意过于复杂的 SQL 写在注解里可读性和维护性会变差。实操心得当自定义 SQL 分页失效时开启 MP 的 SQL 日志输出是首要排查手段。查看最终执行的 SQL 语句看LIMIT等分页子句是否被正确添加。如果没添加说明拦截器没生效如果添加了但语法错误可能是 SQL 解析或DbType设置问题。2.4 依赖与版本冲突被忽视的“环境杀手”开发时一切正常一上测试或生产环境就失效很可能遇到了依赖冲突。MyBatis 版本不兼容MP 严重依赖于 MyBatis 的底层 API。如果你项目中的mybatis和mybatis-spring-boot-starter版本与mybatis-plus-boot-starter推荐的版本不匹配可能会导致拦截器注册失败或执行异常。解决方案查看 MP 官方文档的“入门”章节使用其推荐的依赖管理BOM或直接复制官方 Starter 的依赖声明避免手动指定版本。多数据源配置冲突在配置了多数据源如动态数据源的场景下你需要确保每个SqlSessionFactory都注入了正确的MybatisPlusInterceptor。一个常见的错误是只在主数据源配置了拦截器而查询时使用了从数据源导致分页失效。Bean ConfigurationProperties(prefix spring.datasource.dynamic.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } Bean public SqlSessionFactory masterSqlSessionFactory(Qualifier(masterDataSource) DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean sessionFactory new MybatisSqlSessionFactoryBean(); sessionFactory.setDataSource(dataSource); // 关键必须为每个 SqlSessionFactory 设置拦截器 sessionFactory.setPlugins(new Interceptor[]{mybatisPlusInterceptor()}); return sessionFactory.getObject(); }Spring Boot 版本过高或过低虽然不常见但极端版本的 Spring Boot 可能会带来意外的类加载或 Bean 初始化顺序问题影响拦截器的注入。2.5 特定数据库方言的“坑”MP 通过DbType来适配不同数据库的分页语法。但有些数据库或有特殊模式需要额外注意。OracleOracle 的分页语法比较特殊。MP 默认使用ROWNUM进行嵌套查询来实现分页。如果你的 Oracle 版本较老或 SQL 模式特殊可能需要关注生成的 SQL 性能。此外在 Oracle 下查询语句中不能出现;分号。PostgreSQL/Greenplum语法与 MySQL 类似但某些高级特性或扩展可能不被支持。国产数据库如达梦、人大金仓这些数据库大多兼容 MySQL 或 PostgreSQL 语法但仍有细微差别。务必在PaginationInnerInterceptor中指定准确的DbType如DbType.DM对应达梦如果 MP 未内置支持可能需要自定义方言。SQL Server 2005/2008旧版本 SQL Server 使用ROW_NUMBER()分页而新版本支持OFFSET FETCH。MP 能自动处理但同样需要正确设置DbType.SQL_SERVER。2.6 其他隐蔽原因事务方法内部分页查询被“合并”在一个声明式事务方法中如果先执行了一个不分页的查询又执行了一个分页查询在某些极其特殊的场景下与缓存或连接状态相关可能会产生干扰。但这属于非常边缘的情况优先排查上述几点。Wrapper 使用不当使用QueryWrapper或LambdaQueryWrapper时分页功能是正常的。但如果你在 Wrapper 中手动拼接了limit语句例如wrapper.last(limit 10)这会在拦截器改写后的 SQL 末尾追加limit 10导致语法错误或结果混乱。应避免在分页查询的 Wrapper 中使用.last()添加 limit。插件执行顺序如果你自定义了其他 MyBatis 插件拦截器并且其Intercepts注解的type和method与分页拦截器相同都是Executor和query那么插件执行的顺序通过Order或实现Ordered接口可能会产生影响。确保 MP 的核心拦截器顺序合理。3. 系统性的排查流程与实操诊断当分页不生效时不要盲目尝试遵循一个系统的排查流程可以事半功倍。3.1 第一步开启完整 SQL 日志确认“现场”这是诊断的黄金法则。你需要看到最终发送到数据库的 SQL 语句是什么。在application.yml中配置mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印完整SQL包括参数 # 或者使用更优雅的日志框架配置 logging: level: com.your.mapper.package: debug # 将你的Mapper接口所在包级别设为DEBUG执行你的分页查询方法观察控制台日志。场景A日志中根本没有出现LIMIT、OFFSET、ROWNUM等分页关键字。结论分页拦截器完全没有工作。下一步立即跳转到3.2节检查配置和调用方式。场景B日志中出现了分页关键字但SQL语法错误导致数据库报错。结论拦截器工作了但生成的SQL不对。下一步检查DbType配置是否正确2.1节检查自定义SQL是否过于复杂2.3节。3.2 第二步逐层验证调用链从外到内确保分页意图被正确传递。检查 Service 层调用你是否使用了IService.page(Page)方法这是最标准的方式。检查 Mapper 层方法签名如果是自定义 Mapper 方法Page参数是否是第一个且唯一一个Page类型参数检查 Page 对象构造new Page(current, size)中的current是否大于0虽然 MP 内部有处理但传入0可能被视为不分页取决于版本和配置。3.3 第三步检查依赖与配置核对依赖树使用mvn dependency:tree或 Gradle 的依赖分析工具检查是否存在多个不同版本的mybatis或mybatis-spring依赖导致冲突。确保 MP Starter 的版本与 Spring Boot 版本兼容。确认配置类生效在MybatisPlusConfig类中打一个断点或者添加一行日志输出确保 Spring 容器启动时确实加载并实例化了这个配置 Bean。多数据源专项检查如果你的项目使用多数据源请重复2.4节的检查确保每个SqlSessionFactoryBean都通过setPlugins()方法设置了拦截器。3.4 第四步简化与隔离测试如果以上步骤都无法定位就需要进行隔离测试排除干扰。新建一个最简单的测试接口在一个全新的 Controller 方法里直接注入UserService调用userService.page(new Page(1, 5))。不使用任何查询条件。使用单元测试编写一个 SpringBootTest只测试这个分页方法。确保测试环境与主应用共享相同的配置。对比成功与失败案例如果简单测试成功但业务代码失败则用“二分法”逐步将业务代码中的复杂逻辑如复杂的 QueryWrapper、自定义的 ResultHandler、其他拦截器等添加到测试中直到复现问题。4. 高频问题场景与解决方案实录这里记录了几个我实际遇到过的、具有代表性的“坑”及其解决办法。4.1 场景自定义的ResultHandler导致分页total为 0问题描述在 Mapper 方法中为了进行复杂的结果集映射使用了Options注解指定了ResultHandler。分页查询能返回正确的当前页数据 (records)但返回的Page对象中的总记录数 (total) 始终为 0。原因分析MP 计算total的原理是在执行分页查询前会先自动执行一条COUNT(*)语句。当使用自定义ResultHandler时这个处理器可能会干扰到 MP 拦截器对COUNT查询结果的处理流程导致无法正确获取总数。解决方案推荐避免在分页查询中使用ResultHandler尝试通过ResultMap或 XML 中的resultMap来定义复杂的映射关系。如果必须使用需要手动处理总数。可以先执行一次selectCount查询获取总数然后再执行分页查询获取数据最后手动组装Page对象。但这失去了 MP 分页的便利性。// 手动计算总数 QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(status, 1); long total userService.count(wrapper); // 执行分页查询不使用MP的Page参数而是用Wrapper的last方法模拟需谨慎 PageUser page new Page(1, 10); page.setTotal(total); wrapper.last(LIMIT (page.getCurrent() - 1) * page.getSize() , page.getSize()); ListUser records userService.list(wrapper); page.setRecords(records); // 注意此方法破坏了MP的封装且.last(“LIMIT”)在非MySQL数据库上不通用仅作应急参考。4.2 场景在Transactional只读事务中分页查询性能骤降问题描述某个声明为Transactional(readOnly true)的服务方法中进行分页查询时发现响应很慢。日志显示COUNT(*)语句执行时间异常长。原因分析某些数据库驱动或连接池如较旧版本的 MySQL Connector/J在只读事务下对于COUNT(*)这类聚合查询可能会采用不同的执行计划或者因为事务隔离级别、快照等因素导致全表扫描。此外如果表数据量巨大且没有合适的索引COUNT(*)本身就会很慢。解决方案优化COUNT查询为分页查询的WHERE条件字段添加索引。如果业务允许考虑使用估算行数如 MySQL 的SHOW TABLE STATUS或缓存总条数避免每次分页都执行COUNT。调整事务边界评估是否真的需要将分页查询放在一个大的只读事务中。有时可以移除Transactional或者使用PROPAGATION_REQUIRES_NEW开启一个新事务。升级驱动与连接池确保使用的数据库驱动和连接池如 HikariCP是最新稳定版。4.3 场景多表联查分页total数量不对问题描述一个涉及LEFT JOIN多张表的复杂分页查询返回的数据条数正确但total总记录数远大于预期。原因分析这是 MP 分页的一个经典局限。MP 自动生成的COUNT语句默认是对原始查询语句进行智能改写将其变为SELECT COUNT(*) FROM (你的原始查询语句) tmp。对于简单的单表查询这没问题。但对于复杂的多表JOIN这个子查询效率很低而且如果JOIN导致行数膨胀一对多COUNT的结果会是笛卡尔积的数量而不是主表的唯一记录数。解决方案MP 的Page对象提供了setSearchCount(false)方法来禁用自动COUNT查询。PageUserVO page new Page(1, 10); page.setSearchCount(false); // 关键告诉MP不要自动查总数 PageUserVO resultPage userMapper.selectComplexPage(page, queryParam); // 此时 resultPage.getTotal() 为 0 long correctTotal manuallyCountComplexQuery(queryParam); // 自己编写一个精确的COUNT查询 resultPage.setTotal(correctTotal);你需要自己手动编写一个高效的、能准确统计主表记录数的COUNT查询方法。这通常意味着要重写COUNT语句可能要去掉不必要的JOIN或者使用DISTINCT、子查询等。4.4 场景升级 MP 版本后原有分页代码报错问题描述项目从 MP 3.x 升级到 4.x 或更高版本后之前运行良好的分页代码开始报ClassNotFoundException或NoSuchMethodError。原因分析MP 大版本间可能存在 API 不兼容的改动。例如Page类的构造器、PaginationInnerInterceptor的初始化方式等可能发生了变化。解决方案仔细阅读官方升级指南MP 团队通常会在 GitHub 的 Release Notes 或 Wiki 中提供详细的升级说明列出破坏性变更。常见变更点new Page(current, size)构造函数参数顺序或含义。PaginationInnerInterceptor的构造参数从DbType枚举变为IDialect接口实现。分页配置从旧版的PaginationInterceptor改为新版的MybatisPlusInterceptor内部拦截器模式。逐步替换按照指南逐一修改配置类和所有使用分页的代码。建议先在一个独立分支或测试环境中进行。5. 最佳实践与配置优化建议根据上述排查经验和常见问题我总结出以下几条最佳实践可以有效预防和规避大部分分页问题。统一使用IService.page()方法在 Service 层业务代码中尽量使用IService接口提供的page(Page)或page(Page, Wrapper)方法。这保证了调用方式的规范性和一致性减少了在 Mapper 层处理分页参数的复杂度。显式配置并指定DbType无论项目大小都在配置类中显式声明MybatisPlusInterceptor并添加PaginationInnerInterceptor并且务必传入正确的DbType枚举值。不要依赖任何可能不存在的默认配置。为分页查询字段添加索引这是数据库性能的通用准则。WHERE条件中的字段、ORDER BY中的字段都应该考虑添加合适的索引以加速数据定位和排序这对COUNT查询同样重要。复杂查询手动处理COUNT对于涉及多表JOIN、复杂GROUP BY或窗口函数的查询提前规划使用page.setSearchCount(false)禁用自动计数并编写优化的、业务含义准确的COUNT查询。这能从根本上解决总数不准和性能瓶颈。保持依赖整洁使用 Spring Boot 的dependency-management或 MP 官方提供的 BOM 来管理mybatis-plus-boot-starter及其相关依赖的版本避免引入不兼容的 MyBatis 版本。编写分页单元测试为核心的分页查询方法编写单元测试验证在不同页码、页大小、查询条件下返回的数据条数、总数以及排序是否正确。这是保证分页功能长期稳定的有效手段。监控与日志在生产环境中对慢 SQL 进行监控。如果发现分页查询特别是COUNT语句成为瓶颈结合第4点进行优化。在调试阶段善用 SQL 日志功能。分页功能是后端开发中最常用也最易错的功能点之一。MP 通过拦截器机制极大地简化了开发但其背后的原理和依赖条件需要我们了然于胸。记住当分页“失灵”时从 SQL 日志入手沿着“配置 - 调用 - SQL - 环境”的路径系统性排查绝大多数问题都能迎刃而解。希望这份总结能成为你下次排查时的有效备忘录。