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(尤其是
COUNT
SQL)和执行时间,便于性能分析。
- 必配插件:始终正确配置
性能优化:
- 优化
COUNT
:- 确保
COUNT
查询的WHERE
条件有高效索引。 - 对于简单查询,
COUNT(*)
通常很快。 - 对于复杂
JOIN
,考虑optimizeJoin
或业务上接受近似值。 - 考虑使用缓存存储总记录数(如果数据变化不频繁)。
- 确保
- 减少深分页开销:采用游标分页(Cursor/Keyset Pagination)是解决深分页性能问题的根本方法。
- 合理设置
pageSize
:避免过大的pageSize
,这会增加单次查询的内存和网络开销。 - 结合
select
字段过滤:在分页查询中使用QueryWrapper.select(...)
只查询必要的字段,减少数据传输量。 - 监控慢查询:利用数据库的慢查询日志,重点关注执行时间长的分页查询(特别是
COUNT
部分)。
- 优化
总结:MyBatis-Plus 的 IPage
和分页插件极大地简化了分页开发。掌握其核心是正确配置 PaginationInnerInterceptor
。理解其工作原理(自动 COUNT
+ 重写 SQL)有助于规避常见错误。在实践中,需权衡精确总数的需求与性能,善用 searchCount=false
和游标分页来应对大数据量场景,并始终关注 COUNT
查询的性能。