一、核心原理

  1. 基于 MyBatis 拦截器 (Interceptor):

    • MP 的分页功能本质上是通过实现 MyBatis 的 Interceptor 接口实现的。
    • 这个拦截器会拦截即将被执行的 SQL 语句(具体是拦截 Executorquery 方法)。
  2. ThreadLocal 存储分页参数:

    • 当调用 Page 对象进行分页查询时(如 baseMapper.selectPage(page, queryWrapper)),MP 会将当前的 Page 对象(包含 current, size, total 等信息)存储在 ThreadLocal 变量中。
    • ThreadLocal 确保了每个线程(每个请求)的分页参数是独立的,避免了并发问题。
  3. SQL 解析与改写:

    • 拦截器从 ThreadLocal 中获取到当前线程的 Page 对象。
    • 拦截器会分析被拦截的原始 SQL 语句:
      • 如果是 SELECT 语句,并且 Page 对象存在且需要分页(通常是 size > 0),则进行改写。
      • 自动识别数据库类型(通过配置的 DbType 或自动检测),生成对应数据库的分页 SQL
      • 目标 SQL: 将原始查询语句改写成获取指定偏移量(offset)和数量(limit) 数据的语句。
        • 例如 MySQL: SELECT ... FROM ... WHERE ... LIMIT offset, size
        • 例如 Oracle: 使用嵌套 ROWNUMOFFSET ... FETCH ... (12c+)
        • 例如 PostgreSQL: SELECT ... FROM ... WHERE ... LIMIT size OFFSET offset
    • 同时,拦截器还会自动生成一个用于计算总记录数(total)的 COUNT 语句
      • 通常是将原始 SELECT 语句中的字段列表替换成 COUNT(1)COUNT(*),并去掉 ORDER BY 子句(排序对计数无影响)。例如:SELECT COUNT(1) FROM ... WHERE ...
  4. 执行查询:

    • 拦截器首先执行自动生成的 COUNT 语句,获取符合条件的总记录数 total,并将其设置回 Page 对象。
    • 然后执行改写后的分页 SQL 语句,获取当前页的数据列表 records
    • 将查询到的数据列表 records 也设置回 Page 对象。
  5. 清理 ThreadLocal:

    • 在分页查询逻辑执行完成后(无论成功或失败),拦截器或相关的清理机制会清除当前线程 ThreadLocal 中的 Page 对象,防止内存泄漏和后续非分页查询被错误影响。
  6. 返回结果:

    • 方法最终返回的是填充了 records(当前页数据列表)和 total(总记录数)以及其他分页信息(current, size, pages 总页数等)的 Page<T> 对象。这个对象包含了所有前端分页组件需要的信息。

总结原理流程: 用户调用分页方法 (selectPage) -> MP 将分页参数 Page 存入 ThreadLocal -> MyBatis 执行器准备执行 SQL -> 分页拦截器介入 -> 从 ThreadLocalPage -> 生成 COUNT SQL 执行获取 total -> 改写原始 SQL 为分页 SQL -> 执行分页 SQL 获取 records -> 将 totalrecords 设置回 Page 对象 -> 清理 ThreadLocal -> 返回填充好的 Page 对象。

