ElIauk

MySQL 深分页优化

深分页为什么慢 LIMIT 1000000, 20 看起来只是跳过前一百万行,实际执行时 MySQL 要先读出这一百万零二十行,把前一百万行丢掉,再返回最后二十行。如果查询还需要回表,每一行都要按主键去聚簇索引取一次数据,随机 IO 会把耗时进一步放大。翻到后面几页,接口从几十毫秒变成几秒,这不是数

评论
MySQL 深分页优化

深分页为什么慢

LIMIT 1000000, 20 看起来只是跳过前一百万行,实际执行时 MySQL 要先读出这一百万零二十行,把前一百万行丢掉,再返回最后二十行。如果查询还需要回表,每一行都要按主键去聚簇索引取一次数据,随机 IO 会把耗时进一步放大。翻到后面几页,接口从几十毫秒变成几秒,这不是数据库“变慢”了,而是偏移量本身就有线性成本。

覆盖索引能缓解但不能根治:即使 EXPLAIN 显示 Using index,MySQL 仍然要扫描并丢弃 offset 条索引记录。数据量到千万级,任何依赖大偏移量的分页都会遇到瓶颈。

延迟关联:先取主键再回表

最常见的优化是“延迟关联”。子查询只查主键,利用覆盖索引完成过滤和排序,外层再用主键关联取完整字段,回表次数从一百万次降到二十次:

SELECT o.id, o.user_id, o.amount, o.created_at
FROM orders o
INNER JOIN (
    SELECT id
    FROM orders
    WHERE status = 'PAID'
    ORDER BY id
    LIMIT 1000000, 20
) t ON t.id = o.id;

要让子查询走覆盖索引,需要类似 (status, id) 的联合索引。这个写法仍然要扫描一百万条索引记录,只是避免了大量回表,适合无法改造接口、只能改 SQL 的场景。它能把几秒降到几百毫秒,但解决不了“越翻越慢”的趋势。

游标分页:用上一页的位置代替 offset

真正把复杂度降下来的方案是 keyset 分页,也叫游标分页。不传页码,传上一页最后一条记录的排序键,查询变成“从这个位置往后取 20 条”:

SELECT id, user_id, amount, created_at
FROM orders
WHERE status = 'PAID'
  AND id > 1000000
ORDER BY id
LIMIT 20;

配合 (status, id) 索引,MySQL 可以直接定位到 id > 1000000 的位置,扫描 20 行就结束,耗时和页码无关。代价是不能跳页,前端只能“下一页/上一页”,适合信息流、消息列表、导出这类顺序浏览场景。

如果排序字段不是主键,要用“排序字段 + 主键”组成游标,避免排序值重复导致跨页丢数据:

SELECT id, user_id, amount, created_at
FROM orders
WHERE status = 'PAID'
  AND (created_at, id) < ('2024-06-01 12:00:00', 1000000)
ORDER BY created_at DESC, id DESC
LIMIT 20;

这里必须保证 (status, created_at, id) 有联合索引,并且排序方向与索引一致。MySQL 8.0 对行构造器比较的支持比旧版本好,但上线前仍要用 EXPLAIN ANALYZE 确认没有退化成全表扫描。

业务层的配合

不是所有分页都值得用游标。后台管理系统需要“跳到第 50 页”,产品经理不会接受只能点下一页,这时候可以限制最大页数,比如只允许前 100 页,超出后要求加时间范围、状态等过滤条件。把“无限翻页”改成“带条件的检索”,往往比任何 SQL 技巧都有效。

数据量再大,应该考虑归档和分区。历史订单按年归档到冷表,热表只保留近一年数据,分页的基数直接降一个数量级。搜索类需求交给 Elasticsearch,用 search_after 做深分页,MySQL 只负责按主键取详情。

总数 COUNT(*) 也是深分页的隐藏成本。翻到第一百万行时,COUNT(*) 可能比数据查询还慢。产品上可以接受“约 1.2 万条”这种近似值,或者把总数缓存起来,不要每次翻页都精确统计。

排查步骤

先用 EXPLAIN ANALYZE 对比两种写法,重点看 actual rows 和 loops:

EXPLAIN ANALYZE
SELECT id, user_id, amount, created_at
FROM orders
WHERE status = 'PAID'
ORDER BY id
LIMIT 1000000, 20;

EXPLAIN ANALYZE
SELECT id, user_id, amount, created_at
FROM orders
WHERE status = 'PAID'
  AND id > 1000000
ORDER BY id
LIMIT 20;

再看回表次数和扫描行数:

SHOW SESSION STATUS LIKE 'Handler_read%';

执行前后对比 Handler_read_next、Handler_read_rnd_next 的增量,能直观看到 offset 写法多扫了多少行。如果 EXPLAIN 里出现 Using filesort,说明排序没走索引,先补索引再谈分页优化。

常见坑

1. 排序字段不唯一

只按 created_at 排序,同一秒有多条记录时,MySQL 不保证跨页顺序稳定,用户会看到重复或漏掉的数据。排序键一定要带主键做兜底,例如 ORDER BY created_at DESC, id DESC。

2. 游标参数被篡改

把 lastId 直接暴露给前端,用户可以传一个任意值跳过权限过滤。游标值要做签名或服务端加密,并且和查询条件绑定,不能只当成一个普通参数。

3. 深分页和 JOIN 一起用

多表 JOIN 时,LIMIT 作用在最终结果集上,参与 JOIN 的每一行都可能被扫描和排序。先把主表分页取出来,再关联详情,通常比直接 JOIN 后再分页稳定。

4. 用 LIMIT 做“导出”

导出全量数据时写 LIMIT 0, 1000000,一次性把百万行读进内存,数据库和 JVM 都扛不住。导出应该按主键分批拉取,每批几千条,处理完再取下一批。

5. 忽略索引顺序

游标分页的 WHERE 条件里,等值列要放在范围列前面。(status, id) 可以用,(id, status) 在 status 是范围条件时效果会差很多。索引顺序要和查询模式匹配,不能只看字段有没有被索引。

小结

深分页的本质是“数据库为跳过的行付出了真实扫描成本”。延迟关联减少回表,游标分页消除 offset,业务限制和归档降低数据量。三种手段可以叠加使用,但优先选游标分页——它把复杂度从 O(offset) 降到了 O(limit)。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者