»MySQL 进阶:慢查询排查、EXPLAIN 诊断与索引失效深度优化指南

2026-06-192026-06-19数据库11 分钟读完(约 2863 字)

MySQL 性能优化的核心逻辑可以概括为:精准定位慢 SQL → 利用执行计划诊断内核缺陷 → 遵循最左前缀与覆盖索引原则调优 → 杜绝索引失效。本文将沿着这条黄金链路,逐层深入。


性能排查三步走:定位 → 剖析 → 诊断

┌──────────────────────────────────────────────────────────────────┐
│                    MySQL 性能排查黄金链路                            │
│                                                                  │
│  ┌──────────────┐    ┌──────────────────┐    ┌──────────────┐    │
│  │  第一步:定位   │ → │   第二步:剖析      │ → │  第三步:诊断  │    │
│  │              │    │                  │    │              │    │
│  │  慢查询日志    │    │  SHOW PROFILE    │    │   EXPLAIN    │    │
│  │  找到"谁慢"    │    │  找到"慢在哪里"    │    │  找到"为什么慢" │    │
│  └──────────────┘    └──────────────────┘    └──────────────┘    │
│                                                                  │
│  输出:具体 SQL      输出:耗时阶段分布      输出:执行计划 + 优化方案 │
└──────────────────────────────────────────────────────────────────┘

第一步:开启慢查询日志 — 定位"谁慢"

首先通过全局状态判断数据库的负载特征:

-- 查看当前数据库是以查询为主还是增删改为主
-- Com_______ 是 7 个下划线,分别对应 select/insert/update/delete/replace/...
SHOW GLOBAL STATUS LIKE 'Com_______';

/etc/my.cnf 中配置慢查询日志:

[mysqld]
# 开启慢查询日志总开关
slow_query_log = 1
# 慢查询日志文件路径
slow_query_log_file = /var/log/mysql/slow.log
# 慢查询阈值(单位:秒),超过此值的 SQL 将被记录
long_query_time = 2
# 记录未使用索引的查询(可选,线上谨慎开启)
log_queries_not_using_indexes = 0

配置完成后验证:

SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';

第二步:SHOW PROFILE — 剖析"慢在哪里"

慢查询日志告诉你谁慢,而 Profiling 告诉你慢在哪个阶段

-- 1. 检查当前 MySQL 是否支持 profiling
SELECT @@have_profiling;

-- 2. 开启 profiling(会话级别)
SET profiling = 1;

-- 3. 执行你的业务 SQL
SELECT * FROM tb_user WHERE phone = '17799990010';

-- 4. 查看当前会话中所有 SQL 的耗时快照
SHOW PROFILES;

-- 5. 针对某一特定 Query ID,查看其生命周期的精确耗时分布
SHOW PROFILE FOR QUERY 1;

-- 6. 查看 CPU、I/O、内存等详细资源消耗
SHOW PROFILE CPU, BLOCK IO, MEMORY FOR QUERY 1;
耗时阶段含义重点关注
Sending data从存储引擎读取数据并返回给客户端数值过大 = 回表过多/数据量大
Creating sort index在内存/磁盘中创建排序索引出现即说明 ORDER BY 未走索引
Creating tmp table创建临时表(GROUP BY/DISTINCT 等)内存临时表还好,磁盘临时表灾难
Sorting result对结果集排序配合 Creating sort index 分析
converting HEAP to MyISAM内存临时表溢出转为磁盘表严重性能杀手

第三步:EXPLAIN 执行计划 — 诊断"为什么慢"

对于定位到的慢 SQL,在前面加上 EXPLAIN 关键字,直接查看 MySQL 优化器的执行方案:

EXPLAIN SELECT id, name FROM tb_user WHERE phone = '17799990010';

EXPLAIN 核心字段逐列拆解

执行计划返回的结果中,有五个字段直接决定了 SQL 的生死:

EXPLAIN 结果 →
┌──────────┬───────────┬──────┬──────┬─────────┬──────┬──────┬──────┐
│    id    │ select_   │table │ type │possible_│ key  │key_  │ ref  │ ...
│          │  type     │      │      │  keys   │      │ len  │      │
└──────────┴───────────┴──────┴──────┴─────────┴──────┴──────┴──────┘
                                                      ↓
                                                ┌──────┬─────────────┐
                                                │ rows │    Extra    │
                                                └──────┴─────────────┘

type — 访问类型(性能从优到差)

