2750 字
约 9 分钟
16
S11-X15 · 分页与排序:深分页、游标与覆盖索引
2024-12-31
2026-11-08

后台列表页翻到第 500 页突然卡住,接口从几十毫秒变成几秒,第一反应往往是"数据库慢了"。真正的原因通常是 LIMIT 10000, 20 这行 SQL:它要求 MySQL 先读出前 10020 行、丢掉前 10000 行,再返回 20 行。翻得越深,丢得越多,成本与 offset 成正比。这篇把深分页、排序执行路径和总数统计三条线拆开。以下按 MySQL 8.0 + InnoDB 核对。


1. LIMIT offset, size 到底做了什么

-- 朴素写法:offset 越大,扫描与丢弃的行越多
SELECT id, user_id, amount, created_at
FROM t_order
WHERE status = 1
ORDER BY id DESC
LIMIT 100000, 20;

EXPLAIN SELECT id FROM t_order WHERE status = 1 ORDER BY id DESC LIMIT 100000, 20;
-- type=ref key=idx_status rows≈N Extra=Using index; Using where (8.0 会显示 Backward index scan)

执行过程分三步:按索引顺序取出满足条件的行 → 逐行丢弃前 offset 行 → 返回剩下的 size 行。被丢弃的行如果不在覆盖索引里,还要为每一行做一次回表,等于做了十万次无用的随机读。

分页方式 每页需要处理的行 能否跳页 结果一致性
LIMIT offset, size offset + size 能,任意页 翻页期间新数据插入会导致行漂移
延迟关联(覆盖索引先取主键) offset + size(但只扫索引) 能 同上
游标 / search_after size 不能,只能上一页/下一页 稳定,锚点之后不重不漏
限制最大页数 有上限 能,但只有前 N 页 同上

2. 三种优化:延迟关联、游标、限制页数

2.1 延迟关联:让丢弃发生在索引里

先只在覆盖索引上翻页拿到主键,再回表取这 20 行的完整字段:

SELECT o.id, o.user_id, o.amount, o.created_at
FROM t_order o
JOIN (
  SELECT id FROM t_order
  WHERE status = 1
  ORDER BY id DESC
  LIMIT 100000, 20
) t ON o.id = t.id;

子查询走 idx_status (status, id) 或主键,Extra 应为 Using index,十万次丢弃都发生在索引页上,只有最终 20 行回表。代价是 SQL 变复杂,且要求排序列在主键或某个索引里能形成有序前缀。

2.2 游标分页:把 offset 换成"锚点"

-- 第一页
SELECT id, created_at, amount FROM t_order
WHERE status = 1
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- 后续页:上一页最后一行的 (created_at, id) 作为锚点
SELECT id, created_at, amount FROM t_order
WHERE status = 1
  AND (created_at, id) < ('2026-11-01 10:00:00', 88421)
ORDER BY created_at DESC, id DESC
LIMIT 20;

前提有三个:排序键唯一且稳定(用 (created_at, id) 这种组合保证不重不漏)、必须有索引支撑这个有序扫描((status, created_at, id))、业务允许不能跳页。第三点常被忽略——后台运营常需要"跳到第 30 页",这时游标就不适用了。

2.3 限制最大页数

电商前台、日志列表这类场景,用户翻到第 1000 页本身没有意义。把 offset 上限卡在比如 5000,超出返回"请用筛选条件缩小范围"。这不是技术优化,是产品约束,但它比任何 SQL 改写都有效。


3. 排序的两条执行路径

ORDER BY 只有两种命运:顺着索引读,或者把结果集取出来自己排(Using filesort)。

EXPLAIN SELECT id, amount FROM t_order
WHERE status = 1 ORDER BY created_at DESC;
-- Extra: Using filesort  → created_at 不在 idx_status 的有序前缀里

ALTER TABLE t_order ADD KEY idx_status_created (status, created_at);
EXPLAIN SELECT id, amount FROM t_order
WHERE status = 1 ORDER BY created_at DESC;
-- key=idx_status_created Extra=NULL  → 顺索引读,无需排序

Using filesort 不一定用磁盘,它是"额外排序"的统称。内部有两种策略:

策略 触发条件 数据在哪 特点
全字段排序(原表模式) 需要返回的字段总长度小于 max_length_for_sort_data sort_buffer 一次排序即得完整行,内存占用大
rowid 排序(优先队列) 行宽超过阈值 sort_buffer 存 (排序键, rowid) 排完还要按 rowid 回表取字段,多一轮随机读

sort_buffer_size 不够时,排序会落盘做归并:把数据切成若干块分别排好写临时文件,再多路归并。SHOW STATUS LIKE 'Sort%' 里的 Sort_merge_passes 持续增长,就是落盘的信号:

SHOW VARIABLES LIKE 'sort_buffer_size';
SHOW VARIABLES LIKE 'max_length_for_sort_data';
SHOW STATUS LIKE 'Sort_%';
-- Sort_scan / Sort_range / Sort_rows / Sort_merge_passes

调大 sort_buffer_size 是每连接分配,几百个并发连接同时排序会把内存吃光,这不是一个可以随手调大的参数。


4. ORDER BY 用上索引的三个条件

