1. possible_keys到底是什么:执行计划里最容易被误读的字段
面试里一聊到MySQL执行计划,possible_keys基本是必问的一个字段。很多人背过答案——“possible_keys是可能用到的索引,key是实际用到的索引”,但真到面试官追问“为什么possible_keys有值但key是NULL”“为什么possible_keys是NULL但查询却不慢”的时候,现场就卡壳了。
我自己的体会是,理解possible_keys不能只停留在字段含义上,得把它放到优化器的决策链路里去看。你今天建了一个索引,不代表优化器一定会用它,possible_keys只是优化器在解析SQL时圈出来的一份“候选名单”,而key字段才是最终拍板的结果。这两个字段之间的差值,就是优化器“权衡”的过程。
拿一条最简单的查询举例:
SELECT id, name FROM user WHERE age > 18;假设user表上有两个索引:idx_age和idx_name_age,执行EXPLAIN之后,possible_keys大概率会同时出现这两个索引的名字,但key字段可能只选其中一个,甚至可能两个都不选,直接走全表扫描。
这就是possible_keys的底层逻辑——它只回答“有哪些索引可能被用到”,不负责告诉你“到底用了哪个”。很多初级开发者看到possible_keys里有一堆索引名就以为索引生效了,这其实是个极大的误解。
1.1 从一条EXPLAIN语句说起:possible_keys和key的直观区别
我们直接看实际输出。假设有一张订单表t_order,结构如下:
CREATE TABLE `t_order` ( `id` int NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `user_id` int NOT NULL, `amount` decimal(10,2) DEFAULT NULL, `status` tinyint NOT NULL DEFAULT '0', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB;执行:
EXPLAIN SELECT * FROM t_order WHERE user_id = 100 AND create_time > '2024-01-01';输出结果中:
- possible_keys:
idx_user_id, idx_create_time - key:
idx_user_id
意思很明确:优化器在解析时认为user_id和create_time两个字段各自对应的索引都有机会参与查询,所以把两个索引都放进了候选名单。但在真正计算执行代价之后,它选择了idx_user_id,因为user_id = 100是一个等值匹配,估算出的扫描行数远低于create_time的范围扫描。
这里有个关键点需要展开讲:possible_keys的生成阶段在优化器进行“访问路径选择”之前,它更像是一个基于WHERE条件的“初步筛选”。MySQL会根据SQL语句中出现的列、操作符类型、索引定义等信息,把一个查询里所有“理论上能匹配”的索引都先列出来。这个阶段不会去估算扫描行数,也不关心数据分布,纯粹是静态匹配。
1.2 优化器的“备选清单”逻辑:候选索引是怎么被筛出来的
理解possible_keys,先理解它的生成规则。优化器在收到一条查询后,会做以下几件事:
第一步:解析WHERE条件中的列。把查询涉及的所有列提取出来,比如WHERE user_id = 100 AND create_time > '2024-01-01',提取出user_id和create_time。
第二步:检查列的索引覆盖情况。如果某个列上存在索引,就把这个索引加入候选。
第三步:检查是否有联合索引可以整体匹配。这一步容易被忽略——比如我们有联合索引(a, b),WHERE条件里只出现a,优化器也会把(a, b)加入possible_keys,因为它可以基于最左前缀使用a部分。
第四步:检查索引与操作符的兼容性。比如LIKE '%abc'这种前置通配符,虽然name列上有索引,但优化器会放弃把该索引加入possible_keys,因为B+树无法支持以通配符开头的模糊匹配。
这个筛选过程整体上是“格式层面”的判断,不做代价估算。所以你会发现possible_keys经常看起来“什么都能用”,而key则非常“挑剔”。如果面试官问到这里,你可以延伸一句:possible_keys是语法层面的可能性集合,key是物理执行层面的真实决策。
1.3 面试最常踩的误区:possible_keys为NULL不等于没戏
面试里最经典的陷阱题是:
“我EXPLAIN一条查询,possible_keys是NULL,但是查询只要十几毫秒,这是为什么?”
很多人一看到possible_keys是NULL就慌,觉得索引没用上,SQL写得有问题。但真相是:possible_keys为NULL只表示优化器认为“没有可用的索引”,不代表查询一定会慢。
举个生活中的例子:你去超市买东西,购物清单上只写了“买一箱牛奶”。如果超市入口处就摆着牛奶,你拿完直接走收银台,全程不需要在超市里逛——这个场景里,“在超市里逛一圈找商品”这个动作根本没有发生,但你的购物目标高效完成了。对应到MySQL,当表数据量很小、或者查询要读取的行数占表总行数的比例特别高时,优化器会认为全表扫描比走索引更划算。
比如一张只有几百行记录的基础配置表:
EXPLAIN SELECT * FROM sys_config WHERE config_key = 'cache_ttl';哪怕config_key上有唯一索引,possible_keys也大概率是NULL,因为优化器一算:整个表可能就一个数据页,全表扫描只需要一次磁盘I/O,而走索引反而要多一次回表操作,纯属浪费。
所以,面试时遇到“possible_keys为NULL”的题目,正确回答方向应该是:先看表的数据量级,再看查询条件,最后看优化器的代价估算逻辑。如果表是几百行的小表,全表扫描完全合理;如果是几千万行的大表,那就需要排查是不是索引没建、或者SQL写法导致索引失效了。
2. 为什么possible_keys经常“失灵”:索引失效场景逐层拆解
面试官接下来通常会追问第二个问题:“那什么情况下,明明有索引,possible_keys里面却看不到它?”这是从“认识概念”到“理解原理”的分水岭。
我在实际排查慢SQL时发现,索引失效的场景比想象中要多得多。很多开发者的第一反应是“索引建错了”,但仔细一查,索引定义没问题,问题出在SQL的写法上。下面把最典型的四类场景拆开讲,每类背后都对应一个优化器的具体行为逻辑。
2.1 索引列上做函数或计算:优化器为什么要放弃候选索引
第一类高频场景:在索引列上使用函数。
-- 假设birthday列上有索引 EXPLAIN SELECT * FROM t_user WHERE DATE_FORMAT(birthday, '%Y-%m-%d') = '2024-01-01';这条SQL的possible_keys大概率是NULL。为什么?因为B+树索引存储的是原始列值,索引排序也是基于原始值的。一旦对列套上函数,索引里存的值就没办法参与匹配了——MySQL必须先读出每一行的原始birthday值,然后调用DATE_FORMAT函数计算,再和等号右边的值比较。这一步“先计算再比较”的操作,本质上已经把索引的有序性破坏了。
同样的问题还有在索引列上做算术运算:
-- 假设price列上有索引 EXPLAIN SELECT * FROM t_product WHERE price * 0.8 > 100;price * 0.8是一个表达式,索引里存的是price的原始值,不是price * 0.8的计算结果。MySQL无法直接通过B+树定位到满足条件的位置,只能全表扫描然后逐行计算。
注意:这是一个非常容易被忽视的问题。正确的写法是把计算挪到等号右边:
WHERE price > 100 / 0.8。这样优化器就能正常使用price列的索引,possible_keys和key都会正常显示。
2.2 隐式类型转换:字符串和数字的“透明陷阱”
第二类场景是隐式类型转换。这一类的隐蔽性在于——SQL语句看起来完全正常,不报错、不告警,但索引就是没被用上。
最常见的坑是字符串字段和数字之间的比较:
-- phone列是varchar类型,但查询时用了数字比较 EXPLAIN SELECT * FROM t_user WHERE phone = 13800138000;这一条在MySQL里,possible_keys大概率是NULL或者不包含phone上的索引。原因是MySQL把等号两边的类型做了隐式转换——它会把字符串类型的phone列转换成数字再比较。转换之后,索引失效了。
更隐蔽的情况发生在字符集不一致时。两个表关联查询,一个表字段是utf8mb4,另一个表字段是utf8,关联字段在utf8表上的索引就很可能不会被使用。因为MySQL必须先把utf8字段转成utf8mb4才能比较,转换操作同样破坏了索引匹配。
这类问题怎么定位?最快的办法是看EXPLAIN结果里的key_len字段,如果某个索引的key_len异常短,往往就是发生了类型转换。另外一个办法是执行SHOW WARNINGS,查看MySQL改写后的SQL,能看到自动加上的转换函数。
2.3 前置通配符和OR条件:另两类索引失效的典型成因
先看模糊查询:
-- name列上有索引 EXPLAIN SELECT * FROM t_user WHERE name LIKE '%张%';%开头的模糊查询,优化器无法利用B+树索引的左前缀匹配特性,possible_keys为空是正常的。但如果是张%这种后缀通配符,索引就能用得上。这一点面试时经常考,要答得精准。
然后是OR条件:
-- age列有索引,name列有索引 EXPLAIN SELECT * FROM t_user WHERE age = 18 OR name = '张三';这条SQL在MySQL 5.7及更早版本里,很可能会出现possible_keys包含两个索引但key为NULL的情况。优化器原本的思路是:如果OR的两端都能走索引,就分别走索引然后合并结果(index merge)。但如果其中一个条件无法使用索引,整个查询可能退化为全表扫描——因为MySQL无法简单地用索引合并两个结果集。
到了MySQL 8.0,index merge能力增强了一些,但仍然存在限制。实际排查时,更稳妥的做法是把OR拆成UNION ALL:
SELECT * FROM t_user WHERE age = 18 UNION ALL SELECT * FROM t_user WHERE name = '张三' AND age <> 18;这样每个分支都能独立使用索引,执行计划更可控。
2.4 联合索引的最左前缀原则:字段没带全导致候选名单缺席
联索引的最左前缀原则是另一个高频考点,但它和possible_keys的关系常常被忽略。很多人知道“联合索引必须从最左列开始”,却不知道这个原则对possible_keys的具体影响。
假设我们有联合索引idx_city_age(city, age)。来看两种查询:
-- 查询1:包含city,possible_keys会出现idx_city_age EXPLAIN SELECT * FROM t_user WHERE city = '上海' AND age > 20; -- 查询2:只包含age,不包含city EXPLAIN SELECT * FROM t_user WHERE age > 20;查询2的possible_keys一定不会出现idx_city_age。原因是B+树联合索引的排序规则是先按city排序,再按age排序。只有age条件时,MySQL无法利用索引的有序性——因为整体上索引仍然按city有序,age只在一个个city分组内部有序,直接拿age去搜索是没有意义的。
这里有个容易混淆的知识点:MySQL 8.0新引入了“跳跃式扫描”(Skip Scan)优化,允许联合索引在缺少最左前缀的情况下使用,但限制条件很多,要求第一列基数很低、第二列基数高,实际触发率不高。面试时可以把这一点作为加分项提出来,但要注意说明“不是所有情况都能触发”。
3. 候选索引明明存在,优化器为什么不用:统计信息与回表成本
面试进行到这一步,已经问到“possible_keys有值但key是NULL”的场景了。这是最考验理解深度的地方——当一个索引出现在possible_keys里,说明它在格式上没问题,完全有资格参与执行计划。但优化器反复权衡后仍然放弃,核心原因不外乎两个:统计信息不准,成本估算太高。
3.1 统计信息失真的影响:ANALYZE TABLE能解决什么问题
InnoDB引擎维护索引统计信息的方式是采样。MySQL不会每一次操作都去精确统计每个索引的基数,而是通过随机采样部分索引页来估算。这在大多数场景下够用,但一旦数据发生了大规模变化——比如批量删除了大量记录、导入了海量数据——统计信息就可能严重滞后。
统计信息滞后会直接导致优化器做出错误决策。最常见的情况是:表里实际只有1万行数据满足条件,但统计信息告诉优化器“这个索引的选择性很差,预估会扫描20万行”。于是优化器放弃了索引,走了全表扫描。而实际执行时,全表扫描可能反倒更慢。
遇到这种情况,修复手段很直接:
ANALYZE TABLE t_order;执行之后,MySQL会重新采样,更新索引基数统计信息。很多“一夜之间某个查询忽然变慢”、重启后又恢复的案例,本质上就是统计信息老化导致的。我排查线上问题时,如果EXPLAIN结果的rows估算值和实际明显不符,先跑一次ANALYZE TABLE往往会有奇效。
值得一提的是,MySQL 8.0里统计信息默认开启了持久化,不再像5.7那样每次重启都重新统计,但还是需要定期维护。这是一个面试时很少有人答得出来的细节。
3.2 回表代价和基数的博弈:优化器怎样权衡全表扫描和索引扫描
再聊第二个放弃索引的原因:回表代价。这一点,理解“二级索引与主键索引的数据组织方式”是前提。
InnoDB里,表数据本身按主键聚簇存放,二级索引的叶子节点存储的是主键值。走二级索引查找数据时,如果SELECT的字段不全在索引里,就必须根据主键值回表,再到聚簇索引里去取整行数据。这个过程叫“回表”。
那么问题来了:如果有一条查询要返回的列很多,走二级索引的话,每次定位到一条索引记录,都要回表一次。数据量大了以后,回表造成的随机I/O非常可观。优化器在评估时会把回表次数也计入成本——如果预估回表次数超过全表扫描的代价,它就会放弃索引。
-- department_id 有索引,但要返回全部列 EXPLAIN SELECT * FROM t_employee WHERE department_id = 10;假设department_id选择度不高,满足条件的数据占了全表的30%。优化器一算:这30%的行如果都走二级索引加回表,每次回表都是随机I/O,而全表扫描是顺序I/O。在机械硬盘时代这个差距尤其明显,SSD上差距缩小了但依然存在。最终优化器很可能选择全表扫描,possible_keys里能看到idx_department_id,但key为NULL。
这里可以延伸出一个面试加分点:只要查询涉及的所有字段都能覆盖在同一个索引里,SELECT返回的数据不需要回表,成本大幅下降。比如把SELECT *改成SELECT department_id, employee_name,且二级索引是(department_id, employee_name),就能触发“覆盖索引”优化。这时候即使选择度不高,优化器也更倾向于走索引。
3.3 强制索引的利与弊:FORCE INDEX什么时候才是正确的选择
讨论到这里,有经验的开发者可能会想到一个东西:FORCE INDEX。既然优化器有时候“犯糊涂”,能不能手动指定索引?
FORCE INDEX确实存在,但我的建议是:能不用就不用,用了也一定要有监控兜底。
举个具体例子:
SELECT * FROM t_order FORCE INDEX (idx_create_time) WHERE create_time > '2024-01-01' AND status = 1;强制指定idx_create_time后,执行计划会稳定使用这个索引。但问题在于:如果数据分布发生变化,比如在某个时间范围内符合条件的行数暴增,走这个索引的效率可能断崖式下跌。而且FORCE INDEX写在SQL里,意味着后续所有经过这条SQL的请求都会被强制指定,一旦索引被删除或改名,SQL直接报错。
我的习惯是:先通过ANALYZE TABLE刷新统计信息,再看优化器选择是否恢复正常。只有在统计信息正常但仍然选错的情况下,才考虑FORCE INDEX,并且加上完善的监控告警,一旦执行计划不合理就立刻介入。面试时谈到这个点,可以强调“FORCE INDEX是最后的兜底手段,而不是常规优化手段”,这会让面试官觉得你对数据库调优有清醒的认知。
4. 实战排查:一条慢SQL从误判到定位的全过程
概念聊了这么多,看一个完整的实战案例,把上面的原理串起来。这是我从慢查询日志里捞出来的一条真实SQL,为了演示方便做了脱敏简化,但排查思路保持不变。
4.1 复现场景:三张表联查的异常执行计划
业务场景是一个订单列表页,需要按用户查询订单,同时关联商品和店铺信息:
SELECT o.order_no, o.amount, p.product_name, s.shop_name FROM t_order o JOIN t_product p ON o.product_id = p.id JOIN t_shop s ON o.shop_id = s.id WHERE o.user_id = 12345 AND o.status = 1 ORDER BY o.create_time DESC LIMIT 20;表结构和索引情况:
t_order:user_id上有idx_user_id,create_time上有idx_create_time,没有(user_id, status)联合索引t_product:主键索引,数据量约50万t_shop:主键索引,数据量约2万
实际表现是:接口超时,慢查询日志显示耗时2秒以上。
直接看EXPLAIN:
EXPLAIN SELECT o.order_no, o.amount, p.product_name, s.shop_name FROM t_order o JOIN t_product p ON o.product_id = p.id JOIN t_shop s ON o.shop_id = s.id WHERE o.user_id = 12345 AND o.status = 1 ORDER BY o.create_time DESC LIMIT 20;结果非常有意思:
t_order表:possible_keys是idx_user_id, idx_create_time,key是idx_user_id,rows估算为8300,Extra里有Using filesortt_product表:possible_keys是PRIMARY,key是PRIMARY,rows估算为1t_shop表:possible_keys是PRIMARY,key是PRIMARY,rows估算为1
初步看,idx_user_id已经被用上了,为什么还慢?
4.2 一步一步定位问题:从possible_keys到rows再到filtered
慢在哪里?注意Extra字段里的Using filesort。
ORDER BY o.create_time DESC需要排序。当前执行计划选中了idx_user_id做驱动查询,拿到的8300行数据是按照user_id的顺序排列的,不是按create_time排列的。所以MySQL需要在内存或磁盘上把这8300行重新排序,再取前20条。8300行排序其实不算多,但问题在于每一行都要回表取create_time和status字段——对,idx_user_id不包含这些列,回表次数是8300次。
到这里思路就清晰了:性能瓶颈不是“索引用没用”,而是“索引选得不完美”。如果有一个联合索引同时包含user_id、status、create_time,就能在索引层面同时完成过滤和排序。
继续深挖:possible_keys里明明有idx_create_time,优化器为什么不用?用idx_create_time排序,理论上可以避免filesort,直接按时间倒序扫描并提前终止(因为LIMIT 20)。但优化器认为:user_id = 12345这个条件的选择性远高于create_time的范围条件,用时间索引会扫描大量不相关的行,代价更高,所以放弃了。
这个决策在数据量小的场景下没错,可一旦当前用户的订单数特别多,回表排序的成本反而盖过了扫描成本。
4.3 最终的优化方案与效果验证
优化手段非常明确:新建联合索引,让过滤和排序都在索引内完成。
ALTER TABLE t_order ADD INDEX idx_user_status_time (user_id, status, create_time);这个索引的设计思路:
user_id:等值匹配,放在最左status:等值匹配,放在第二create_time:排序字段,放在最后
执行完加索引后再次EXPLAIN,关键变化如下:
- possible_keys:
idx_user_id, idx_create_time, idx_user_status_time - key:
idx_user_status_time - rows:从8300降到了125
- Extra:不再出现
Using filesort
耗时从2秒降到30毫秒左右。这里有一个面试答题点:联合索引中,排序字段放在等值条件字段之后,查询可以完全利用索引的有序性完成排序,避免filesort。
这个案例值得反复体会的是:慢SQL的排查不是“有索引就行”,而是“索引是否精准匹配了查询的全部需求”。possible_keys给了你一个宽广的视野,但真正决定性能的是key以及key对应的索引结构是否覆盖了过滤、排序、覆盖这三类需求。
4.4 面试回答的结构化思路:怎么组织答案才能拿到加分项
把上面的实战经验转化成面试回答,我建议按下面这个结构组织,信息密度高且逻辑清晰:
第一层:概念定义。possible_keys是优化器认为可能用到的索引列表,key是最终实际使用的索引。possible_keys是语法层面的静态匹配,key是代价层面的动态决策。
第二层:候选索引生成机制。MySQL根据WHERE条件提取列,检查列上的索引定义,筛选出所有在格式上兼容的索引。这一层不涉及代价估算。
第三层:放弃候选索引的核心原因。统计信息失真时,估算的扫描行数不可靠;回表代价过高时,全表扫描比索引扫描更划算;SQL写法导致索引字段失效时,索引直接从候选名单中排除。
第四层:实际案例佐证。用上面的三表联查案例,说明“索引存在但选型不精准”同样会导致性能问题,以及联合索引如何同时解决过滤和排序。
这一套回答下来,面试官基本上能确认你不只是背了概念,而是真的做过深入的SQL调优。
5. 常见问题速查与避坑清单
最后整理一份高频问题速查表,全是日常工作中容易踩的坑,面试前可以拿来快速过一遍。
5.1 关于possible_keys的高频问题速查表
| 问题 | 现象 | 原因 | 解决思路 |
|---|---|---|---|
| possible_keys为NULL,但查询不慢 | 小表全表扫描 | 表数据量小,全表扫描代价更低 | 不用处理,这是优化器的正常决策 |
| possible_keys为NULL,查询很慢 | 索引完全不可用 | 索引列上使用了函数、隐式类型转换、前置通配符等 | 改写SQL,消除索引列上的表达式 |
| possible_keys有值,key为NULL | 优化器判断扫描索引成本更高 | 回表代价过高,或统计信息显示选择性差 | 刷新统计信息,考虑覆盖索引 |
| possible_keys和key相同,但依然慢 | 索引不包含排序字段 | 查询中有ORDER BY/GROUP BY但索引未覆盖 | 调整联合索引结构,让排序字段包含在索引中 |
| possible_keys出现联合索引,但key用的是单列索引 | 最左前缀原则生效但优化器选了更优的单列 | 单列索引的估算成本更低 | 对比两种索引的实际执行情况,决定是否调整索引 |
| 强制使用某索引后反而更慢 | FORCE INDEX导致执行计划不稳定 | 强制索引锁死SQL执行计划,数据分布变化后失真 | 恢复统计信息,必要时重新评估索引设计 |
5.2 几个需要牢记的实操行为
先提醒一个日常操作习惯:排查慢SQL时,先看EXPLAIN里的rows和Extra,再回头看possible_keys和key。possible_keys给了你一张“理论上的可能性清单”,但真正决定性能的往往是rows是否精准、Extra里有没有Using filesort或Using temporary。
再看索引设计的原则:联合索引的字段顺序要遵循“等值字段优先、排序字段随后、范围字段最后”的排列方式。这个顺序不是死记硬背,而是按照B+树匹配逻辑推导出来的。等值条件可以直接定位、缩小范围;排序字段如果在索引中连续出现,就能避免额外的排序操作;范围字段放最后,是为了最大化匹配精度。
再提醒一个线上操作的教训:不要为了一个慢查询随手加索引。我之前见过最夸张的情况,一张表里加了十几个单列索引,写入性能大幅下降,因为每一条INSERT都要同步维护所有索引。加索引之前,先确认现有索引里有没有能被新查询复用的。比如已经有了idx_user_status(user_id, status),再加idx_user_time(user_id, create_time)时,要考虑是否可以直接把它们合并成idx_user_status_time(user_id, status, create_time),一个索引同时服务两条查询路径。
5.3 持续更新的彩蛋:为什么这个主题值得反复琢磨
“持续更新”这四个字并不是标题噱头。MySQL的优化器行为会随着版本演进持续变化,比如MySQL 8.0引入了Hash Join、Skip Scan、直方图统计信息,这些特性都会间接影响possible_keys的生成和执行计划的选择。今天写的这些经验,放在MySQL 5.7上适用,放在MySQL 8.0上大部分仍然成立,但细节上已经有所不同。
举个例子:MySQL 8.0的直方图统计信息可以让优化器在没有索引的列上获得更准确的数据分布估算,从而改变“是否使用索引”的决策。这就意味着,以前那些通过建索引解决的慢查询,在8.0版本里可能需要调整思路。
所以我的建议是:把possible_keys当作理解优化器决策的入口,而不是终点。每次遇到慢SQL,打开EXPLAIN多问自己一句——为什么优化器选了这条路?有没有更优的路?它为什么没选那条路?这些问题积累多了,你对MySQL执行计划的理解会比背一百道面试题都要扎实。我自己在工作里也是一直这样处理的,后续遇到新的执行计划变化或新的优化场景,我会持续在这篇基础上继续补充更新。