系统讲解MySQL慢查询的完整排查流程

描述

问题背景

MySQL 慢查询是影响业务响应速度的最常见根因。业务高峰期一次看似简单的 SELECT 查询,可能拖慢整个系统——前端页面加载转圈、API 超时、后台任务堆积、数据库连接池耗尽。更要命的是,慢查询往往不是单一问题,而是索引缺失、表结构不合理、SQL 写法糟糕、统计信息过时、硬件资源不足等多重因素叠加的结果。初期只是偶尔卡顿,后期随着数据量增长演变成全面崩溃。

这篇文章面向初中级 MySQL 运维和后端开发工程师,从一个典型的"页面加载慢"投诉出发,系统讲解 MySQL 慢查询的完整排查流程:先开启慢查询日志抓出现存的慢查询,再用 EXPLAIN 分析执行计划,找到索引问题和全表扫描,再结合 MySQL 8.0 的新特性做优化,最后给出索引设计规范和 SQL 编写规范。所有内容基于 MySQL 5.7 和 MySQL 8.0 两个主流版本,部分特性差异会做说明。

适用场景

业务接口响应时间突然变长,怀疑是数据库查询慢

监控显示 QPS 正常但 DB CPU 使用率持续偏高

慢查询日志文件快速增长,磁盘空间告警

需要优化历史遗留 SQL,消除全表扫描

新功能上线前做 SQL 审核,发现潜在性能问题

主从复制延迟持续偏高,怀疑是慢查询阻塞了复制线程

分库分表前评估哪些大表需要优先拆分

MySQL 查询慢的常见根因

在动手排查之前,先理解可能导致查询慢的原因有哪些:

一、索引层面:

缺少 WHERE 条件列上的索引,导致全表扫描

索引失效:函数/运算在索引列上、类型转换、LIKE 前缀通配符

索引选择不当:MySQL 选错了索引(索引统计信息不准)

联合索引顺序不对,最左前缀原则不满足

二、SQL 写法层面:

SELECT * 读取全部列,没有利用覆盖索引

多表 JOIN 时没有给小表加驱动限制

子查询嵌套过深或用 IN (SELECT ...) 导致全表

OR 条件导致索引失效

分页查询 OFFSET 过大(深度分页问题)

缺少 LIMIT 限制返回行数

三、表结构层面:

表过大(单表超过千万行且无分区)

字段类型选择不当(VARCHAR(255) 存大文本、TEXT 字段参与排序)

没有合理使用分区表

字段冗余设计不合理导致更新阻塞

四、数据库配置层面:

Buffer Pool 设置太小,热数据无法全部缓存

脏页刷新策略不合理导致磁盘 IO 突增

并发连接数设置过高导致上下文切换

临时表和排序缓冲区不足

五、硬件和系统层面:

磁盘 IO 性能不足(SSD vs HDD)

内存不足导致 swap

CPU 核心数不足

网络延迟(跨机房部署)

第一步:开启慢查询日志

慢查询日志是排查的起点。如果还没有开启,先开启它。

1.1 确认慢查询日志是否开启

 

-- 查看慢查询相关配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'log_output';

-- 输出示例:
-- slow_query_log                | ON
-- slow_query_log_file          | /var/lib/mysql/mysql-slow.log
-- long_query_time              | 2.000000   (超过 2 秒的查询才记录)
-- log_output                   | FILE

 

long_query_time 默认是 10 秒,对生产环境来说太宽松了。建议设置为 1 秒,有能力的话设置为 0.5 秒或更低,但要注意开启后日志量会增加。

1.2 临时开启慢查询日志(不重启)

 

-- 将超过 1 秒的查询记录到慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log_file = '/var/lib/mysql/mysql-slow.log';

-- 同时开启记录未使用索引的查询(注意:MySQL 8.0 中这个参数被移除)
-- MySQL 5.7 中可以开启
SET GLOBAL log_queries_not_using_indexes = 'ON';

-- 确认修改生效
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';

 

注意:以上修改在 MySQL 重启后会丢失。如果要永久生效,需要修改配置文件。

1.3 永久开启慢查询日志

 

