MyBatis-Plus 的分页功能是其核心特性之一,通过 IPage 接口和分页插件 (PaginationInnerInterceptor) 的结合,实现了简单、高效、数据库友好的分页查询。它能自动处理不同数据库的分页语法(如 LIMIT, ROWNUM, ROW_NUMBER())。

1. 核心概念

  • 目的:提供一种统一、便捷的方式来执行数据库分页查询,获取分页数据及分页元信息(总记录数、当前页、页大小等)。
  • 核心组件
    • IPage<T>分页参数和结果的载体接口。它既接收分页请求参数(当前页、页大小),也承载分页查询结果(记录列表、总记录数等)。
    • Page<T>IPage<T>标准实现类。开发者通常直接使用 Page 对象。
    • PaginationInnerInterceptor分页插件。这是 MyBatis-Plus 分页功能的核心驱动。它是一个 MyBatis 的 Interceptor,在 SQL 执行前拦截,根据 IPage 参数自动重写 SQL 语句(添加 LIMIT/OFFSET 或数据库特定的分页语法),并执行一个额外的 COUNT 查询来获取总记录数。
    • page 方法:BaseMapper 中定义的用于执行分页查询的方法,如 IPage<T> selectPage(IPage<T> page, Wrapper<T> queryWrapper)
  • 工作流程
    1. 创建 Page<T> 对象,设置 current (当前页) 和 size (每页大小)。
    2. 构建 QueryWrapper 定义查询条件。
    3. 调用 BaseMapperselectPage(Page<T>, QueryWrapper) 方法。
    4. PaginationInnerInterceptor 拦截该请求。
    5. 插件先执行一个 COUNT(*) 查询(基于原始查询条件)获取总记录数 total
    6. 插件根据 total, current, size 计算 pages (总页数) 和 offset (偏移量)。
    7. 插件重写原始查询 SQL,添加分页子句(如 LIMIT size OFFSET offset)。
    8. 执行重写后的 SQL,获取当前页的数据列表 records
    9. total, pages, current, size, records 等信息填充到传入的 Page<T> 对象中并返回。
  • 优势
    • 简单易用:API 简洁,几行代码即可实现分页。
    • 数据库无关:自动适配多种数据库的分页语法。
    • 功能完整:返回丰富的分页信息(总记录数、总页数、是否有上/下一页等)。
    • 性能可控:通过 searchCount 参数可关闭 COUNT 查询(适用于总记录数不重要或数据量巨大时)。

2. 操作步骤 (非常详细)

步骤 1: 配置分页插件 (PaginationInnerInterceptor)

这是必须的一步!没有配置插件,分页功能无法工作。

2.1 创建 MyBatis-Plus 配置类

// MyBatisPlusConfig.java
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 {

    /**
     * 配置 MyBatis-Plus 拦截器
     */
    @Bean
    public MyBatisPlusInterceptor myBatisPlusInterceptor() {
        MyBatisPlusInterceptor interceptor = new MyBatisPlusInterceptor();
        
        // 创建分页插件
        PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
        
        // 设置数据库类型 (可选,但推荐指定,避免自动检测出错)
        // DbType.H2, DbType.MYSQL, DbType.MARIADB, DbType.ORACLE, DbType.POSTGRE_SQL, DbType.SQL_SERVER 等
        paginationInnerInterceptor.setDbType(DbType.MYSQL); 
        
        // 【重要】设置单页最大限制数量,默认无限制 (设置为 -1 或 null 表示无限制)
        // 防止恶意请求(如 size=999999)
        paginationInnerInterceptor.setMaxLimit(500L); 
        
        // 【重要】是否开启 count 的 join 优化, 只针对部分 left join
        // 开启后,对于 left join 查询,count 查询可能会更高效,但需确保逻辑正确
        // paginationInnerInterceptor.setOptimizeJoin(true); // 根据需要开启
        
        // 将分页插件添加到拦截器链
        interceptor.addInnerInterceptor(paginationInnerInterceptor);
        
        return interceptor;
    }
}

注意:Spring Boot 项目中,此配置类需被 Spring 扫描到(通常放在主应用类同包或子包下)。

