慢 SQL 不是加索引就完了:一次分页查询优化的拆解

慢 SQL 不是加索引就完了:一次分页查询优化的拆解

这两年我见过不少线上慢查询,第一反应都是“补个索引试试”。这个动作不能说错,但很多时候它只解决了表象。真正拖垮接口延迟的,往往是查询写法、分页方式和回表成本叠在一起。

最近碰到一个典型场景:订单列表页按创建时间倒序分页,数据量大概 3000 万。原始 SQL 很直接:

SELECT id, order_no, user_id, amount, status, created_at
FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC
LIMIT 100000, 20;

线上表现很差。前几页还行,翻到后面延迟明显飙升,有时候一条查询能跑到 2 秒以上。EXPLAIN 看起来也不是完全没走索引,但问题在于 LIMIT 100000, 20 这种深分页,本质上还是要先扫描并丢掉前面那 10 万行。扫描到了不代表成本低。

一、先把“找主键”和“取完整数据”拆开

如果这时候只补一个普通索引:

CREATE INDEX idx_orders_status_created
ON orders(status, created_at);

性能会有改善,但不够稳定。原因很简单,查询字段太多,MySQL 大概率还是要大量回表。更稳妥的做法是先把“找主键”这件事和“取完整数据”拆开:

SELECT o.id, o.order_no, o.user_id, o.amount, o.status, o.created_at
FROM orders o
JOIN (
  SELECT id
  FROM orders
  WHERE status = 'PAID'
    AND created_at < '2026-07-01 12:00:00'
  ORDER BY created_at DESC
  LIMIT 20
) t ON o.id = t.id
ORDER BY o.created_at DESC;

这里有两个关键点。

  1. 不再使用 offset 深分页,改成基于游标的翻页。前端把上一页最后一条记录的 created_at 传回来,后端继续往后查。这样数据库不用做无意义的跳过动作,扫描范围会小很多。
  2. 子查询尽量只拿 id,让联合索引覆盖这一步,再回主表补齐剩余字段。对热点列表页,这种“先定位,再取详情”的模式非常实用。

二、索引要跟着查询写法调整

我最后用的是:

CREATE INDEX idx_orders_status_created_id
ON orders(status, created_at DESC, id);

id 放进索引,不只是为了覆盖查询,也是为了在排序字段重复时给结果一个稳定的次序。否则只按 created_at 排序,翻页过程中很容易出现重复或漏数。

落地时还有一个经常被忽略的点:不要只看 EXPLAIN 里有没有 Using index。更该盯的是扫描行数、回表次数,以及接口在真实数据分布下的 P95 延迟。测试库里 10 万行和线上 3000 万行,结论很可能完全相反。

我一般会顺手跑两条命令做交叉验证:

EXPLAIN ANALYZE SELECT ...;
SHOW INDEX FROM orders;

前者看执行阶段到底花时间在哪,后者确认索引顺序是不是和查询条件一致。很多“索引明明建了还是慢”的问题,最后都死在最左匹配或者排序方向不一致上。

三、数据库优化先想清访问路径

数据库优化说到底不是堆技巧,而是先把访问路径想清楚。SQL 到底要扫描多少、丢弃多少、回表多少次,这三个问题比“要不要加索引”更重要。把这层想明白,很多慢查询其实都能用比较朴素的方法收回来。

posted @ 2026-07-29 09:04  fitch_liu  阅读(9)  评论(0)    收藏  举报