大家好,我是数据库小学妹 👋
上周帮同事查一个慢查询,300万行的订单表,查询耗时6秒。
SELECT order_id, user_id, amount, status
FROM orders
WHERE user_id = 1024 AND status = 'paid'
ORDER BY create_time DESC
LIMIT 50;
表上有一个联合索引:
ALTER TABLE orders ADD INDEX idx_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_id | 5 | 只用了第一列 |
| WHERE user_id = 1024 AND status = 'paid' | user_id + status | 88 | 前两列都匹配 |
| 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')
这个排序决定了查询能走多远。
三列等值匹配,索引完美利用:
WHERE user_id = 1024 AND status = 'paid' AND create_time = '2026-07-15'
-- key_len 用到三列
前两列等值,第三列范围扫描,索引完全利用:
WHERE user_id = 1024 AND status = 'paid' AND create_time > '2026-07-01'
-- key_len 用到三列,第三列做范围扫描
第一列等值,第二列范围,第三列等值。第三列无法利用索引排序,只能在内存中过滤:
WHERE user_id = 1024 AND create_time > '2026-07-01' AND status = '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 * FROM orders
WHERE user_id = 1024 AND status LIKE '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)
SELECT user_id, status, create_time
FROM orders
WHERE user_id = 1024 AND status = 'paid';
只需要三个字段,恰好都在联合索引里。MySQL 不需要回表,直接在索引的B+树上就能拿到所有数据。
每次回表就是一次随机I/O。数据不在 buffer pool里时,每次随机读可能要几毫秒。覆盖索引完全避免了回表,因为索引树比数据页小得多,更可能全在buffer pool里。
我曾经优化过一个查询,把 SELECT * 改成只查需要的3个字段,EXPLAIN 从 Using where 变成了 Using index。当时觉得没什么大不了,后来发现那个查询每天跑上千次,改完 CPU 降了15%。
SELECT * 是覆盖索引的天敌。只要 SELECT *,就一定需要回表。代码审查时看到 SELECT *,第一反应就是这个查询能不能改成只查需要的列。
联合索引列顺序:选错扫描范围差很多
联合索引最容易被忽视的是列顺序。同样三个字段,排列顺序不同,扫描范围可以差几十倍。
一张100万行的用户行为表,需要支持以下查询:
-- 查询1:高频,按用户查最近行为
WHERE user_id = ? ORDER BY action_time DESC
-- 查询2:中频,按用户和动作类型查
WHERE user_id = ? AND action_type = ?
-- 查询3:低频,按动作类型查
WHERE action_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+)看实际执行时间和预估是否一致。
你的项目里有没有"建了索引却没生效"的情况?怎么查出来的,评论区说说看。
我是数据库小学妹,咱们下篇见 👋