MyBatis 是如何进行分页的?分页插件的原理是什么?
结论先行:MyBatis 自带的分页是 RowBounds 逻辑分页——把结果全部查回内存再截取,数据量大时等于自杀。生产上用物理分页:靠分页插件(主流是 PageHelper)拦截 Executor.query,改写 SQL 追加 LIMIT,并额外执行一条 COUNT 查询算总数。
1. 逻辑分页 vs 物理分页
| 维度 | RowBounds 逻辑分页 | 插件物理分页 |
|---|---|---|
| SQL | 原样执行,无 LIMIT | 改写成 ... LIMIT offset, size |
| 数据传输 | 全表/全结果集回传 | 只回传当前页 |
| 内存占用 | 与总行数成正比 | 与页大小成正比 |
| 总数 | 需要自己另查 | 插件自动执行 count |
| 适用 | 结果集很小(配置表、字典) | 任何列表查询 |
// 逻辑分页:看 SQL 日志会发现没有 limit
List<Order> list = sqlSession.selectList("selectAll", null, new RowBounds(0, 10));
RowBounds 的 offset/limit 只传给 ResultSetHandler,由它在遍历结果时跳过前 offset 行、取 limit 行。SQL 仍然是全量查询。
2. 分页插件的拦截点
mapper.selectPage(query)
│
▼ Executor.query(ms, param, rowBounds, handler) ← 插件在此拦截
PageInterceptor.intercept()
├─ 1. 从 ThreadLocal(PageContext)取出 Page 参数
├─ 2. 判断是否需要 count:查 count 并放入 Page
├─ 3. 改写 SQL:原 SQL + " LIMIT ?, ?",参数追加 offset/size
├─ 4. 执行查询,把结果塞进 Page
└─ 5. finally 清空 ThreadLocal
改写动作发生在 MappedStatement 层面:新建一个 MappedStatement,用 BoundSql 包住改写后的 SQL,再交给原 Executor 执行。这样后续的 StatementHandler、ParameterHandler 完全不用感知分页。
3. PageHelper 的标准用法
// 1. 紧挨着查询调用 startPage,中间不要插入别的查询
PageHelper.startPage(pageNum, pageSize);
List<OrderVO> list = orderMapper.selectByCondition(query);
// 2. 用 PageInfo 包装,拿到 total/pages 等元信息
PageInfo<OrderVO> pageInfo = new PageInfo<>(list);
return Result.success(pageInfo);
pagehelper:
helper-dialect: mysql # 方言,决定 limit 语法
reasonable: true # pageNum<1 查首页,超出总页数查末页
support-methods-arguments: true
params: count=countSql
page-size-zero: false # pageSize=0 时是否查全部
startPage 把分页参数放进 ThreadLocal,插件在拦截时取出。所以它必须紧跟查询,且不能跨线程——异步查询里 startPage 会失效,这是最常见的坑。
4. count 查询的优化
默认情况下插件会把原 SQL 包一层 SELECT count(0) FROM (原SQL) tmp,这在带 ORDER BY、多表 join 时很慢。可以显式指定 count 语句:
<select id="selectByCondition" resultType="OrderVO">
SELECT o.id, o.amount, u.name AS userName
FROM t_order o
LEFT JOIN t_user u ON u.id = o.user_id
WHERE o.status = #{status}
ORDER BY o.id DESC
</select>
<!-- 专门的 count 语句:去掉 join、去掉 order by -->
<select id="selectByCondition_COUNT" resultType="long">
SELECT COUNT(1)
FROM t_order o
WHERE o.status = #{status}
</select>
PageHelper 识别到 xxx_COUNT 命名约定就会直接用它,省掉一次昂贵的子查询。
5. 深分页问题
LIMIT 1000000, 20 并不是「跳过 100 万行」,而是先扫描 100 万零 20 行再丢弃前面的。数据量大时越翻越慢。
| 方案 | 写法 | 适用 |
|---|---|---|
| 延迟关联 | SELECT * FROM t_order o JOIN (SELECT id FROM t_order ORDER BY id LIMIT 1000000,20) t ON o.id=t.id | 能走覆盖索引,减少回表 |
| 游标分页 | WHERE id > lastId ORDER BY id LIMIT 20 | 只能上一页/下一页,不能跳页 |
| 业务限制 | 只允许看前 N 页 | 后台列表最常见 |
游标分页没有 total 概念,接口设计上要放弃「总页数」,很多 C 端信息流就是这么做的。
6. 常见追问
追问一:startPage 之后没有紧跟查询会怎样?
分页参数会「泄漏」到下一次查询,导致莫名其妙的分页,甚至 ThreadLocal 未清理造成内存泄漏。PageHelper 在 finally 里清理,但跨方法传递仍会错位。
追问二:分页插件和 MyBatis-Plus 的分页插件能共存吗?
不要共存。两者都拦截 Executor.query,会重复改写 SQL(出现两个 LIMIT)。选一个即可,MyBatis-Plus 用 PaginationInnerInterceptor。
追问三:count 查询能异步吗? 能。两次查询互相独立,可以先 count 再查列表,也可以并发执行(需独立连接)。但并发会多占一个连接,高并发下不一定划算。
追问四:为什么 reasonable=true 有风险?
它会把越界的 pageNum 悄悄改成边界页,前端拿到「第 100 页」却返回最后一页数据,容易造成误解。对账、导出类接口应保持 false。
追问五:插件分页对 UNION、子查询支持吗?
支持,但改写后的 LIMIT 会加在最外层,语义上是「先 union 再分页」,符合预期。若原 SQL 自带 LIMIT,插件会再包一层,注意语义叠加。
小结
MyBatis 原生只有「查全量再截取」的逻辑分页,真正的分页靠插件在 Executor.query 处改写 SQL。工程上要关注三件事:startPage 与查询必须相邻、count 语句要单独优化、深分页要换成游标或延迟关联。