2681 字
约 8 分钟
15
S08-X05 · 分页插件的实现原理与深分页优化

"列表接口翻到第 3 页就卡"、"count 查询比列表查询还慢"、"翻到第 5000 页直接超时"——这三个现象指向同一条链路上的不同环节:逻辑分页、count 语句、深偏移。

分页插件的价值是把 limit 注入 SQL 的过程自动化,但它无法替你解决 offset 的物理代价。这篇拆开看:插件做了什么、PageHelper 的 ThreadLocal 为什么危险、深分页为什么慢、什么情况下分页本身就不成立。


1. 逻辑分页:RowBounds 为什么不能用

MyBatis 自带 RowBounds,它是逻辑分页:

RowBounds bounds = new RowBounds(100000, 20);
List<User> list = mapper.listAll(bounds);   // 先查全部,再在内存里截断

RowBounds 不改 SQL:让 ResultSet 跳过前 offset 行、只取 limit 行,数据库仍把前 10 万行扫了一遍并回传,MyBatis 只是把前面的丢弃。

维度 RowBounds(逻辑) 插件改写 limit(物理)
SQL 是否含 limit 否 是
数据库扫描量 全部行 offset + limit 行
适用 结果集几百行的小表 生产一律用这个

结论:RowBounds 只适合结果小的场景。


2. 分页插件怎么工作

所有分页插件(PageHelper、MyBatis-Plus 的 PaginationInnerInterceptor)都是同一个套路:拦截 Executor.query,改写 BoundSql,再按需补一条 count。