# 编辑 MySQL 配置文件(根据安装方式不同,配置文件位置不同)
# RPM 安装:/etc/my.cnf
# Docker 安装:挂载的配置文件
# 使用 mysqld --verbose --help | grep -A 10 'Default options' 查找

sudo vi /etc/my.cnf

# 在 [mysqld] 段添加或修改以下内容:
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/lib/mysql/mysql-slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
log_output = FILE

# 保存后重启 MySQL(需要确认影响范围)
sudo systemctl restart mysqld

 

风险提醒:重启 MySQL 会断开所有现有连接,生产环境需要:

确认业务是否有重连机制

确认是否有长事务未提交(重启前最好确认)

通知相关业务方

准备好回滚方案(将原配置文件备份)

1.4 用 pt-query-digest 分析慢查询日志

pt-query-digest 是 Percona Toolkit 中的工具,比直接查看日志文件效率高得多。它能聚合相似查询、排序出最慢的查询、展示查询频率和响应时间分布。

 

# 如果没有安装 Percona Toolkit,先安装
# CentOS/RHEL:
sudo yum install percona-toolkit -y
# Debian/Ubuntu:
sudo apt-get install percona-toolkit -y

# 分析慢查询日志
pt-query-digest /var/lib/mysql/mysql-slow.log

# 输出结构:
#   1) 查询的指纹(将参数泛化后的标准化 SQL)
#   2) 查询时间统计:Response time、Calls、R/M/V
#   3) SQL 文本本身
#   4) 执行计划(如果有 EXPLAIN 输出)

# 只看前 10 个最慢的查询
pt-query-digest --limit 10 /var/lib/mysql/mysql-slow.log

# 只看某个时间范围后的查询
pt-query-digest --since '2026-04-29 1000' /var/lib/mysql/mysql-slow.log

# 排除某个用户查询
pt-query-digest --filter '$event->{user} =~ /^(?!repl_user)/' /var/lib/mysql/mysql-slow.log

 

pt-query-digest 输出的第一部分是最关键的——按响应时间排序的查询列表。第一条就是当前最需要优化的查询。

第二步:用 EXPLAIN 分析执行计划

找到慢查询后,用 EXPLAIN 分析它的执行计划。执行计划是 MySQL 优化器的决策结果,告诉你 MySQL 准备怎么执行这条查询。

2.1 基本 EXPLAIN 用法

 

-- 在 MySQL 客户端中执行
EXPLAIN ;

-- MySQL 8.0 支持 EXPLAIN ANALYZE,能实际执行并测量真实运行时间和行数
EXPLAIN ANALYZE ;

-- 完整输出包含 12 个字段
-- id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra

 

2.2 逐字段解读执行计划

type 字段(最重要的字段之一):

type 字段描述了 MySQL 如何找到数据行,从好到坏排序:

type 值 含义 优化建议
system 表只有一行(系统表) 最优,无需关注
const 通过主键或唯一索引一次找到 最优,查询已经最优
eq_ref JOIN 时通过主键或唯一索引关联 正常
ref 通过普通索引等值匹配 可接受,注意索引选择性
range 索引范围扫描 正常,注意范围大小
index 全索引扫描 性能差,通常需要优化
ALL 全表扫描 严重问题,必须优化

如果 type 是 ALL,说明这条查询在走全表扫描,是慢查询的最常见根因。

rows 字段:MySQL 优化器预估需要扫描的行数。这个数字越大,性能通常越差。但注意这是预估,不一定准确(统计信息过期时会不准)。

Extra 字段:包含大量优化决策信息,需要重点关注的内容:

 

Using filesort    -- 使用了文件排序(外部排序),非常慢,需要优化
Using temporary   -- 创建了临时表,很慢,需要优化
Using index      -- 利用了覆盖索引,性能好
Using index condition -- 利用了索引下推
Using where      -- 在存储引擎层过滤数据

 

常见有问题的 Extra 组合:

Using filesort:ORDER BY 的列没有索引,或索引顺序和 ORDER BY 不一致

Using temporary:DISTINCT、GROUP BY、UNION 等操作需要临时表

Using filesort + Using temporary:同时出现,说明既需要排序又需要临时表,最糟糕的情况

2.3 实际案例分析

 