级别type 值含义常见触发条件
🟢 极优system系统表,仅一行极少见
🟢 极优const主键或唯一索引等值匹配WHERE id = 1
🟢 优秀eq_ref联表查询中,驱动表的每一行在被驱动表中有且仅有一条匹配联表 ON 条件是主键/唯一键
🟡 良好ref非唯一索引等值匹配WHERE name = 'Tom'
🟡 良好range索引范围扫描WHERE age > 20BETWEENINLIKE '张%'
🟠 较差index全索引扫描SELECT name FROM users(仅查索引列也走全索引)
🔴 灾难ALL全表扫描未命中任何索引,磁盘 I/O 爆炸

硬性准则:线上业务 SQL 的 type 不得低于 range,出现 ALL 必须优化。

key — 实际使用的索引

-- key 列为 NULL:未使用任何索引 → 必须优化
-- key 列有值(如 idx_phone):使用了该索引 → 结合 key_len 判断用了几列

key_len — 索引使用的字节数

key_len 可以精确反映联合索引用了几列

-- 假设联合索引 INDEX(name, age, phone)
-- name VARCHAR(50) utf8mb4 → 50*4+2 = 202 字节
-- age INT                    → 4+1 = 5 字节(默认允许 NULL 加 1)
-- phone CHAR(11) utf8mb4    → 11*4 = 44 字节

-- key_len = 202 → 仅用了 name 列
-- key_len = 207 → 用了 name + age 列
-- key_len = 251 → 用了 name + age + phone 列

rows — 预估扫描行数

MySQL 优化器基于统计信息估算的扫描行数,越小越好。如果 rows=100000 而实际只需要 1 行,说明索引选择出了问题。

Extra — 额外信息(重点关注)

Extra 值含义是好是坏
Using index覆盖索引:所需数据全在索引树上,无需回表🟢 最优,追求目标
Using index condition索引下推(ICP):存储引擎层先过滤,再送回 Server 层🟢 良好
Using whereServer 层做了额外过滤🟡 一般,说明索引不够精准
Using index for group-by索引直接完成 GROUP BY,无需额外排序🟢 优秀
Using filesort无法利用索引排序,被迫在内存/磁盘中外部排序🔴 必须优化
Using temporary创建了临时表(常见于 GROUP BY、DISTINCT、UNION)🔴 严重,必须优化
Using join buffer联表查询使用了 Join Buffer(BNL 或 Hash Join)🟡 视情况而定

索引失效的七大致命场景

即使建立了索引,如果 SQL 踩了以下陷阱,B+Tree 也会被 MySQL 放弃,退化为全表扫描。

失效速查表

序号失效场景错误示例底层原因
对索引列做函数运算WHERE LEFT(name, 3) = 'Tom'WHERE DATE(create_time) = '2026-01-01'B+Tree 存的是原始值,套函数后无法在树上定位
隐式类型转换WHERE phone = 17799990010(phone 是 VARCHAR)MySQL 会自动将字符串转为数字,等同于对索引列做函数
LIKE 前导模糊WHERE name LIKE '%张'B+Tree 按从左到右排序,前缀未知时树上无法定位
OR 连接无索引列WHERE phone = '138...' OR age = 18(age 无索引)OR 一侧无索引 → 全量回表比全表扫描更慢 → 优化器放弃
负向条件(!= / <> / NOT IN)WHERE status != 1不等于条件的匹配行分散在整棵树各处,无法精确定位范围
IS NULL / IS NOT NULLWHERE name IS NULLNULL 值在 B+Tree 中排在索引最前端,范围无法确定
数据分布导致优化器主动放弃查询结果占全表 30% 以上优化器认为全表扫描比"回表查 30% 数据"更快,主动放弃索引

场景①详细拆解:索引列上做运算

-- ❌ 索引失效:对索引列使用函数
SELECT * FROM orders WHERE DATE(create_time) = '2026-01-01';

-- ✅ 等价改写:让索引列以原始形态参与比较
SELECT * FROM orders
WHERE create_time >= '2026-01-01 00:00:00'
  AND create_time < '2026-01-02 00:00:00';

场景②详细拆解:隐式类型转换

-- 表结构:phone VARCHAR(11)
-- ❌ 索引失效:字符串列与整数比较,MySQL 隐式转换为 CAST(phone AS SIGNED)
SELECT * FROM tb_user WHERE phone = 17799990010;

-- ✅ 正确写法:字符串加引号
SELECT * FROM tb_user WHERE phone = '17799990010';

