云选科普

MySQL 慢查询优化:别急着加索引,先看这五个信号

MySQL 查询变慢时,索引并不总是答案。本文从 EXPLAIN、索引写放大、深分页、JOIN 驱动表和线上锁等待五个角度,整理一套适合云数据库与自建实例的排查路径。

先建立正确的排查顺序

遇到慢查询,很多人第一反应是“补一个索引”。这个动作有时有效,但也可能让写入、复制延迟或磁盘压力变得更糟。更稳妥的顺序是:先确认执行计划,再看数据访问方式,最后把慢查询发生时的实例状态纳入判断。

对于云数据库和自建 MySQL,都可以把问题拆成三层:

  1. SQL 是否扫描了过多数据:看 typerows、过滤条件和排序方式。
  2. 索引是否带来新的写入成本:检查冗余索引、索引列更新频率和复制压力。
  3. 当时是否存在外部阻塞:包括锁等待、元数据锁、磁盘 I/O 或缓存抖动。

下面按这条路径展开。

1. key 有值,不代表查询已经高效

EXPLAIN 中的 key 只能说明优化器选择了某个索引,不能单独证明查询完成了精准定位。一个典型误区是把 type=index 当成“已经走索引”。

type=index 表示遍历整个索引,而不是按条件定位到一小段范围。假设订单表只有按时间排序的索引,查询却要筛选一个低选择性的售后标记:

SELECT id, order_no, pay_time
FROM t_order
WHERE refund_flag = 1
ORDER BY pay_time DESC
LIMIT 10;

优化器可能从时间索引的最新记录开始扫描,并逐条回表检查 refund_flag。如果满足条件的订单很少,为了找到 10 行结果,就可能读取大量索引记录和数据行。此时虽然 key 不为空,实际工作量仍然接近全量遍历。

看执行计划时,至少关注三项:

  • typeALLindex 都需要警惕;rangeref 等通常更接近按条件缩小范围,但仍要结合实际数据判断。
  • rows:这是优化器估算需要检查的行数。估算值接近表规模时,索引的收益通常有限。
  • ExtraUsing filesortUsing temporary 不一定意味着错误,但需要确认排序和临时表是否成为主要成本。

如果过滤条件和排序方向经常固定,可以评估联合索引,例如把高选择性过滤列与排序列放在同一索引中。但不要机械套用列顺序:应结合等值条件、范围条件、排序需求以及索引覆盖情况,用实际执行计划和压测验证。

2. 加索引可能改善读取,却增加写入放大

InnoDB 的每个二级索引都需要维护独立的 B+Tree。插入一行数据时,除了更新聚簇索引,还可能要更新多个二级索引;更新了索引列时,还会涉及旧索引项删除标记和新索引项写入。索引越多,写入路径、页面分裂、缓存占用和刷盘压力就越复杂。

因此,不能只拿一条查询从 800 ms 降到 120 ms 之类的单点结果,就认定索引方案成功。更应该同时观察:

  • 写入吞吐和提交延迟;
  • 主从或只读副本延迟;
  • Buffer Pool 命中、磁盘 I/O 和日志写入;
  • 索引是否真的被稳定使用。

在删除索引前,先确认没有业务依赖,并结合实际观察周期判断是否为长期未使用索引。可以参考 sys.schema_unused_indexes,但它受统计周期、重启和业务流量影响,不能把一次查询结果直接当成删除依据。生产变更还应先在低峰期、从库或灰度环境验证。

更实用的原则是:先查能否合并或删除冗余索引,再考虑新增索引。读写比例高、写入频繁的订单、流水和日志表尤其要注意这一点。

3. 深分页的瓶颈不在 LIMIT,而在跳过

下面这种查询看起来只返回 20 行:

SELECT id, created_at, amount
FROM orders
ORDER BY id
LIMIT 100000, 20;

数据库仍然需要定位并跳过前面的 100000 行,然后才能返回后 20 行。页码越深,扫描和回表成本越容易累积,在线接口还可能因为并发放大这种开销。

如果业务允许,优先使用基于游标或键集的分页,把上一页最后一条记录作为下一页起点:

