MyBatis 是如何进行分页的?分页插件的原理是什么?

#Java#面试八股

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 语句要单独优化、深分页要换成游标或延迟关联。

笔记加载中…