【数据库】慢SQL优化 - MYSQL

一、数据库故障的关键点

引起数据库故障的因素有操作系统层面、存储层面,还有断电断网的基础环境层面(以下称为外部因素),以及应用程序操作数据库和人为操作数据库这两个层面(以下称内部因素)。这些故障中外部因素发生的概率较小,可能几年都未发生过一起。许多 DBA 从入职到离职都可能没有遇到过外部因素导致的故障,但是内部因素导致的故障可能每天都在产生。内部因素中占据主导的是应用程序导致的故障,可以说数据库故障的主要元凶就是应用程序的 SQL 写的不够好。

SQL 是由开发人员编写的,但是责任不完全是开发人员的。

  1. SQL 的成因:SQL 是为了实现特定的需求而编写的,那么需求的合理性是第一位的。一般来说,在合理的需求下即使有问题的 SQL 也是可以挽救的。但是如果需求不合理,那么就为 SQL 问题埋下了隐患。
  2. SQL 的设计:这里的设计主要是数据库对象的设计。即使是合理的需求,椰果在数据库设计层面没有把控的缓解或者保证,那么很多优化就会大打折扣。
  3. SQL 的实现:这部分是开发人员所涉及的工作。但是这已经是流程的末端,这个时候改善的手段依然有,但是属于挽救措施。

在笔者多年的工作中,数据库故障主要来源于三个方面:不合理的需求、不合理的设计和不合理的实现。而这些如果从管理和流程上明确规定由经验丰富的 DBA 介入,那么对数据库故障的源头是有很大的控制作用的。

上述摘自薛晓刚老师的 《DBA 实战手记》 3.2 节。

二、慢 SQL 的常见成因

在分析 SQL 语句时,SQL 运行缓慢绝对是最主要的问题,没有之一。慢 SQL 是数据库性能瓶颈的主要表现,其核心成因可归纳为以下几类:

  1. 索引问题:缺失索引导致全表扫描、索引失效(如函数操作索引列、隐式类型转换)、索引设计不合理(单值索引 vs 复合索引选择错误)。
  2. 查询写法缺陷:SELECT * 全字段查询、复杂子查询嵌套、无过滤条件的大范围扫描、低效 JOIN 操作。
  3. 数据量与结构:表数据量过大未分区、字段类型设计不合理(如用 VARCHAR 存储数字)、大字段(TEXT/BLOB)频繁查询。
  4. 执行计划异常:优化器误判(统计信息过时)、JOIN 顺序错误、临时表与文件排序滥用。

下面通过实例表和纯 SQL 生成的数据,结合执行计划工具详解优化方法。

三、实验表结构及数据

1. 表结构(电商场景)

sql 复制代码
-- 用户表
CREATE TABLE users (
  user_id INT AUTO_INCREMENT PRIMARY KEY,
  username VARCHAR(50) NOT NULL,
  email VARCHAR(100) UNIQUE,
  age INT,
  register_time DATETIME,
  INDEX idx_age (age),
  INDEX idx_register_time (register_time)
) ENGINE=InnoDB;

-- 商品表
CREATE TABLE products (
  product_id INT AUTO_INCREMENT PRIMARY KEY,
  product_name VARCHAR(100) NOT NULL,
  price DECIMAL(10,2),
  category_id INT,
  stock INT,
  INDEX idx_category (category_id),
  INDEX idx_name_price (product_name, price)
) ENGINE=InnoDB;

-- 订单表
CREATE TABLE orders (
  order_id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  product_id INT NOT NULL,
  order_time DATETIME,
  amount DECIMAL(10,2),
  status TINYINT, -- 1:待支付 2:已支付 3:已取消
  INDEX idx_user_time (user_id, order_time),
  INDEX idx_product_id (product_id)
) ENGINE=InnoDB;

2. 生成测试数据(存储过程)

生成用户数据(10 万条)
sql 复制代码
DELIMITER //
CREATE PROCEDURE prod_generate_users()
BEGIN
  DECLARE i INT DEFAULT 1;
  DECLARE batch_size INT DEFAULT 1000; -- 每批处理1000条记录
  DECLARE total INT DEFAULT 100000;   -- 总记录数

  WHILE i <= total DO
    START TRANSACTION;

    -- 插入当前批次的记录
    WHILE i <= total AND i <= (batch_size * FLOOR((i-1)/batch_size) + batch_size) DO
      INSERT INTO users (username, email, age, register_time)
      VALUES (
        CONCAT('user_', i),
        CONCAT('user_', i, '@example.com'),
        FLOOR(RAND() * 40) + 18,
        DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY)
      );
      SET i = i + 1;
    END WHILE;

    COMMIT;
  END WHILE;
END //
DELIMITER ;

-- 执行存储过程
CALL prod_generate_users();
生成商品数据 1 万条)
sql 复制代码
DELIMITER //
CREATE PROCEDURE prod_generate_products()
BEGIN
  DECLARE i INT DEFAULT 1;
  DECLARE batch_size INT DEFAULT 1000; -- 每批处理1000条记录
  DECLARE total INT DEFAULT 10000;     -- 总记录数

  WHILE i <= total DO
    START TRANSACTION;

    -- 插入当前批次的记录
    WHILE i <= total AND i <= (batch_size * FLOOR((i-1)/batch_size) + batch_size) DO
      INSERT INTO products (product_name, price, category_id, stock)
      VALUES (
        CONCAT('product_', i),
        ROUND(RAND() * 999 + 1, 2),      -- 1-1000元
        FLOOR(RAND() * 20) + 1,          -- 1-20类分类
        FLOOR(RAND() * 1000) + 10        -- 10-1009库存
      );
      SET i = i + 1;
    END WHILE;

    COMMIT;
  END WHILE;
END //
DELIMITER ;

