所有内容均为测试可用,真实
当前位置:绿茶加糖-郭保升 > 数据库资料 > 正文

用SQL执行顺序定位SQL慢查询瓶颈

我们先看问题:“SQL执行结果正确,但耗时很长,如何用SQL执行顺序定位瓶颈?”为了回答这个问题,下面,我们分三个层次展开:先一目了然给出答案,再详细分析,最后通过模拟示例逐步拆解。

简答:用EXPLAIN分析执行计划,我们重点看“全表扫描”、“回表”、“排序”三个环节。具体步骤如下:

1、执行EXPLAIN 目标SQL,我们查看执行计划;
2、如果有ALL(全表扫描):我们检查WHERE字段是否有索引,新增索引覆盖筛选;
3、如果有Using filesort(文件排序):我们检查ORDER BY字段是否有索引,新增联合索引覆盖排序;
4、如果ExtraUsing index,且SELECT字段不在索引中(存在回表):我们检查SELECT字段是否在索引中,新增覆盖索引避免回表;

示例(慢查询优化):

-- 慢查询:全表扫描+文件排序
EXPLAIN SELECT user_id, SUM(amount) FROM orders WHERE order_time >= '2024-01-01' GROUP BY user_id ORDER BY SUM(amount) DESC;

-- 优化:新增覆盖索引(order_time:筛选,user_id:分组,amount:聚合)
CREATE INDEX idx_time_user_amount ON orders(order_time, user_id, amount);

详解:我们详细分析如何利用SQL执行顺序定位SQL性能瓶颈?

SQL执行引擎处理查询时遵循特定的逻辑执行顺序(如:FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT)。EXPLAIN输出的执行计划(Execution Plan)揭示的是数据库引擎实际执行查询的步骤和顺序(物理执行顺序)。通过分析执行计划,我们可以定位哪个步骤消耗了最多资源(通常是I/O或CPU),从而找到性能瓶颈。

一、获取执行计划

1、执行EXPLAIN [FORMAT=JSON] <我们的SQL语句>;

2、FORMAT=JSON(MySQL 5.6.5+)或EXPLAIN ANALYZE(PostgreSQL, MySQL 8.0.18+)可以提供更详细的信息,包括实际执行时间行数,比基础的EXPLAIN更有价值。我们应优先使用这些格式。

3、不要只看文字描述,我们要理解执行树的结构和流向(通常从最内层/最底层的表开始扫描)。

二、分析执行计划,定位性能瓶颈

1、type列 (访问类型):这是关键指标之一,表示如何查找表中的行。性能从优到劣大致为:system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL

(1)重点关注ALL(全表扫描):这是常见瓶颈点。意思是数据库必须读取整张表的所有行。此时我们要检查:
① WHERE子句中的条件字段是否有合适的索引?
② 索引是否因为函数操作(如:WHERE YEAR(date_column) = 2024)、类型转换或不当的比较符导致失效?
③ 表数据量是否过大?

(2)关注index(全索引扫描):虽然它比ALL好(只读索引,不读数据行),但如果索引很大或需要扫描大部分索引,也可能很慢。此时,我们要考虑是否能把它优化为rangeref

(3)关注range(索引范围扫描):通常较好,但如果范围太大(如:id > 0),效果接近全索引扫描。原因是:当range扫描的范围覆盖索引的90%以上数据时,优化器可能会放弃range,转而选择ALL(全表扫描)(因为全表扫描的 “顺序I/O”比“大范围索引扫描 + 可能的回表” 更高效)。

2、key列(实际使用的索引):我们确认是否使用了预期的索引。如果为NULL,则没有使用索引。

3、rows列(预估扫描行数):表示执行器预估需要检查的行数。我们对比rows和实际表大小:如果rows远大于实际符合条件的行数,说明索引选择性差或统计信息不准确。EXPLAIN ANALYZE会显示实际扫描行数,更精准。

4、filtered列(过滤比例)(MySQL):表示存储引擎层返回的行中,有多少百分比会被后续条件(在Server层)过滤掉。低百分比(如:<10%)通常表示索引效率高;高百分比(接近100%)可能意味着索引筛选效果差。

5、Extra列(额外信息),包含非常重要的执行细节:

