深分页与索引优化

列表页翻到几百页之后接口突然变慢,是后端最常见的性能问题之一。根因往往不是“数据量大”,而是 LIMIT 1000000, 20 这类写法迫使数据库先扫描并丢弃前一百万行。本章讲清深分页的成因、四种优化写法、联合索引的最左前缀规则,以及索引失效与 EXPLAIN 的排查清单。

深分页的代价:扫描并丢弃

LIMIT offset, n 的执行过程是:先定位满足条件的行,逐行取出(可能回表),丢弃前 offset 行,最后返回 n 行。offset 越大,白扫的行越多,代价近似线性增长。

-- 慢:扫描约 1000020 行,丢弃 1000000 行,只返回 20 行
SELECT id, title, created_at FROM article ORDER BY id LIMIT 1000000, 20;
执行量拆解
├─ 扫描行数   ≈ 1000020
├─ 回表次数   ≈ 1000020(若走的是二级索引)
├─ 返回行数   = 20
└─ 有效利用率 ≈ 0.002%

优化方向只有两个:减少扫描行数,或减少回表次数。

优化一:延迟关联

先用覆盖索引只查主键(不回表),取出 20 个 id 后再回表拿详情:

-- 快:子查询在索引上完成,最后只回表 20 次
SELECT a.id, a.title, a.created_at
FROM article a
JOIN (SELECT id FROM article ORDER BY id LIMIT 1000000, 20) t ON a.id = t.id;

前提是排序字段上有索引;ORDER BY 的列没有索引时,子查询自己就要全表扫描并排序。

优化二:游标分页

让客户端记住上一页最后一条的游标值,下一页用它做范围查询,offset 恒为 0:

-- 第一页
SELECT id, title FROM article ORDER BY id DESC LIMIT 20;
-- 后续页:lastId 取上一页最后一条的 id
SELECT id, title FROM article WHERE id < 1000000 ORDER BY id DESC LIMIT 20;

代价是不能随机跳页,适合“下拉加载更多”。若排序列不唯一(例如按创建时间),用组合游标保证不重不漏:

SELECT id, title FROM article
WHERE (created_at, id) < ('2024-05-01 10:00:00', 1000000)
ORDER BY created_at DESC, id DESC LIMIT 20;

优化三:覆盖索引

查询涉及的列全部包含在索引中,就无需回表,“扫一百万行”的成本会低一个数量级:

ALTER TABLE article ADD INDEX idx_status_id (status, id);
EXPLAIN SELECT id FROM article WHERE status = 1 ORDER BY id LIMIT 1000000, 20;
-- 输出 Extra: Using where; Using index  (Using index 即覆盖索引,不回表)

不要为了覆盖把大字段塞进索引,索引过宽会拖慢写入并占用更多内存。

优化四:业务层限制

  • 限制最大翻页数(如只允许翻到第 100 页),超出引导用户增加筛选条件。
  • 搜索场景做深分页降级:超过阈值只返回前 N 页,并提示收窄条件。
  • 冷数据统计与导出走离线任务或搜索引擎,不要压在线库。

联合索引与最左前缀

联合索引 (a, b, c) 相当于先按 a、再按 b、再按 c 排序,因此条件必须从最左列开始连续匹配。

ALTER TABLE orders ADD INDEX idx_uid_status_ctime (user_id, status, created_at);
查询条件能否用上索引说明
WHERE user_id=?命中第 1 段
WHERE user_id=? AND status=?连续命中前 2 段
WHERE status=?不能跳过最左列
WHERE user_id=? ORDER BY status排序同样遵循最左前缀
WHERE user_id=? AND created_at>?部分user_id 用于定位,created_at 无法继续剪枝

范围条件会中断后续列的索引使用,因此通用写法是:等值条件放前面,范围条件放后面。

索引失效清单

写法是否失效原因与改法
WHERE DATE(created_at)='2024-05-01'失效列上有函数,改成 created_at>='2024-05-01' AND created_at<'2024-05-02'
varchar 列写 WHERE user_id = 1001失效隐式类型转换等价于对列做函数,入参类型要与列一致
WHERE name LIKE '%张'失效前导通配符无法利用有序性,尽量改成 LIKE '张%'
WHERE a=1 OR b=2(b 无索引)可能失效OR 两侧都要能走索引,否则退化为全表扫描
WHERE status != 0通常失效区分度低时优化器直接选择全表扫描
WHERE age + 1 > 18失效列参与运算,改成 age > 17
WHERE user_id IN (SELECT ...)视优化器而定可能走不上索引,改写为 JOIN 更稳定

判断标准一句话:索引列是否以“裸列”形式出现在可比较的位置上。

EXPLAIN 关键列解读

EXPLAIN SELECT id, title FROM article WHERE status = 1 ORDER BY id LIMIT 20;
关注点
type访问类型:const > eq_ref > ref > range > index > ALL,出现 ALL 基本是全表扫描
key实际使用的索引,为 NULL 表示没走索引
rows预估扫描行数,深分页时这里会是几十万甚至上百万
filtered过滤后剩余比例,过低说明条件区分度差
ExtraUsing index 覆盖索引;Using filesort 额外排序;Using temporary 用到临时表

深分页的判断口诀:rows 很大 + key 只用到主键 + Extra 里没有 Using index,基本可确定是“扫描丢弃”造成的。

小结:深分页的本质是扫描量而非数据量,优先改用游标分页,其次用延迟关联与覆盖索引;再配合最左前缀规则与失效清单排查索引问题,最后用 EXPLAIN 的 type、rows、Extra 三列做确认。

笔记加载中…