步骤 2: 准备实体类和 Mapper

确保实体类和 Mapper 接口已正确配置。

// User.java
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;

@Data
@TableName("user")
public class User {
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @TableField("name")
    private String name;
    @TableField("age")
    private Integer age;
    @TableField("email")
    private String email;
    @TableField("status")
    private Integer status;
    @TableField("create_time")
    private LocalDateTime createTime;
    // ... 其他字段
}
// UserMapper.java
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserMapper extends BaseMapper<User> {
    // 继承了 BaseMapper,自动拥有 selectPage 方法
}

步骤 3: 在 Service 或 Controller 中使用 IPage/Page

这是执行分页查询的主要步骤。

3.1 基本分页查询

// UserService.java
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    /**
     * 基本分页查询所有用户
     * @param currentPage 当前页码 (从 1 开始)
     * @param pageSize 每页大小
     * @return 包含分页数据的 IPage 对象
     */
    public IPage<User> getUserPage(int currentPage, int pageSize) {
        // 1. 创建 Page 对象,设置分页参数
        // Page<User> 是 IPage<User> 的实现
        Page<User> page = new Page<>(currentPage, pageSize);
        // 等价于:
        // Page<User> page = new Page<>();
        // page.setCurrent(currentPage);
        // page.setSize(pageSize);

        // 2. (可选) 创建 QueryWrapper 定义查询条件
        // 如果需要查询条件,例如只查状态为1的用户
        QueryWrapper<User> wrapper = new QueryWrapper<>();
        wrapper.eq("status", 1); // WHERE status = 1
        // 可以添加更多条件...

        // 3. 执行分页查询
        // selectPage 方法会自动触发 PaginationInnerInterceptor
        IPage<User> userPage = userMapper.selectPage(page, wrapper);
        // 注意:传入的 'page' 对象会被插件修改并填充结果,也可以直接使用返回值

        return userPage; // 返回包含 records, total, pages 等信息的对象
    }
}

3.2 在 Controller 中调用并返回结果

// UserController.java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/users")
    public R<IPage<User>> getUsers(
            @RequestParam(defaultValue = "1") int current,
            @RequestParam(defaultValue = "10") int size) {
        
        IPage<User> userPage = userService.getUserPage(current, size);
        
        // 假设 R 是一个通用的响应包装类
        return R.success(userPage);
    }
}

步骤 4: 理解和使用 IPage 返回结果

IPage 对象包含了丰富的分页信息。

// 在 Service 或 Controller 中处理返回的 IPage
IPage<User> userPage = userService.getUserPage(1, 10);

// 获取核心数据
List<User> records = userPage.getRecords(); // 当前页的数据列表
long total = userPage.getTotal();          // 总记录数
long pages = userPage.getPages();          // 总页数 (根据 total 和 size 计算)
long current = userPage.getCurrent();      // 当前页码
long size = userPage.getSize();            // 每页大小

// 获取辅助信息 (非常有用)
boolean hasPrevious = userPage.hasPrevious(); // 是否有上一页
boolean hasNext = userPage.hasNext();         // 是否有下一页
boolean isFirst = userPage.isFirst();         // 是否是第一页
boolean isLast = userPage.isLast();           // 是否是最后一页
// long currentSize = userPage.getCurrent();  // 当前页实际返回的记录数 (可能小于 size,如果是最后一页)

// 在前端或 API 响应中,通常将这些信息一并返回
Map<String, Object> response = new HashMap<>();
response.put("records", records);
response.put("total", total);
response.put("pages", pages);
response.put("current", current);
response.put("size", size);
response.put("hasPrevious", hasPrevious);
response.put("hasNext", hasNext);
// ... 

步骤 5: 高级用法

5.1 关闭 COUNT 查询

当总记录数不重要或数据量极大时,可以关闭 COUNT 查询以提升性能。