场景③详细拆解:LIKE 模糊匹配

WHERE name LIKE '张%'   → 可以定位到"张"开头的第一个位置,走 range 扫描  ✅
WHERE name LIKE '%张%'  → 前后都模糊,必须遍历整棵树                         ❌
WHERE name LIKE '%张'   → 头部模糊,无法利用 B+Tree 的有序性                ❌

JOIN 联表查询优化

MySQL 表连接算法演进

算法适用版本原理性能
Simple Nested-Loop Join所有版本驱动表每一行遍历被驱动表全表🔴 极差 O(n×m)
Block Nested-Loop Join(BNL)5.5+将驱动表批量缓存到 Join Buffer,减少被驱动表扫表次数🟡 一般
Index Nested-Loop Join所有版本被驱动表有索引,每次利用索引精确定位匹配行🟢 最佳
Hash Join8.0.18+将驱动表数据构建哈希表,被驱动表 O(1) 查找🟢 优秀(无索引时)

JOIN 优化核心原则

-- ❌ 两张大表无索引 JOIN → 全表笛卡尔积
SELECT * FROM orders o, users u WHERE o.user_id = u.id;

-- ✅ 小表驱动大表 + 被驱动表关联字段有索引
-- users 100 行(小表、驱动表)→ orders 100 万行(大表、被驱动表)
SELECT * FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE u.status = 1;

JOIN 优化四原则

  1. 小表驱动大表:驱动表越小,被驱动表被扫的次数越少
  2. 被驱动表的 JOIN 字段必须有索引:否则每次都要全表扫描
  3. 避免 SELECT *:只取需要的列,减少返回数据量,为覆盖索引创造条件
  4. MySQL 8.0.18+ 可利用 Hash Join:两张表都没索引时,确保 join_buffer_size 足够大
-- 查看 join_buffer 大小
SHOW VARIABLES LIKE 'join_buffer_size';
-- 默认 256KB,生产环境建议 1-4MB
SET GLOBAL join_buffer_size = 4194304;

深分页问题与优化

问题:传统分页翻到后面越来越慢

-- 第 1 页:飞快,扫描 10 行
SELECT * FROM orders ORDER BY id LIMIT 0, 10;

-- 第 10 万页:灾难级慢!MySQL 需先扫描前 1000010 行再丢弃前 1000000 行
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;
LIMIT 1000000, 10 的执行过程:
┌──────────────────────────────────────────────────────────┐
│  MySQL 扫描 1,000,010 行 → 丢弃前 1,000,000 行 → 返回 10 行 │
│                                                          │
│  99.999% 的工作量被白白浪费在"扫描并丢弃"上!                  │
└──────────────────────────────────────────────────────────┘

解决方案:延迟关联(Deferred Join)

-- ❌ 传统分页(全表扫描后丢弃)
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;

-- ✅ 延迟关联:先在索引上定位 ID,再回表取完整数据
SELECT o.* FROM orders o
INNER JOIN (
    SELECT id FROM orders ORDER BY id LIMIT 1000000, 10
) AS tmp ON o.id = tmp.id;
方案适用场景原理
延迟关联有自增主键先在覆盖索引上完成分页,只拿 10 个 ID 回表
游标分页(推荐)滚动加载/APPWHERE id > 上一页最后一条ID LIMIT 10,永远不走 OFFSET
覆盖索引 + 子查询联合索引覆盖了查询列SELECT * FROM t WHERE id IN (SELECT id FROM t LIMIT N,10)
ES / 搜索引擎全文搜索 + 分页深分页不是关系数据库的强项

ORDER BY 排序优化

filesort 的产生机制

ORDER BY 的列无法利用索引的有序性时,MySQL 会触发 filesort

-- ✅ 利用索引排序(B+Tree 叶子已按 name 排序,直接扫描即可)
SELECT * FROM users ORDER BY name;

-- ❌ filesort(name 和 age 排序方向不一致,或排序键和查询条件不同)
SELECT * FROM users WHERE status = 1 ORDER BY create_time DESC;

filesort 的两种模式

模式配置参数行为性能
单路排序(Single-pass)max_length_for_sort_data 足够大一次性读出所有需要的列,在 sort_buffer 中排序后直接返回🟢 较快
双路排序(Two-pass)行数据超过 max_length_for_sort_data只读排序键和 rowid,排序后再按 rowid 回表取完整行🔴 更慢(多一次回表)

ORDER BY 优化策略

