MySQL 笔记:深分页优化,limit 100000,10 的几种解法

· Mysql笔记 MySQL 索引 SQL优化 · 0 阅读 · 约 5 分钟

前言

列表页第一页很快,翻到几千页之后接口开始冒烟。问题出在这句再普通不过的 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 硬扛