-- 原始慢查询
EXPLAIN SELECT * FROM orders WHERE user_id = 12345 ORDER BY created_at DESC LIMIT 10;

-- 输出:
-- id: 1
-- select_type: SIMPLE
-- table: orders
-- type: ALL              <-- 全表扫描!这是问题
-- possible_keys: NULL    <-- 没有可用索引
-- key: NULL
-- rows: 1523872         <-- 扫描了 150 万行
-- filtered: 100.00
-- Extra: Using where; Using filesort  <-- 还用了文件排序

-- 优化后(添加索引后)
EXPLAIN SELECT * FROM orders WHERE user_id = 12345 ORDER BY created_at DESC LIMIT 10;

-- 输出:
-- id: 1
-- select_type: SIMPLE
-- table: orders
-- type: ref              <-- 使用了索引查找
-- possible_keys: idx_user_id_created  <-- 可用索引
-- key: idx_user_id_created  <-- 实际使用的索引
-- key_len: 4             <-- 索引长度
-- ref: const
-- rows: 10               <-- 只扫描了 10 行
-- filtered: 100.00
-- Extra: Backward index scan; Using where  <-- 倒序扫描索引,Using filesort 消失

 

2.4 联合索引的索引列顺序判断

 

-- 如果查询条件是 WHERE a = 1 AND b = 2 AND c > 3 ORDER BY d
-- 索引顺序应该是:(a, b, c, d)
-- 因为最左前缀原则要求索引列从左到右依次使用

-- 验证索引顺序是否正确
EXPLAIN SELECT * FROM orders WHERE a = 1 AND b = 2 AND c > 3 ORDER BY d;

-- 如果 Extra 中仍然出现 Using filesort,说明索引顺序不对
-- 需要创建索引:(a, b, c, d)

 

第三步:识别常见索引失效场景

慢查询的另一个重灾区是索引明明存在,但因写法问题导致索引失效,优化器选择全表扫描。以下是常见场景。

3.1 在索引列上使用函数或运算

 

-- 索引失效示例:WHERE YEAR(created_at) = 2026
EXPLAIN SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- type: ALL (全表扫描),因为 MySQL 必须对每一行计算 YEAR() 才能判断

-- 正确写法:使用范围查询
EXPLAIN SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
-- type: range(使用索引范围扫描)

-- 索引失效示例:WHERE price * 1.1 > 100
EXPLAIN SELECT * FROM products WHERE price * 1.1 > 100;
-- 同样全表扫描

-- 正确写法:WHERE price > 100 / 1.1
EXPLAIN SELECT * FROM products WHERE price > 100 / 1.1;

 

3.2 LIKE 前缀通配符

 

-- 索引失效:LIKE 以通配符开头
EXPLAIN SELECT * FROM users WHERE name LIKE '%wang%';
-- type: ALL,全表扫描

-- 正确写法:如果必须模糊匹配,考虑全文索引(FULLTEXT)
EXPLAIN SELECT * FROM users WHERE name LIKE 'wang%';
-- type: range,使用索引

-- 如果业务确实需要 %wang% 这种匹配,考虑:
-- 1. 全文索引:ALTER TABLE users ADD FULLTEXT(name);
-- 2. 分词 + 倒排索引
-- 3. Elasticsearch

 

3.3 隐式类型转换

 

-- phone 是 VARCHAR(20) 类型的列,存储了字符串 "13800138000"
-- 如果查询时传入数字,索引失效

EXPLAIN SELECT * FROM users WHERE phone = 13800138000;
-- phone 是字符串类型,但传入的是数字,MySQL 会将字符串转换为数字再比较
-- 导致全表扫描

-- 正确写法:使用字符串字面量
EXPLAIN SELECT * FROM users WHERE phone = '13800138000';

 

3.4 OR 条件导致索引失效

 

-- OR 条件:只有当所有条件都有索引时才会使用索引
EXPLAIN SELECT * FROM users WHERE name = 'zhangsan' OR email = 'zhangsan@example.com';
-- 如果 name 有索引、email 没有索引,MySQL 仍然选择全表扫描

-- 优化写法:拆分成 UNION
EXPLAIN SELECT * FROM users WHERE name = 'zhangsan'
UNION
SELECT * FROM users WHERE email = 'zhangsan@example.com';

