MySQL执行计划possible_keys深度解析:从索引候选到优化器决策
发布时间:2026/10/6 3:44:39 锦皓数字建站

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) ) ENGINEInnoDB;执行EXPLAIN SELECT * FROM t_order WHERE user_id 100 AND create_time 2024-01-01;输出结果中possible_keys:idx_user_id, idx_create_timekey: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.0index merge能力增强了一些但仍然存在限制。实际排查时更稳妥的做法是把OR拆成UNION ALLSELECT * 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包含citypossible_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_orderuser_id上有idx_user_idcreate_time上有idx_create_time没有(user_id, status)联合索引t_product主键索引数据量约50万t_shop主键索引数据量约2万实际表现是接口超时慢查询日志显示耗时2秒以上。直接看EXPLAINEXPLAIN 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_timekey是idx_user_idrows估算为8300Extra里有Using filesortt_product表possible_keys是PRIMARYkey是PRIMARYrows估算为1t_shop表possible_keys是PRIMARYkey是PRIMARYrows估算为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_keysidx_user_id, idx_create_time, idx_user_status_timekeyidx_user_status_timerows从8300降到了125Extra不再出现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执行计划的理解会比背一百道面试题都要扎实。我自己在工作里也是一直这样处理的后续遇到新的执行计划变化或新的优化场景我会持续在这篇基础上继续补充更新。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。