CALL prod_generate_products();
生成订单数据(100 万条)
sql 复制代码
DELIMITER //
CREATE PROCEDURE prod_generate_orders()
BEGIN
  DECLARE i INT DEFAULT 1;
  DECLARE batch_size INT DEFAULT 500;  -- 每批处理500条记录(订单数据量大,批次更小)
  DECLARE total INT DEFAULT 1000000;    -- 总记录数
  DECLARE max_user INT;
  DECLARE max_product INT;
  DECLARE rand_product_id INT;
  DECLARE product_price DECIMAL(10,2);

  -- 获取用户和商品的最大ID
  SELECT MAX(user_id) INTO max_user FROM users;
  SELECT MAX(product_id) INTO max_product FROM products;

  WHILE i <= total DO
    START TRANSACTION;

    -- 插入当前批次的记录
    WHILE i <= total AND i <= (batch_size * FLOOR((i-1)/batch_size) + batch_size) DO
      -- 优化:预先计算随机商品ID和价格,避免子查询
      SET rand_product_id = FLOOR(RAND() * max_product) + 1;
      SELECT price INTO product_price FROM products WHERE product_id = rand_product_id LIMIT 1;

      INSERT INTO orders (user_id, product_id, order_time, amount, status)
      VALUES (
        FLOOR(RAND() * max_user) + 1,    -- 随机用户
        rand_product_id,                 -- 随机商品
        DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY), -- 近1年订单
        product_price * (FLOOR(RAND() * 5) + 1),            -- 1-5件数量
        FLOOR(RAND() * 3) + 1             -- 随机状态
      );
      SET i = i + 1;
    END WHILE;

    COMMIT;
  END WHILE;
END //
DELIMITER ;

CALL prod_generate_orders();

查看数据

复制代码
select count(1) from users
union all
select count(1) from products
union all
select count(1) from orders;

+----------+
| count(1) |
+----------+
|   100000 |
|    10000 |
|  1000000 |
+----------+

四、执行计划详解:从分析到优化完整指南

三类工具的选择指南

工具 核心价值 适用场景
EXPLAIN 快速预判执行计划(预估) 日常开发、索引设计验证、排查明显低效操作
optimizer_trace 深入优化器决策过程(分析"为什么这么做") 复杂查询的索引选择问题、JOIN 顺序优化
EXPLAIN ANALYZE 量化实际执行性能(精确耗时、行数) 性能瓶颈量化、优化效果对比、分页/排序分析

通过这三类工具的组合使用,可从"预判"到"分析"再到"量化",全面掌握 MySQL 查询的执行逻辑,精准定位并解决性能问题。

1. 基础分析工具:EXPLAIN------预判查询执行逻辑

EXPLAIN是 MySQL 中最常用的执行计划分析工具,无需实际执行查询,即可返回优化器对查询的执行方案(如索引选择、扫描方式等),帮助提前发现性能隐患。

核心语法与使用场景
sql 复制代码
-- 对任意SELECT查询执行分析
EXPLAIN SELECT 列名 FROM 表名 WHERE 条件;
-- 支持复杂查询(JOIN、子查询等)
EXPLAIN SELECT u.username, o.order_id FROM users u JOIN orders o ON u.user_id = o.user_id WHERE u.age > 30;
+----+-------------+-------+------------+------+----------------------+----------------------+---------+------------------+-------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys        | key                  | key_len | ref              | rows  | filtered | Extra       |
+----+-------------+-------+------------+------+----------------------+----------------------+---------+------------------+-------+----------+-------------+
|  1 | SIMPLE      | u     | NULL       | ALL  | PRIMARY,idx_age      | NULL                 | NULL    | NULL             | 99864 |    50.00 | Using where |
|  1 | SIMPLE      | o     | NULL       | ref  | idx_user_time_amount | idx_user_time_amount | 4       | testdb.u.user_id |     9 |   100.00 | Using index |
+----+-------------+-------+------------+------+----------------------+----------------------+---------+------------------+-------+----------+-------------+
2 rows in set, 1 warning (0.14 sec)

适用场景

  • 快速判断查询是否使用索引、是否存在全表扫描;
  • 分析 JOIN 语句的表连接顺序和连接方式;
  • 定位Using filesort(文件排序)、Using temporary(临时表)等低效操作。
字段深度解读

在 MySQL 的 EXPLAIN 执行计划中,除了 typekeyrowsExtra 这几个核心字段外,其他字段也承载着查询执行逻辑的关键信息。以下是 EXPLAIN 所有字段的完整含义:

