Mybatis分页实现原理与PageHelper插件深度解析
1.4 利用PageHelper实现Mybatis分页
在Mybatis开发中,分页查询是非常常见的需求。Mybatis自身提供了RowBounds逻辑分页,但它是一次性查询所有数据再进行内存截取,性能极差,尤其不适合大数据量场景。因此,物理分页 成为必然选择。而手动为每条SQL拼接limit、rownum等数据库方言语句又十分繁琐且难以维护。PageHelper作为一款优秀的Mybatis分页插件,通过拦截器机制优雅地实现了物理分页,本文将深入剖析其原理、使用方法,并结合流程图与UML类图进行全方位解析。
一、PageHelper简介
PageHelper是国内开发者abel533开源的Mybatis分页插件,支持主流数据库(MySQL、Oracle、PostgreSQL、SQL Server等),它通过Mybatis提供的插件接口,在SQL执行前拦截待执行的SQL,根据数据库方言动态生成物理分页语句(如LIMIT ? OFFSET ?)并自动执行总数查询,最后封装为PageInfo对象返回给业务层。开发者仅需调用PageHelper.startPage(pageNum, pageSize)即可轻松实现分页。
二、快速集成与使用示例
2.1 Maven依赖
xml
<!-- 推荐使用最新版本,此处以5.3.2为例 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper</artifactId>
<version>5.3.2</version>
</dependency>
若使用Spring Boot,建议直接引入pagehelper-spring-boot-starter:
xml
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.6</version>
</dependency>
2.2 配置方式
Spring Boot配置(application.yml):
yaml
pagehelper:
helperDialect: mysql # 数据库方言
reasonable: true # 分页合理化,pageNum<1自动调整为1,>pages自动调整为pages
supportMethodsArguments: true # 支持通过Mapper接口参数自动分页
params: count=countSql # 自动查询总记录数
传统Mybatis配置(mybatis-config.xml):
xml
<plugins>
<plugin interceptor="com.github.pagehelper.PageInterceptor">
<property name="helperDialect" value="mysql"/>
<property name="reasonable" value="true"/>
</plugin>
</plugins>
2.3 基本使用代码示例
java
// Mapper接口
public interface UserMapper {
List<User> selectAllUsers();
}
// Service层调用
public PageInfo<User> queryUserByPage(int pageNum, int pageSize) {
// 1. 开启分页(紧跟在Mapper调用之前)
PageHelper.startPage(pageNum, pageSize);
// 2. 执行原查询(无需手动写limit)
List<User> userList = userMapper.selectAllUsers();
// 3. 封装为PageInfo(包含总记录数、总页数、当前页数据等)
PageInfo<User> pageInfo = new PageInfo<>(userList);
return pageInfo;
}
执行上述代码后,控制台输出的SQL自动变为:
sql
-- 原SQL
SELECT * FROM user
-- 实际执行的SQL
SELECT * FROM user LIMIT ? OFFSET ?
-- 以及一条COUNT查询
SELECT count(0) FROM user
三、分页原理深度剖析
3.1 Mybatis插件机制回顾
Mybatis允许通过实现Interceptor接口来拦截四大核心组件的方法调用:
- Executor (执行器):核心执行方法
query、update - StatementHandler(SQL语句处理器)
- ParameterHandler(参数处理器)
- ResultSetHandler(结果集处理器)
PageHelper正是拦截了Executor的query方法 ,在SQL执行前进行改写。自定义插件需使用@Intercepts和@Signature注解声明拦截的目标对象、方法及参数类型。
3.2 PageHelper核心原理
分页插件的核心流程可以概括为:
- 分页参数存储 :
PageHelper.startPage()方法将分页参数(页码、条数、是否count等)存入ThreadLocal变量中。 - 拦截SQL执行 :当执行Mapper方法时,Mybatis调用
Executor.query,被PageInterceptor拦截。 - 判断是否需要分页 :从
ThreadLocal中取出分页参数,若无则直接放行。 - 生成分页SQL :
- 通过
MetaObject反射工具获取原始的BoundSql及SQL语句。 - 利用
Dialect方言适配器(如MysqlDialect)生成物理分页SQL(例如SELECT ... LIMIT ?,?)。 - 生成
COUNT查询SQL(若需要总记录数)。
- 通过
- 执行SQL :先执行COUNT查询获取总记录数(若配置了
count=true),再执行分页查询获取当前页数据。 - 封装结果 :将分页信息(总记录数、页码、每页条数等)封装到
Page对象中,并替换Executor.query的返回结果。 - 清理ThreadLocal :分页结束后移除
ThreadLocal中的分页参数,防止线程复用导致错误。
3.3 分页SQL改写示例
原始SQL(假设MySQL数据库):
sql
SELECT id, name, age FROM user WHERE status = 1 ORDER BY id
经过PageInterceptor拦截并利用MysqlDialect重写后的分页SQL:
sql
-- 分页查询SQL(添加LIMIT子句)
SELECT id, name, age FROM user WHERE status = 1 ORDER BY id LIMIT ? OFFSET ?
-- 总记录数SQL(外层嵌套COUNT)
SELECT count(0) FROM (SELECT id, name, age FROM user WHERE status = 1) tmp_count
3.4 执行流程图
下图展示了PageHelper从开启分页到获取结果的完整流程:
否
是
业务层调用 PageHelper.startPage
分页参数存入 ThreadLocal
执行 Mapper 方法
Mybatis 调用 Executor.query
PageInterceptor 拦截 query 方法
从 ThreadLocal 获取分页参数
是否需要分页?
直接执行原始 SQL
获取原始 SQL 与参数
利用 Dialect 生成 COUNT SQL
执行 COUNT 查询得到 total
利用 Dialect 生成分页 SQL
执行分页查询得到数据列表
将 total 和列表封装到 Page 对象
返回 Page 对象并清理 ThreadLocal
结束
3.5 核心类图
下图展示了PageHelper中关键类及Mybatis插件接口的关系:
uses
uses
<<interface>>
Interceptor
+intercept(Invocation) : Object
+plugin(Object) : Object
+setProperties(Properties)
PageInterceptor
-Dialect dialect
-String countSuffix
+intercept(Invocation) : Object
+plugin(Object) : Object
+setProperties(Properties)
<<interface>>
Dialect
+getCountSql(MappedStatement, BoundSql, Object, RowBounds) : String
+getPageSql(MappedStatement, BoundSql, Object, RowBounds, Page) : String
+afterPage(List, Object, BoundSql) : void
AbstractHelperDialect
+getPageSql(String, Page, RowBounds) : String
+getCountSql(String, Page) : String
MysqlDialect
+getPageSql(String, Page, RowBounds) : String
OracleDialect
+getPageSql(String, Page, RowBounds) : String
<<utility>>
SqlUtil
+setLocalPage(Page)
+getLocalPage() : Page
+clearLocalPage()
类图说明:
PageInterceptor实现Mybatis的Interceptor接口,是分页插件的核心拦截器。Dialect接口定义了生成分页SQL和COUNT SQL的方法,不同数据库有各自实现(如MysqlDialect)。SqlUtil通过ThreadLocal管理分页参数,并提供清空机制。
四、源码关键片段解析
为了加深理解,以下是PageInterceptor中intercept方法的核心简化逻辑:
java
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 1. 获取分页参数(从ThreadLocal中)
Page page = SqlUtil.getLocalPage();
if (page == null) {
// 无需分页,直接执行原方法
return invocation.proceed();
}
// 2. 获取执行相关的对象
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
RowBounds rowBounds = (RowBounds) args[2];
BoundSql boundSql = ms.getBoundSql(parameter);
// 3. 生成COUNT SQL并执行
String countSql = dialect.getCountSql(ms, boundSql, parameter, rowBounds);
// 执行COUNT查询获取总记录数...
// 4. 生成分页SQL
String pageSql = dialect.getPageSql(ms, boundSql, parameter, rowBounds, page);
// 5. 利用反射修改BoundSql中的SQL属性
BoundSql newBoundSql = new BoundSql(ms.getConfiguration(), pageSql,
boundSql.getParameterMappings(), parameter);
// 6. 执行分页查询
List result = executor.query(ms, parameter, rowBounds, resultHandler,
newCacheKey, newBoundSql);
// 7. 封装Page结果并返回
return new PageInfo(result, page.getTotal());
}
实际源码还包含了复杂的分页合理化、自动COUNT、参数映射等处理,上述代码仅为展示核心逻辑。
五、常见问题与最佳实践
5.1 注意事项
- 分页参数线程安全 :由于使用
ThreadLocal存储分页参数,务必确保PageHelper.startPage()之后立即执行Mapper方法,不要在中间插入其他耗时操作或异步线程。分页结束后PageHelper会自动清理,但遇到异常时可能需要手动调用PageHelper.clearPage()。 - 支持复杂的SQL :PageHelper能够处理多表关联、子查询、
GROUP BY等复杂SQL的COUNT查询优化,但某些极端情况可能需要手动指定countSql。 - 分页合理化 :建议开启
reasonable=true,当pageNum超出总页数时自动返回最后一页数据,避免前端报错。 - 性能调优 :如果只需要数据列表而不需要总记录数(如滚动加载),可以设置
count=false跳过COUNT查询。
5.2 对比传统分页方式
| 方式 | 原理 | 性能 | 代码侵入性 |
|---|---|---|---|
| RowBounds逻辑分页 | 全表查询,内存截取 | 极差 | 低 |
| 手动拼接limit | 物理分页 | 高 | 高,每个SQL都需修改 |
| PageHelper插件 | 拦截SQL,动态改写 | 高 | 低,仅需一行调用 |
5.3 最佳实践示例
java
public PageResult<User> getUsersWithCondition(UserQueryParam param) {
// 1. 开启分页(建议紧跟在方法第一行)
PageHelper.startPage(param.getPageNum(), param.getPageSize(), true);
// 2. 执行查询(Mapper中无需任何分页逻辑)
List<User> list = userMapper.selectByCondition(param);
// 3. 转换为自定义PageResult
PageInfo<User> pageInfo = new PageInfo<>(list);
return new PageResult<>(pageInfo.getTotal(), pageInfo.getList());
}
六、总结
PageHelper通过优雅地实现Mybatis的Interceptor接口,在Executor.query执行前拦截SQL,根据数据库方言动态生成物理分页语句,实现了无侵入的高效分页方案。其核心设计包括:
- ThreadLocal传递分页参数,解耦业务层与持久层。
- 策略模式支持多种数据库方言,扩展性强。
- 反射技术 动态修改
BoundSql,实现SQL重写。
参考资源
- PageHelper官方文档:https://pagehelper.github.io/
- Mybatis官方文档:插件开发章节