SELECT id, created_at, amount
FROM orders
WHERE id > :last_id
ORDER BY id
LIMIT 20;

这种方式更适合按稳定且有索引的字段连续翻页。若需要按时间排序,要处理同一时间戳下的并列值,可使用 (created_at, id) 这样的复合游标,并保持排序和条件一致。

后台导出、报表和搜索结果还要考虑数据在翻页期间新增或删除的问题。需要稳定快照时,应采用明确的时间边界、业务版本号或专门的离线查询方案,而不是单纯把 LIMIT 的偏移量调大。

4. 大 JOIN 慢,先检查驱动表和过滤顺序

多表 JOIN 并不天然比拆成多次查询慢,关键在于连接顺序、过滤选择性和中间结果规模。一个常见问题是:真正的时间窗口或状态条件没有尽早缩小数据集,优化器选择了不合适的驱动表,后续循环就会放大成本。

排查时可以按以下步骤做:

  1. EXPLAINEXPLAIN ANALYZE 看每一层实际读取量与估算量是否偏离。
  2. 检查连接列是否有匹配索引,条件是否能在连接前尽早过滤。
  3. 更新表统计信息,确认数据分布变化没有让估算失真。
  4. 只有在确认单条 JOIN 计划确实不理想时,再评估拆成阶段性查询或使用更合适的中间结果方案。

例如,报表只涉及某个时间窗口内的少量订单,可以先取得符合窗口条件的订单 ID,再按索引查询明细、商品或商家信息。这样做不是“JOIN 越少越好”,而是控制每一步进入下一层的数据规模。小表连接、过滤后结果很小的场景,保留一条 JOIN 往往更简单。

5. 慢查询有时只是锁和资源问题的受害者

同一条 SQL 在测试库只需几毫秒,生产环境却频繁进入慢日志,不一定说明 SQL 本身变差。慢查询发生的那个时间点,实例可能同时遇到:

  • 大事务持有行锁,其他请求排队;
  • DDL 或未提交事务持有元数据锁;
  • 批处理占满磁盘 I/O;
  • 缓存被大范围扫描挤出,热点页命中率下降;
  • 连接池、CPU 或临时空间达到瓶颈。

所以要把单条 SQL 的分析和现场分析结合起来。可以先查看活动会话和等待状态:

SELECT id, user, time, state, LEFT(info, 120) AS sql_text
FROM information_schema.processlist
WHERE time > 5
ORDER BY time DESC;

随后根据 MySQL 版本和监控能力检查 InnoDB 锁等待、元数据锁、事务持续时间、磁盘 I/O 与连接数。行锁等待和元数据锁属于不同链路,不能只查一种视图就下结论。云数据库用户还应结合控制台的慢日志、活跃会话、CPU、磁盘和连接监控,把 SQL 时间点对齐。

一份可执行的慢查询清单

可以把处理流程固定成下面几步:

  1. 复现并保留现场:记录 SQL、参数、执行时间、实例负载和数据量。
  2. 看完整执行计划:不要只看 key,重点核对 typerows、过滤比例、排序和临时表。
  3. 确认数据访问路径:判断是全表/全索引扫描、深分页、回表过多,还是 JOIN 中间结果膨胀。
  4. 评估索引总账:新增索引的收益要和写入、存储、复制及维护成本一起算。
  5. 排除环境因素:检查锁、事务、I/O、CPU、缓存、连接池和后台任务。
  6. 小范围验证再发布:使用真实参数和接近生产的数据分布,确认读写两侧都没有明显回退。

结语

MySQL 慢查询优化不是“看到慢就加索引”。执行计划告诉你数据库准备怎么走,实例现场告诉你当时为什么走不动,索引和分页设计则决定这条路径能否长期承受业务增长。

EXPLAIN、数据分布和运行时监控放在一起判断,通常比单点修改 SQL 更容易找到真正的瓶颈,也更适合云数据库和自建 MySQL 的持续运维。

继续浏览

还想继续看,可以再看这些文章

适合想继续在同一主题下横向阅读、对比不同切入角度的用户。