-- 注意:如果 email 列的选择性很差(很多重复值),即使有索引也不该用

 

3.5 确认索引是否被使用

 

-- 查看某条查询实际使用的索引
EXPLAIN SELECT * FROM orders WHERE status = 'completed' AND created_at > '2026-04-01';

-- possible_keys: 显示 MySQL 认为可用的索引
-- key: 显示实际使用的索引

-- 如果 possible_keys 有值但 key 是 NULL:
-- 可能是 MySQL 认为全表扫描更快(数据量小、统计信息不准)
-- 或者索引列的选择性太差(大量重复值)

-- 查看索引的选择性(基数)
SHOW INDEX FROM orders;
-- Cardinality 列显示索引列的唯一值数量
-- 如果 Cardinality 很小(接近行数),说明选择性差,不适合建索引

 

第四步:深度分页问题

深度分页(OFFSET 很大)是慢查询的重灾区。比如 LIMIT 100000, 10 这种查询,MySQL 需要先扫描前 100010 行,然后丢弃前 100000 行,返回最后 10 行。OFFSET 越大,浪费越多。

4.1 识别深度分页查询

 

-- 这种查询就是典型的深度分页
SELECT * FROM orders WHERE status = 'completed' ORDER BY id DESC LIMIT 100000, 10;

-- pt-query-digest 输出的 Query 特征:
-- FROM orders WHERE status = 'completed' ORDER BY id DESC LIMIT ... OFFSET ...
-- Rows in set after limit: 10 (但实际扫描了 100010 行)

 

4.2 优化方案一:使用游标分页

利用主键 ID 的单调性,避免 OFFSET:

 

-- 第一页
SELECT * FROM orders WHERE status = 'completed' ORDER BY id DESC LIMIT 10;
-- 记住最后一行的 id,比如是 987654

-- 第二页:利用上一页最后的 ID 作为起点
SELECT * FROM orders
WHERE status = 'completed' AND id < 987654
ORDER BY id DESC LIMIT 10;
-- 这个查询利用了主键索引,时间复杂度是 O(log N + 10),而不是 O(N)

 

注意:游标分页只适合有明确排序场景,且排序字段最好是单调递增/递减的(时间倒序、ID 倒序)。如果需要随机跳页,这种方式不适用。

4.3 优化方案二:延迟关联

减少回表次数:

 

-- 原始深度分页(慢)
SELECT * FROM orders WHERE status = 'completed' ORDER BY id DESC LIMIT 100000, 10;

-- 优化:先通过索引只查主键,再关联回原表取其他列
SELECT o.* FROM orders o
INNER JOIN (
    SELECT id FROM orders WHERE status = 'completed' ORDER BY id DESC LIMIT 100000, 10
) AS t ON o.id = t.id;

 

4.4 优化方案三:记录总页数上限

 

-- 对搜索结果设置最大页数限制,超过后返回空或提示用户使用更精确的搜索条件
-- 例如:限制最大 OFFSET 为 10000
SELECT * FROM orders
WHERE status = 'completed' AND created_at > '2026-04-01'
ORDER BY id DESC
LIMIT 10 OFFSET 100000;

-- 如果超过 10000 页,返回友好提示或建议用户使用更精确的搜索条件

 

第五步:多表 JOIN 优化

多表 JOIN 是慢查询的另一大来源。JOIN 的执行顺序、驱动表选择、索引条件都会极大影响性能。

5.1 查看 JOIN 执行计划

 

EXPLAIN SELECT u.name, o.order_no, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active' AND o.created_at > '2026-04-01';

-- id: 执行顺序,id 相同从上到下执行,id 越大优先级越高
-- select_type: SIMPLE(简单查询)、DERIVED(派生表/子查询)、UNION 等
-- table: 查询涉及的表
-- type: 关联类型
-- key: 实际使用的索引

-- 重点关注:
-- 1. 驱动表是谁(通常是第一张表)
-- 2. 是否有全表扫描(type=ALL)
-- 3. JOIN 的 key 是否正确关联

 

5.2 JOIN 优化原则

原则一:小表驱动大表。