@Intercepts({
  @Signature(type = Executor.class, method = "query",
    args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class PageInterceptor implements Interceptor {
  @Override
  public Object intercept(Invocation invocation) throws Throwable {
    Object[] args = invocation.getArgs();
    MappedStatement ms = (MappedStatement) args[0];
    BoundSql boundSql = ms.getBoundSql(args[1]);
    // 1) 生成 count SQL 并单独执行
    // 2) 把原 SQL 改写成带 limit 的形式,替换 args[0]
    // 3) 调 invocation.proceed()
    return invocation.proceed();
  }
}

四个动作对应四件事:

动作 目的 关键点
拦截 Executor.query 拿到最终 SQL 与参数 必须拦 Executor,拦 StatementHandler 拿不到 count 时机
改写 SQL 加 limit 物理分页 方言不同:MySQL limit ?,?、Oracle rownum、PG 同 MySQL
单独执行 count 算总数 复用同一个 Executor,否则拿不到同一连接

MyBatis 3.5.x 的 Executor 还有六参数 query 重载(带 CacheKey、BoundSql),只签四参数的插件会漏拦截走缓存的路径——"开了缓存后分页失效"就这么来。


3. PageHelper 的用法与 ThreadLocal 隐患

PageHelper.startPage(pageNum, pageSize);      // 只设置参数,不发 SQL
List<User> list = userMapper.search(query);   // 这第一条查询被分页
PageInfo<User> info = new PageInfo<>(list);   // 包一层拿总数

startPage 把 Page 放进 ThreadLocal,PageInterceptor 拦截时取出并消费,由此有两条硬约束:

  1. 必须紧邻那条要被分页的查询。中间插一次 mapper 调用(校验、查字典),分页参数就被那条查询吃掉:列表变成没分页,而校验查询被莫名加了 limit。
  2. 同一线程内必须被消费。startPage 后走分支没查库,参数残留在 ThreadLocal,线程池复用该线程时会被下一次查询误用,表现为偶发数据串页。
配置项 常用值 作用
helperDialect mysql 指定方言,不配则自动探测
reasonable true pageNum<1 查第一页,pageNum 超总页数查最后一页
supportMethodsArguments false 从方法参数里自动取分页参数,易误伤,建议关
params pageNum;pageSize 配合上一项使用

reasonable=true 把越界页码"修正"成合法页:好处是不报错,坏处是爬虫翻到第 10 万页时你永远返回最后一页。对外接口更适合 false 并显式返回空列表。


4. count 语句的三处优化

插件默认生成 select count(0) from (原 SQL) tmp,SQL 简单时才改写成 count(0) from 表 where ...;前者等于同一份扫描做两遍。

现象 原因 处理
count 比列表查询慢 包成派生表,order by 也被带进去 手动写 count,去掉 order by
count 扫全表 条件列无索引 给 where 列建索引,让 count 走覆盖索引
count 结果不对 原 SQL 有 group by 或 distinct 明确业务语义:是"行数"还是"分组数"
大表 count 慢到不可接受 InnoDB 无行数缓存 改为"是否有下一页"判断,或用近似值
<select id="countByQuery" resultType="long">
  select count(1)
  from t_user
  <where>
    <if test="status != null">and status = #{status}</if>
    <if test="name != null and name != ''">and name like concat(#{name},'%')</if>
  </where>
</select>

三个细节:

  • count(*)、count(1)、count(0) 在 InnoDB 里基本等价,优化器都选最小的二级索引扫描;决定耗时的是扫描行数,不是括号里写什么。count(列) 才跳过 NULL,才有语义差别。
  • 去掉 order by:排序对计数无意义,却可能触发 filesort,派生表形态下它被原样包进去。
  • 让 count 走覆盖索引:select count(1) from t_user where status=1 在 (status) 上只扫索引页,不必回表。

若前端只关心"有没有下一页",count 就没必要:

PageHelper.startPage(pageNum, pageSize, false);   // 第三个参数 false = 不执行 count

代价是没有总页数。列表页把"共 N 条"改成"加载更多",count 的整块成本就消失了。


5. 深分页:limit 1000000, 20 为什么慢

MySQL 执行 limit offset, n 是先定位到第 offset+1 行再取 n 行:offset 之前的行不返回,但必须被扫描丢弃。offset=100 万时,这一页的代价就是扫 100 万行。

优化方案 做法 代价
游标(seek method) where id > #{lastId} order by id limit 20 只能顺序翻页,不能跳页
延迟关联 先用覆盖索引查出主键,再回表取字段 多一次 join,SQL 变复杂
业务限制 只允许翻到前 N 页,超出走筛选条件 改变产品交互

游标分页是最有效的一种,走主键索引的范围扫描,代价与页深无关:

<select id="pageByCursor" resultType="com.demo.entity.User">
  select id, name, status
  from t_user
  where id &gt; #{lastId}
  order by id
  limit #{pageSize}
</select>

延迟关联保持跳页能力,把随机回表换成两次索引访问:

select u.id, u.name, u.status
from t_user u
join (select id from t_user order by id limit 1000000, 20) t on u.id = t.id;

内层只在主键索引上走,取到 20 个 id 后回表 20 次,外层不再是 100 万行回表。前提是 order by 列有索引且能用上覆盖索引。

第三种是产品层收口:限制最大页数(比如 200 页),超出要求用户加筛选条件。不优雅,但对"数据量大、不需精确跳页"的后台列表最实用。


6. limit 在分库分表下为什么失效

limit offset, n 的语义是"全局第 offset+1 行开始的 n 行",而分片后每个库只能看到自己那部分数据。

场景 单库行为 分片后行为
limit 0, 10 取前 10 行 各分片各取 10 行,需归并取前 10
limit 100, 10 跳过前 100 行 各分片独立跳过 100 行 → 结果错
order by create_time 全局有序 各分片局部有序,需内存归并

正确做法是中间件把 offset+limit 下推到每个分片,再在内存归并取前 offset+limit 条、丢弃前 offset 条(ShardingSphere 的做法)。成本因此变成"分片数 × (offset+limit)",offset 越大越不可接受。分表后的分页应能用游标就用游标(按分片键或时间戳),否则把 offset 压小,或按分片键路由到单分片。


7. 分页参数校验

参数 校验 不校验的后果
pageNum >= 1,上限如 10000 负数导致 SQL 异常或全表
pageSize 1..100(后台可放宽到 500) 传 100000 就是一次拖库
pageSize = 0 拒绝 PageHelper 会当成"不分页",返回全表

pageSize=0 值得单列:它不是"查 0 条",而是"不加 limit"。对外接口必须显式拒绝 0 与负数。

排序字段用 ${} 拼进 SQL 时必须走白名单映射(ct -> create_time),不要直拼前端字符串。


8. 排错

现象 原因 处理
接口返回全表数据 pageSize=0,或 startPage 没执行到 校验参数;确认 startPage 在查询前一行
分页参数"跑"到别的查询上 startPage 与目标查询之间插了别的 mapper 调用 把 startPage 紧贴查询
总数恒为 0 或有总数没数据 count SQL 被 distinct/group by 改写坏 手写 count 语句并绑定
order by 字段无索引,翻页越来越慢 每页都 filesort 给排序字段建索引,或改按主键排序
开了二级缓存后分页错乱 插件只拦了四参数 query 插件签名兼容六参数重载,或对分页语句 useCache="false"
自定义拦截器与分页插件互相覆盖 拦截器顺序不确定 用 @Order 或配置里的 plugins 顺序固定,业务拦截器放外层

最后一条值得展开:多个 Interceptor 都改写 SQL 时,执行顺序决定谁看到原 SQL。分页插件必须看到未加 limit 的完整 SQL,所以通常排在内层(更靠近 Executor);顺序错的表现是分页偶尔生效。


9. 边界

需求 更合适的手段
后台精确跳页、数据量中等 插件分页 + count
前台无限滚动 游标分页,不要 count
导出全量数据 流式查询 ResultHandler,不要分页
深页 + 可跳页 延迟关联,或限制最大页
分库分表 分片键路由 + 游标

一句话:插件解决的是"把 limit 拼对",不解决"offset 很大"。后者是数据模型与交互设计的问题,只能靠游标、延迟关联或产品约束。


10. 自测

  1. RowBounds 为什么不改写 SQL?它在哪一层做的截断?
  2. 分页插件为什么必须拦截 Executor.query 而不是 StatementHandler?
  3. startPage 之后先执行了一次权限校验查询,会发生什么?
  4. count(*) 与 count(1) 在 InnoDB 上的差别有多大?真正影响 count 耗时的是什么?
  5. limit 1000000, 20 慢在哪一步?延迟关联为什么能缓解?
  6. 分库分表下 limit 100, 10 为什么会返回错误结果?中间件如何修正?
S08-X05 · 分页插件的实现原理与深分页优化
https://www.clxhxhhr.top/posts/5814/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。