线上出过一次事故:订单列表页翻到后面越来越卡,运营反馈翻到第几千页时,一个请求要跑十几秒。 DBA 查看慢查询日志,SQL 本身语法没问题:
vbnet
SELECT * FROM orders ORDER BY id LIMIT 100000, 10;
没有语法错误,但查询就是很慢。这就是典型的深分页问题。
今天把这件事从头到尾捋一遍:慢的根源是什么,常见解法优势在哪、又有哪些失效场景,以及我最终落地的方案。
一、先搞清楚它到底慢在哪
很多人对深分页的理解只有一句话:offset 越大,扫描的行越多。这句话没错,但只说对了一半。
MySQL 执行 LIMIT 100000, 10 的真实过程:沿着索引读出前 100010 行,丢弃前面 100000 行,只返回最后的 10 行。 如果这条 SQL 走的是二级索引(例如按 create_time 排序),前 10 万行每读取一行都要回表:拿主键去聚簇索引查询完整行数据,每一次回表都是一次随机 IO。
也就是说,深分页的损耗是双重的:无效扫描 + 无效回表。 这也能解释一个常见现象:同样偏移 10 万行,按主键排序的分页,比按二级索引排序快很多。因为主键索引本身就是数据,不需要回表。
只记住 "扫描行数多",你只能想到有限的解法;把回表开销也纳入考量,优化思路才会更开阔。
二、游标法:速度快,但有边界
最经典的优化方案就是游标分页:不再使用 offset,以上一页最后一条记录的 ID 作为查询起点。
sql
-- 示例
SELECT * FROM orders WHERE id > #{lastId} ORDER BY id LIMIT 10;
主键索引可以直接定位起点,只扫描 10 行,offset 带来的大量扫描、回表开销全部消失。实测千万级单表场景,原本几秒的查询可以压到几毫秒。
但方案评审时,我经常会提醒它的局限性:游标法的适用条件远比写法苛刻。
- 排序字段不是主键,比如按
create_time排序:可以用时间做游标,但时间会重复,需要组合(create_time, id)联合游标; - 业务需要跳页:无法拿到 "上一页最后一条 ID",游标直接失效。运营后台直接跳转到第 500 页这类需求,游标法不支持;
- 筛选条件动态多变:十几个可选查询维度,很难为每一种组合建立联合索引;
- 主键是雪花 ID / UUID:ID 不保证连续有序,
id > ?的查询语义本身存在隐患。
游标法非常适合 C 端 "下一页、顺序浏览" 的场景,性能上限很高,但它不是万能方案。
三、延迟关联:绝大多数场景的最优解
游标无法使用的时候,我的首选方案是延迟关联。核心思路:把查询主键和查询完整数据拆成两步。
子查询只在索引上查询 id,如果提前建好 (create_time, id) 这类联合覆盖索引,子查询只走索引,全程不需要回表。哪怕扫描 10 万行,只是顺序读取索引,开销很低。拿到 10 条主键之后,再统一回表 10 次获取全部字段。
示例 SQL:
vbnet
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders ORDER BY create_time LIMIT 100000, 10
) t ON o.id = t.id;
对比原始写法:同样扫描 10 万行,回表次数从 10 万次下降到 10 次,性能是量级上的提升。
延迟关联优势:
- 不需要改动前端交互,支持跳页;
- 兼容动态筛选条件;
- 仅在 SQL 和索引层面改造,业务侵入性小。
它不是速度最快的方案,但通用性最好。大部分深分页问题,做到延迟关联 + 覆盖索引就足够解决。
四、选型:不是所有深分页都值得优化
最后聊一个容易被忽略的点:优化的前提是值得优化。
- 百万以内表:原生 limit 基本够用,不要过度设计;
- 千万级单表、C 端高频顺序翻页:优先游标分页,性能最好;
- 多条件筛选、需要支持跳页:延迟关联 + 覆盖索引;
- 数据量更大、查询维度极其复杂:再考虑同步数据到 Elasticsearch、ClickHouse 等专用查询引擎做分页。
这里要权衡成本:数据同步延迟、一致性维护、额外组件运维成本。单纯为了解决分页接口引入 ES,很多时候属于杀鸡用牛刀。
还有一个反直觉的结论:后台管理系统低频查询,比如一个月才翻几百页,慢 2 秒其实影响很小。资源优先投入到 C 端列表这种高 QPS 的核心链路。
回到开头那次线上事故: C 端订单列表改成游标分页,管理后台复杂筛选页面使用延迟关联。两周上线后,慢查询日志里再也没有出现这条慢 SQL。
深分页优化没有标准答案,只有场景和边界。 把慢的原因拆成扫描开销、回表开销两层,理清每个方案的适用边界,远比死记 "用 ID 游标代替 limit" 更有价值。