分页查询几乎每个业务系统都会用到——列表展示、后台管理、接口返回数据,LIMIT m, n 随手就来。但当页码翻深、offset 变大时,查询会变得越来越慢,甚至拖垮数据库,这就是常说的“深分页”问题。这一节我们就来剖析它的根因,并给出几种经过验证的优化方案。
13.2.1 深分页为什么会慢
假设有这样一条典型的分页 SQL:
SELECT * FROM orders WHERE status = 1 ORDER BY id LIMIT 1000000, 20;
很多人以为 MySQL 会直接从第 1,000,001 条开始取 20 条,但实际上它的执行过程是:
- 从订单表(或索引)中按
ORDER BY id的顺序,读取出 前 1,000,020 条满足条件的行。 - 把前面 1,000,000 条扔掉。
- 把最后 20 条返回给客户端。
也就是说,哪怕你只需要 20 条数据,MySQL 也得把前 100 万条数据扫一遍。随着 offset 变大,需要扫描的数据量线性增长,I/O 和 CPU 成本都会飙升。如果业务允许用户翻到第 1000 页,这个 SQL 可能一次就吃掉几百 MB 的内存,查询耗时从毫秒级变成秒级甚至几十秒。
更糟的是,如果 WHERE 条件还要筛选,或者表没有合适的索引,可能还需要回表读取完整的行数据,成本就更高了。而 ORDER BY 一旦用了文件排序(Using filesort),哪怕 offset 不大,性能也会很差。
13.2.2 方案一:延迟关联——用覆盖索引减少回表开销
既然深分页的开销主要来自于“丢弃的海量行”,那我们能不能只扫描索引、不碰数据行,拿到目标主键后再去查完整行?这就是延迟关联(也叫索引分页)的思路。
改写方法分为两步:
-- 第一步:在索引上快速找到目标范围的主键 ID
SELECT id FROM orders WHERE status = 1 ORDER BY id LIMIT 1000000, 20;
-- 第二步:用主键 ID 去回表取完整数据
SELECT * FROM orders WHERE id IN (第一步的结果);
合并成一条 SQL 就是最常见的延迟关联写法:
SELECT * FROM orders AS o
INNER JOIN (
SELECT id FROM orders
WHERE status = 1
ORDER BY id
LIMIT 1000000, 20
) AS tmp ON o.id = tmp.id;
前提是 (status, id) 上建立了联合索引,或者 id 本身就是主键且 status 有单独索引。这样,子查询内部的 ORDER BY id 可以利用联合索引走覆盖扫描,只需要遍历索引页,完全不需要回表。扫描 100 万行索引的成本远低于扫描 100 万行完整数据。拿到 20 个主键之后,再通过主键关联取数据,回表也只有 20 次,整个查询可能在 0.1 秒内完成。
看 EXPLAIN 结果时,你会注意到子查询的 Extra 列中出现了 Using index,表示使用了覆盖索引。这是该方案的核心标志。
13.2.3 方案二:基于游标的分页——让 offset 消失
延迟关联能缓解问题,但并没有消除“扫描再丢弃”的过程,只是把丢弃的代价降到最低。如果你的业务场景允许(比如 App 无限下拉、滑动翻页),最彻底的优化方案是不用 offset,改用游标。
游标分页的原理很直观:每次查询时,不告诉数据库“跳过多少行”,而是告诉它“从某条记录之后开始取”。比如:
-- 第一页
SELECT * FROM orders WHERE status = 1 ORDER BY id LIMIT 20;
-- 得到最后一条 id 是 100
-- 第二页
SELECT * FROM orders WHERE status = 1 AND id > 100 ORDER BY id LIMIT 20;
每一页的查询都通过 WHERE id > 上一页最后一条的id 来定位起始点,完全没有 offset,数据库每次只需要扫出 20 行即可。这种方案的性能与页码深度完全无关,即使是“第100万页”也能瞬间返回。
但它的局限也很明显:
- 只能顺序翻页,不能跳页。用户不能直接点“第 50 页”,只能一页一页往下划。
- 排序字段必须是不重复的、连续的或至少单调递增的。主键
id是最理想的选择;如果按时间排序,需要保证时间戳足够的精度,或者用id和时间戳结合避免重复。 - 如果
WHERE条件中有其他筛选且索引不完美,仍然需要利用合适的联合索引。推荐在业务上设计合理的分页字段,并与前端交互方式达成一致。
在很多 C 端产品(比如信息流、商品列表)中,游标分页是主流做法,因为它将分页查询从 O(n) 优化到了 O(1),对数据库极其友好。
13.2.4 方案三:业务层限制与设计取舍
有时候,即使 SQL 已经优化到了极致,深分页的性能仍然不理想,或者业务上确实需要跳页能力。这时候就得从产品设计和架构层面想办法。
- 限制最大翻页深度:比如只允许翻到前 100 页,超过的部分引导用户使用更精细的筛选条件。Twitter、微博等平台很早就用这种方式保护后端。
- 用搜索引擎分担:当数据量极大(千万级、亿级)时,列表查询的排序和过滤需求可以交给 Elasticsearch、MeiliSearch 等搜索引擎来完成。它们天生为分页和全文检索设计,深分页也有 scroll / search_after 等高效方案。
- 离线预计算或缓存:对排行榜、月度统计这类非实时数据,可以定时计算并缓存 Top 数据,避免每次请求都直接打到数据库。
- 避免全量数据翻页:后台管理系统中的“导出全部”功能不要通过分页查询拼装,直接用流式读取或走离线导出通道。
13.2.5 实践中的注意事项
- 先确保索引正确:任何分页优化都要以索引为首要前提。没有合适索引,offset 再小也会慢。
ORDER BY字段必须落到索引中,且最好与WHERE条件构成联合索引,以保证数据本身就是有序读取的。 - 警惕隐式排序:如果 SQL 里没有显式
ORDER BY,MySQL 返回的顺序是不确定的。每次执行LIMIT可能得到不同的结果,这比性能问题更危险。分页查询必须有明确的ORDER BY。 - MySQL 8.0 的倒序索引:对于需要双向分页的场景(上一页/下一页),可以利用 MySQL 8.0 对
ORDER BY id DESC使用倒序索引的能力,避免额外排序。 - 结合业务量做决策:如果用户总数就几千个,深分页根本不是问题。优化要基于实际数据量和访问模式,不要过早优化。
归根到底,深分页优化是一个“用索引减少扫描、用游标替代偏移、用架构弥补SQL”的过程。平时写 LIMIT 0,20 的时候顺手想一下:如果这里后面加两个零,SQL 还撑得住吗?有这个意识,就比写出裸 LIMIT 已经好一大步了。