MySQL 优化器通常会自动选择小表作为驱动表(数据量小的表),但不是绝对的。可以用 STRAIGHT_JOIN 强制指定驱动表:

 

-- 强制使用 users 作为驱动表(需要根据实际数据量判断)
SELECT STRAIGHT_JOIN u.name, o.order_no, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active' AND o.created_at > '2026-04-01';

 

原则二:被驱动表的关联列必须有索引。

 

-- orders.user_id 如果没有索引,每次关联都是全表扫描
-- 先确认索引是否存在
SHOW INDEX FROM orders;

-- 如果不存在,创建索引
ALTER TABLE orders ADD INDEX idx_user_id (user_id);

 

5.3 常见 JOIN 问题分析

 

-- 问题一:多表 JOIN 后出现 Using filesort
EXPLAIN SELECT u.name, o.order_no
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
ORDER BY o.created_at DESC;
-- 如果 o.created_at 上没有索引,排序会用到文件排序

-- 解决方案:添加联合索引
ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at);

-- 问题二:子查询导致的性能问题
EXPLAIN SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE status = 'inactive');
-- IN (SELECT ...) 在 MySQL 5.7 及之前性能很差,会先执行外层再执行内层

-- 优化:改写为 JOIN
EXPLAIN SELECT o.* FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE u.status = 'inactive';

-- 问题三:LEFT JOIN 右表条件放错位置
SELECT u.name, o.order_no
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'completed'  -- 条件放在 ON
WHERE o.id IS NULL;  -- 找没有订单的用户

-- 这样做是对的,但很多人会把这个条件放到 WHERE 里:
SELECT u.name, o.order_no
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 'completed';  -- 错误:LEFT JOIN 条件放 WHERE 会把 LEFT 变成 INNER

 

第六步:查看 MySQL 实时状态和进程

有时候慢查询不是单条 SQL 的问题,而是大量并发查询把数据库打满了。需要从全局视角看数据库负载。

6.1 查看当前连接数和活跃查询

 

-- 查看当前连接状态
SHOW STATUS LIKE 'Threads%';
-- Threads_connected: 当前连接数
-- Threads_running: 当前正在执行的查询数(不包括等待中的)

-- 查看最大连接数配置
SHOW VARIABLES LIKE 'max_connections';
-- 默认 151,建议根据实际并发需求调整

-- 查看当前所有连接
SHOW PROCESSLIST;
-- 或
SHOW FULL PROCESSLIST;

-- 输出各列含义:
-- Id: 连接 ID
-- User: 用户名
-- Host: 客户端 IP
-- db: 连接的数据库
-- Command: 当前命令(Sleep/Query/Connect 等)
-- Time: 执行时间(秒)
-- State: 状态
-- Info: SQL 内容(只显示前 100 字符)

 

重点关注:Command=Query 且 Time 很长(比如超过 60 秒)的查询,说明有慢查询正在执行。

6.2 查看数据库锁等待

锁等待是导致查询卡住的常见原因,特别是有大事务或长事务时。

 

-- 查看当前锁等待信息(MySQL 8.0)
SELECT * FROM performance_schema.data_lock_waits;

-- 查看当前的事务
SELECT * FROM information_schema.INNODB_TRX;

-- 输出各列含义:
-- trx_id: 事务 ID
-- trx_state: 事务状态(RUNNING/LOCK WAIT/ROLLING BACK)
-- trx_started: 事务开始时间
-- trx_rows_locked: 锁住的行数
-- trx_mysql_thread_id: 对应的 MySQL 连接 ID
-- trx_query: 正在执行的 SQL

-- 查看具体的锁信息
SHOW ENGINE INNODB STATUS;
-- 输出信息量很大,包含:
-- LATEST DETECTED DEADLOCK: 最近检测到的死锁
-- TRANSACTIONS: 当前所有事务和锁的状态
-- LOCK WAIT: 等待中的锁
-- ROW OPERATIONS: 当前的行操作统计

 

发现长时间 LOCK WAIT 的事务:

 

-- 查看超过 60 秒仍未完成的事务
SELECT
    trx_id,
    trx_state,
    trx_started,
    TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
    trx_rows_locked,
    trx_query,
    ps.user AS mysql_user,
    host_info
