"列表接口翻到第 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 拦截时取出并消费,由此有两条硬约束:
- 必须紧邻那条要被分页的查询。中间插一次 mapper 调用(校验、查字典),分页参数就被那条查询吃掉:列表变成没分页,而校验查询被莫名加了 limit。
- 同一线程内必须被消费。
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 > #{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. 自测
RowBounds为什么不改写 SQL?它在哪一层做的截断?- 分页插件为什么必须拦截
Executor.query而不是StatementHandler? startPage之后先执行了一次权限校验查询,会发生什么?count(*)与count(1)在 InnoDB 上的差别有多大?真正影响 count 耗时的是什么?limit 1000000, 20慢在哪一步?延迟关联为什么能缓解?- 分库分表下
limit 100, 10为什么会返回错误结果?中间件如何修正?