问题背景
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 定期分析慢查询日志
确保每个高并发查询都有合适的索引
不要在低选择性列上单独建索引
批量操作分批提交,避免长事务
全部0条评论
快来发表一下你的评论吧 !