FROM information_schema.INNODB_TRX
JOIN performance_schema.threads AS ps ON ps.processlist_id = trx_mysql_thread_id
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
ORDER BY duration_sec DESC;

 

风险提醒:KILL 正在执行的事务是破坏性操作,可能导致事务回滚。如果要 KILL,先确认:

该事务是否是正常的长事务(比如数据导入)

是否已经提交(已提交的话 KILL 没有意义)

回滚本身也需要时间(大事务回滚可能更慢)

6.3 查看数据库整体性能指标

 

-- 查看 Buffer Pool 命中率
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
-- Innodb_buffer_pool_read_requests: 读请求数
-- Innodb_buffer_pool_reads: 从磁盘读取的次数(未命中)
-- 命中率 = 1 - (reads / read_requests),应该 > 95%

-- 计算公式(在 MySQL 客户端执行)
SELECT
    variable_value AS read_requests,
    variable_value AS disk_reads
FROM performance_schema.global_status
WHERE variable_name IN ('Innodb_buffer_pool_read_requests', 'Innodb_buffer_pool_reads');

-- 查看 QPS 和 TPS
SHOW STATUS LIKE 'Questions';
SHOW STATUS LIKE 'Uptime';

-- 查看慢查询数量
SHOW GLOBAL STATUS LIKE 'Slow_queries';

-- 查看已经创建临时表和磁盘临时表的数量
SHOW GLOBAL STATUS LIKE 'Created_tmp%';
-- Created_tmp_disk_tables 不应该太高,否则说明排序缓冲区不足

 

第七步:索引设计与优化实战

7.1 如何判断一个查询是否需要索引

原则:在 WHERE 条件、JOIN ON 条件、ORDER BY、GROUP BY 中出现的列,考虑建索引。

 

-- 高频查询:WHERE status = 'completed' AND created_at > '2026-04-01'
-- 应该建联合索引:(status, created_at)

-- 低频查询:如果某查询每天只执行几次,即使慢也不值得花太多精力优化
-- 优先优化高频查询

 

7.2 联合索引的最左前缀原则

联合索引 (a, b, c) 能支持的查询场景:

 

 WHERE a = 1                  -- 使用索引 (a)
 WHERE a = 1 AND b = 2        -- 使用索引 (a, b)
 WHERE a = 1 AND b = 2 AND c = 3  -- 使用完整索引 (a, b, c)
 WHERE a IN (1, 2)            -- 使用索引 (a)
 WHERE a = 1 AND c = 3        -- 使用索引 (a),但 c 部分无法利用索引
 WHERE b = 2                  -- 不使用索引
 WHERE c = 3                  -- 不使用索引
 WHERE b = 2 AND c = 3        -- 不使用索引

 

ORDER BY 同样遵循最左前缀原则:

 

 WHERE a = 1 ORDER BY b       -- 使用索引 (a, b),有序
 WHERE a = 1 ORDER BY b, c    -- 使用索引 (a, b, c),有序
 WHERE a = 1 ORDER BY c       -- 不使用索引排序,会产生 filesort

 

7.3 覆盖索引

覆盖索引是指一个索引包含了查询需要的所有列,这样 MySQL 不需要回表(访问主键索引),直接在索引树就能拿到结果:

 

-- 查询:只需要 id, user_id, order_no 三列
-- 如果这三个列都在索引中,就是覆盖索引

-- 创建覆盖索引
ALTER TABLE orders ADD INDEX idx_cover (user_id, order_no, id);

-- 验证是否使用了覆盖索引(Extra 列显示 Using index)
EXPLAIN SELECT user_id, order_no, id FROM orders WHERE user_id = 123;
-- Extra: Using index  <-- 说明用了覆盖索引,不需要回表

EXPLAIN SELECT * FROM orders WHERE user_id = 123;
-- Extra: 没有 Using index  <-- SELECT * 会回表取所有列

 

7.4 索引设计规范

规范一:避免在低选择性列上建索引

 

-- 例如 status 字段只有 3 个值(pending/active/completed),选择性极低
-- 对这种字段建 B-Tree 索引几乎没有意义
SELECT COUNT(DISTINCT status) / COUNT(*) AS selectivity FROM orders;
-- 如果结果 < 0.01,说明选择性太低,不适合单独建索引

