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)。
- 工作流程:
- 创建
Page<T>对象,设置current(当前页) 和size(每页大小)。 - 构建
QueryWrapper定义查询条件。 - 调用
BaseMapper的selectPage(Page<T>, QueryWrapper)方法。 PaginationInnerInterceptor拦截该请求。- 插件先执行一个
COUNT(*)查询(基于原始查询条件)获取总记录数total。 - 插件根据
total,current,size计算pages(总页数) 和offset(偏移量)。 - 插件重写原始查询 SQL,添加分页子句(如
LIMIT size OFFSET offset)。 - 执行重写后的 SQL,获取当前页的数据列表
records。 - 将
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. 常见错误
忘记配置
PaginationInnerInterceptor:- 表现:调用
selectPage但返回的IPage对象中total=0,pages=0,且records包含了所有数据(未分页)。 - 原因:没有分页插件,
selectPage退化为selectList。 - 解决:确保在配置类中正确注册了
PaginationInnerInterceptor并添加到MyBatisPlusInterceptor。
- 表现:调用
分页参数错误:
- 错误:
Page<User> page = new Page<>(0, 10);(页码从 0 开始)。 - 后果:MyBatis-Plus 通常期望页码从 1 开始。
current=0可能导致计算错误或查询第 0 页(通常为空)。 - 解决:确保
current参数从 1 开始。前端传参时注意转换。
- 错误:
maxLimit限制导致查询失败:- 表现:当
pageSize超过maxLimit设置的值时,查询可能被限制或抛出异常(取决于插件版本和配置)。 - 解决:检查
PaginationInnerInterceptor的maxLimit配置,确保它符合业务需求,或在必要时调整/移除限制(但需防范恶意请求)。
- 表现:当
COUNT查询性能问题:- 表现:分页查询很慢,即使只取第一页。
- 原因:
COUNT(*)查询本身很慢,尤其当查询涉及复杂JOIN或大表时。 - 解决:
- 优化
COUNT查询的 SQL(确保有合适的索引)。 - 考虑是否真的需要精确的总页数。如果不需要,使用
setSearchCount(false)。 - 对于大数据量,考虑使用近似总数(如
EXPLAIN估算)或异步统计总数。
- 优化
IPage对象被错误复用:- 错误:将同一个
Page对象实例用于多次不同的selectPage调用。 - 后果:可能导致状态混乱,因为插件会修改传入的
Page对象。 - 解决:每次分页查询都创建新的
Page对象实例。
- 错误:将同一个
4. 注意事项
- 插件是必须的:再次强调,没有
PaginationInnerInterceptor,分页功能无效。 COUNT查询开销:分页总会执行一次额外的COUNT查询(除非searchCount=false),这在某些场景下是性能瓶颈。- 深分页性能:当
current非常大时(如第 10000 页),OFFSET值会很大,数据库需要跳过大量行,效率低下。考虑使用游标分页(Cursor-based Pagination)或键集分页(Keyset Pagination)替代OFFSET/LIMIT。 searchCount=false的影响:关闭COUNT后,total和pages为 0,hasNext()逻辑可能失效(它依赖total计算),前端需要通过是否有下一页数据来判断。IPage的线程安全性:IPage对象本身不是线程安全的。不要在多线程间共享同一个IPage实例。- 与
@SqlParser注解:@SqlParser(filter = true)可以阻止 SQL 解析,可能影响分页插件的正常工作,需谨慎使用。 last("LIMIT ...")冲突:避免在使用selectPage的QueryWrapper上使用last("LIMIT ..."),这会与分页插件生成的LIMIT冲突。
5. 使用技巧
- 统一分页参数处理:在 Controller 层创建一个通用的分页参数 DTO,并在 Service 层统一转换为
Page对象。 - 利用
hasNext()/hasPrevious():前端可以根据这些布尔值来决定是否显示“下一页”或“上一页”按钮,比计算current < pages更直观。 maxLimit防御:设置合理的maxLimit是防止 DDoS 攻击的有效手段。optimizeJoin优化:对于包含LEFT JOIN且主表数据量远大于关联表的查询,开启optimizeJoin可能显著提升COUNT查询性能。但需测试验证,因为它可能改变COUNT的语义(例如,LEFT JOIN可能导致COUNT被放大)。- 异步获取总数:对于需要精确总数但
COUNT很慢的场景,可以考虑异步任务定期统计总数并缓存,分页查询时使用缓存的总数。
6. 最佳实践与性能优化
最佳实践:
- 必配插件:始终正确配置
PaginationInnerInterceptor。 - 设置
maxLimit:为PaginationInnerInterceptor设置合理的maxLimit以防范风险。 - 明确分页需求:评估业务是否真的需要精确的总页数。如果只是“加载更多”,优先考虑
searchCount=false。 - 避免深分页:对于可能产生深分页的场景(如按时间排序的无限滚动),优先考虑游标分页。例如,记录上一页最后一条记录的
id或create_time,下一页查询WHERE create_time < lastCreateTime ORDER BY create_time DESC LIMIT size。 - 索引优化:确保分页查询(尤其是
ORDER BY字段)和COUNT查询都能有效利用数据库索引。 - 封装响应:将
IPage的核心信息(records,total,current,size,hasNext等)封装到一个标准的 API 响应 DTO 中返回给前端。 - 日志监控:记录分页查询的 SQL(尤其是
COUNTSQL)和执行时间,便于性能分析。
- 必配插件:始终正确配置
性能优化:
- 优化
COUNT:- 确保
COUNT查询的WHERE条件有高效索引。 - 对于简单查询,
COUNT(*)通常很快。 - 对于复杂
JOIN,考虑optimizeJoin或业务上接受近似值。 - 考虑使用缓存存储总记录数(如果数据变化不频繁)。
- 确保
- 减少深分页开销:采用游标分页(Cursor/Keyset Pagination)是解决深分页性能问题的根本方法。
- 合理设置
pageSize:避免过大的pageSize,这会增加单次查询的内存和网络开销。 - 结合
select字段过滤:在分页查询中使用QueryWrapper.select(...)只查询必要的字段,减少数据传输量。 - 监控慢查询:利用数据库的慢查询日志,重点关注执行时间长的分页查询(特别是
COUNT部分)。
- 优化
总结:MyBatis-Plus 的 IPage 和分页插件极大地简化了分页开发。掌握其核心是正确配置 PaginationInnerInterceptor。理解其工作原理(自动 COUNT + 重写 SQL)有助于规避常见错误。在实践中,需权衡精确总数的需求与性能,善用 searchCount=false 和游标分页来应对大数据量场景,并始终关注 COUNT 查询的性能。