[特殊字符] 深入理解 PageHelper 分页原理:从 startPage 到 SQL 改写全过程
在日常开发中,分页几乎是后端系统中最常见的功能之一。
我们在使用 MyBatis + PageHelper 时,只需要一句:
PageHelper.startPage(pageNum, pageSize);
List<User> list = userMapper.selectUserList();
就能实现分页查询。
看似简单,但你是否想过:PageHelper 是如何知道要分页的?
SQL 中的 LIMIT 是在哪里拼接的?
今天我们就来深入分析一下 PageHelper 的工作原理。
🧩 一、分页从哪开始?—— startPage()
分页逻辑的入口就在:
PageHelper.startPage(pageNum, pageSize);
来看它的源码(简化版):
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {Page<E> page = new Page(pageNum, pageSize, count);page.setReasonable(reasonable);page.setPageSizeZero(pageSizeZero);Page<E> oldPage = getLocalPage();if (oldPage != null && oldPage.isOrderByOnly()) {page.setOrderBy(oldPage.getOrderBy());}setLocalPage(page);return page;
}
这段代码其实做了三件事:
- 创建一个 Page 对象,保存分页参数(页码、每页数量、是否统计总数等);
- 检查旧的 Page 是否存在(如果只设置了排序规则,就继承它);
- 把当前 Page 对象存入 ThreadLocal 中。
🧠 二、ThreadLocal:分页的关键所在
分页之所以能“自动生效”,关键在于 PageHelper 使用了 ThreadLocal
。
每个线程都会维护一个自己的分页上下文对象(Page),
这意味着每次请求(通常一个 HTTP 请求对应一个线程)都可以拥有独立的分页参数,不会互相干扰。
简而言之:
startPage()
只是把分页参数存到了当前线程中。
当我们执行 userMapper.selectUserList()
时,
MyBatis 拦截器 会从 ThreadLocal 中取出这个 Page 对象,
在 SQL 执行前,自动为 SQL 拼接上分页语句。
🧩 三、拦截器如何改写 SQL?
PageHelper 注册了一个 MyBatis 拦截器:
@Intercepts({@Signature(type = StatementHandler.class,method = "prepare",args = {Connection.class, Integer.class}
)})
每当 MyBatis 准备执行 SQL 时,这个拦截器会被触发。
它会:
- 判断当前线程是否存在 Page(也就是我们在
startPage()
放进去的); - 如果存在,就改写 SQL,拼上分页语句;
- 执行一次 count 查询,用于获取总记录数;
- 最终返回结果列表 + 分页信息。
例如,原 SQL:
SELECT * FROM sys_user WHERE status = 1;
经过 PageHelper 改写后变为:
SELECT * FROM sys_user WHERE status = 1 LIMIT 0, 10;
同时还会额外执行:
SELECT COUNT(1) FROM sys_user WHERE status = 1;
🧩 四、分页结果是怎么返回的?
分页查询返回后,我们一般会调用:
return getDataTable(list);
而若依框架中的 getDataTable()
方法是这样写的:
protected TableDataInfo getDataTable(List<?> list)
{TableDataInfo rspData = new TableDataInfo();rspData.setCode(HttpStatus.SUCCESS);rspData.setRows(list);rspData.setTotal(new PageInfo(list).getTotal());return rspData;
}
其中 PageInfo
内部会读取 PageHelper 存在 ThreadLocal 中的分页信息,
包括:
- 总记录数 total
- 当前页码 pageNum
- 每页数量 pageSize
- 总页数 pages
最终生成 JSON 响应:
{"code": 200,"rows": [ ...用户数据... ],"total": 153
}
⚙️ 五、参数说明与高级特性
PageHelper 的分页控制非常灵活,它的 startPage()
还有以下参数:
参数名 | 含义 |
---|---|
pageNum | 当前页码 |
pageSize | 每页数量 |
count | 是否统计总记录数 |
reasonable | 是否启用页码合理化(超出范围时自动调整) |
pageSizeZero | 是否允许 pageSize=0 返回所有记录 |
例如:
PageHelper.startPage(1, 0, true, true, true);
→ 代表:第一页,不分页(返回全部数据),执行 count 查询。
🧩 六、为什么要用 ThreadLocal?
使用 ThreadLocal 的核心目的,是为了在 不修改原 SQL 的情况下实现分页。
因为 MyBatis 执行 SQL 的过程是多层封装的,
分页插件无法直接知道当前的分页参数。
因此 PageHelper 采用 ThreadLocal 方案:
- Controller 调用
startPage()
时,将分页信息保存到线程上下文; - MyBatis 拦截器在执行 SQL 前,从线程中读取分页参数;
- 执行结束后自动清理 ThreadLocal,防止污染其他线程。
💣 七、常见问题:分页内存泄漏?
由于 PageHelper 使用了 ThreadLocal,如果使用不当,也可能造成内存泄漏。
例如线程池复用时,如果分页对象没有清理干净,旧的分页参数可能残留。
解决方法:
PageHelper 内部已在 finally 块中清理 ThreadLocal;
如果是自定义 ThreadLocal,请务必在使用后调用:
threadLocal.remove();
✅ 八、总结
步骤 | 说明 |
---|---|
startPage() | 在当前线程中创建分页上下文(Page对象) |
MyBatis 拦截器 | 在执行 SQL 前读取 Page 信息并改写 SQL |
count 查询 | 自动统计总记录数 |
PageInfo | 封装分页结果 |
ThreadLocal | 实现线程级隔离,保证分页参数安全传递 |
一句话总结:
PageHelper 通过 ThreadLocal + MyBatis 拦截器 实现了“无侵入式分页”。
开发者无需在 SQL 中写 LIMIT,就能优雅地实现数据库层分页。
💬 九、写在最后
分页是每个后端工程师都会接触的功能,但理解它的底层原理,
不仅能帮助我们更好地使用 PageHelper,也能在设计自己的分页组件时少走弯路。
下次你再写:
startPage();
List<User> list = userMapper.selectUserList();
就能清楚知道:这不仅仅是分页,而是一套完整的 线程上下文 + SQL 拦截体系 在默默为你工作。