-- 但如果是联合索引的第一列,还是有意义的(配合其他高选择性列)

 

规范二:字符串前缀索引

如果字符串列很长(VARCHAR(255) 或 TEXT),直接建索引太占空间。用前缀索引:

 

-- 对 email 字段建立前 10 个字符的前缀索引
ALTER TABLE users ADD INDEX idx_email_prefix (email(10));

-- 前缀长度选择:让前缀的选择性接近完整列
SELECT
    COUNT(DISTINCT LEFT(email, 5)) / COUNT(*) AS prefix_5,
    COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS prefix_10,
    COUNT(DISTINCT LEFT(email, 15)) / COUNT(*) AS prefix_15,
    COUNT(DISTINCT email) / COUNT(*) AS full
FROM users;
-- 选择使前缀选择性接近 full 值的最小长度

 

规范三:不要在频繁更新的字段上建索引

 

-- 每次更新 orders 表的 updated_at 字段时,索引也需要更新
-- 如果 updated_at 更新非常频繁,索引维护开销会很大
-- 评估方式:查看该字段的 UPDATE 频率和查询频率的对比

 

第八步:配置层面的优化

除了 SQL 和索引,MySQL 本身的一些参数也会影响查询性能。

8.1 Buffer Pool 配置

Buffer Pool 是 MySQL 最核心的缓存区域,存放表数据、索引、锁信息等。如果 Buffer Pool 不够大,热数据无法全部缓存,频繁 disk IO 导致查询变慢。

 

-- 查看 Buffer Pool 大小配置
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
-- 默认通常很小,需要调整为物理内存的 50%-80%(留内存给 OS 和其他用途)

-- 查看 Buffer Pool 使用情况
SHOW STATUS LIKE 'Innodb_buffer_pool%';
-- Innodb_buffer_pool_pages_total: 总页数
-- Innodb_buffer_pool_pages_free: 空闲页数
-- Innodb_buffer_pool_pages_dirty: 脏页数(已修改未刷盘)
# 修改 Buffer Pool 大小(需要重启 MySQL)
# 编辑 /etc/my.cnf
sudo vi /etc/my.cnf
# 在 [mysqld] 段添加:
innodb_buffer_pool_size = 8G  # 调整为 8GB,根据实际内存和业务需求来

# 如果 MySQL 8.0,可以使用在线调整(不需要重启)
SET GLOBAL innodb_buffer_pool_size = 8589934592;

 

8.2 慢查询日志参数调优

 

# /etc/my.cnf 中的慢查询相关配置
[mysqld]
# 超过 1 秒的查询记录到慢查询日志
long_query_time = 1

# 慢查询日志文件路径
slow_query_log_file = /var/lib/mysql/mysql-slow.log

# 开启慢查询日志
slow_query_log = 1

# 记录未使用索引的查询(MySQL 5.7 有用,8.0 已移除该参数)
log_queries_not_using_indexes = 1

# 日志输出格式(建议 FILE,不要 TABLE,TABLE 会有锁问题)
log_output = FILE

# 限制慢查询日志文件大小,避免撑爆磁盘
max_slow_log_size = 100M

 

8.3 连接数和临时表配置

 

-- 查看最大连接数
SHOW VARIABLES LIKE 'max_connections';
-- 默认 151,建议根据峰值并发调整
-- 注意:每个连接都占用内存,盲目加太大会 OOM

-- 查看临时表和排序缓冲区使用情况
SHOW GLOBAL STATUS LIKE 'Created_tmp%';
SHOW VARIABLES LIKE 'tmp_table_size';
SHOW VARIABLES LIKE 'max_heap_table_size';

-- 如果 Created_tmp_disk_tables 很高,考虑增加 tmp_table_size
SET GLOBAL tmp_table_size = 256M;
SET GLOBAL max_heap_table_size = 256M;

 

第九步:完整排查流程总结

9.1 慢查询排查标准流程

第一步:抓取慢查询

 

-- 1. 确认慢查询日志开启
SHOW VARIABLES LIKE 'slow_query_log';

-- 2. 如果没开启,临时开启
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;