public IPage<User> getUserPageWithoutCount(int currentPage, int pageSize) {
    Page<User> page = new Page<>(currentPage, pageSize);
    
    // 关闭 count 查询
    page.setSearchCount(false);
    
    QueryWrapper<User> wrapper = new QueryWrapper<>();
    wrapper.eq("status", 1);
    
    IPage<User> userPage = userMapper.selectPage(page, wrapper);
    
    // 此时 userPage.getTotal() 将为 0L, userPage.getPages() 也将为 0L
    // 但 userPage.getRecords() 仍然包含当前页数据
    // 通常用于"加载更多"场景,前端根据是否有数据判断是否还有下一页
    return userPage;
}

5.2 自定义 IPage 实现 (较少用)

如果 Page 类不能满足需求,可以实现 IPage<T> 接口。

// MyCustomPage.java
import com.baomidou.mybatisplus.core.metadata.IPage;
import java.io.Serializable;
import java.util.List;

public class MyCustomPage<T> implements IPage<T>, Serializable {
    private long current;
    private long size;
    private long total;
    private List<T> records;
    private String customField; // 添加自定义字段

    // 实现 IPage 接口的所有 getter/setter 方法...
    // ... (省略具体实现)

    // 自定义 getter/setter
    public String getCustomField() { return customField; }
    public void setCustomField(String customField) { this.customField = customField; }

    // 必须重写 getTotal() 和 getRecords() 等方法
    @Override
    public long getTotal() { return total; }
    @Override
    public void setTotal(long total) { this.total = total; }
    @Override
    public List<T> getRecords() { return records; }
    @Override
    public void setRecords(List<T> records) { this.records = records; }
    @Override
    public long getCurrent() { return current; }
    @Override
    public void setCurrent(long current) { this.current = current; }
    @Override
    public long getSize() { return size; }
    @Override
    public void setSize(long size) { this.size = size; }
    // ... 其他方法如 hasNext(), hasPrevious() 等也需要实现
}

// 使用 (不常见)
MyCustomPage<User> customPage = new MyCustomPage<>();
customPage.setCurrent(1);
customPage.setSize(10);
customPage.setCustomField("someValue");

IPage<User> result = userMapper.selectPage(customPage, wrapper);
// result 实际上是 customPage 对象(被插件修改了)

3. 常见错误

  1. 忘记配置 PaginationInnerInterceptor

    • 表现:调用 selectPage 但返回的 IPage 对象中 total=0, pages=0,且 records 包含了所有数据(未分页)。
    • 原因:没有分页插件,selectPage 退化为 selectList
    • 解决:确保在配置类中正确注册了 PaginationInnerInterceptor 并添加到 MyBatisPlusInterceptor
  2. 分页参数错误

    • 错误Page<User> page = new Page<>(0, 10); (页码从 0 开始)。
    • 后果:MyBatis-Plus 通常期望页码从 1 开始。current=0 可能导致计算错误或查询第 0 页(通常为空)。
    • 解决:确保 current 参数从 1 开始。前端传参时注意转换。
  3. maxLimit 限制导致查询失败

    • 表现:当 pageSize 超过 maxLimit 设置的值时,查询可能被限制或抛出异常(取决于插件版本和配置)。
    • 解决:检查 PaginationInnerInterceptormaxLimit 配置,确保它符合业务需求,或在必要时调整/移除限制(但需防范恶意请求)。
  4. COUNT 查询性能问题

    • 表现:分页查询很慢,即使只取第一页。
    • 原因COUNT(*) 查询本身很慢,尤其当查询涉及复杂 JOIN 或大表时。
    • 解决
      • 优化 COUNT 查询的 SQL(确保有合适的索引)。
      • 考虑是否真的需要精确的总页数。如果不需要,使用 setSearchCount(false)
      • 对于大数据量,考虑使用近似总数(如 EXPLAIN 估算)或异步统计总数。
  5. IPage 对象被错误复用

    • 错误:将同一个 Page 对象实例用于多次不同的 selectPage 调用。
    • 后果:可能导致状态混乱,因为插件会修改传入的 Page 对象。
    • 解决:每次分页查询都创建新的 Page 对象实例。