(1)Using filesort(文件排序):瓶颈点!表示无法利用索引完成排序(ORDER BYGROUP BY),需要在内存或磁盘上进行额外排序操作。排序操作消耗CPU和内存,数据量大时非常慢。此时我们要检查:

① ORDER BYGROUP BY的字段是否有索引?特别是多字段排序/分组时,顺序是否匹配?
② 能否创建覆盖索引(包含SELECT字段),避免回表后再排序?

(2)Using temporary(使用临时表):瓶颈点!表示需要创建内部临时表来处理查询(常见于GROUP BYDISTINCTUNION、子查询等等)。创建和操作临时表(尤其在磁盘上)开销很大。此时我们要检查:

① 复杂的GROUP BYDISTINCT能否优化?
② 子查询能否改写为JOIN
③ 临时表是否太大,导致使用磁盘(SHOW STATUS LIKE 'Created_tmp%tables' 查看磁盘临时表使用情况)。

例外:在某些情况下,GROUP BY/DISTINCT可避免Using temporary

GROUP BY/DISTINCT的字段与索引的前缀字段完全匹配,且索引顺序与分组顺序一致时,数据库可直接利用索引的有序性聚合,无需临时表。 例如:索引为idx_user_order(user_id, order_time),查询SELECT user_id, COUNT(*) FROM orders GROUP BY user_id,此时GROUP BY user_id与索引前缀一致,Extra不会出现Using temporary

(3)Using index condition(索引条件下推 - ICP)(MySQL):通常是一个优化,表示部分WHERE条件在存储引擎层用索引过滤了。这不是瓶颈点,但提示我们使用了索引。

(4)Using where:表示在存储引擎返回行后,Server层还需要应用WHERE条件进行过滤。如果rows很大且filtered很低,说明索引过滤效果不好,Server层过滤负担重。

(5)Select tables optimized away:好现象!表示优化器发现查询可以从索引直接获取结果(如:MIN(key_column)),无需访问数据行。

6、possible_keys列(可能使用的索引):列出查询可能使用的索引。如果key列为NULL,但possible_keys有值,说明优化器认为使用索引不如全表扫描快(可能因为索引选择性差、统计信息不准或查询需要大部分数据)。

三、针对性优化

1、全表扫描(type: ALL):

(1)我们为WHERE子句中的筛选条件字段创建索引。

(2)我们检查索引是否失效(函数、类型转换、OR条件不当等)。

(3)考虑分区表(对大表)。

2、文件排序(Using filesort):

(1)我们为ORDER BYGROUP BY的字段创建索引。多字段时,索引字段顺序需与ORDER BY/GROUP BY顺序一致。

(2)创建覆盖索引(Covering Index),即索引包含SELECTWHEREORDER BY/GROUP BY涉及的所有字段。这样查询可以仅通过索引完成,避免回表和数据排序。

3、临时表(Using temporary):

(1)我们优化GROUP BY查询,确保GROUP BY字段有索引。

(2)我们尝试简化复杂查询,避免不必要的DISTINCT或子查询。

(3)我们增大tmp_table_sizemax_heap_table_size参数(MySQL),让更多临时表在内存中处理。

4、回表过多:即使使用了索引(type: ref, range),如果SELECT的字段不在索引中(非覆盖索引),数据库引擎需要根据索引找到的主键值,去数据文件中,读取整行数据(回表)。如果回表次数很多(rows很大),也会很慢。

(1)我们创建覆盖索引(包含所有SELECT字段)。

(2)我们只查询必要的字段(避免SELECT *)。

:如何通过EXPLAIN精准识别回表?

回表的判断依据是key(使用的索引)与SELECT字段的匹配关系:

  • key对应的索引包含SELECTWHEREGROUP BY/ORDER BY的所有字段 → Extra显示Using index(覆盖索引),无回表;
  • key对应的索引仅包含WHERE/GROUP BY字段,不包含SELECT字段 → 无Using index,必然回表。

例如:索引idx_order_time(order_time),查询SELECT user_id FROM orders WHERE order_time >= '2024-01-01' → 无Using index,需回表(通过order_time索引找到主键,再去聚簇索引读user_id)。

5、索引选择性差:即使有索引且被使用(type: ref),如果索引列的值重复度很高(如:status字段只有几个值),rows值仍然会很大。

(1)我们考虑使用复合索引,增加筛选条件。

