MySQL 分页查询刚开始通常都很快,写法也很自然:
SELECT * FROM post ORDER BY id DESC LIMIT 20 OFFSET 0;
但页码一深,问题就来了。比如后台列表跳到第几万页,或者接口里出现:
SELECT * FROM test WHERE val = 4 LIMIT 300000, 5;
这类 SQL 即使字段上有索引,也可能变慢。原因不是“索引没用”,而是 OFFSET 太大时,数据库必须先走过大量用不到的行,再把它们丢掉。
OFFSET 的成本不是从结果开始算
LIMIT 300000, 5 的直觉很容易被误解。它不是直接定位到第 300001 条,然后读 5 条就结束。更接近的理解是:
- 按查询条件和排序规则找到扫描起点。
- 沿着索引顺序往后读。
- 先跳过前 300000 条。
- 再返回后面的 5 条。
前面被跳过的 300000 条虽然不会出现在结果里,但它们对应的索引扫描成本已经发生了。
所以深分页慢的核心不是“表里有多少行”,而是“这次查询为了到达目标页,要路过多少行”。
SELECT * 会放大回表成本
如果查询只需要索引里的字段,MySQL 可以少读很多数据。但如果写的是 SELECT *,情况就变重了。
InnoDB 的二级索引叶子节点里通常保存的是索引列和主键值。通过二级索引找到候选记录后,如果还要拿整行数据,就要按主键回到聚簇索引里再读一次,这就是回表。
深分页时,很多被回表读取的行最后会被 OFFSET 丢掉。也就是说,数据库做了大量“读出来再扔掉”的工作。
这也是为什么同样是分页,下面两类查询体感差很多:
-- 更容易慢:宽行 + 大 OFFSET
SELECT * FROM test WHERE val = 4 LIMIT 300000, 5;
-- 成本相对小:只查索引列或主键
SELECT id FROM test WHERE val = 4 LIMIT 300000, 5;
索引仍然有价值,但它不能把大 OFFSET 变成零成本。
延迟关联可以减少无效回表
如果业务暂时必须保留页码分页,可以先做一个折中:先只查主键,再回表取目标页的完整数据。
示意写法:
SELECT t.*
FROM test t
JOIN (
SELECT id
FROM test
WHERE val = 4
ORDER BY id
LIMIT 300000, 5
) page ON page.id = t.id;
这样做的思路是:深分页那一步尽量只在索引上走,先拿到最终需要的 5 个 id,再对这 5 行回表取完整字段。
它不能消灭大 OFFSET 的索引扫描成本,但可以减少 SELECT * 带来的大量无效回表。对于宽表、字段多、行数据大的场景,收益会更明显。
不过这不是万能方案。如果 OFFSET 非常大,索引扫描本身仍然会慢。
游标分页从上一页书签继续读
如果业务不要求“任意跳到第 8473 页”,更推荐用游标分页,也常叫 keyset pagination。
游标分页的核心是:客户端带上上一页最后一条记录的排序键,下一页从这个位置继续读。
例如按 created_at 和 id 倒序:
-- 第一页
SELECT *
FROM post
WHERE category_id = 1
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 下一页:上一页最后一条是 created_at = '2026-06-10 10:00:00', id = 10086
SELECT *
FROM post
WHERE category_id = 1
AND (created_at, id) < ('2026-06-10 10:00:00', 10086)
ORDER BY created_at DESC, id DESC
LIMIT 20;
这类查询的好处是,它不需要每次都从第一页开始数到第 N 页,而是直接从上次结束的位置附近继续读。只要索引设计匹配,越往后翻页,单页成本也能保持相对稳定。
页码分页和游标分页怎么选
可以按业务交互方式来判断:
| 场景 | 更适合的方式 |
| — | — |
| 后台管理页,需要跳页、查第几页 | 页码分页,可以配合限制最大页数和延迟关联 |
| 信息流、时间线、消息列表、日志列表 | 游标分页 |
| 导出大量数据 | 不要用普通分页接口硬翻,改成异步导出或批任务 |
| 搜索结果只看前几页 | 页码分页可以接受,但限制深页访问 |
| 数据变化频繁,要求翻页稳定 | 游标分页更稳 |
页码分页的优点是用户理解简单,可以显示总页数和跳页。缺点是越深越贵,而且数据变化时可能出现重复或漏数据。
游标分页的优点是性能稳定,适合连续向后翻。缺点是不适合任意跳页,也不天然提供“总共有多少页”。
索引要和筛选和排序一起设计
无论使用哪种分页方式,索引都要和 WHERE、ORDER BY 一起看。
比如下面这个游标查询:
SELECT *
FROM post
WHERE category_id = 1
AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;
可以考虑围绕 category_id、created_at、id 设计联合索引。否则即使用了游标条件,排序和过滤也可能变成额外成本。
实际落地时不要只凭感觉改索引,至少要看:
EXPLAIN的访问类型和使用索引。- 是否出现 filesort。
- 回表行数是否过大。
- 慢查询里的扫描行数和返回行数差距。
小结
MySQL 深分页慢,根源通常不是“没加索引”这么简单,而是大 OFFSET 要求数据库先路过大量记录,再丢掉它们。
实用处理顺序可以这样记:
- 小页码分页:普通
LIMIT offset, size可以接受。 - 深页码但必须保留跳页:考虑限制最大页数、只查主键再延迟关联。
- 连续翻页或信息流:优先改成游标分页。
- 大批量导出:不要伪装成分页接口,做异步导出或批处理。
分页优化的重点不是把 SQL 写得更花,而是先分清业务到底需要“跳到某一页”,还是只需要“从上一页继续看”。这个问题分清了,后面的索引和 SQL 才有方向。




