前言
这篇文章系统梳理了 MySQL 查询慢的排查和优化方法,读完你可以用一套清晰思路回答性能调优类面试题。

面试里问 MySQL 性能优化,最怕答成散点式。比如回一句“加索引”——然后呢?这显然不够。真正靠谱的回答,应该从 定位问题 开始,再到 索引优化、SQL 优化、分页优化、表结构优化、缓存和架构优化,层层递进。核心目标就一句话:
让 MySQL 少扫描、少回表、少排序、少建临时表、少做无效 IO。
一、先用 EXPLAIN 定位问题
查询慢,不要上来就加索引。第一步应该是看执行计划。常用命令就是:
EXPLAIN SELECT * FROM user WHERE age = 18;
EXPLAIN 可以告诉你 MySQL 准备怎么执行这条 SQL。重点看下面这些字段:
| 字段 | 含义 | 重点 |
|---|---|---|
possible_keys | 可能使用的索引 | 优化器发现的候选索引 |
key | 实际使用的索引 | 为 NULL 说明没用索引 |
key_len | 实际使用的索引长度 | 常用于判断联合索引用到几列 |
rows | 预估扫描行数 | 越大越危险 |
type | 访问类型 | 判断查询效率的关键 |
Extra | 额外信息 | 看排序、临时表、覆盖索引 |
1. possible_keys 和 key 的区别
possible_keys 表示 MySQL 认为可能用得上的索引。key 表示最终真正用到的索引。比如:
possible_keys: idx_agekey: NULLtype: ALL
这说明 idx_age 理论上可用,但优化器最终没用,查询还是全表扫描。原因可能是数据量小、索引区分度低、回表成本高,或者统计信息不准确。
2. key_len 看联合索引用得深不深
假设有联合索引:
CREATE INDEX idx_name_age_city ON user(name, age, city);
如果 SQL 是:
SELECT * FROM user WHERE name = 'Tom' AND age = 18;
key_len 可以帮你判断索引用到了 name,还是用到了 name + age。它不是越长越好,它的价值是判断 联合索引是否被充分使用。
3. rows 看扫描量大不大
rows 是优化器预估要扫描的行数。比如:
type: ALLrows: 1000000
这基本就是危险信号,它说明 MySQL 可能要扫描 100 万行。当然,rows 是估算值,如果统计信息不准,可以执行:
ANALYZE TABLE user;
让 MySQL 重新统计。
二、重点看 type:扫描方式决定效率
type 表示 MySQL 找数据的方式。常见类型从差到好大致是:
ALL < index < range < ref < eq_ref < const
1. ALL:全表扫描
ALL 是最差的情况,表示 MySQL 要扫描整张表:
type: ALLkey: NULL
这种情况通常要重点优化。
2. index:全索引扫描
index 是全索引扫描,它比全表扫描稍好一点,因为扫的是索引树。但本质还是从头扫到尾,数据量大时也很慢。
3. range:索引范围扫描
常见于:
WHERE age > 18WHERE price BETWEEN 10 AND 80WHERE id IN (1, 2, 3)
从 range 开始,索引作用就比较明显了。
4. ref:非唯一索引等值查询
比如:
SELECT * FROM user WHERE age = 18;
如果 age 是普通索引,可能出现 type: ref,因为 age = 18 可能匹配多行。
5. eq_ref:唯一索引关联查询
常见于多表 JOIN。比如订单表关联用户表:
SELECT *FROM orders oJOIN user u ON o.user_id = u.id;
如果 u.id 是主键或唯一索引,可能出现 eq_ref。
6. const:主键或唯一索引查一行
比如:
SELECT * FROM user WHERE id = 1;
这种查询非常快。
三、Extra 里三个高频信号
Extra 是执行计划里的补充信息,面试里最常问这三个:
Using filesortUsing temporaryUsing index
1. Using filesort:额外排序
Using filesort 表示 MySQL 不能直接利用索引顺序完成排序,只能自己额外排序。注意,filesort 不一定真的写磁盘文件,它可能在内存里排,数据太大时才会落盘。比如:
SELECT * FROM user ORDER BY age;
如果 age 没有索引,MySQL 可能会:
扫描数据取出结果额外排序返回结果
优化方式是让排序字段走索引:
CREATE INDEX idx_age ON user(age);
2. Using temporary:使用临时表
Using temporary 表示 MySQL 创建了内部临时表保存中间结果。常见于 GROUP BY、ORDER BY、DISTINCT、UNION。比如:
SELECT age, COUNT(*)FROM userGROUP BY age;
如果不能利用索引完成分组,MySQL 可能会把中间结果放进临时表。临时表为什么慢?因为 MySQL 要额外写入中间结果,再读取、排序或分组,数据量大时还可能落盘。内部临时表可以有索引,但通常由 MySQL 自动决定。你优化的方向不是手动控制临时表,而是让原 SQL 能直接利用索引完成分组或排序。
3. Using index:覆盖索引
Using index 是好信号。它表示查询需要的数据只从索引里就能拿到,不需要回表。比如有索引:
CREATE INDEX idx_name_age ON user(name, age);
执行:
SELECT age FROM user WHERE name = 'Tom';
name 用于过滤,age 用于返回,两个字段都在索引里,这就是覆盖索引。
四、索引优化:不要只会说“加索引”
索引的本质是减少扫描范围,但不是越多越好,它会占空间,也会降低写入速度。
1. 哪些字段适合建索引
常见适合建索引的字段:经常出现在 WHERE 里的字段;经常用于 ORDER BY 的字段;经常用于 GROUP BY 的字段;多表 JOIN 的关联字段;区分度高的字段。比如:
SELECT * FROM orders WHERE user_id = 1001;
可以考虑:
CREATE INDEX idx_user_id ON orders(user_id);
2. 哪些字段不适合建索引
区分度很低的字段,比如性别、状态;很少用于查询条件的字段;经常更新的字段;小表上的普通字段;大文本字段的完整索引。比如 gender 只有男、女,建索引后筛选度很低,优化器可能仍然选择全表扫描。
3. 联合索引要遵循最左匹配原则
假设有联合索引:
CREATE INDEX idx_a_b_c ON t(a, b, c);
可以命中索引的情况:
WHERE a = 1WHERE a = 1 AND b = 2WHERE a = 1 AND b = 2 AND c = 3
不太能充分命中的情况:
WHERE b = 2WHERE c = 3WHERE b = 2 AND c = 3
因为缺少最左边的 a。
4. 联合索引字段顺序怎么排
常见原则:等值查询字段放前面;区分度高的字段优先;高频查询字段优先;排序、分组字段尽量接在后面。比如高频 SQL 是:
SELECT *FROM ordersWHERE user_id = 1001 AND status = 1ORDER BY create_time DESC;
可以考虑:
CREATE INDEX idx_user_status_timeON orders(user_id, status, create_time);
这样既能过滤,也能帮助排序。
五、常见索引失效场景
很多慢 SQL 不是没索引,而是索引用不上。
1. 左模糊查询
WHERE name LIKE '%Tom'WHERE name LIKE '%Tom%'
这种通常无法走普通 B+Tree 索引。因为索引是从左到右有序的,如果你不知道开头是什么,MySQL 很难按索引定位。可以优化成右模糊:
WHERE name LIKE 'Tom%'
2. 对索引列使用函数
WHERE DATE(create_time) = '2026-06-08'
这样会让索引列参与函数计算。可以改成范围查询:
WHERE create_time >= '2026-06-08 00:00:00' AND create_time < '2026-06-09 00:00:00'
3. 对索引列做表达式计算
WHERE age + 1 = 19
可以改成:
WHERE age = 18
4. 隐式类型转换
如果 phone 是字符串类型,却这样写:
WHERE phone = 13800138000
可能发生隐式类型转换。应该写成:
WHERE phone = '13800138000'
5. OR 使用不当
WHERE name = 'Tom' OR age = 18
如果 name 有索引,但 age 没索引,可能导致索引效果变差。可以考虑给两个字段都建合适索引,或者拆成 UNION。
6. 范围查询后的字段
联合索引中,范围查询后面的字段通常不能继续用于精确定位。比如索引:
CREATE INDEX idx_a_b_c ON t(a, b, c);
SQL:
WHERE a = 1 AND b > 10 AND c = 3
这里 a 可以用,b 可以做范围扫描,c 通常不能继续用于缩小索引扫描范围。
六、如果 MySQL 选错索引怎么办
有时 EXPLAIN 会发现优化器选了不合适的索引。不要第一反应就强制索引,先看三个问题:
- 统计信息是否不准;
- 索引区分度是否太低;
- SQL 是否让索引失效。
可以先更新统计信息:
ANALYZE TABLE products;
如果确认优化器确实选错了,可以使用索引提示。
1. USE INDEX
建议优化器考虑某个索引:
SELECT *FROM products USE INDEX (idx_buyprice)WHERE buyPrice BETWEEN 10 AND 80;
2. FORCE INDEX
强制 MySQL 尽量使用某个索引:
EXPLAIN SELECT productName, buyPriceFROM products FORCE INDEX (idx_buyprice)WHERE buyPrice BETWEEN 10 AND 80ORDER BY buyPrice;
如果生效,可能看到 key: idx_buyprice 和 type: range。
3. IGNORE INDEX
让优化器忽略某个索引:
SELECT *FROM products IGNORE INDEX (idx_name)WHERE buyPrice BETWEEN 10 AND 80;
但这些都属于干预手段,更推荐先从索引设计和 SQL 写法上解决。
七、查询优化:少查、少回表、少 JOIN
1. 避免 SELECT *
SELECT * 的问题很直接:它会查出整行数据,带来四类成本——更多的磁盘 IO、网络传输、应用反序列化,以及更容易破坏覆盖索引。比如你只需要:
SELECT id, name FROM user WHERE id = 1;
就不要写:
SELECT * FROM user WHERE id = 1;
2. 尽量使用覆盖索引
普通二级索引查询流程:查二级索引拿到主键 id → 回表查完整行 → 返回结果。覆盖索引查询流程:查二级索引直接拿到需要字段 → 返回结果。少了回表,自然更快。
3. JOIN 要小结果集驱动大结果集
JOIN 可以理解成嵌套循环。如果小表有 100 行,大表有 100 万行,理想方式是先查小结果集 100 行,每行去大表按索引查。反过来先扫大表 100 万行,每行去小表匹配,成本就高很多。更准确地说,不是物理小表驱动大表,而是 过滤后的小结果集驱动大结果集。
4. 被驱动表关联字段要有索引
比如:
SELECT *FROM orders oJOIN user u ON o.user_id = u.id;
如果 u.id 是主键,匹配很快。如果被驱动表关联字段没索引,可能每次匹配都扫描一遍表,这会非常慢。
5. 高频 JOIN 可以考虑冗余字段
比如订单列表每次都要展示用户名,原设计是:
orders(id, user_id, amount)user(id, name)
每次都 JOIN 很麻烦。可以在订单表冗余:
orders(id, user_id, user_name, amount)
查询就变成单表。代价是数据冗余和一致性维护,适合读多写少、字段变化少的场景。订单商品名、下单价格这类历史快照字段,也很适合冗余。
八、分页优化:重点解决深分页
普通分页:
SELECT * FROM tb_sku LIMIT 200000, 10;
它不是直接跳到第 200000 行,MySQL 通常要先扫描前 200010 行,再丢掉前 200000 行。这就是深分页慢的原因。
1. 基于位置分页
如果主键自增,可以改成:
SELECT *FROM tb_skuWHERE id > 200000ORDER BY idLIMIT 10;
这样 MySQL 可以从索引位置继续往后查,效率高很多。但它适合连续翻页,不适合随机跳页。
2. 先查 id 再回表
如果必须深分页,可以先用覆盖索引查 id:
SELECT idFROM tb_skuORDER BY create_timeLIMIT 200000, 10;
再根据 id 回表查详情。也可以写成 JOIN:
SELECT s.*FROM tb_sku sJOIN ( SELECT id FROM tb_sku ORDER BY create_time LIMIT 200000, 10) t ON s.id = t.id;
这样至少避免一开始就搬运完整行数据。
九、表结构优化:大表和宽表要拆
如果单表数据达到千万级,查询压力可能明显增加。但不要一上来就分库分表,先看索引和 SQL 是否还能优化。
1. 垂直拆分宽表
如果一张表字段很多,可以按访问频率拆。比如:
user(id, name, phone, a vatar, description, remark, ext_json)
可以拆成:
user(id, name, phone)user_profile(user_id, a vatar, description, remark, ext_json)
高频字段放主表,低频大字段放扩展表,这样能减少单行大小,提高缓存命中率。
2. 冷热数据分离
订单表常见做法是按时间冷热分离:最近 3 个月订单放热表,历史订单放冷表,普通查询只查热表,可以降低高频查询的数据规模。
3. 分库分表
当单库或单表成为瓶颈时,再考虑分库分表。常见方式:
| 方式 | 解决问题 |
|---|---|
| 垂直分库 | 按业务拆库,降低单库压力 |
| 垂直分表 | 拆宽表,减少单行体积 |
| 水平分库 | 数据分散到多个库 |
| 水平分表 | 大表拆成多个小表 |
分库分表会引入复杂度,比如分布式事务、跨库 JOIN、全局唯一 ID、分页排序、扩容迁移等。所以它是后手,不是起手式。
十、缓存优化:读多写少优先考虑
热点数据可以放 Redis,缓存的目标是减少数据库压力。常见读流程是旁路缓存:
应用先查缓存缓存命中,直接返回缓存未命中,查询数据库数据库返回后,写入缓存
写流程常见做法是:先更新数据库,再删除缓存。为什么不是先删缓存再更新数据库?因为并发下可能出现旧数据回写缓存。更推荐的做法是:先保证数据库成功,再删除缓存,让下一次读取重新加载新数据。
缓存也不是银弹,要考虑几个问题:
1. 缓存穿透
查询的数据根本不存在,请求每次都打到数据库。解决方式:缓存空值,或使用布隆过滤器。
2. 缓存击穿
某个热点 key 过期,大量请求同时打到数据库。解决方式:热点 key 不设置短过期,加互斥锁,或提前异步刷新。
3. 缓存雪崩
大量 key 同时过期,数据库瞬间被打爆。解决方式:过期时间加随机值,多级缓存,限流降级。
十一、架构优化:读写分离和分库分表
当 SQL 和索引已经优化过,但压力仍然大,就要看架构。
1. 主从复制
MySQL 主从复制大致流程:主库写入数据 → 主库记录 binlog → 从库拉取 binlog → 从库回放日志 → 从库得到相同数据。
2. 读写分离
读写分离的思路:写请求 → 主库,读请求 → 从库。这样可以提高读并发。但要注意主从延迟,比如用户刚下单,马上查订单,如果读从库可能查不到。这类强一致场景可以强制读主库。
3. 分库分表解决什么
分库主要解决单库资源瓶颈,分表主要解决单表数据量过大。简单理解:
单库扛不住 -> 分库单表太大 -> 分表字段太多 -> 垂直拆表读压力大 -> 读写分离 + 缓存
十二、慢查询和锁等待怎么定位
慢查询不一定都是索引问题。有时 SQL 本身不慢,是被锁卡住了。面试里可以把它拆成两类:
执行慢:SQL 扫描多、排序多、回表多等待慢:SQL 在等锁、等 IO、等连接
1. 开启慢查询日志
慢查询日志用来记录执行时间超过阈值的 SQL。常见参数:
SHOW VARIABLES LIKE 'slow_query_log';SHOW VARIABLES LIKE 'long_query_time';
临时开启可以这样:
SET GLOBAL slow_query_log = ON;SET GLOBAL long_query_time = 1;
long_query_time = 1 表示超过 1 秒的 SQL 会被记录。生产环境一般要结合业务情况设置,不是越小越好,太小会产生大量日志。
2. 慢查询日志看什么
慢查询日志里常看:Query_time(SQL 总耗时)、Lock_time(等待锁的时间)、Rows_sent(返回行数)、Rows_examined(扫描行数)以及 SQL 原文。如果 Rows_examined 很大,说明扫描多,重点查索引和 SQL 写法。如果 Lock_time 很大,说明可能被锁阻塞,重点查事务和锁等待。
3. 用工具分析慢日志
慢查询日志很多时,不适合手动看。可以用:
mysqldumpslow -s t -t 10 slow.log
含义是按总耗时排序,取前 10 条。也可以用 pt-query-digest,它能聚合相似 SQL,看总耗时、平均耗时、执行次数和扫描行数。面试里说出这个工具,会显得更接近真实排查。
4. 当前正在执行什么 SQL
如果线上突然变慢,可以看当前连接:
SHOW FULL PROCESSLIST;
重点看 Command 是否是 Query,Time 是否很长,State 是否在等锁,Info 里具体 SQL 是什么。常见状态包括:
Sending dataWaiting for table metadata lockLockedCreating sort indexCopying to tmp table
其中 Waiting for table metadata lock 很典型,它表示在等元数据锁,也就是 MDL 锁。
5. 什么是 MDL 锁
MDL 是 metadata lock,元数据锁,它保护表结构不被并发破坏。比如一个事务正在查询表,另一个线程执行 ALTER TABLE user ADD COLUMN age INT;,这个 DDL 可能会等待 MDL 锁。更麻烦的是,后续新的查询也可能排队,于是一个 DDL 就拖慢一堆 SQL。所以线上执行 DDL 要特别小心。
6. 查 InnoDB 事务和锁等待
可以看 InnoDB 当前状态:
SHOW ENGINE INNODB STATUSG
重点看 LATEST DETECTED DEADLOCK(最近死锁信息)、TRANSACTIONS(当前事务)、是否有 lock wait、哪个事务持有锁、哪个事务在等待锁。MySQL 8.0 也可以查系统表:
SELECT * FROM performance_schema.data_locks;SELECT * FROM performance_schema.data_lock_waits;
这些表可以看到谁持有锁、谁在等锁。
7. 常见锁等待原因
慢查询里的锁等待,常见原因有:大事务长时间不提交;更新没有命中索引导致锁范围变大;间隙锁导致插入被阻塞;DDL 等 MDL 锁;事务里夹杂慢接口调用;批量更新一次改太多数据。比如:
UPDATE user SET status = 1 WHERE phone = '13800138000';
如果 phone 没索引,MySQL 可能扫描大量记录,在事务隔离级别和执行条件影响下,锁范围可能明显扩大。所以更新、删除语句的条件字段也要有索引。
8. 死锁怎么排查
死锁不是“数据库坏了”,它是两个事务互相等对方释放锁。例如:事务 A 锁住 id = 1,再等 id = 2;事务 B 锁住 id = 2,再等 id = 1,就会形成死锁。InnoDB 会自动检测死锁,并回滚其中一个事务。排查方式:SHOW ENGINE INNODB STATUSG 看 LATEST DETECTED DEADLOCK。优化方式:固定访问顺序;缩短事务时间;减少一次事务影响的数据量;更新条件走索引;避免用户交互或远程调用放在事务里。
9. 面试怎么回答慢查询定位
可以这样说:
我会先看慢查询日志,通过 Query_time、Lock_time、Rows_examined 判断是执行慢还是等锁慢。如果 Rows_examined 很大,就用 EXPLAIN 看执行计划,重点看 type、key、rows、Extra,再优化索引和 SQL。如果 Lock_time 很大,就看 SHOW FULL PROCESSLIST,确认当前 SQL 是否在 Waiting for lock 或 metadata lock。然后用 SHOW ENGINE INNODB STATUS,或者 performance_schema.data_locks、data_lock_waits,定位哪个事务持有锁,哪个事务在等待。如果是死锁,看 LATEST DETECTED DEADLOCK,再从事务访问顺序、索引、事务范围几个方向优化。
慢查询排查不是只看 SQL,也要看它是不是在等锁。执行慢看 EXPLAIN,等待慢看锁和事务。
十三、面试回答模板
如果面试官问:一张表查询很慢,你有哪些解决方案? 可以这样回答:
我会先用慢查询日志定位慢 SQL,再用 EXPLAIN 分析执行计划。重点看 type、key、rows、Extra。如果是全表扫描,就看是否缺索引或索引失效。如果 rows 很大,就考虑减少扫描范围。如果有 Using filesort 或 Using temporary,就看 ORDER BY、GROUP BY 是否能通过索引优化。然后从几个层面处理:第一,优化索引,比如 WHERE、JOIN、ORDER BY、GROUP BY 字段建合适索引,多个字段高频组合查询就建联合索引,并遵循最左匹配原则。第二,避免索引失效,比如不要左模糊、不要对索引列使用函数或表达式,避免隐式类型转换,注意 OR 条件。第三,优化 SQL,避免 SELECT *,只查必要字段,尽量使用覆盖索引减少回表。JOIN 时让小结果集驱动大结果集,并保证被驱动表关联字段有索引。高频 JOIN 可以通过冗余字段减少关联。第四,优化分页,深分页不要直接 LIMIT 大 offset,可以改成基于 id 的位置分页,或者先查 id 再回表。第五,优化表结构,大表可以考虑冷热分离、水平拆表,宽表可以考虑垂直拆分。但拆表不是第一选择,要先看 SQL 和索引。第六,引入缓存,热点数据可以放 Redis,读请求使用旁路缓存,写请求可以先更新数据库再删除缓存,同时处理缓存穿透、击穿和雪崩。如果单机数据库压力仍然大,再考虑主从复制、读写分离、分库分表等架构方案。
最后可以补一句:
MySQL 调优不是只会加索引,而是先定位瓶颈,再分层优化。核心是减少扫描行数、减少回表、减少排序和临时表,并降低数据库整体压力。
十四、复习速记版
最后给你一版背诵用口诀:
先定位:慢日志 + EXPLAIN看计划:type、key、rows、Extra查索引:缺不缺、准不准、失没失效改 SQL:少查列、少回表、少 JOIN治分页:避开深 offset拆结构:大表拆、宽表拆、冷热分加缓存:热点进 Redis上架构:主从、读写分离、分库分表
再记住这几个判断:
key 为 NULL -> 没用索引rows 很大 -> 扫描多type = ALL -> 全表扫描Using filesort -> 额外排序Using temporary -> 用了临时表Using index -> 覆盖索引,好事