(2)如果数据分布极度倾斜,可能我们需要其他策略。

6、JOIN效率低:检查JOIN的顺序(执行计划中的嵌套顺序)、ON条件的字段是否有索引、被驱动表(内层表)的访问类型(避免ALL)。

7、统计信息不准:优化器依赖统计信息选择执行计划。如果EXPLAINrows预估与实际相差甚远,可能导致选择了次优计划。

执行ANALYZE TABLE <table_name>;(MySQL)或ANALYZE;(PostgreSQL)更新统计信息。

四、瓶颈分析

-- 慢查询:全表扫描+文件排序
EXPLAIN SELECT user_id, SUM(amount) FROM orders WHERE order_time >= '2024-01-01' GROUP BY user_id ORDER BY SUM(amount) DESC;

1、WHERE order_time >= '2024-01-01':如果 order_time无索引,则type可能是ALL(全表扫描)。即使有索引,因为是范围查询,后续用于GROUP BY的索引可能失效。

2、GROUP BY user_id:如果没有合适的索引支持分组,需要临时表 (Using temporary)。

3、ORDER BY SUM(amount) DESC:对聚合结果排序,无法利用user_idorder_time的索引,必然导致文件排序(Using filesort)。

五、优化建议

-- 优化:新增覆盖索引(order_time:筛选,user_id:分组,amount:聚合)
CREATE INDEX idx_time_user_amount ON orders(order_time, user_id, amount);

1、order_time:满足范围筛选条件。

2、user_id:紧跟在等值或范围筛选条件后,支持GROUP BY user_id(如果order_time是等值条件,user_id可以用于分组;如果是范围,效果可能打折扣,但比没有好)。

3、amount:作为“附加列”包含在索引中。这使得这个索引成为覆盖索引

(1)查询的字段user_idamount都在索引中。

(2)WHERE条件order_time在索引中。

(3)GROUP BY user_id可以利用索引(至少部分利用,避免全表临时表)。

(4)我们认为,重要的是:SUM(amount)计算所需的数据amount可以直接从索引中读取,避免了回表。虽然ORDER BY SUM(amount)可能仍然需要排序(除非优化器能直接利用索引顺序,但因为SUM是聚合,一般无法直接利用索引的物理顺序来避免排序操作),但避免了回表已经能大幅减少I/O。

六、小结

1、我们优先使用EXPLAIN FORMAT=JSONEXPLAIN ANALYZE获取更精确的实际执行信息。

2、我们系统性地阅读执行计划:理解执行顺序(树结构),关注typekeyrowsfilteredExtra列。

3、我们重点识别瓶颈点:ALL(全表扫描)、filesort(文件排序)、temporary(临时表)、大量回表(非覆盖索引且rows大)。

4、针对性优化:创建/调整索引(特别是覆盖索引)、优化查询写法、更新统计信息、调整参数。

5、测试验证:优化后务必再次执行EXPLAIN和实际运行查询,对比性能提升效果。

模拟:通过模拟数据和执行计划对比,我们一步一步展开以上示例,直观观看“执行顺序如何暴露瓶颈”,以及“索引如何改变执行路径”。

假设我们有一张订单表orders,结构如下:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    amount DECIMAL(10,2) NOT NULL,
    order_time DATETIME NOT NULL,
    product_name VARCHAR(255),
    -- 其他字段……
    INDEX idx_order_time (order_time) -- 注意:初始只有这个索引!
);

插入100万条模拟数据(user_id随机1~10000,amount随机10~1000,order_time在2023-01-01到2025-12-31之间均匀分布)。

第一步:执行原始慢查询 + EXPLAIN

原始查询:

EXPLAIN FORMAT=JSON
SELECT user_id, SUM(amount) as total_amount
FROM orders 
WHERE order_time >= '2024-01-01' 
GROUP BY user_id 
ORDER BY total_amount DESC;

假设EXPLAIN输出关键部分(简化版)

