mybatis实现分页
MyBatis 分页深度解析:从原理到工程实践(含自定义插件与 PageHelper 实战)
MyBatis 分页是企业级开发中高频需求,核心分为逻辑分页(内存分页)和物理分页(数据库层面分页)两类。系统梳理 MyBatis 分页的实现方式,包括逻辑分页原理、自定义物理分页插件深度解析、第三方插件(PageHelper)整合,以及工程化最佳实践,帮助你彻底掌握分页逻辑并规避性能陷阱。
MyBatis 分页的两种核心类型
在深入代码前,需先明确两种分页的本质区别,这是选择分页方案的基础:
| 分页类型 | 实现原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 逻辑分页 | 先查询全表数据到内存,再通过 RowBounds 截取指定范围数据 |
无需改写 SQL,MyBatis 原生支持 | 大数据量下全表查询导致 OOM,性能极差 | 小数据量(如 < 1000 条)、简单测试场景 |
| 物理分页 | 在 SQL 中添加分页语法(如 MySQL LIMIT、Oracle ROWNUM),数据库仅返回指定范围数据 |
性能优(仅查询所需数据),支持大数据量 | 需改写 SQL,需适配不同数据库语法 | 生产环境、大数据量分页(如列表查询) |
逻辑分页:MyBatis 原生 RowBounds
RowBounds 是 MyBatis 内置的逻辑分页工具,无需额外配置,但仅适用于小数据量场景。
原理剖析
- 核心属性:
offset(起始索引,默认 0)、limit(每页条数,默认Integer.MAX_VALUE); - 执行流程:
- MyBatis 执行 SQL 时,先查询全表数据到
ResultSet; - 通过
RowBounds跳过offset条数据,读取limit条数据到内存; - 丢弃剩余数据,返回截取后的结果。
- MyBatis 执行 SQL 时,先查询全表数据到
使用示例
(1)Mapper 接口
// 方法参数中添加 RowBounds(无需 @Param,MyBatis 自动识别)
List<Student> selectByClassId( int classId, RowBounds rowBounds);
(2)Mapper XML
<select id="selectByClassId" resultType="Student">
SELECT id, name FROM student WHERE class_id = #{classId}
</select>
(3)调用代码
// 分页参数:第 1 页(offset = (1-1)*10 = 0),每页 10 条
RowBounds rowBounds = new RowBounds(0, 10);
List<Student> students = studentMapper.selectByClassId(1, rowBounds);
致命缺陷(必须规避)
- 性能问题:无论
limit多大,都会查询全表数据,若表数据达 10 万 + 条,会导致内存溢出(OOM); - 无总条数:仅返回当前页数据,无法获取总条数和总页数,需单独查询(如
SELECT COUNT(*) FROM student); - 不支持复杂 SQL:若 SQL 含
GROUP BY、ORDER BY,全表查询后内存排序性能极差。
结论:生产环境严禁使用 RowBounds 进行分页,仅用于本地测试或极小数据量场景。
物理分页:自定义插件实现(深度解析你的代码)
物理分页的核心是在 SQL 中动态添加分页语法,MyBatis 插件(Interceptor)通过拦截 SQL 执行流程实现这一目标。
1. 自定义分页插件(仅分页,无总条数)
插件拦截 StatementHandler#prepare 方法,在创建 PreparedStatement 前改写 SQL,添加 LIMIT 语法。
(1)核心原理
- 拦截目标:
StatementHandler#prepare(Connection, Integer)—— 该方法负责创建Statement对象,是改写 SQL 的最佳时机; - 关键步骤:
- 分离代理链:MyBatis 插件会生成多层代理对象(如
Plugin代理),需通过MetaObject获取最原始的StatementHandler目标对象; - 获取配置与参数:从
MappedStatement中获取 SQL 语句,从ParameterHandler中获取分页参数(page、size); - 改写 SQL:拼接
LIMIT (page-1)*size, size到原始 SQL 末尾; - 执行原方法:将改写后的 SQL 注入
BoundSql,继续执行 SQL。
- 分离代理链:MyBatis 插件会生成多层代理对象(如
(2)代码逐行解析
public class MyPageInterceptor implements Interceptor {
private int page;
private int size;
public Object intercept(Invocation invocation) throws Throwable {
// 1. 获取被拦截的 StatementHandler 代理对象
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
// 2. 使用 MetaObject 操作 MyBatis 内部对象(规避私有属性访问限制)
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
// 3. 分离代理链:获取最原始的目标对象(避免多层代理导致的属性获取失败)
while (metaObject.hasGetter("h")) { // 处理 Plugin 代理的 "h" 属性
metaObject = SystemMetaObject.forObject(metaObject.getValue("h"));
}
while (metaObject.hasGetter("target")) { // 处理其他代理的 "target" 属性
metaObject = SystemMetaObject.forObject(metaObject.getValue("target"));
}
// 4. 获取 MappedStatement(包含 SQL 配置信息)
MappedStatement ms = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
String mapperId = ms.getId();
// 5. 仅对后缀为 "ByPager" 的方法进行分页(约定优于配置,避免所有 SQL 都被拦截)
if (mapperId.matches(".+ByPager$")) {
// 6. 获取分页参数(从 ParameterHandler 中获取 @Param 传递的 page 和 size)
ParameterHandler paramHandler = (ParameterHandler) metaObject.getValue("delegate.parameterHandler");
Map<String, Object> params = (Map<String, Object>) paramHandler.getParameterObject();
this.page = (int) params.get("page");
this.size = (int) params.get("size");
// 7. 改写 SQL:添加 LIMIT 语法
String originalSql = (String) metaObject.getValue("delegate.boundSql.sql");
String paginatedSql = originalSql + " LIMIT " + (page - 1) * size + "," + size;
metaObject.setValue("delegate.boundSql.sql", paginatedSql); // 注入改写后的 SQL
}
// 8. 执行原方法:继续创建 Statement 并执行 SQL
return invocation.proceed();
}
public Object plugin(Object target) {
// 生成代理对象:仅对 StatementHandler 类型的对象进行代理
return Plugin.wrap(target, this);
}
public void setProperties(Properties properties) {
// 从配置中读取默认参数(如默认每页条数)
this.page = Integer.parseInt(properties.getProperty("limit", "10"));
}
}
(3)插件注册(Spring 配置)
需在 MyBatis 配置中注册插件,确保拦截器生效:
<!-- mybatis-config.xml -->
<plugins>
<plugin interceptor="com.example.interceptor.MyPageInterceptor">
<!-- 配置默认每页条数 -->
<property name="limit" value="20"/>
</plugin>
</plugins>
2. 自定义分页插件(含总条数查询)
插件拦截 Executor#query 方法,先执行 COUNT(*) 查询获取总条数,再执行分页 SQL,最终返回包含总条数的 Page 对象,解决了 “仅分页无总条数” 的问题。
(1)核心改进点
- 拦截目标:
Executor#query(MappedStatement, Object, RowBounds, ResultHandler)—— 该方法负责执行查询,可在分页前先查总条数; - 关键新增步骤:
- 构造 COUNT SQL:基于原始 SQL 生成
SELECT COUNT(*) FROM (原始SQL) temp; - 创建 COUNT 专用 MappedStatement:复制原始
MappedStatement,修改返回类型为Integer,用于执行总条数查询; - 传递参数:处理
BoundSql的additionalParameters(如Criteria动态参数),确保 COUNT SQL 参数正确; - 封装分页结果:将分页数据和总条数封装到自定义
Page对象,返回给业务层。
- 构造 COUNT SQL:基于原始 SQL 生成
(2)核心代码解析(总条数查询部分)
// 拦截 Executor 的 query 方法
public class OffsetLimitInterceptor implements Interceptor {
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
RowBounds rowBounds = (RowBounds) args[2];
PageParam pageParam = new PageParam(rowBounds);
// 1. 非分页请求:直接执行原查询
if (pageParam.getOffset() == 0 && pageParam.getLimit() == Integer.MAX_VALUE) {
return invocation.proceed();
}
Executor executor = (Executor) invocation.getTarget();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
BoundSql boundSql = ms.getBoundSql(parameter);
// 2. 处理动态参数(如 MyBatis Generator 的 Criteria 动态 SQL 参数)
Field additionalParamsField = BoundSql.class.getDeclaredField("additionalParameters");
additionalParamsField.setAccessible(true);
Map<String, Object> additionalParams = (Map<String, Object>) additionalParamsField.get(boundSql);
// 3. 执行总条数查询
if (rowBounds instanceof PageParam) {
// 3.1 创建 COUNT 专用的 MappedStatement(返回类型为 Integer)
MappedStatement countMs = newMappedStatement(ms, Integer.class);
// 3.2 生成 COUNT SQL
String countSql = "SELECT COUNT(*) FROM (" + boundSql.getSql() + ") temp";
// 3.3 构建 COUNT 对应的 BoundSql(传递动态参数)
BoundSql countBoundSql = new BoundSql(ms.getConfiguration(), countSql, boundSql.getParameterMappings(), parameter);
for (String key : additionalParams.keySet()) {
countBoundSql.setAdditionalParameter(key, additionalParams.get(key));
}
// 3.4 执行 COUNT 查询,获取总条数
CacheKey countCacheKey = executor.createCacheKey(countMs, parameter, RowBounds.DEFAULT, countBoundSql);
List<Object> countResult = executor.query(countMs, parameter, RowBounds.DEFAULT, (ResultHandler) args[3], countCacheKey, countBoundSql);
int totalCount = (int) countResult.get(0);
pageParam.setTotalCount(totalCount); // 存入分页参数
}
// 4. 执行分页查询(改写 SQL 加 LIMIT)
String pageSql = boundSql.getSql() + " LIMIT " + pageParam.getOffset() + "," + pageParam.getLimit();
BoundSql pageBoundSql = new BoundSql(ms.getConfiguration(), pageSql, boundSql.getParameterMappings(), parameter);
for (String key : additionalParams.keySet()) {
pageBoundSql.setAdditionalParameter(key, additionalParams.get(key));
}
CacheKey pageCacheKey = executor.createCacheKey(ms, parameter, rowBounds, pageBoundSql);
List<?> pageResult = executor.query(ms, parameter, RowBounds.DEFAULT, (ResultHandler) args[3], pageCacheKey, pageBoundSql);
// 5. 封装分页结果(包含数据和总条数)
return new Page<>(pageResult, pageParam);
}
// 构建 COUNT 专用的 MappedStatement(复制原始 MS,修改返回类型为 Integer)
private MappedStatement newMappedStatement(MappedStatement ms, Class<Integer> resultType) {
MappedStatement.Builder builder = new MappedStatement.Builder(
ms.getConfiguration(), ms.getId() + "_count", ms.getSqlSource(), ms.getSqlCommandType()
);
// 构建 COUNT 查询的 ResultMap(返回 Integer 类型)
ResultMap countResultMap = new ResultMap.Builder(
ms.getConfiguration(), ms.getId() + "_count_result", resultType, new ArrayList<>()
).build();
builder.resource(ms.getResource())
.parameterMap(ms.getParameterMap())
.resultMaps(Arrays.asList(countResultMap)) // 设置返回类型为 Integer
.useCache(ms.isUseCache())
.cache(ms.getCache());
return builder.build();
}
// 其他方法(plugin、setProperties)略...
}
3. 自定义插件的优缺点
| 优点 | 缺点 |
|---|---|
| 完全自定义,可适配特殊分页逻辑(如多表联查分页) | 需手动处理不同数据库语法(MySQL 用 LIMIT,Oracle 用 ROWNUM) |
| 无第三方依赖,轻量 | 维护成本高(需处理参数传递、动态 SQL、缓存等问题) |
| 可灵活控制拦截范围(如按方法名匹配) | 无分页合理化(需手动处理页码越界,如 page <1 或 page> 总页数)1> |
物理分页:第三方插件 PageHelper(生产首选)
自定义插件虽灵活,但实际项目中99% 的场景会选择 PageHelper—— 国内最流行的 MyBatis 分页插件,支持自动适配数据库方言、分页合理化、总条数查询等功能,无需手动写 SQL 和拦截器。
1. PageHelper 核心优势
- 自动方言适配:支持 MySQL、Oracle、SQL Server、PostgreSQL 等主流数据库,无需手动写
LIMIT/ROWNUM; - 分页合理化:自动处理页码越界(如页码 <1 时默认 1,页码> 总页数时默认最后一页);1>
- 零侵入:无需修改 Mapper 接口和 XML,仅需在查询前设置分页参数;
- 支持多种分页方式:基本分页、带总条数分页、分页 + 排序、嵌套查询分页。
2. 整合步骤(Spring Boot 为例)
(1)引入依赖
<!-- PageHelper 核心依赖 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.7</version> <!-- 稳定版,适配 Spring Boot 2.x -->
</dependency>
(2)配置分页参数(application.yml)
pagehelper:
helper-dialect: mysql # 数据库方言(自动检测可省略,建议显式配置)
reasonable: true # 分页合理化:页码越界时自动调整(如 page=0 → page=1)
support-methods-arguments: true # 支持通过 Mapper 方法参数传递分页参数
params: count=countSql # 总条数查询的 SQL 标识
page-size-zero: true # 允许 pageSize=0(查询所有数据,相当于不分页)
(3)基本使用(三种方式)
方式 1:使用 PageHelper.startPage 静态方法(最常用)
public class StudentService {
private StudentMapper studentMapper;
public PageInfo<Student> getByClassId(int classId, int pageNum, int pageSize) {
// 1. 设置分页参数:pageNum(页码)、pageSize(每页条数)
PageHelper.startPage(pageNum, pageSize);
// 2. 执行普通查询(PageHelper 会自动拦截 SQL 加分页语法)
List<Student> students = studentMapper.selectByClassId(classId);
// 3. 封装分页结果(包含总条数、总页数、当前页数据等)
return new PageInfo<>(students);
}
}
// Mapper 接口(无需任何分页参数,保持普通查询)
public interface StudentMapper {
List<Student> selectByClassId( int classId);
}
// Mapper XML(普通 SQL,无需加 LIMIT)
<select id="selectByClassId" resultType="Student">
SELECT id, name FROM student WHERE class_id = #{classId}
</select>
方式 2:通过 Mapper 方法参数传递分页参数(Page 对象)
// Mapper 接口(参数为 Page 对象)
public interface StudentMapper {
List<Student> selectByClassIdWithPage( int classId, Page<Student> page);
}
// Service 调用(直接传递 Page 对象)
public PageInfo<Student> getByClassIdWithPage(int classId, int pageNum, int pageSize) {
Page<Student> page = new Page<>(pageNum, pageSize);
List<Student> students = studentMapper.selectByClassIdWithPage(classId, page);
return new PageInfo<>(students);
}
方式 3:分页 + 排序
public PageInfo<Student> getByClassIdOrder(int classId, int pageNum, int pageSize) {
// 添加排序:ORDER BY id DESC
PageHelper.startPage(pageNum, pageSize).setOrderBy("id DESC");
List<Student> students = studentMapper.selectByClassId(classId);
return new PageInfo<>(students);
}
3. PageHelper 原理简析
PageHelper 本质也是 MyBatis 插件,核心拦截 Executor#query 方法,流程与你的自定义插件类似,但做了更完善的封装:
- 方言适配:通过
Dialect接口实现不同数据库的分页语法(如MySqlDialect生成LIMIT,OracleDialect生成ROWNUM子查询); - 分页参数解析:通过
PageHelper.startPage存储分页参数到ThreadLocal,确保多线程安全; - SQL 改写:拦截
BoundSql,自动添加分页语法和排序语法; - 结果封装:将查询结果封装为
Page对象,包含total(总条数)、pages(总页数)、list(当前页数据)等属性。
工程化最佳实践
1. 分页参数封装(统一规范)
避免方法参数中传递零散的 pageNum 和 pageSize,建议封装为分页参数类:
/**
* 统一分页参数类
*/
public class PageQuery {
private Integer pageNum = 1; // 默认第1页
private Integer pageSize = 10; // 默认每页10条
// 排序字段(可选)
private String sortField;
// 排序方向(ASC/DESC,可选)
private String sortDir = "ASC";
// 生成排序字符串(如 "id DESC")
public String getOrderBy() {
if (StrUtil.isBlank(sortField)) {
return null;
}
// 防 SQL 注入:仅允许字母、数字、下划线的排序字段
if (!sortField.matches("^[a-zA-Z0-9_]+$")) {
throw new IllegalArgumentException("排序字段非法");
}
return sortField + " " + sortDir.toUpperCase();
}
}
使用示例:
// Service 方法
public PageInfo<Student> getByClassId(PageQuery pageQuery, int classId) {
PageHelper.startPage(pageQuery.getPageNum(), pageQuery.getPageSize())
.setOrderBy(pageQuery.getOrderBy());
List<Student> students = studentMapper.selectByClassId(classId);
return new PageInfo<>(students);
}
2. 总条数查询优化
避免不必要的总条数:若仅需 “下一页是否存在”(如滚动加载),可关闭总条数查询,减少一次COUNT(*)开销:
// PageHelper 关闭总条数查询 Page<Object> page = PageHelper.startPage(pageNum, pageSize); page.setCount(false); // 不查询总条数复杂 SQL 的 COUNT 优化:若原始 SQL 含GROUP BY、DISTINCT,直接COUNT(*)性能差,可手动写优化的 COUNT SQL:
<!-- Mapper XML:手动定义 COUNT SQL --> <select id="selectByClassIdCount" resultType="java.lang.Long"> SELECT COUNT(DISTINCT s.id) FROM student s LEFT JOIN classes c ON s.class_id = c.id WHERE s.class_id = #{classId} </select> // Service 中手动查询总条数 public PageInfo<Student> getByClassId(PageQuery pageQuery, int classId) { long total = studentMapper.selectByClassIdCount(classId); // 手动查总条数 PageHelper.startPage(pageQuery.getPageNum(), pageQuery.getPageSize()); List<Student> students = studentMapper.selectByClassId(classId); PageInfo<Student> pageInfo = new PageInfo<>(students); pageInfo.setTotal(total); // 手动设置总条数 return pageInfo; }
3. 规避常见陷阱
- 逻辑分页的 OOM 风险:生产环境绝对禁止使用
RowBounds,尤其是表数据量 > 1 万条的场景; - PageHelper 的线程安全问题:
PageHelper.startPage基于ThreadLocal,无需担心多线程问题,但需确保 “设置分页参数” 和 “执行查询” 在同一个线程; - 分页合理化配置:务必开启
reasonable: true,避免用户传入pageNum=10000(远超总页数)导致返回空数据,影响用户体验; - SQL 注入风险:排序字段需校验(如前文
PageQuery中的matches校验),避免用户传入sortField="id; DROP TABLE student;"导致注入。
总结:分页方案选择建议
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 本地测试、小数据量(< 1000 条) | RowBounds 逻辑分页 | 无需配置,快速测试 |
| 生产环境、标准分页需求 | PageHelper 插件 | 自动适配方言、分页合理化、低维护成本 |
| 特殊分页逻辑(如多表联查、自定义 COUNT) | 自定义插件 | 灵活可控,适配特殊业务场景 |
| 分布式场景(分库分表) | 分库分表中间件(如 Sharding-JDBC) | 中间件自动处理跨库分页,避免手动拼接 SQL |
MyBatis 分页的核心是 “优先选择物理分页,避免逻辑分页”,而 PageHelper 作为成熟插件,能覆盖 99% 的生产场景,建议优先使用;仅当存在特殊业务逻辑(如自定义分页语法、复杂总条数计算)时,才考虑自定义插件