MySQL联合索引失效怎么解决?从key_len逆向分析

大家好,我是数据库小学妹 👋

上周帮同事查一个慢查询,300万行的订单表,查询耗时6秒。

SELECTorder_id,user_id,amount,statusFROMordersWHEREuser_id=1024ANDstatus='paid'ORDERBYcreate_timeDESCLIMIT50;

表上有一个联合索引:

ALTERTABLEordersADDINDEXidx_user_status_time(user_id,status,create_time);

三个字段都在索引里,查询条件用了user_id和status,排序用了create_time。

EXPLAIN 的结果让我愣了一下。type 是 ref,key 是 idx_user_status_time,看起来走了索引。但 key_len 只有 5 字节。

这个联合索引有三个字段,user_id 是 bigint 占8字节,status 是 varchar 加排序规则至少占几十字节,key_len 不可能只有5。

顺着 key_len 查下去,发现联合索引的匹配过程比"最左前缀"四个字要复杂得多。


key_len 是什么

很多人看 EXPLAIN 只看 type 和 rows,忽略了 key_len。看 key_len 能直接判断联合索引匹配到了哪一列。

key_len 表示优化器实际使用的索引字节数,不是索引的总长度,而是 WHERE 条件中能匹配到的索引部分的长度。

还是 idx_user_status_time(user_id, status, create_time) 这个索引。

先算每个字段在索引中占多少字节。user_id 是 int,4字节,允许 NULL 加1字节标记。status 是 varchar(20),utf8mb4 编码下每个字符最多4字节,20×4+2(varchar长度标记)=82字节,允许NULL加1字节。create_time 是 datetime,MySQL 8.0占5字节,允许NULL加1字节。

不同查询条件下的 key_len 预期值:

查询条件实际匹配列预期 key_len说明
WHERE user_id = 1024仅 user_id5只用了第一列
WHERE user_id = 1024 AND status = ‘paid’user_id + status88前两列都匹配
WHERE user_id = 1024 AND status = ‘paid’ AND create_time > ‘2026-07-01’三列94第三列做范围扫描

回到同事那个查询,WHERE 里有 user_id 和 status 两个条件,理论上 key_len 应该是 88。实际只有 5。说明联合索引只用了第一列 user_id,status 完全没被匹配上。

为什么 status = ‘paid’ 这个等值条件用不上索引第二列?问题出在字符集。

这张表默认字符集是 utf8mb4,但 status 字段建表时被单独指定成了 utf8。查询条件传入的字符串走的是连接字符集 utf8mb4,和索引定义的 utf8 不一致。MySQL 遇到字符集不匹配时,会做隐式转换,把索引列的值转成查询条件的字符集再比较。这个转换让第二列的 B+ 树排序失效,优化器只能停在第一列。

修好字符集后,key_len 从5变成了88。查询从6秒降到0.08秒。

看 key_len 就能知道联合索引实际用到了哪一列,而不是定义了几列。


最左前缀:从左开始用,遇到范围就断?

MySQL教程都会讲"最左前缀原则"。大多数人都这么记。用起来基本够用,但有些边界情况会出问题。

最左前缀的本质是联合索引的B+树按照索引列的组合值排序。先按第一列排,第一列相同的按第二列排,第二列相同的按第三列排。

(user_id, status, create_time) 在B+树中的排序: (1, 'paid', '2026-07-01') (1, 'paid', '2026-07-02') (1, 'unpaid', '2026-06-15') (2, 'paid', '2026-07-03') (2, 'shipped', '2026-07-01')

这个排序决定了查询能走多远。

三列等值匹配,索引完美利用:

WHEREuser_id=1024ANDstatus='paid'ANDcreate_time='2026-07-15'-- key_len 用到三列

前两列等值,第三列范围扫描,索引完全利用:

WHEREuser_id=1024ANDstatus='paid'ANDcreate_time>'2026-07-01'-- key_len 用到三列,第三列做范围扫描

第一列等值,第二列范围,第三列等值。第三列无法利用索引排序,只能在内存中过滤:

WHEREuser_id=1024ANDcreate_time>'2026-07-01'ANDstatus='paid'-- 第二列范围查询,第三列失效,key_len 只用到前两列

范围查询之后的列无法被B+树的排序结构利用。上面第三个例子就是,create_time 在 status 范围查询之后,索引排序用不上,只能在 server 层做 filesort。

我搞错过一次。WHERE 条件的顺序是 status, user_id, create_time,和索引定义顺序不同。我以为 MySQL 会自动调整顺序匹配,结果 EXPLAIN 显示只用了第一列。

MySQL 8.0 的优化器会自动调整 WHERE 条件顺序来匹配索引,但前提是优化器知道该用哪个索引。如果 WHERE 条件中间跳过了某一列,优化器可能直接放弃这个索引。


索引下推(ICP):不是失效,是帮你省回表

有时候 EXPLAIN 的 Extra 列显示 “Using index condition”,但 key_len 只覆盖了部分列。这是索引下推(Index Condition Pushdown, ICP)。

WHERE 条件中有部分列无法利用索引匹配时,MySQL 不会立刻回表,而是在存储引擎层用索引中已有的数据做进一步过滤,减少回表次数。