{
  "query_block": {
    "select_id"1,
    "cost_info": { "query_cost""120000.00" },
    "table": {
      "table_name""orders",
      "access_type""range",           // 使用了order_time索引进行范围扫描
      "possible_keys": ["idx_order_time"],
      "key""idx_order_time",
      "used_key_parts": ["order_time"],
      "rows_examined_per_scan"500000// 预估扫描50万行(2024年后的数据)
      "rows_produced_per_join"500000,
      "filtered""100.00",
      "cost_info": { "read_cost""20000.00""eval_cost""100000.00" },
      "used_columns": ["user_id""amount""order_time"],
      "attached_condition""(`orders`.`order_time` >= '2024-01-01')",
      "using_filesort"true           // 瓶颈1:需要文件排序
    },
    "grouping_operation": {
      "using_temporary_table"true,   // 瓶颈2:需要临时表
      "using_filesort"false          // GROUP BY本身不排序,ORDER BY才排序
    }
  }
}

注:FORMAT=JSON输出更结构化,我们这里做了简化。关键看access_typerowsusing_temporary_tableusing_filesort

第二步:根据执行计划定位瓶颈点(按执行顺序)

SQL逻辑执行顺序是:FROM → WHERE → GROUP BY → SELECT → ORDER BY

数据库实际执行路径(根据EXPLAIN):

1、FROM + WHERE阶段

  • access_type: range + key: idx_order_time → 用上了order_time索引,避免了全表扫描(ALL)。
  • rows: 500000 → 因为是范围查询(2024-01-01之后),预估要扫描50万行数据。第一步的代价!

2、GROUP BY阶段using_temporary_table: true → 瓶颈点1!虽然WHERE用索引找到了50万行,但这些行是按order_time排序的,而GROUP BY需要按user_id分组。数据库无法直接利用现有索引完成分组,必须创建一个临时表(通常是哈希表或排序表),来存储(user_id, SUM(amount))的中间结果。创建和维护这个临时表(尤其在内存不足时会落盘),开销巨大!

3、ORDER BY 阶段using_filesort: true → 瓶颈点2!ORDER BY的是SUM(amount),它是一个聚合计算的结果,没有任何索引能直接对这个结果排序。数据库必须对上一步GROUP BY产生的所有分组结果(假设有8000个不同user_id),进行一次额外的排序操作(内存排序或磁盘外部排序)。这些排序操作消耗CPU和可能的I/O。

4、SELECT 阶段(回表问题)idx_order_time索引虽然帮助我们快速定位了order_time >= '2024-01-01'记录位置(主键值),但SELECT需要user_idamount。可是idx_order_time只包含order_time字段。对于索引扫描找到的每一行(50万行),数据库都需要根据主键值回表(回聚簇索引/主键索引),去数据行中读取user_idamount50万次回表!这是巨大的随机I/O开销,是隐藏的、主要的性能杀手。在基础EXPLAIN中可能不直接显示“回表”,但used_columns包含了非索引列,且access_type不是index(覆盖索引),就暗示了回表。

瓶颈点主要有:

  • 主要瓶颈:50万次回表(I/O密集)。
  • 次要瓶颈:GROUP BY导致的临时表(CPU/内存/可能I/O)。
  • 第三瓶颈:ORDER BY导致的文件排序(CPU/可能I/O)。
第三步:创建优化索引并再次EXPLAIN

根据建议,我们创建覆盖索引:

-- 优化:新增覆盖索引(order_time:筛选,user_id:分组,amount:聚合)
CREATE INDEX idx_time_user_amount ON orders(order_time, user_id, amount);

再次执行:

EXPLAIN FORMAT=JSON
SELECT user_id, SUM(amount) as total_amount
FROM orders 
WHERE order_time >= '2024-01-01' 
GROUP BY user_id 
ORDER BY total_amount DESC;

假设优化后,EXPLAIN输出的主要部分

{
  "query_block": {
    "select_id"1,
    "cost_info": { "query_cost""8000.00" }, // 成本大幅下降!
    "table": {
      "table_name""orders",
      "access_type""range",
      "possible_keys": ["idx_order_time""idx_time_user_amount"],
      "key""idx_time_user_amount",           // 使用了新索引
      "used_key_parts": ["order_time"],        // 主要用order_time做范围筛选
      "rows_examined_per_scan"500000,        // 扫描行数没变(范围查询)
      "rows_produced_per_join"500000,
      "filtered""100.00",
      "cost_info": { "read_cost""3000.00""eval_cost""5000.00" },
      "used_columns": ["user_id""amount""order_time"],
      "attached_condition""(`orders`.`order_time` >= '2024-01-01')",
      "using_index"true,                     // 关键!覆盖索引,无需回表!
      "using_filesort"true                   // 排序依然需要(聚合结果无法索引)
    },
    "grouping_operation": {
      "using_temporary_table"true,           // 临时表可能依然需要
      "using_filesort"false
    }
  }
}
第四步:分析优化效果(执行顺序视角)