字段名 核心含义 补充说明
id 查询中每个操作的唯一标识(多表/子查询时用于区分执行顺序)。 - 若 id 相同:表示操作在同一层级,按表的顺序(从左到右)执行(如 JOIN 时的驱动表和被驱动表)。 - 若 id 不同:id 越大优先级越高,先执行(如子查询会嵌套在主查询内部,id 更大)。
select_type 查询的类型(区分简单查询、子查询、联合查询等)。 常见值: - SIMPLE:简单查询(无子查询、JOIN 等复杂结构)。 - PRIMARY:主查询(包含子查询时,最外层的查询)。 - SUBQUERY:子查询(SELECT 中的子查询,不依赖外部结果)。 - DERIVED:衍生表(FROM 中的子查询,会生成临时表)。 - UNIONUNION 语句中第二个及以后的查询。 - UNION RESULTUNION 结果集的合并操作。
table 当前操作涉及的表名(或临时表别名,如 derived2 表示衍生表)。 若为 NULL:可能是 UNION RESULT(合并结果集时无具体表),或子查询的中间结果。
partitions 查询匹配的分区(仅对分区表有效)。 非分区表显示 NULL;分区表会显示匹配的分区名称(如 p2023 表示命中 p2023 分区)。
type 访问类型(索引使用效率等级,最关键的性能指标)。 详见前文"type 字段优先级与解读",从优到差反映索引利用效率(如 const > ref > ALL)。
possible_keys 优化器认为可能使用的索引(候选索引列表)。 该字段仅表示"可能有效"的索引,不代表实际使用;若为 NULL,说明没有可用索引。
key 实际使用的索引(NULL 表示未使用任何索引)。 possible_keys 有值但 keyNULL,可能是索引选择性差(如字段值重复率高)或优化器判断全表扫描更快。
key_len 实际使用的索引长度(字节数)。 用于判断复合索引的使用情况: - 若 key_len 等于复合索引总长度,说明整个索引被使用; - 若较短,说明仅使用了复合索引的前缀部分(需检查是否因类型不匹配导致索引截断,如字符串未指定长度)。
ref 表示哪些字段或常量被用来匹配索引列。 - 若为常量(如 const):表示用固定值匹配索引(如 WHERE id=1)。 - 若为表名.字段(如 u.user_id):表示用其他表的字段关联当前表的索引(如 JOIN 时的关联条件)。
rows 优化器预估的扫描行数(基于表统计信息估算)。 数值越小越好,反映查询的"工作量";若远大于实际数据量,可能是统计信息过时,需执行 ANALYZE TABLE 表名 更新。
filtered 经过过滤条件后,剩余记录占预估扫描行数的比例(百分比)。 filtered=50 表示扫描的 rows 中,有 50% 满足过滤条件;值越高,说明过滤效率越好(减少后续处理的数据量)。
Extra 额外的执行细节(补充说明索引使用、排序、临时表等特殊行为)。 包含大量关键信息,如 Using filesort(文件排序)、Using temporary(临时表)等,是优化的核心线索(详见前文)。
type字段优先级与解读(从优到差)
type 含义 性能影响
system 表中只有 1 行数据(如系统表),无需扫描 理想状态,仅特殊场景出现。
const 通过主键/唯一索引匹配 1 行数据(如WHERE id=1 高效,索引精确匹配,推荐。
eq_ref 多表 JOIN 时,被驱动表通过主键/唯一索引匹配,每行只返回 1 条数据 高效,适合关联查询(如orders.user_id关联users.user_id主键)。
ref 非唯一索引匹配,可能返回多行(如WHERE age=30age为普通索引) 较好,索引部分匹配,需关注返回行数。
range 索引范围扫描(如BETWEENIN>等) 中等,比全表扫描高效,适合范围查询(需确保索引覆盖条件)。
index 扫描整个索引树(未命中索引过滤条件,仅用索引排序/覆盖) 低效,相当于"索引全表扫描"(如SELECT id FROM usersid为主键但无过滤)。
ALL 全表扫描(未使用任何索引) 极差,大表中会导致查询超时,必须优化。
Extra字段关键值解读
Extra 含义 优化方向
Using where 使用WHERE条件过滤,但未使用索引(全表扫描后过滤) 为过滤字段创建索引。
Using index 索引覆盖扫描(查询字段均在索引中,无需回表查数据) 理想状态,说明索引设计合理(如SELECT user_id FROM orders使用user_id索引)。
Using where; Using index 既用索引过滤,又用索引覆盖 最优状态,索引同时满足过滤和查询需求。
Using filesort 无法通过索引排序,需在内存/磁盘中排序(大结果集极慢) 优化排序字段,创建"过滤+排序"复合索引(如WHERE status=1 ORDER BY time(status, time)索引)。
Using temporary 需要创建临时表存储中间结果(如GROUP BY非索引字段) 避免在大表上使用GROUP BY非索引字段,或创建包含分组字段的复合索引。
Using join buffer 多表 JOIN 未使用索引,通过连接缓冲区匹配 为 JOIN 条件字段创建索引(如ON u.user_id = o.user_id,需o.user_id索引)。
案例:从EXPLAIN结果到优化

需求 :查询年龄 30-40 岁的用户用户名和邮箱。
原始查询

sql 复制代码
EXPLAIN SELECT username, email FROM users WHERE age BETWEEN 30 AND 40;

执行计划结果(问题版)

复制代码
+----+-------------+-------+------------+------+---------------+------+---------+------+-------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows  | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+------+---------+------+-------+----------+-------------+
|  1 | SIMPLE      | users | NULL       | ALL  | idx_age       | NULL | NULL    | NULL | 99776 |    47.03 | Using where |
+----+-------------+-------+------------+------+---------------+------+---------+------+-------+----------+-------------+

问题分析

  • type=ALL:全表扫描,未使用索引;
  • possible_keys=idx_agekey=NULL:索引存在但未被使用(可能因统计信息过时或索引选择性差)。

优化步骤

  1. 确认索引是否有效:SHOW INDEX FROM users WHERE Key_name='idx_age';(若不存在则创建);
  2. 更新表统计信息:ANALYZE TABLE users;(让优化器获取准确数据分布);
  3. 优化后预期结果:type=rangekey=idx_ageExtra=Using where; Using index(若usernameemail不在索引中,至少实现range扫描)。

2. 深入优化工具:optimizer_trace------揭秘优化器决策过程

EXPLAIN只能展示执行计划的"结果",而optimizer_trace可以展示优化器生成计划的"过程"(如索引选择的权衡、成本计算、JOIN 顺序决策等),适合分析复杂查询的深层性能问题。

核心作用与适用场景
  • 分析"明明有索引却不用"的原因(优化器认为全表扫描成本更低?);
  • 对比不同索引的成本差异,指导索引设计;
  • 解读 JOIN 语句中表连接顺序的决策逻辑(为什么 A 表驱动 B 表而不是相反?)。
使用步骤与注意事项
sql 复制代码
-- 默认是关闭的
show global variables like 'optimizer_trace';
+-----------------+--------------------------+
| Variable_name   | Value                    |
+-----------------+--------------------------+
| optimizer_trace | enabled=off,one_line=off |
+-----------------+--------------------------+

-- 开启跟踪(修改当前会话)
SET optimizer_trace = "enabled=on";

-- 仅影响当前会话(看global全局还是off的)
show session variables like 'optimizer_trace';
+-----------------+-------------------------+
| Variable_name   | Value                   |
+-----------------+-------------------------+
| optimizer_trace | enabled=on,one_line=off |
+-----------------+-------------------------+

-- 执行目标查询
SELECT * FROM orders WHERE user_id = 100 AND order_time > '2024-01-01';

-- 查看跟踪结果
SELECT * FROM information_schema.optimizer_trace\G
*************************** 1. row ***************************
                            QUERY: SELECT * FROM orders WHERE user_id = 100 AND order_time > '2024-01-01'
                            TRACE: {
  "steps": [
    {
      "join_preparation": {
        "select#": 1,
        "steps": [
          {
            "expanded_query": "/* select#1 */ select `orders`.`order_id` AS `order_id`,`orders`.`user_id` AS `user_id`,`orders`.`product_id` AS `product_id`,`orders`.`order_time` AS `order_time`,`orders`.`amount` AS `amount`,`orders`.`status` AS `status` from `orders` where ((`orders`.`user_id` = 100) and (`orders`.`order_time` > '2024-01-01'))"
          }
        ]
      }
    },
    {
      "join_optimization": {
        "select#": 1,
        "steps": [
          {
            "condition_processing": {
              "condition": "WHERE",
              "original_condition": "((`orders`.`user_id` = 100) and (`orders`.`order_time` > '2024-01-01'))",
              "steps": [
                {
                  "transformation": "equality_propagation",
                  "resulting_condition": "((`orders`.`order_time` > '2024-01-01') and multiple equal(100, `orders`.`user_id`))"
                },
                {
                  "transformation": "constant_propagation",
                  "resulting_condition": "((`orders`.`order_time` > '2024-01-01') and multiple equal(100, `orders`.`user_id`))"
                },
                {
                  "transformation": "trivial_condition_removal",
                  "resulting_condition": "((`orders`.`order_time` > TIMESTAMP'2024-01-01 00:00:00') and multiple equal(100, `orders`.`user_id`))"
                }
              ]
            }
          },
          {
            "substitute_generated_columns": {
            }
          },
          {
            "table_dependencies": [
              {
                "table": "`orders`",
                "row_may_be_null": false,
                "map_bit": 0,
                "depends_on_map_bits": [
                ]
              }
            ]
          },
          {
            "ref_optimizer_key_uses": [
              {
                "table": "`orders`",
                "field": "user_id",
                "equals": "100",
                "null_rejecting": true
              }
            ]
          },
          {
            "rows_estimation": [
              {
                "table": "`orders`",
                "range_analysis": {
                  "table_scan": {
                    "rows": 925560,
                    "cost": 93207.1
                  },
                  "potential_range_indexes": [
                    {
                      "index": "PRIMARY",
                      "usable": false,
                      "cause": "not_applicable"
                    },
                    {
                      "index": "idx_user_time",
                      "usable": true,
                      "key_parts": [
                        "user_id",
                        "order_time",
                        "order_id"
                      ]
                    },
                    {
                      "index": "idx_product_id",
                      "usable": false,
                      "cause": "not_applicable"
                    }
                  ],
                  "setup_range_conditions": [
                  ],
                  "group_index_range": {
                    "chosen": false,
                    "cause": "not_group_by_or_distinct"
                  },
                  "skip_scan_range": {
                    "potential_skip_scan_indexes": [
                      {
                        "index": "idx_user_time",
                        "usable": false,
                        "cause": "query_references_nonkey_column"
                      }
                    ]
                  },
                  "analyzing_range_alternatives": {
                    "range_scan_alternatives": [
                      {
                        "index": "idx_user_time",
                        "ranges": [
                          "user_id = 100 AND '2024-01-01 00:00:00' < order_time"
                        ],
                        "index_dives_for_eq_ranges": true,
                        "rowid_ordered": false,
                        "using_mrr": false,
                        "index_only": false,
                        "in_memory": 1,
                        "rows": 10,
                        "cost": 3.76,
                        "chosen": true
                      }
                    ],
                    "analyzing_roworder_intersect": {
                      "usable": false,
                      "cause": "too_few_roworder_scans"
                    }
                  },
                  "chosen_range_access_summary": {
                    "range_access_plan": {
                      "type": "range_scan",
                      "index": "idx_user_time",
                      "rows": 10,
                      "ranges": [
                        "user_id = 100 AND '2024-01-01 00:00:00' < order_time"
                      ]
                    },
                    "rows_for_plan": 10,
                    "cost_for_plan": 3.76,
                    "chosen": true
                  }
                }
              }
            ]
          },
          {
            "considered_execution_plans": [
              {
                "plan_prefix": [
                ],
                "table": "`orders`",
                "best_access_path": {
                  "considered_access_paths": [
                    {
                      "access_type": "ref",
                      "index": "idx_user_time",
                      "chosen": false,
                      "cause": "range_uses_more_keyparts"
                    },
                    {
                      "rows_to_scan": 10,
                      "access_type": "range",
                      "range_details": {
                        "used_index": "idx_user_time"
                      },
                      "resulting_rows": 10,
                      "cost": 4.76,
                      "chosen": true
                    }
                  ]
                },
                "condition_filtering_pct": 100,
                "rows_for_plan": 10,
                "cost_for_plan": 4.76,
                "chosen": true
              }
            ]
          },
          {
            "attaching_conditions_to_tables": {
              "original_condition": "((`orders`.`user_id` = 100) and (`orders`.`order_time` > TIMESTAMP'2024-01-01 00:00:00'))",
              "attached_conditions_computation": [
              ],
              "attached_conditions_summary": [
                {
                  "table": "`orders`",
                  "attached": "((`orders`.`user_id` = 100) and (`orders`.`order_time` > TIMESTAMP'2024-01-01 00:00:00'))"
                }
              ]
            }
          },
          {
            "finalizing_table_conditions": [
              {
                "table": "`orders`",
                "original_table_condition": "((`orders`.`user_id` = 100) and (`orders`.`order_time` > TIMESTAMP'2024-01-01 00:00:00'))",
                "final_table_condition   ": "((`orders`.`user_id` = 100) and (`orders`.`order_time` > TIMESTAMP'2024-01-01 00:00:00'))"
              }
            ]
          },
          {
            "refine_plan": [
              {
                "table": "`orders`",
                "pushed_index_condition": "((`orders`.`user_id` = 100) and (`orders`.`order_time` > TIMESTAMP'2024-01-01 00:00:00'))",
                "table_condition_attached": null
              }
            ]
          }
        ]
      }
    },
    {
      "join_execution": {
        "select#": 1,
        "steps": [
        ]
      }
    }
  ]
}
MISSING_BYTES_BEYOND_MAX_MEM_SIZE: 0
          INSUFFICIENT_PRIVILEGES: 0


-- 关闭跟踪(避免性能消耗)
SET optimizer_trace = "enabled=off";

注意事项

  • 仅在分析复杂查询时使用,开启后会增加 CPU 和内存消耗;
  • 结果中MISSING_BYTES_BEYOND_MAX_MEM_SIZE>0表示内容被截断,需调大optimizer_trace_max_mem_size(默认 1MB);
  • PROCESS权限才能查看information_schema.optimizer_trace
关键结果解读

从 JSON 结果中重点关注以下部分:

  • rows_estimation:优化器对各表行数的估算(若与实际偏差大,需更新统计信息);
  • potential_range_indexes:优化器考虑的所有候选索引(包括未被选中的);
  • analyzing_range_alternatives:各索引的成本对比(cost字段,值越小越优);
  • chosen_range_access_summary:最终选择的索引及原因(如cost=3.76的索引被选中)。

示例解读

potential_range_indexes中包含idx_user_time,但chosen_range_access_summary未选中,需查看cost是否高于全表扫描(可能因索引选择性差,优化器认为全表扫描更快),此时需优化索引(如增加区分度更高的前缀字段)。

3. 精准量化工具:EXPLAIN ANALYZE(MySQL 8.0+)------实测执行性能

EXPLAIN ANALYZE是 MySQL 8.0 引入的增强功能,会实际执行查询,并返回精确的执行时间、扫描行数等 metrics,适合量化性能瓶颈(如排序耗时、扫描行数与预期的偏差)。

核心优势与使用场景
  • 对比EXPLAINEXPLAIN返回"预估"值,EXPLAIN ANALYZE返回"实际"值(如actual timeactual rows);
  • 适合分析分页查询(LIMIT offset, size)、排序、JOIN 等操作的真实耗时;
  • 量化索引优化效果(如优化前后的执行时间对比)。

警告

  • 对大表执行EXPLAIN ANALYZE会消耗实际资源(如全表扫描 1000 万行),生产环境谨慎使用
  • SELECT语句(如UPDATEDELETE)禁用,避免误操作数据。
  • 会实际执行语句,因此非 DQL 语句谨慎执行!!!
案例:分析分页查询性能

需求 :分析"查询状态为 2 的订单,按时间排序后取第 10001-10020 条"的性能瓶颈。
查询

sql 复制代码
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE status = 2
ORDER BY order_time
LIMIT 10000, 20\G

未优化结果

复制代码
EXPLAIN: -> Limit/Offset: 20/10000 row(s)  (cost=93205 rows=20) (actual time=744..744 rows=20 loops=1)
    -> Sort: orders.order_time, limit input to 10020 row(s) per chunk  (cost=93205 rows=925560) (actual time=742..743 rows=10020 loops=1)
        -> Filter: (orders.`status` = 2)  (cost=93205 rows=925560) (actual time=3.26..591 rows=333807 loops=1)
            -> Table scan on orders  (cost=93205 rows=925560) (actual time=3.25..505 rows=1e+6 loops=1)

关键信息解读

总执行时间:744ms(根节点的actual time

各阶段实际耗时:

  1. 表扫描(Table scan on orders):(505ms - 3.25ms)*1 ≈ 502ms
  2. 过滤(Filter):(591ms - 3.26ms)*1 ≈ 588ms(包含表扫描时间)
  3. 排序(Sort):(743ms - 3.26ms)*1 ≈ 740ms(包含表扫描和过滤时间)
字段 含义
cost 优化器预估的执行成本(数值越小越好,基于 CPU 消耗、IO 操作等计算)
rows(预估) 优化器预估的需要处理的行数(反映查询的"工作量",数值越小效率越高)
actual time 实际执行时间(格式为 开始时间..结束时间,单位为毫秒)
actual rows 实际处理的行数(反映真实数据量,与预估 rows 差异过大需关注)
loops 操作执行的循环次数(非join或带子查询的sql通常为 1)

优化方案:创建覆盖"过滤+排序"的复合索引:

sql 复制代码
CREATE INDEX idx_status_time ON orders(status, order_time);

优化后结果

复制代码
EXPLAIN: -> Limit/Offset: 20/10000 row(s)  (cost=48225 rows=20) (actual time=48.4..48.4 rows=20 loops=1)
    -> Index lookup on orders using idx_status_time (status=2)  (cost=48225 rows=462780) (actual time=25.3..47.9 rows=10020 loops=1)

优化效果

  • 总时间从 744ms 降至 48ms(提升 93%);
  • 消除全表扫描和文件排序,直接通过索引定位数据(Index lookup)。

五、案例 - 索引问题与表结构

1. 索引失效 - 函数操作索引列

案例 SQL

sql 复制代码
EXPLAIN SELECT COUNT(1) FROM users WHERE YEAR(register_time) = 2025;
+----+-------------+-------+------------+-------+---------------+-------------------+---------+------+-------+----------+--------------------------+
| id | select_type | table | partitions | type  | possible_keys | key               | key_len | ref  | rows  | filtered | Extra                    |
+----+-------------+-------+------------+-------+---------------+-------------------+---------+------+-------+----------+--------------------------+
|  1 | SIMPLE      | users | NULL       | index | NULL          | idx_register_time | 6       | NULL | 99776 |   100.00 | Using where; Using index |
+----+-------------+-------+------------+-------+---------------+-------------------+---------+------+-------+----------+--------------------------+
1 row in set, 1 warning (0.00 sec)

EXPLAIN SELECT COUNT(1) FROM users WHERE register_time >= '2025-01-01' AND register_time < DATE_ADD('2025-01-01', INTERVAL 1 YEAR);
+----+-------------+-------+------------+-------+-------------------+-------------------+---------+------+------+----------+--------------------------+
| id | select_type | table | partitions | type  | possible_keys     | key               | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+-------+------------+-------+-------------------+-------------------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | users | NULL       | range | idx_register_time | idx_register_time | 6       | NULL | 5300 |   100.00 | Using where; Using index |
+----+-------------+-------+------------+-------+-------------------+-------------------+---------+------+------+----------+--------------------------+
1 row in set, 1 warning (0.00 sec)

执行计划问题 :对索引列register_time使用了函数,导致索引失效。
优化手段:改为范围查询,不要对索引列使用函数。

2. 索引失效 - 隐式类型转换

案例 SQL

sql 复制代码
EXPLAIN select count(1) from products where product_name=12345;
+----+-------------+----------+------------+-------+----------------+----------------+---------+------+------+----------+--------------------------+
| id | select_type | table    | partitions | type  | possible_keys  | key            | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+----------+------------+-------+----------------+----------------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | products | NULL       | index | idx_name_price | idx_name_price | 408     | NULL | 9642 |    10.00 | Using where; Using index |
+----+-------------+----------+------------+-------+----------------+----------------+---------+------+------+----------+--------------------------+
1 row in set, 3 warnings (0.00 sec)

EXPLAIN select count(1) from products where product_name='12345';
+----+-------------+----------+------------+------+----------------+----------------+---------+-------+------+----------+-------------+
| id | select_type | table    | partitions | type | possible_keys  | key            | key_len | ref   | rows | filtered | Extra       |
+----+-------------+----------+------------+------+----------------+----------------+---------+-------+------+----------+-------------+
|  1 | SIMPLE      | products | NULL       | ref  | idx_name_price | idx_name_price | 402     | const |    1 |   100.00 | Using index |
+----+-------------+----------+------------+------+----------------+----------------+---------+-------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

执行计划问题product_name是 varchar 类型,但查询条件使用了数字类型,发生隐式类型转换导致索引失效。参考:select count(1) from products where CAST(product_name AS SIGNED)=12345;
优化手段:查询条件类型与索引列字段一致。

可能有小伙伴会问,明明查出来是 0 行,怎么执行计划 rows=1 呢,实际在扫索引时 mysql 也已经知道了没有匹配的结果,理论返回 0 行就行,但 mysql 代码里写死了这种情况返回 1,他们也没有解释那就这样吧。

3. 索引设计不合理 - 单值索引 vs 复合索引选择错误

案例 SQL

sql 复制代码
-- 删掉原本的 idx_category 方便验证该案例
alter table products drop index idx_category,add index idx_price_category(price,category_id);

EXPLAIN SELECT * FROM products WHERE category_id = 5 AND price > 900;
+----+-------------+----------+------------+-------+--------------------+--------------------+---------+------+------+----------+-----------------------+
| id | select_type | table    | partitions | type  | possible_keys      | key                | key_len | ref  | rows | filtered | Extra                 |
+----+-------------+----------+------------+-------+--------------------+--------------------+---------+------+------+----------+-----------------------+
|  1 | SIMPLE      | products | NULL       | range | idx_price_category | idx_price_category | 6       | NULL | 1003 |    10.00 | Using index condition |
+----+-------------+----------+------------+-------+--------------------+--------------------+---------+------+------+----------+-----------------------+
1 row in set, 1 warning (0.00 sec)

-- 优化索引顺序
alter table products drop index idx_price_category,add index idx_category_price(category_id,price);

EXPLAIN SELECT * FROM products WHERE category_id = 5 AND price > 900;
+----+-------------+----------+------------+-------+--------------------+--------------------+---------+------+------+----------+-----------------------+
| id | select_type | table    | partitions | type  | possible_keys      | key                | key_len | ref  | rows | filtered | Extra                 |
+----+-------------+----------+------------+-------+--------------------+--------------------+---------+------+------+----------+-----------------------+
|  1 | SIMPLE      | products | NULL       | range | idx_category_price | idx_category_price | 11      | NULL |   45 |   100.00 | Using index condition |
+----+-------------+----------+------------+-------+--------------------+--------------------+---------+------+------+----------+-----------------------+
1 row in set, 1 warning (0.00 sec)

-- 改回去
alter table products drop index idx_category_price,add index idx_category(category_id);

执行计划问题 :虽然有复合索引idx_price_category(price, category_id),但范围查询price > 900导致索引截断。
优化手段 :调整索引顺序为(category_id, price)

六、案例 - 执行计划异常与查询写法缺陷

1. 优化器误判(统计信息过时,以 products 表为例)

案例 SQL

sql 复制代码
-- 查询特定分类的商品,products表有idx_category索引
EXPLAIN SELECT * FROM products WHERE category_id = 15;

ANALYZE TABLE products; -- 更新统计信息后,执行计划会选择idx_category索引

执行计划问题 :优化器误判(因统计信息过时),认为全表扫描比走idx_category快(实际category_id=15的数据量很小)。
统计信息过时的原因:

  • 数据量剧变:批量插入 / 删除 / 更新超过表总量的 10%。
  • 分布剧变:字段值的重复度、基数发生显著变化(如从低重复到高重复)。
  • 配置限制:关闭自动更新、采样精度不足。
  • 结构变更:新增索引、修改字段类型后未同步更新统计信息。

统计信息过时会直接导致优化器误判执行计划(如全表扫描 vs 索引扫描、JOIN 顺序错误),因此需在上述场景中定期执行 ANALYZE TABLE 或开启自动更新(非极致写入场景)。

优化手段:更新表统计信息,帮助优化器正确判断。

2. SELECT * 全字段查询

案例 SQL

sql 复制代码
-- 查询用户的订单记录,使用SELECT * 导致读取不必要字段
SELECT * FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE u.user_id = 100;


SELECT u.username, o.order_id, o.order_time, o.amount
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE u.user_id = 100;

问题users表的emailregister_timeorders表的status等非必要字段被读取,增加 IO 和内存开销(尤其orders表数据量达 100 万条)。多余字段占用大量 IO 带宽,拖慢查询。
优化手段:只查询业务需要的字段

3. 复杂子查询嵌套

案例 SQL

sql 复制代码
-- 统计2024 年每个用户的订单总金额(包含所有用户,即使无订单也显示 0)
EXPLAIN ANALYZE SELECT
  u.user_id,
  u.username,
  -- 子查询:统计该用户2024年的订单总金额(无订单则返回NULL,用IFNULL转为0)
  IFNULL((SELECT SUM(o.amount)
          FROM orders o
          WHERE o.user_id = u.user_id
            AND o.order_time >= '2024-01-01'
            AND o.order_time < '2025-01-01'), 0) AS total_2024_amount
FROM users u \G;
*************************** 1. row ***************************
EXPLAIN: -> Table scan on u  (cost=10098 rows=99776) (actual time=0.147..44.7 rows=100000 loops=1)
-> Select #2 (subquery in projection; dependent)
    -> Aggregate: sum(o.amount)  (cost=3.26 rows=1) (actual time=0.0155..0.0155 rows=1 loops=100000)
        -> Index lookup on o using idx_user_time (user_id=u.user_id), with index condition: ((o.order_time >= TIMESTAMP'2024-01-01 00:00:00') and (o.order_time < TIMESTAMP'2025-01-01 00:00:00'))  (cost=2.36 rows=9.02) (actual time=0.0133..0.0143 rows=4.74 loops=100000)

1 row in set, 1 warning (1.87 sec)


-- 覆盖索引:包含所有查询需要的字段
alter table orders drop index idx_user_time,add index idx_user_time_amount(user_id, order_time, amount);

EXPLAIN ANALYZE SELECT
  u.user_id,
  u.username,
  COALESCE(s.total_amount, 0) AS total_2024_amount
FROM users u
LEFT JOIN (
  SELECT
    user_id,
    SUM(amount) AS total_amount
  FROM orders
  WHERE order_time >= '2024-01-01'
    AND order_time < '2025-01-01'
  GROUP BY user_id  -- 预聚合订单数据
) s ON u.user_id = s.user_id \G;
*************************** 1. row ***************************
EXPLAIN: -> Nested loop left join  (cost=1.01e+9 rows=10e+9) (actual time=706..892 rows=100000 loops=1)
    -> Table scan on u  (cost=10098 rows=99776) (actual time=0.175..39.2 rows=100000 loops=1)
    -> Index lookup on s using <auto_key0> (user_id=u.user_id)  (cost=113560..113562 rows=10) (actual time=0.00809..0.00831 rows=0.988 loops=100000)
        -> Materialize  (cost=113559..113559 rows=100725) (actual time=706..706 rows=98795 loops=1)
            -> Group aggregate: sum(orders.amount)  (cost=103487 rows=100725) (actual time=1.24..571 rows=98795 loops=1)
                -> Filter: ((orders.order_time >= TIMESTAMP'2024-01-01 00:00:00') and (orders.order_time < TIMESTAMP'2025-01-01 00:00:00'))  (cost=93205 rows=102819) (actual time=1.23..487 rows=473886 loops=1)
                    -> Covering index scan on orders using idx_user_time_amount  (cost=93205 rows=925560) (actual time=1.23..340 rows=1e+6 loops=1)

1 row in set (0.92 sec)

执行计划问题 :10 万次子查询重复执行 sum(),累积开销大。
优化手段

  • 用覆盖索引避免回表 IO:子查询和主查询均通过 idx_user_time_amount 获取所需字段(user_id、order_time、amount),无需回表。
  • 减少中间结果集大小(如预聚合):将 SUM(amount)的计算放在子查询中,避免主查询处理大量中间结果。

七、案例 - 分页查询

传统分页问题(LIMIT 大偏移量)

案例 SQL:

sql 复制代码
-- 查询第100001-100020条订单(偏移量10万)
EXPLAIN ANALYZE
SELECT * FROM orders
ORDER BY order_id  -- 按主键排序(默认也可能按此排序)
LIMIT 100000, 20 \G;
*************************** 1. row ***************************
EXPLAIN: -> Limit/Offset: 20/100000 row(s)  (cost=1074 rows=20) (actual time=50.3..50.3 rows=20 loops=1)
    -> Index scan on orders using PRIMARY  (cost=1074 rows=100020) (actual time=2.84..46.1 rows=100020 loops=1)

1 row in set (0.05 sec)

核心问题:

  • LIMIT 100000, 20 需要扫描前 100020 行数据,然后丢弃前 100000 行,仅返回 20 行,99.98%的扫描是无效的。
  • 即使有排序,大偏移量仍会导致全表扫描+排序,IO 和 CPU 开销极高。
分页优化核心思路
优化方案 核心思路 适用场景 性能提升幅度
主键偏移量分页 WHERE id > 偏移值替代LIMIT 偏移量 按连续主键排序,有上一页 ID 50-100 倍
条件+边界值分页 WHERE 条件 AND 字段 > 上一页值 按非主键排序,有过滤条件 30-50 倍
延迟关联+小范围 LIMIT 先查 ID 再回表,减少大偏移量数据量 必须用大偏移量,无边界值 2-3 倍
优化方案 1:主键偏移量分页(利用连续主键)

通过已知的最后一条记录的主键 (如第 100000 行的order_id)作为条件,直接定位到起始位置,避免扫描偏移量内的所有行。

优化后 SQL:

sql 复制代码
-- 假设第100000行的order_id为100000(可从上次查询获取)
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE order_id > 100000  -- 直接定位到偏移量位置
ORDER BY order_id
LIMIT 20 \G;  -- 仅取20行
*************************** 1. row ***************************
EXPLAIN: -> Limit: 20 row(s)  (cost=99909 rows=20) (actual time=2.08..2.09 rows=20 loops=1)
    -> Filter: (orders.order_id > 100000)  (cost=99909 rows=498729) (actual time=2.08..2.08 rows=20 loops=1)
        -> Index range scan on orders using PRIMARY over (100000 < order_id)  (cost=99909 rows=498729) (actual time=2.07..2.08 rows=20 loops=1)

1 row in set (0.01 sec)

写法优势:

  • 无需扫描偏移量内数据 :通过WHERE order_id > 100000直接定位到起始点,扫描行数从 100020 降至 20 行。
  • 利用现有主键索引PRIMARY KEY (order_id)天然存在,无需额外索引,通过范围查询(range)快速定位。

适用场景:

  • 分页按连续自增主键排序 (如order_id)。
  • 前端分页可记录上一页最后一条记录的order_id(如"下一页"按钮传递该值)。
优化方案 2:基于条件过滤的分段分页(非主键排序)

当分页需要按非主键字段排序(如order_time),且有固定过滤条件(如status=2,已支付订单),传统LIMIT大偏移量同样低效。

传统写法问题:需扫描前 1020 条符合status=2的记录,丢弃前 10000 条,无效扫描多。

sql 复制代码
-- 按订单时间排序,查询第1001-1020条已支付订单(偏移量1000)
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE status = 2
ORDER BY order_time
LIMIT 10000, 20 \G;
*************************** 1. row ***************************
EXPLAIN: -> Limit/Offset: 20/10000 row(s)  (cost=52012 rows=20) (actual time=23.9..23.9 rows=20 loops=1)
    -> Index lookup on orders using idx_status_time (status=2)  (cost=52012 rows=498729) (actual time=11.6..23.6 rows=10020 loops=1)

1 row in set (0.02 sec)

优化写法(利用上一页边界值):

sql 复制代码
-- 假设上一页最后一条记录的order_time为'2025-05-01 10:00:00',order_id为50000
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE status = 2
  AND order_time >= '2025-05-01 10:00:00'  -- 用上一页时间作为起点
  AND NOT (order_time = '2025-05-01 10:00:00' AND order_id <= 50000)  -- 排除同时间的前序记录
ORDER BY order_time, order_id  -- 时间+ID联合排序,避免重复/遗漏
LIMIT 20 \G;
*************************** 1. row ***************************
EXPLAIN: -> Limit: 20 row(s)  (cost=58539 rows=20) (actual time=12.3..12.3 rows=20 loops=1)
    -> Index range scan on orders using idx_status_time over (status = 2 AND '2025-05-01 10:00:00' <= order_time), with index condition: ((orders.`status` = 2) and (orders.order_time >= TIMESTAMP'2025-05-01 10:00:00') and ((orders.order_time <> TIMESTAMP'2025-05-01 10:00:00') or (orders.order_id > 50000)))  (cost=58539 rows=130086) (actual time=12.3..12.3 rows=20 loops=1)

1 row in set (0.02 sec)

写法优势:

  • 通过条件过滤替代偏移量 :利用上一页最后一条记录的order_timeorder_id作为边界,直接定位到下一页起始位置,避免扫描前 10000 条记录。
  • 联合排序去重ORDER BY order_time, order_id确保排序唯一,避免同时间订单重复或遗漏。
  • 复用现有索引idx_user_time (user_id, order_time)虽以user_id开头,但order_time作为第二列可辅助范围查询(配合status=2过滤)。

适用场景:

  • 非主键字段排序 (如order_time)。
  • 有固定过滤条件(如status=2),可通过条件+边界值快速定位。
  • 前端需记录上一页最后一条记录的order_timeorder_id(如传递给"下一页"接口)。
优化方案 3:延迟关联+小范围 LIMIT(无边界值时)

当无法获取上一页边界值(如"跳转至第 500 页"),且必须使用大偏移量,可通过"先查 ID,再回表"减少无效数据传输。

优化写法:

sql 复制代码
-- 子查询先获取目标页的order_id,再关联回表
EXPLAIN ANALYZE
SELECT o.*
FROM orders o
JOIN (
  -- 子查询仅查ID,数据量小,排序/偏移高效
  SELECT order_id
  FROM orders
  WHERE status = 2
  ORDER BY order_time
  LIMIT 10000, 20  -- 大偏移量仅处理ID,而非全字段
) tmp ON o.order_id = tmp.order_id \G;
*************************** 1. row ***************************
EXPLAIN: -> Nested loop inner join  (cost=52567 rows=20) (actual time=7.07..7.15 rows=20 loops=1)
    -> Table scan on tmp  (cost=50058..50060 rows=20) (actual time=7.04..7.04 rows=20 loops=1)
        -> Materialize  (cost=50058..50058 rows=20) (actual time=7.04..7.04 rows=20 loops=1)
            -> Limit/Offset: 20/10000 row(s)  (cost=50056 rows=20) (actual time=7.01..7.01 rows=20 loops=1)
                -> Covering index lookup on orders using idx_status_time (status=2)  (cost=50056 rows=498729) (actual time=2.36..6.37 rows=10020 loops=1)
    -> Single-row index lookup on o using PRIMARY (order_id=tmp.order_id)  (cost=0.25 rows=1) (actual time=0.00447..0.00453 rows=1 loops=20)

1 row in set (0.01 sec)

写法优势:

  • 减少排序/偏移的数据量 :子查询仅处理order_id(4 字节),比全字段(*包含多个字段,约 50 字节)更轻量,排序和偏移效率更高。
  • 回表数据量小 :仅对 20 条order_id回表查询全字段,避免 10000 条无效记录的全字段传输。

适用场景:

  • 必须使用大偏移量(如"跳转至第 N 页")。
  • 表字段较多(*包含大量数据),通过先查 ID 减少中间数据传输。
相关推荐
IvorySQL5 小时前
PostgreSQL 分区表的 ALTER TABLE 语句执行机制解析
数据库·postgresql·开源
·云扬·5 小时前
MySQL 8.0 Redo Log 归档与禁用实战指南
android·数据库·mysql
野生技术架构师5 小时前
SQL语句性能优化分析及解决方案
android·sql·性能优化
IT邦德5 小时前
Oracle 26ai DataGuard 搭建(RAC到单机)
数据库·oracle
惊讶的猫5 小时前
redis分片集群
数据库·redis·缓存·分片集群·海量数据存储·高并发写
不爱缺氧i5 小时前
完全卸载MariaDB
数据库·mariadb
纤纡.5 小时前
Linux中SQL 从基础到进阶:五大分类详解与表结构操作(ALTER/DROP)全攻略
linux·数据库·sql
jiunian_cn6 小时前
【Redis】渐进式遍历
数据库·redis·缓存
橙露6 小时前
Spring Boot 核心原理:自动配置机制与自定义 Starter 开发
java·数据库·spring boot