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 List<User> listUsers() { // return userMapper.selectList(null); // 返回全量数据 // } // 正确:使用 IService 的 page 方法 public Page<User> listUsersByPage(Page<User> page) { return userService.page(page); // 调用 service.page(page) // 或者使用带查询条件的 page(page, queryWrapper) }userService.page(page)内部会处理分页逻辑,是推荐的使用方式。在 Mapper 层自定义方法,参数顺序错误:
// UserMapper.java 接口 public interface UserMapper extends BaseMapper<User> { // 错误:Page 对象不是第一个参数 // List<User> selectCustomPage(@Param("name") String name, Page<User> page); // 正确:Page 对象必须是第一个参数 List<User> selectCustomPage(Page<User> page, @Param("name") String name); }原理:MP 拦截器通过方法签名识别。它只认第一个参数。如果你的方法有多个参数,务必把
Page放在首位。手动构造了 Page 对象,但传入了错误的参数:
// 创建一个分页对象,页码为1,每页10条 Page<User> page = new Page<>(1, 10); // 执行查询 Page<User> 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 id="selectUserPage" resultType="User"> SELECT * FROM user WHERE status = 1 <!-- 这里不需要手动写 LIMIT,拦截器会自动添加 --> </select>// Mapper 接口 Page<User> selectUserPage(Page<User> 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}") List<User> selectByNamePage(Page<User> 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来适配不同数据库的分页语法。但有些数据库或有特殊模式,需要额外注意。
- Oracle:Oracle 的分页语法比较特殊。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 分页的便利性。// 手动计算总数 QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq("status", 1); long total = userService.count(wrapper); // 执行分页查询(不使用MP的Page参数,而是用Wrapper的last方法模拟,需谨慎) Page<User> page = new Page<>(1, 10); page.setTotal(total); wrapper.last("LIMIT " + (page.getCurrent() - 1) * page.getSize() + "," + page.getSize()); List<User> 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查询。
Page<UserVO> page = new Page<>(1, 10); page.setSearchCount(false); // 关键:告诉MP不要自动查总数 Page<UserVO> 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 -> 环境”的路径系统性排查,绝大多数问题都能迎刃而解。希望这份总结能成为你下次排查时的有效备忘录。