1、FROM + WHERE阶段

  • access_type: range + key: idx_time_user_amount → 依然用索引快速定位。
  • rows: 500000 → 扫描行数不变(因为还是范围查询)。

2、SELECT 阶段(回表解决)using_index: true → 最大优化点!新索引(order_time, user_id, amount)包含了查询需要的所有字段(WHERESELECT)。数据库在扫描索引时,能直接拿到user_idamount的值,完全避免了50万次回表操作。节省了巨量的随机I/O,是性能提升的主要来源。

3、GROUP BY阶段using_temporary_table: true → 可能数据库仍然需要创建临时表来完成GROUP BY操作。虽然索引包含了user_id,但由于WHERE是范围扫描 (order_time >= ...),扫描出来的索引条目是按order_time排序的,而不是按user_id排序的。数据库仍然需要一个临时结构来聚合不同user_id的 amount。不过,因为数据直接从索引读取(顺序I/O),且避免了回表,即使有临时表,其构建速度也快得多。

:如果WHEREorder_time = '2024-01-01'(等值条件),则索引条目在order_time等值后,按user_id有序排列(因为索引是(order_time, user_id, amount),前缀等值后,后缀字段有序),此时GROUP BY user_id可直接利用索引的user_id有序性聚合,无需临时表,using_temporary_tablefalse

4、ORDER BY阶段using_filesort: true → 即数据库仍然需要执行文件排序(filesort)操作来完成ORDER BY排序。对SUM(amount)排序无法利用索引,必须进行文件排序。但因为输入数据(分组后的结果集,比如:8000行)比原始50万行小得多,且数据已在内存中(或更容易放入内存),排序的代价相对变小了。

故有如下优化效果

  • 消除最大瓶颈:50万次回表被消除,I/O性能飞跃。
  • 降低次要瓶颈代价:临时表和文件排序操作的数据源变“轻”了(直接从索引读,无回表),执行速度加快。
  • 总成本下降:从120000降到8000,性能提升约15倍(模拟数据估算)。

由此可见,以上优化方案(创建(order_time, user_id, amount)覆盖索引)之所以有效,主要在于它同时解决了WHERE筛选、SELECT字段获取(避免回表)、并部分辅助了GROUP BY(虽然不能完全避免临时表,但提供了所需数据),从而在执行顺序的多个环节降低了开销。

结论:如何用SQL执行顺序定位性能瓶颈?

1、我们看访问类型(type/access_type):ALL是首先要看的,range/index也可能有优化空间。

2、我们看额外信息(Extra):

  • Using filesort → 排序瓶颈。
  • Using temporary → 临时表瓶颈(GROUP BY/DISTINCT/子查询)。
  • Using where + 高rows → 索引过滤效果差。
  • 没有Using indexSELECT/WHERE用了非索引列 → 回表瓶颈!(这是我们很多新手容易忽略的地方)。

3、我们看扫描行数(rows):预估扫描行数是否远大于最终结果集?如果是,说明中间步骤效率低(如回表、无效过滤)。

4、结合逻辑顺序:我们思考数据库在执行WHERE -> GROUP BY -> ORDER BY每一步时,是否能有效利用现有索引结构,还是被迫进行昂贵的操作(回表、建临时表、排序)。

附录:执行顺序瓶颈定位速查表”

执行阶段
EXPLAIN关键指标
常见瓶颈表现
优化手段
WHERE筛选
type=ALL
key=NULL
全表扫描
建索引、避免函数/类型转换
SELECT字段读取
Using index + 非索引字段
大量回表
创建覆盖索引
GROUP BY
Using temporary
临时表(内存/磁盘)
索引支持分组、改写查询
ORDER BY
Using filesort
文件排序(CPU/磁盘)
索引支持排序、覆盖索引
JOIN
被驱动表type=ALL
嵌套循环效率低
驱动表小、被驱动表有索引

版权保护: 本文由 绿茶加糖-郭保升 原创,转载请保留链接: https://www.guobaosheng.com/shujuku/404.html