二、详细操作步骤 (Spring Boot 环境为例)

  1. 引入依赖 (Maven):

    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>最新版本</version> <!-- 例如 3.5.3.1 -->
    </dependency>
    
  2. 配置分页插件 (关键步骤!): 在 Spring Boot 的配置类中 (通常是 @SpringBootApplication 主类或一个单独的 @Configuration 类) 定义一个 PaginationInnerInterceptor Bean。

    import com.baomidou.mybatisplus.annotation.DbType;
    import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
    import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class MybatisPlusConfig {
    
        @Bean
        public MybatisPlusInterceptor mybatisPlusInterceptor() {
            MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
            // 添加分页插件
            PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor();
            paginationInterceptor.setDbType(DbType.MYSQL); // 根据实际数据库类型设置,可省略(MP通常能自动识别)
            paginationInterceptor.setOverflow(true); // 设置请求的页面大于最大页后操作
                         // true调回到首页,false继续请求。默认false。生产环境建议true。
            interceptor.addInnerInterceptor(paginationInterceptor);
            return interceptor;
        }
    }
    
    • DbType: 明确指定数据库类型(如 MYSQL, ORACLE, POSTGRE_SQL, SQL_SERVER, H2 等)。如果省略,MP 会尝试根据连接自动检测,但在某些环境(如多数据源)下明确指定更可靠。
    • setOverflow(true/false): 处理页码超出范围的情况。true 表示请求页码 > totalPage 时,自动查询第一页(返回首页数据);false 表示继续查询该超大页码(通常返回空列表)。推荐设置为 true
  3. 使用分页查询: 在 Service 或 Controller 层使用 Page 对象和 Mapper 的分页方法。

    import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
    import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    
    @Service
    public class UserServiceImpl implements UserService {
    
        @Autowired
        private UserMapper userMapper; // 你的 Mapper 接口
    
        public Page<User> getUsersByPage(int currentPage, int pageSize, String searchName) {
            // 1. 创建分页对象 Page<T>
            // 参数1:当前页码 (从1开始)
            // 参数2:每页显示条数
            Page<User> page = new Page<>(currentPage, pageSize);
    
            // 2. 创建查询条件 (可选)
            QueryWrapper<User> queryWrapper = new QueryWrapper<>();
            if (searchName != null && !searchName.trim().isEmpty()) {
                queryWrapper.like("name", searchName); // 示例:按名字模糊查询
            }
            // 可以添加其他查询条件:eq, ne, gt, ge, lt, le, between, orderBy...
    
            // 3. 执行分页查询 (核心)
            // 使用 Mapper 的 selectPage(Page<T> page, @Param(Constants.WRAPPER) Wrapper<T> queryWrapper) 方法
            Page<User> resultPage = userMapper.selectPage(page, queryWrapper);
    
            // 4. 返回结果 (resultPage 已包含数据 records 和分页信息 total, pages 等)
            return resultPage;
        }
    }
    
    • Page<T> page = new Page<>(current, size);: 创建分页请求对象,指定当前页码(current 从 1 开始)和每页大小。
    • 构建查询条件 QueryWrapper: 定义需要查询的数据范围。这是可选的,如果没有条件,传 null 即可。
    • userMapper.selectPage(page, queryWrapper);: 调用 Mapper 继承自 BaseMapperselectPage 方法执行分页查询。这是最核心的一步。
    • 返回值 Page<User> resultPage: 该方法执行后,传入的 page 对象会被填充:
      • resultPage.getRecords(): 当前页的数据列表 (List<User>).
      • resultPage.getTotal(): 符合条件的所有记录的总数 (不是当前页的数量)。
      • resultPage.getSize(): 每页大小 (与你传入的 size 一致)。
      • resultPage.getCurrent(): 当前页码 (与你传入的 current 一致)。
      • resultPage.getPages(): 总页数 (根据 totalsize 计算得出)。
  4. 前端使用:Page<User> 对象返回给前端(通常通过 JSON)。前端框架(如 Vue, React)的分页组件可以利用 current, size, total, pages, records 这些属性来渲染分页UI和数据列表。