条件 说明 反例
顺序一致 WHERE 的等值列必须是索引最左前缀 ORDER BY b 而索引是 (a, b) 且 a 无等值条件
方向一致 多列排序的升降方向要同向,或与 8.0 的降序索引匹配 ORDER BY a ASC, b DESC 配普通 (a, b)
字段是索引前缀 排序字段要构成索引的连续前缀 ORDER BY amount 而索引是 (status, created_at)
ORDER BY 子句 索引 (status, created_at, id),WHERE status=1 是否 filesort
created_at DESC 前缀匹配 否
created_at DESC, id DESC 双列同向 否
created_at ASC, id DESC 方向混合 是(除非建降序索引)
amount DESC 不在索引里 是
id DESC(无 status 条件) 缺最左等值列 是,走主键反向扫则否

8.0 支持 KEY idx (a ASC, b DESC) 这类降序索引,MySQL 5.7 只能靠反向扫描整体颠倒,混合方向排序会退化成 filesort。需要混合方向排序时,要么建降序索引,要么在 SQL 层统一方向再在应用里反转。


5. 随机取 N 条:四种写法与代价

-- 1. 直接随机排序:全表算一遍随机数再排序
SELECT id FROM t_order ORDER BY RAND() LIMIT 10;

-- 2. 主键区间随机:要求 id 连续且无大空洞
SELECT id FROM t_order
WHERE id >= (SELECT FLOOR(RAND() * (SELECT MAX(id) FROM t_order)))
ORDER BY id LIMIT 10;

-- 3. 先 COUNT 再随机 offset:offset 深了照样慢
SELECT id FROM t_order LIMIT FLOOR(RAND() * (SELECT COUNT(*) FROM t_order)), 10;

-- 4. 应用层取随机 id 列表再批量查(推荐给"每日一题"这类场景)
SELECT id FROM t_order WHERE id IN (?, ?, ?, ...);
写法 扫描量 随机性 适用
ORDER BY RAND() 全表 + 临时表排序 均匀 小表、离线任务
主键区间 索引范围扫 10 行 有空洞时偏斜 id 连续且删除少的表
COUNT + 随机 offset 一次 COUNT + 深偏移 均匀 中小表
应用层随机 id N 次主键等值 取决于取 id 的方式 高频接口

6. 分页总数:COUNT(*) 与它的替代品

列表页要显示"共 12,345 条",就得算总数。COUNT(*) 在 InnoDB 上必须扫一个最小的二级索引(没有则扫聚簇索引),无法像 MyISAM 那样直接读元数据。

-- 精确值:与列表查询同条件,最贵但最准
SELECT COUNT(*) FROM t_order WHERE status = 1 AND created_at >= '2026-01-01';

-- 估算值:EXPLAIN 的 rows 是采样估算,误差可能很大
EXPLAIN SELECT * FROM t_order WHERE status = 1;

-- 近似值:SHOW TABLE STATUS 的 Rows 也是估算
SHOW TABLE STATUS LIKE 't_order';
取法 精度 成本 适用
COUNT(*) 同条件 精确 随扫描行数线性增长 数据量可控、要求准
COUNT(*) 加缓存 精确(有滞后) 一次计算 + 缓存读 列表页高频、允许分钟级滞后
EXPLAIN 的 rows 估算,偏差可能数倍 极低 只用于"共约 N 条"文案
单独维护计数表 精确 写入时维护 状态组合少、写少读多

总数缓存的坑在于失效条件与列表查询不一致:缓存 key 只带了 status,但列表还按 created_at 过滤,数字就对不上;或者缓存 TTL 是 5 分钟,用户点进第二页看到的总数变了。稳妥做法是缓存 key 与查询条件一一对应,并在写入路径上主动失效,而不是只靠 TTL。


7. 排错:五种分页与排序的异常

现象 原因 处理
前几页快、后面越来越慢 offset 线性扫描 + 回表 延迟关联或游标分页,或限制最大页数
Extra=Using filesort 且 Sort_merge_passes 涨 排序字段不在索引有序前缀 建 (等值列, 排序列) 联合索引
分页结果出现重复或漏行 排序键不唯一,翻页期间数据变动 排序键补主键;改游标分页
COUNT(*) 与列表条数不一致 两者条件不同(少了过滤条件或软删标记) 抽出同一段 where 条件复用
导出接口把库拖垮 用 LIMIT offset 循环导出全表 改按主键区间切批,WHERE id > ? ORDER BY id LIMIT N
ORDER BY RAND() 打满 CPU 全表随机排序 改应用层随机 id 或主键区间

8. 边界:什么情况该换思路

场景 更合适的做法 理由
需要任意跳页 + 数据量大 搜索引擎(ES search_after / 快照分页) 关系库的 offset 成本无法消除
全量导出 主键区间分批 + 流式读取 避免长事务与大 offset
万亿级列表 预计算游标、物化视图 实时排序代价过高
多字段复杂排序 + 高并发 冗余排序列 / 搜索索引 索引组合爆炸
只关心总数趋势 近似统计 + 定时任务 精确 COUNT 成本高

9. 自测

  1. LIMIT 100000, 20 的成本为什么与 offset 成正比?被丢弃的行有没有可能不产生 IO?
  2. 延迟关联把丢弃操作放到了哪里?它为什么要求排序列在索引里?
  3. 游标分页为什么必须用 (created_at, id) 这种组合键而不是单个 created_at?
  4. Using filesort 的两种策略分别什么时候触发?Sort_merge_passes 增长说明什么?
  5. ORDER BY a ASC, b DESC 配普通联合索引为什么排序失效?8.0 怎么解决?
  6. 总数缓存与列表查询条件不一致时,会出现什么现象?怎么保证一致?
S11-X15 · 分页与排序:深分页、游标与覆盖索引
https://www.clxhxhhr.top/posts/5934/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。