-- ❌ 排序 + 大量数据 → sort_buffer 溢出到磁盘
SELECT * FROM orders ORDER BY amount DESC;

-- ✅ 策略1:让 ORDER BY 列命中索引(利用 B+Tree 天然有序性)
SELECT * FROM orders ORDER BY id DESC;  -- 主键天然有序

-- ✅ 策略2:减小排序数据量(配合 LIMIT)
SELECT * FROM orders ORDER BY amount DESC LIMIT 100;

-- ✅ 策略3:提高 sort_buffer_size
SET SESSION sort_buffer_size = 4194304;  -- 4MB

COUNT 查询优化

COUNT(*)、COUNT(1)、COUNT(col)、COUNT(id) 的区别

COUNT(1)      → 1 是常量表达式。server 层遍历每一行,这一行在 server 层会判定常量非空,所以逐行计数。
COUNT(*)      → 专门做了优化,server 层遍历每一行,直接计数——不取字段、不做值判空,最快。
COUNT(id)     → server 层拿到具体的 id 列,检查字段 IS NOT NULL 后计入总数。比 COUNT(*) 多一次取字段操作。
COUNT(col)    → server 层拿到 col 列,检查是否 IS NOT NULL,NULL 行不计入。若 col 无 NOT NULL 约束,
                还要做额外判空操作,性能最差。但若 col 有 NOT NULL 约束,优化器等价为 COUNT(*)。

性能排序COUNT(*)COUNT(1) > COUNT(not_null_col) > COUNT(nullable_col)

大表 COUNT 优化

-- ❌ 每次查询都全表扫描
SELECT COUNT(*) FROM orders;

-- ✅ 方案1:利用 EXPLAIN 的估算值(适合不需要精确值的场景)
EXPLAIN SELECT COUNT(*) FROM orders;  -- rows 列即为估算值

-- ✅ 方案2:查询 information_schema(近似值,非精确)
SELECT TABLE_ROWS FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'mydb' AND TABLE_NAME = 'orders';

-- ✅ 方案3:Redis 维护计数器(每次写入同步增减)
-- INCR orders:count  -- 写入订单时 +1

-- ✅ 方案4:汇总表定时刷新(适合报表场景)
-- CREATE TABLE orders_stats AS SELECT COUNT(*) AS total FROM orders;

MySQL 优化器代价模型(Cost Model)

理解优化器为什么选择(或放弃)索引,才能写出一劳永逸的高性能 SQL。

优化器的决策逻辑

1. 优化器统计索引的基数(Cardinality / 区分度)
2. 根据 WHERE 条件计算每个可能索引的 I/O 成本和 CPU 成本
3. 选择总成本最低的执行方案

查看优化器的倾向

-- 查看最后一次查询的优化器成本估算
SHOW STATUS LIKE 'Last_query_cost';
-- 返回值含义:读取多少个随机数据页的成本

-- 强制使用指定索引(绕过优化器,仅限调试!)
SELECT * FROM tb_user FORCE INDEX(idx_phone) WHERE phone = '13800138000';

-- 查看表的索引统计信息
SHOW INDEX FROM tb_user;
-- Cardinality 列:索引中不重复值的估算数量,值越接近总行数说明区分度越高

优化器误判的常见原因

原因表现解决方案
统计信息不准确明明有索引但优化器选了全表扫描ANALYZE TABLE tb_user; 更新统计信息
数据倾斜某些热点值占了大部分行对倾斜列单独建立索引
回表代价估算偏高优化器"以为"回表很贵,实际很快用覆盖索引规避回表
ORDER BY 干扰优化器为了免排序选了次优索引建立覆盖 ORDER BY + WHERE 的联合索引

INSERT / UPDATE 的索引代价

建索引不能只顾查询,写入性能同样受索引制约:

-- 假设一张表有 5 个索引
INSERT INTO tb_user VALUES (...);
-- MySQL 实际做了:1 次数据插入 + 5 次索引维护 = 6 次磁盘写操作
维度无索引表索引过多表
INSERT 性能极高每次插入需更新所有索引树
UPDATE 索引列先删除旧索引记录 + 插入新索引记录
磁盘占用仅数据数据 + N 个索引树
查询性能全表扫描索引加速

指导原则:查询频率低的表少建索引,写入频繁的表严格控制索引数量。


大厂索引设计七条铁律