-- 联合索引 idx_user_status(user_id, status)SELECT*FROMordersWHEREuser_id=1024ANDstatusLIKE'p%';

user_id 等值匹配,status LIKE 前缀匹配。‘p%’ 是前缀匹配,B+树可以快速定位到以 ‘p’ 开头的 status 值。

EXPLAIN 显示 Using index condition,意味着先用 user_id 等值定位,在这个范围内用 status LIKE ‘p%’ 在存储引擎层过滤,只有过滤通过的行才回表取完整数据。

没有 ICP 的话,所有 user_id = 1024 的行都要回表,然后在 server 层过滤 status。我一开始看到 Using index condition 以为是坏信号,查了文档才知道是 MySQL 在帮我减少回表。

ICP 是 MySQL 5.6 引入的,默认开启。


覆盖索引:不用回表

当查询需要的所有列都在索引里时,MySQL 不需要回表查数据页,直接从索引返回结果。EXPLAIN 的 Extra 列显示 “Using index”。

-- 联合索引 idx_user_status_time(user_id, status, create_time)SELECTuser_id,status,create_timeFROMordersWHEREuser_id=1024ANDstatus='paid';

只需要三个字段,恰好都在联合索引里。MySQL 不需要回表,直接在索引的B+树上就能拿到所有数据。

每次回表就是一次随机I/O。数据不在 buffer pool里时,每次随机读可能要几毫秒。覆盖索引完全避免了回表,因为索引树比数据页小得多,更可能全在buffer pool里。

我曾经优化过一个查询,把 SELECT * 改成只查需要的3个字段,EXPLAIN 从 Using where 变成了 Using index。当时觉得没什么大不了,后来发现那个查询每天跑上千次,改完 CPU 降了15%。

SELECT *是覆盖索引的天敌。只要SELECT *,就一定需要回表。代码审查时看到SELECT *,第一反应就是这个查询能不能改成只查需要的列。


联合索引列顺序:选错扫描范围差很多

联合索引最容易被忽视的是列顺序。同样三个字段,排列顺序不同,扫描范围可以差几十倍。

一张100万行的用户行为表,需要支持以下查询:

-- 查询1:高频,按用户查最近行为WHEREuser_id=?ORDERBYaction_timeDESC-- 查询2:中频,按用户和动作类型查WHEREuser_id=?ANDaction_type=?-- 查询3:低频,按动作类型查WHEREaction_type=?

方案A:(user_id, action_type, action_time)
方案B:(user_id, action_time, action_type)
方案C:(action_type, user_id, action_time)

方案A对查询1和查询2都高效。user_id 等值后,查询1用 action_time 排序无需 filesort,查询2用 action_type 等值过滤。查询3无法走索引。

方案B对查询1高效。user_id 等值后 action_time 可直接用于排序。查询2也能走索引,action_type 用 ICP 过滤。查询3同样无法走索引。

方案C对查询3高效。action_type 作为第一列可直接走索引。但对查询1和查询2效率较低,action_type 范围扫描后再定位 user_id,扫描范围更大。

选哪个,看查询频率。查询1占80%以上,方案B最优。查询3频率不低的话,可能需要两个联合索引。

我踩过的坑是按"区分度最高的列放前面"建索引。user_id 区分度最高,100万个不同值,action_type 只有10个。我选了方案C。结果查询1和查询2全慢了,它们的频率远高于查询3,我为了优化低频查询牺牲了高频查询。

联合索引的列顺序应该按查询频率排,不是按区分度。区分度原则只在查询频率相近时有参考价值。


避坑清单

定期用 key_len 验证索引使用情况。别建完索引就不管了。挑慢查询日志里的SQL跑 EXPLAIN,看 key_len 是否符合预期。key_len 远小于索引总长度,说明有列没被利用,可能是字符集不一致、隐式类型转换、或者范围查询打断了后续列。

有次我对一个 int 字段传了字符串参数,EXPLAIN 显示 key_len 只有第一列的长度。排查了半小时才发现是隐式类型转换惹的祸,后面的列全部失效。从那以后,ORM 里传参我都会检查类型,不依赖框架自动转换。

列顺序按查询频率排,不是按区分度。网上教程说"区分度高的列放前面"只对了一半。区分度影响选择性,查询频率影响实际命中率。区分度低但命中率高的列放前面往往收益更大。判断查询频率可以看慢查询日志里各SQL的出现频次,或者用 performance_schema 的统计表。

一个表的联合索引不要超过5个。每个索引都增加 INSERT/UPDATE/DELETE 的代价。一张表8个联合索引,写入性能降了60%,我亲眼见的。


联合索引建起来简单,三列加个索引就行。用起来要注意的地方不少。列顺序、字符集、隐式转换、覆盖索引、ICP,处理不好任何一个,查询都会慢出几个数量级。

我现在的习惯是:拿到一个查询,先画出来它需要走索引的列和排序的列,再决定联合索引的列顺序。建完跑一遍 EXPLAIN,盯着 key_len 看是否用到了预期的列。最后用 EXPLAIN ANALYZE(MySQL 8.0.18+)看实际执行时间和预估是否一致。

你的项目里有没有"建了索引却没生效"的情况?怎么查出来的,评论区说说看。

我是数据库小学妹,咱们下篇见 👋