-- 3. 用 pt-query-digest 分析(推荐)
pt-query-digest /var/lib/mysql/mysql-slow.log --limit 20

 

第二步:分析执行计划

 

-- 对最慢的查询逐条执行 EXPLAIN
EXPLAIN ;

-- 关注:
-- type: 是否有 ALL(全表扫描)
-- rows: 预估扫描行数是否过大
-- Extra: 是否有 Using filesort、Using temporary

 

第三步:定位根因并优化

 

根因类型                  解决方案
────────────────────────────────────────────────────
type=ALL                 添加 WHERE 条件列的索引
Extra: Using filesort    添加 ORDER BY 列的索引,或调整索引顺序
Extra: Using temporary   优化 GROUP BY / DISTINCT / UNION 写法
索引失效(函数/运算)     改写 SQL,不在索引列上使用函数
深度分页(OFFSET 很大)   改用游标分页或延迟关联
JOIN 驱动表不对           加 STRAIGHT_JOIN 或加索引
Buffer Pool 命中率低     调大 innodb_buffer_pool_size

 

第四步:验证优化效果

 

-- 优化后再次 EXPLAIN,确认 type/rows/Extra 是否改善

-- 对比优化前后的查询时间
SET profiling = 1;
;
SHOW PROFILES;
SHOW PROFILE FOR QUERY ;

-- 检查慢查询日志中该查询是否消失

 

9.2 SQL 编写规范(避免慢查询)

规范一:避免 SELECT *

 

-- 慢:读取所有列
SELECT * FROM orders WHERE user_id = 123;

-- 快:只读取需要的列,利用覆盖索引
SELECT order_no, amount, created_at FROM orders WHERE user_id = 123;

 

规范二:WHERE 条件要具体

 

-- 慢:类型隐式转换
SELECT * FROM users WHERE phone = 13800138000;

-- 快:使用正确的类型
SELECT * FROM users WHERE phone = '13800138000';

 

规范三:分页查询使用游标

 

-- 慢:深度分页
SELECT * FROM orders ORDER BY id DESC LIMIT 100000, 10;

-- 快:游标分页
SELECT * FROM orders WHERE id <  ORDER BY id DESC LIMIT 10;

 

规范四:批量操作分批提交

 

-- 慢:一次删除 100 万行(产生大事务、长时间锁)
DELETE FROM logs WHERE created_at < '2026-01-01';

-- 快:分批删除,每次 1000 行
DELETE FROM logs WHERE created_at < '2026-01-01' LIMIT 1000;
-- 循环执行,直到删完

 

规范五:避免在 JOIN 前先做 WHERE 过滤

 

-- 慢:先 JOIN 再过滤,MySQL 可能先笛卡尔积再过滤
SELECT o.*, u.name
FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE u.status = 'inactive';

-- 快:先在子查询中过滤,再 JOIN(让 MySQL 先处理小数据集)
SELECT o.*, u.name
FROM orders o
INNER JOIN (SELECT id, name FROM users WHERE status = 'inactive') u ON o.user_id = u.id;

 

总结

MySQL 慢查询的排查核心在于:先用慢查询日志抓出现存问题,再用 EXPLAIN 分析执行计划找到索引和查询写法的问题。

最重要的三个判断标准:

type 列是否为 ALL(全表扫描,是最需要优化的)

rows 预估扫描行数是否过大(超过十万行就要警惕)

Extra 列是否有 Using filesort 或 Using temporary(说明有排序或临时表开销)

最常见的慢查询根因:

WHERE 条件列缺少索引,导致全表扫描

在索引列上使用函数或运算,导致索引失效

深度分页 OFFSET 过大

SELECT * 不利用覆盖索引

JOIN 被驱动表没有索引

优化优先级:

全表扫描(type=ALL)必须优先解决,几乎任何情况下都该加索引

Using filesort 其次优化,通常加个索引就能消除

深度分页改写游标分页

最后考虑配置层面的优化(Buffer Pool、临时表大小)

日常预防:

上线前用 EXPLAIN 审核每条新 SQL

用 pt-query-digest 定期分析慢查询日志

确保每个高并发查询都有合适的索引

不要在低选择性列上单独建索引

批量操作分批提交,避免长事务

 

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分