原则说明反面案例
① 量大频高才建数据量(>10 万行)且查询频次高才建索引几百行的配置表建索引纯粹浪费
② 卡点扫描建WHEREORDER BYGROUP BYJOIN 字段优先建只在 SELECT 列表中出现但不参与过滤的列不需要索引
③ 区分度高是灵魂选区分度高的列(手机号、UID),区分度 = distinct/总数sex 区分度只有 2~3,建索引毫无意义
④ 长字段前缀索引VARCHAR(500) / TEXT 通过前缀索引降体积全文索引 FULLTEXT 替代前缀索引(搜索场景)
⑤ 联合索引优先多条件查询优先建联合索引而非多个单列索引3 个单列索引可能只命中 1 个,另外 2 个白白浪费
⑥ 控制索引总量索引不是越多越好有些表建了 10+ 个索引,写入性能直接凉透
⑦ NOT NULL 约束索引列设 NOT NULL,优化器决策更精准NULL 值在索引中占空间,且 IS NULL 查询易失效

实战案例:一条慢 SQL 的完整优化

以下是一个真实的订单查询优化案例:

-- 原始 SQL(耗时 8.2s)
SELECT o.id, o.order_no, o.amount, u.name, u.phone
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.create_time >= '2026-01-01'
  AND o.status = 1
ORDER BY o.amount DESC
LIMIT 0, 20;

排查过程

1. EXPLAIN 诊断:
   type: ALL(orders 表全表扫描) ← 致命
   Extra: Using where; Using filesort; Using temporary

2. 查看表结构:
   orders 表 200 万行,无 create_time + status 联合索引
   user_id 有单列索引

3. 问题定位:
   - WHERE 过滤条件 create_time + status 都无索引 → 全表扫描
   - ORDER BY amount 无索引 → filesort
   - JOIN 的 user_id 虽有索引,但驱动表本身太大

优化方案

-- 1. 建立联合索引(最关键的一步)
CREATE INDEX idx_orders_time_status_amount ON orders(create_time, status, amount);

-- 2. 改写 SQL(利用索引覆盖 + 延迟关联)
SELECT o.id, o.order_no, o.amount, u.name, u.phone
FROM orders o
INNER JOIN (
    SELECT id FROM orders
    WHERE create_time >= '2026-01-01' AND status = 1
    ORDER BY amount DESC
    LIMIT 20
) AS tmp ON o.id = tmp.id
LEFT JOIN users u ON o.user_id = u.id
ORDER BY o.amount DESC;
指标优化前优化后
耗时8.2s0.03s
typeALLrangeeq_ref
rows2,000,00020
ExtraUsing filesort; Using temporaryUsing index

生产环境监控与巡检 SQL

-- 1. 查看当前正在执行的慢查询
SHOW FULL PROCESSLIST;

-- 2. 查看哪些表没有主键(InnoDB 下非常危险)
SELECT t.table_schema, t.table_name
FROM information_schema.TABLES t
LEFT JOIN information_schema.STATISTICS s
    ON t.table_schema = s.table_schema AND t.table_name = s.table_name AND s.index_name = 'PRIMARY'
WHERE t.table_schema NOT IN ('mysql','information_schema','performance_schema','sys')
  AND t.table_type = 'BASE TABLE'
  AND s.index_name IS NULL;

-- 3. 查看冗余索引(某个索引是另一个索引的前缀)
SELECT * FROM sys.schema_redundant_indexes;

-- 4. 查看未使用的索引(需开启 userstat)
SELECT * FROM sys.schema_unused_indexes;

-- 5. 分析表,更新索引统计信息
ANALYZE TABLE tb_user;

总结

MySQL 性能优化的核心知识链:

  1. 排查链路:慢查询日志定位 SQL → SHOW PROFILE 剖析耗时 → EXPLAIN 诊断执行计划
  2. EXPLAIN 五要素type 定生死 / key 看用没用 / key_len 看用几列 / rows 看扫多少 / Extra 看藏雷
  3. 索引失效七场景:函数运算、类型转换、前导模糊、OR 无索引、负向条件、IS NULL、数据倾斜
  4. 高级优化三板斧
    • 深分页 → 延迟关联 / 游标分页
    • JOIN 慢 → 小表驱动大表 + 被驱动表索引 + MySQL 8.0 Hash Join
    • ORDER BY 慢 → 利用索引有序性,杜绝 filesort
  5. 记住一句话type=ALL 必须优化,Using filesort 高度警惕,Using index 是终极追求
MySQL 进阶:慢查询排查、EXPLAIN 诊断与索引失效深度优化指南 | Shanhai