三、常见错误

  1. 分页完全不生效(返回所有数据):

    • 原因 99%: 没有在 Spring 配置中注册 PaginationInnerInterceptor 插件! 检查你的 @Configuration 类中的 MybatisPlusInterceptor Bean 是否添加了 PaginationInnerInterceptor
    • 其他原因:传入的 Page 对象的 size 值 <= 0。MP 默认 size <= 0 时不会进行分页,返回所有数据。
  2. total (总记录数) 为 0 或错误:

    • 复杂 SQL / 自定义 SQL: 如果查询使用了 joinunion、复杂的子查询或者你在 Mapper XML 中自定义了 SQL (@Select 或 XML 中的 <select>),MP 自动生成的 COUNT 语句可能不正确(语法错误或语义错误),导致 total 查询失败或结果错误。
    • 解决方案: 为该方法提供自定义的 COUNT 查询语句。在 Mapper 接口中:
      @Select("SELECT * FROM user ${ew.customSqlSegment}") // 你的复杂查询
      @Select("SELECT COUNT(*) FROM user ${ew.customSqlSegment}") // 对应的COUNT查询
      Page<User> selectMyComplexPage(@Param("page") Page<User> page, @Param(Constants.WRAPPER) QueryWrapper<User> wrapper);
      
      或者在 XML 中定义两个 <select> 语句,id 分别为 selectMyComplexPageselectMyComplexPage_COUNT (MP 约定后缀 _COUNT)。_COUNT 方法的返回类型是 LongInteger
    • DbType 设置错误: 导致生成的 COUNT 或分页 SQL 语法错误。
    • 查询条件 WrapperCOUNT 查询中未生效: 确保你的 Wrapper 条件在 COUNT 查询中也正确应用了。MP 默认会传递相同的 Wrapper
  3. Page 对象中的 records 为空,但 total 大于 0:

    • 页码超出范围: 当前页码 current 大于总页数 pages。检查你的页码计算逻辑。注意 setOverflow 的设置:如果设置为 false 且请求超大页码,MP 会尝试查询,但数据库没有数据,所以 records 为空;如果设置为 true,MP 会自动将页码置为 1 并返回第一页数据。
  4. 性能问题:

    • COUNT 查询慢: 当数据量极大 (total 很大) 且查询条件复杂时,COUNT 语句可能执行很慢。
    • 深度分页慢: 使用 LIMIT offset, size 方式的分页(如 MySQL),当 offset 非常大时(如第 10000 页),数据库需要扫描和跳过大量记录,性能急剧下降。
  5. 内存泄漏 (罕见):

    • 理论上,如果 ThreadLocal 中的 Page 对象没有被正确清理,在长时间运行的线程(如线程池中的线程)复用时就可能造成内存泄漏。MP 插件内部通常有清理机制,但极端情况或自定义插件不当可能引发。遵循标准配置和使用一般不会有问题。

四、注意事项

  1. current 从 1 开始: 这是 MP 分页的约定,传入 0 会被当作 1 处理。
  2. 必须配置插件: 这是分页功能生效的前提,务必确保 PaginationInnerInterceptor 被添加到 MybatisPlusInterceptor 中并注册为 Spring Bean。
  3. DbType 设置: 明确指定数据库类型能提高兼容性和稳定性,尤其是在多数据源或自动检测不可靠的环境中。
  4. 复杂 SQL 与 COUNT 查询: 对于包含多表连接 (JOIN)、GROUP BYUNION 或复杂子查询的 SQL,强烈建议提供自定义的、高效的 COUNT 查询语句。不要依赖 MP 自动生成,它很可能出错或效率低下。
  5. Wrapper 条件一致性: 确保用于数据查询 (selectPage) 的 Wrapper 条件与用于 COUNT 查询的条件是一致的,否则 total 将不准确。MP 默认会将同一个 Wrapper 应用于两者。
  6. Page 对象的作用域: Page 对象通常应在单个分页查询请求的方法内创建和使用。避免将其长期存储或跨请求/线程传递。
  7. 深度分页问题: 认识到 LIMIT offset, size 在超大 offset 时的性能瓶颈。对于深度分页需求,考虑其他方案(见性能优化)。
  8. COUNT 查询需求: 如果前端只需要当前页数据,不需要知道总记录数和总页数(例如移动端上拉加载更多,只关心“是否有下一页”),可以使用 Page 的特殊构造函数 Page(long current, long size, boolean isSearchCount)。将 isSearchCount 设为 false 可以跳过 COUNT 查询,大幅提升性能。MP 3.5+ 更推荐使用 Page 的无 count 模式构造方法 Page(long current, long size) 结合特定 Mapper 方法(如 selectPage(Page, Wrapper) 默认还是会查 count,需要使用 selectList 配合手动设置 Pagerecords,或者使用 selectPageWithoutCount 这类扩展方法 - 需自定义或关注 MP 更新)。更直接的方式是在 3.5+ 中:
    Page<User> page = new Page<>(current, size, false); // 第三个参数 false 表示不执行 count 查询
    userMapper.selectPage(page, queryWrapper); // 注意:此时 page.getTotal() 为 0, page.getPages() 为 0
    // 只有 page.getRecords() 有数据。你需要自己判断是否有下一页?(通常根据返回的 records.size() < pageSize 判断是最后一页)
    