4. 注意事项

  1. 插件是必须的:再次强调,没有 PaginationInnerInterceptor,分页功能无效。
  2. COUNT 查询开销:分页总会执行一次额外的 COUNT 查询(除非 searchCount=false),这在某些场景下是性能瓶颈。
  3. 深分页性能:当 current 非常大时(如第 10000 页),OFFSET 值会很大,数据库需要跳过大量行,效率低下。考虑使用游标分页(Cursor-based Pagination)或键集分页(Keyset Pagination)替代 OFFSET/LIMIT
  4. searchCount=false 的影响:关闭 COUNT 后,totalpages 为 0,hasNext() 逻辑可能失效(它依赖 total 计算),前端需要通过是否有下一页数据来判断。
  5. IPage 的线程安全性IPage 对象本身不是线程安全的。不要在多线程间共享同一个 IPage 实例。
  6. @SqlParser 注解@SqlParser(filter = true) 可以阻止 SQL 解析,可能影响分页插件的正常工作,需谨慎使用。
  7. last("LIMIT ...") 冲突:避免在使用 selectPageQueryWrapper 上使用 last("LIMIT ..."),这会与分页插件生成的 LIMIT 冲突。

5. 使用技巧

  1. 统一分页参数处理:在 Controller 层创建一个通用的分页参数 DTO,并在 Service 层统一转换为 Page 对象。
  2. 利用 hasNext()/hasPrevious():前端可以根据这些布尔值来决定是否显示“下一页”或“上一页”按钮,比计算 current < pages 更直观。
  3. maxLimit 防御:设置合理的 maxLimit 是防止 DDoS 攻击的有效手段。
  4. optimizeJoin 优化:对于包含 LEFT JOIN 且主表数据量远大于关联表的查询,开启 optimizeJoin 可能显著提升 COUNT 查询性能。但需测试验证,因为它可能改变 COUNT 的语义(例如,LEFT JOIN 可能导致 COUNT 被放大)。
  5. 异步获取总数:对于需要精确总数但 COUNT 很慢的场景,可以考虑异步任务定期统计总数并缓存,分页查询时使用缓存的总数。

6. 最佳实践与性能优化

  1. 最佳实践

    • 必配插件:始终正确配置 PaginationInnerInterceptor
    • 设置 maxLimit:为 PaginationInnerInterceptor 设置合理的 maxLimit 以防范风险。
    • 明确分页需求:评估业务是否真的需要精确的总页数。如果只是“加载更多”,优先考虑 searchCount=false
    • 避免深分页:对于可能产生深分页的场景(如按时间排序的无限滚动),优先考虑游标分页。例如,记录上一页最后一条记录的 idcreate_time,下一页查询 WHERE create_time < lastCreateTime ORDER BY create_time DESC LIMIT size
    • 索引优化:确保分页查询(尤其是 ORDER BY 字段)和 COUNT 查询都能有效利用数据库索引。
    • 封装响应:将 IPage 的核心信息(records, total, current, size, hasNext 等)封装到一个标准的 API 响应 DTO 中返回给前端。
    • 日志监控:记录分页查询的 SQL(尤其是 COUNT SQL)和执行时间,便于性能分析。
  2. 性能优化

    • 优化 COUNT
      • 确保 COUNT 查询的 WHERE 条件有高效索引。
      • 对于简单查询,COUNT(*) 通常很快。
      • 对于复杂 JOIN,考虑 optimizeJoin 或业务上接受近似值。
      • 考虑使用缓存存储总记录数(如果数据变化不频繁)。
    • 减少深分页开销:采用游标分页(Cursor/Keyset Pagination)是解决深分页性能问题的根本方法。
    • 合理设置 pageSize:避免过大的 pageSize,这会增加单次查询的内存和网络开销。
    • 结合 select 字段过滤:在分页查询中使用 QueryWrapper.select(...) 只查询必要的字段,减少数据传输量。
    • 监控慢查询:利用数据库的慢查询日志,重点关注执行时间长的分页查询(特别是 COUNT 部分)。

总结:MyBatis-Plus 的 IPage 和分页插件极大地简化了分页开发。掌握其核心是正确配置 PaginationInnerInterceptor。理解其工作原理(自动 COUNT + 重写 SQL)有助于规避常见错误。在实践中,需权衡精确总数的需求与性能,善用 searchCount=false游标分页来应对大数据量场景,并始终关注 COUNT 查询的性能。