ElIauk

MySQL 索引原理与最左前缀

索引不是"加了就快" "查询慢?加个索引就好了。"这句话在单条件查询里通常成立,但到了联合索引就容易出问题:明明建了 (user_id, status, create_time) 三个字段的索引,查询里三个条件都写了,EXPLAIN 却显示只用到一部分;或者 WHERE 条件的顺序和索引顺序不一样,

评论
MySQL 索引原理与最左前缀

索引不是"加了就快"

"查询慢?加个索引就好了。"这句话在单条件查询里通常成立,但到了联合索引就容易出问题:明明建了 (user_id, status, create_time) 三个字段的索引,查询里三个条件都写了,EXPLAIN 却显示只用到一部分;或者 WHERE 条件的顺序和索引顺序不一样,有人就以为索引失效了。

索引能不能用上,取决于查询条件和索引列的顺序是否匹配,这就是常说的"最左前缀"。理解它之前,先要知道 InnoDB 的索引长什么样。

先理解 B+ 树和回表

InnoDB 的主键索引是聚簇索引,叶子节点直接存整行数据。二级索引(普通索引)的叶子节点存的是"索引列 + 主键值",查到主键后还要再回主键索引取一次整行,这个过程叫回表。

回表意味着额外的随机 IO。如果查询需要的列都在索引里,就不用回表,EXPLAIN 的 Extra 会显示 Using index,这叫覆盖索引。

举个例子,t_order 表上有联合索引 (user_id, status, create_time):

CREATE TABLE `t_order` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` bigint NOT NULL,
  `status` tinyint NOT NULL,
  `amount` decimal(10,2) NOT NULL,
  `create_time` datetime NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_user_status_time` (`user_id`, `status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

查询 select user_id, status from t_order where user_id = 100 可以只走二级索引;如果查 amount,就必须回表。这也是为什么 select * 往往比只查需要的列慢。

最左前缀到底约束什么

联合索引在 B+ 树里是按照 (user_id, status, create_time) 的顺序排序的:先按 user_id 排,user_id 相同再按 status 排,最后按 create_time 排。所以查询必须从最左列开始,才能利用这个有序结构。

查询条件 能否用上索引 用到的列
user_id = 100 能 user_id
user_id = 100 and status = 1 能 user_id, status
user_id = 100 and status = 1 and create_time > '2023-01-01' 能 三列,create_time 做范围
status = 1 不能 无(缺少最左列)
user_id = 100 and create_time > '2023-01-01' 部分 只能定位 user_id
status = 1 and create_time > '2023-01-01' 不能 无

注意第三行和第五行的区别:范围条件之后的列,不能再用于索引定位。(user_id, status, create_time) 里如果 status 用了范围查询,create_time 就只能靠索引下推在引擎层过滤,不能继续缩小扫描范围,也不能用来避免排序。

这里有一个常见误解:最左前缀约束的是索引定义的顺序,不是 WHERE 里条件的书写顺序。where status = 1 and user_id = 100 和 where user_id = 100 and status = 1 对优化器来说是一样的,它会自己调整。真正不能省的是索引最左边的列。

索引列的顺序怎么定

既然顺序这么重要,建索引时就要有意识地排:

  1. 等值条件在前,范围条件在后。等值列能把扫描范围缩到最小,范围列放在最后,避免它挡住后面的列。
  2. 区分度高的列优先。用户 ID、订单号这类列,过滤后剩余数据少;性别、状态这类列,区分度低,单独建索引意义不大。
  3. 考虑排序和分组。如果查询固定按 create_time 排序,把排序列放在联合索引的合适位置,可以直接利用索引顺序,避免 filesort。
  4. 别建太多索引。每个索引都会增加写入成本,更新频繁的表尤其要克制。

MySQL 8.0 引入了索引跳跃扫描(index skip scan),在某些第一列区分度很低的场景下,即使没写最左列也可能用上索引。但这是优化器的"补救措施",有条件限制,不能当成常规手段依赖。

常见坑

1. 对索引列做函数或运算

-- 用不上 create_time 索引
select * from t_order where date(create_time) = '2023-06-01';
-- 改成范围查询
select * from t_order where create_time >= '2023-06-01' and create_time < '2023-06-02';

对列做函数、加减乘除、类型转换,都会让索引失效。

2. 隐式类型转换

user_id 是 varchar,查询写成 where user_id = 100(数字),MySQL 会把列转成数字再比较,索引直接失效。字符串列一定要传字符串,反过来整数列传字符串通常没问题,但也建议类型一致。

3. 前导模糊查询

like '%关键词' 无法利用索引的最左前缀,只能全表扫描。like '关键词%' 可以。真要做全文搜索,应该上全文索引或者 Elasticsearch。

4. or 连接了无索引列

where user_id = 100 or amount > 500,只要 amount 没索引,整个查询就可能退化成全表扫描。要么给 amount 也建索引,要么拆成两条查询再合并。

5. 字符集或排序规则不一致

两张表关联时,一张 utf8mb4_general_ci、一张 utf8mb4_0900_ai_ci,关联字段的索引可能用不上。建表时统一字符集和排序规则,能省掉很多隐式转换。

6. 索引区分度太低

给 status 这种只有几个值的列单独建索引,优化器算完成本后可能直接放弃,走全表扫描反而更快。低区分度列适合放在联合索引的后面,而不是单独建。

排查步骤

确认索引有没有用上,不要靠猜,直接看执行计划:

EXPLAIN SELECT id, amount
FROM t_order
WHERE user_id = 100 AND status = 1
ORDER BY create_time DESC;

重点看四列:key 是实际使用的索引,key_len 反映用到了联合索引的哪几列,rows 是预估扫描行数,Extra 里有没有 Using filesort、Using index。

如果 key 是 NULL,按"最左列有没有、列上有没有函数、类型是否一致、是不是前导模糊"逐项排查。如果 key 有值但 rows 很大,说明索引选择性不够,或者范围条件太宽。

最后提醒一句:EXPLAIN 的 rows 是估算值,受统计信息影响。数据分布变化大时,先执行 ANALYZE TABLE t_order 更新统计信息,再看执行计划会更准确。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者