五、使用技巧

  1. 自定义 COUNT 语句: 如前所述,对于复杂查询,在 Mapper 中定义对应的 xxx_COUNT 方法或使用 @Select 注解明确提供高效准确的 COUNT SQL。
  2. 链式调用: QueryWrapper 支持链式调用构建条件,使代码更简洁。
    queryWrapper.like("name", "张").eq("status", 1).orderByDesc("create_time");
    
  3. 只返回特定字段:selectPage 前,使用 queryWrapper.select("id", "name", "email") 指定只查询需要的字段,减少数据传输量,提高效率。
  4. 利用 Pageconvert 方法: 如果返回给前端的对象需要转换(如 User -> UserVO),可以在获取 Page<User> 后使用:
    Page<UserVO> userVoPage = resultPage.convert(user -> {
        UserVO vo = new UserVO();
        // ... 转换逻辑 user -> vo ...
        return vo;
    });
    return userVoPage;
    
  5. 判断是否有上一页/下一页: 利用 Page 的属性:
    • hasPrevious(): current > 1
    • hasNext(): current < pages (注意:如果使用了 isSearchCount=false 或者 setOverflow 等,这些方法可能不准确,需自行根据数据判断,如 records.size() == size 则认为可能有下一页)。

六、最佳实践与性能优化

  1. 避免不必要的 COUNT 查询:

    • 前端场景: 如上拉加载更多(无限滚动),通常只需要知道“是否有下一页”,不需要精确的总记录数和总页数。使用 Page(long current, long size, boolean isSearchCount) 并将 isSearchCount 设为 false (MP 3.5+ 更推荐无 count 模式)。 在业务层判断:如果返回的 records.size() < size,说明是最后一页。
    • 缓存 COUNT 结果: 如果数据变化不频繁,且精确 total 是必须的,考虑将 COUNT 结果(或计算 total 的关键信息)进行缓存(如 Redis),避免每次分页都执行昂贵的 COUNT 查询。注意缓存更新策略(数据变更时失效缓存)。
  2. 优化 COUNT 查询:

    • 自定义高效 COUNT 语句: 对于复杂查询,编写优化的 COUNT 语句。避免使用 SELECT COUNT(*) 扫描所有行,尝试使用覆盖索引或更精确的计数方式。
    • 近似计数: 如果业务可以接受近似值(如大型列表的概览),一些数据库提供了快速近似行数的方法(如 MySQL 的 SHOW TABLE STATUSEXPLAIN SELECT ...rows 列,但都不精确)。InnoDB 的 COUNT(*) 本身需要扫描索引,大数据量下可能较慢。
  3. 解决深度分页性能问题:

    • WHERE + id > last_id + ORDER BY id LIMIT 适用于主键或唯一索引有序的情况。记录上一页最后一条记录的 ID (last_id),下一页查询改为 WHERE id > last_id ORDER BY id LIMIT size。性能极佳(O(1)),但不支持跳页,只适合“加载更多”场景。MP 本身不直接支持此模式,需要手动实现。
    • 游标分页 (Cursor): 类似 WHERE id > last_id 的思想,数据库提供游标机制(如基于 keyset)。MP 对游标分页的支持有限,可能需要自定义或使用其他库。适合大数据量连续遍历。
    • 业务层限制: 产品设计上限制可查询的最大页码或深度。
    • 搜索引擎/NoSQL: 对于海量数据分页,考虑将数据导入 Elasticsearch 等搜索引擎,它们对深度分页有更好的支持(但也有限制)。
  4. 索引优化:

    • 确保分页查询涉及的 WHERE 条件字段、ORDER BY 字段以及连接字段都建立了合适的索引。这是提升分页查询性能(尤其是带条件的)的基础。
  5. 返回 VO/DTO:

    • 不要直接将包含敏感信息或过多字段的 Entity 对象 (User) 直接通过 Page 返回给前端。在 Service 层将 Page<Entity> 转换为 Page<VO/DTO> 再返回(使用 convert 方法或手动转换),只暴露必要的字段。
  6. 监控与分析:

    • 对慢分页查询进行监控。分析执行计划 (EXPLAIN),确认 COUNT 语句和分页数据查询语句是否高效利用了索引。

总结:

MyBatis-Plus 的分页插件通过拦截器 + ThreadLocal + SQL 改写的机制,极大地简化了分页开发。正确配置插件是前提。使用时需注意 current 从 1 开始复杂 SQL 要自定义 COUNT 语句。性能优化的核心在于 避免不必要的 COUNT 查询优化 COUNT 查询本身 以及 解决深度分页问题(考虑替代方案)。遵循最佳实践(索引、VO转换、监控)能确保分页功能高效稳定。理解原理有助于快速定位和解决分页过程中遇到的各种问题。