前言
列表页第一页很快,翻到几千页之后接口开始冒烟。问题出在这句再普通不过的 SQL 上:
SELECT id, title, views FROM article
ORDER BY id DESC
LIMIT 100000, 10;
为什么慢
LIMIT 偏移量, 条数 的语义是"跳过前面 N 条再取 10 条",但 InnoDB 没有捷径:它必须按顺序把这 100010 条真的取出来,丢掉前 100000 条。如果 SELECT 里还有索引上没有的列,每条还要回表一次,等于做了十万次主键树查找。页码越深,被丢弃的行越多,代价和偏移量成正比,跟真正返回的 10 行几乎无关。
EXPLAIN 里看不出这个代价有多直观(rows 是估算值,key_len 也正常),真正能反映问题的是慢查询日志里的 Rows_examined 远大于 Rows_sent——扫了十万行,只返回十行。
解法一:延迟关联(也叫子查询分页)
把回表次数从十万降到十:先在覆盖索引上把 10 个主键找出来,再用主键关联取完整行。
SELECT a.id, a.title, a.views
FROM article a
INNER JOIN (
SELECT id FROM article
ORDER BY id DESC
LIMIT 100000, 10
) AS t ON a.id = t.id;
子查询只碰索引(Extra 里是 Using index),排序和跳过都在索引层完成,主查询只对最终 10 行回表。改动小、前端不用动,是最好落地的方案。要注意它只是把回表省掉了,扫描十万个索引条目这件事还在,偏移量再大一级照样会变慢。
解法二:游标翻页(记住上一页的位置)
不传页码,传"上一页最后一条记录的排序键":
SELECT id, title, views FROM article
WHERE id < 1234567
ORDER BY id DESC
LIMIT 10;
代价恒定,因为直接从索引上的位置开始读 10 条。这是 App 信息流、"加载更多"的标准做法。限制也很明确:只能上一页/下一页连续翻,不能跳页;排序键必须唯一且有序,如果按 views DESC 排,要补上主键做决胜列:
WHERE (views, id) < (1024, 987654) -- 元组写法,语义是逐列比较
ORDER BY views DESC, id DESC
LIMIT 10;
元组比较可读性好,但 5.7 的优化器不一定能为它设计出好的索引访问路径,实际项目里更常见的是等价的展开写法:
WHERE views < 1024 OR (views = 1024 AND id < 987654)
ORDER BY views DESC, id DESC
LIMIT 10;
配合 (views, id) 的联合索引,两种写法都能走范围扫描,展开写法更稳。
解法三:让排序列上有索引
深分页慢的一半原因是排序:ORDER BY 走不了索引时,优化器要先把符合条件的行全部取出排序,再丢弃偏移量部分。补一个 (排序列, 主键) 形态的联合索引,让索引顺序就是结果顺序,能同时消掉 Using filesort 和部分扫描。这条和解法一是叠加关系。
解法四:从业务上限制偏移量
- 只允许翻到前 100 页,后面用筛选条件缩小范围(大多数站点搜索也是这么做的)
- 总数用缓存:
SELECT COUNT(*)单独缓存几分钟,翻页时不再重复统计 - 后台报表按时间区间导出,别把十万偏移量的 SQL 放在在线接口上
- 检索要求高的场景把列表数据同步到 Elasticsearch,MySQL 只按主键回表取详情
两个顺带的坑
SELECT SQL_CALC_FOUND_ROWS ... LIMIT 10 加 FOUND_ROWS() 看起来方便,实际会把整个结果集都算一遍来拿总数,深分页时更亏,能不用就不用。
另外注意:翻页时如果期间有新数据插入,同一条记录可能在两页里各出现一次,或者被跳过。排序里加一个稳定决胜列(一般是主键)能避免重复,这是分页正确性问题,和性能无关但更容易被用户发现。
小结
- OFFSET 的代价是"跳过",与偏移量成正比,与返回行数无关
- 延迟关联省回表,游标翻页省跳过,两个思路解决的是不同环节
- 排序列缺索引会让深分页更难收拾,先保证
ORDER BY有索引可用 - 真正深的分页应该在产品设计上消灭,而不是靠 SQL 硬扛