1. 先破除三个流传很广的错误理解
先讲一个真实场景。去年我帮某个业务线排查一个线上慢查询,订单表三百多万行,查询条件写的是 WHERE status = 0 AND order_date >= '2024-03-01',每次执行要两秒多。表上明明建了联合索引 idx_user_date_status(user_id, order_date, status),其中就有 status 和 order_date 这两列,但 explain 显示 type 是 ALL,rows 是三百多万,妥妥的全表扫描。
同事第一反应是“索引失效了”,甚至怀疑统计信息不准,准备直接 ANALYZE TABLE。其实根本不是失效,问题在于:这个联合索引的第一列是 user_id,SQL 里压根没带 user_id。联合索引的匹配规则叫作最左前缀原则——必须从最左边的列开始连续匹配。没带第一列,索引树根本没有入口,后面的列再齐全也白搭。
很多人对最左前缀原则的理解停留在“这五个字”上,真到了写 SQL 和建索引的时候又含糊了。我总结了三个最常见的错误理解,先破掉它们。
1.1 误区一:只要索引里包含某列,查询就能用到它
这个误解最普遍。联合索引 (user_id, order_date, status) 不是三棵独立的 B+ 树,它是一棵树,排序时把三列当成一个整体按键排序。查询要从这棵树里快速找到目标记录,前提是能持续使用这个复合键。一旦跳过了前面的列,中间列在整棵树上就不具备全局有序性,无法用于定位。
可以理解为电话簿:按“姓氏 + 名字”排序。你想直接查“叫小明的人”,但电话簿没有按名字排序,你只能翻完全本找。索引是 (姓氏, 名字),查询条件是 名字 = '小明',跳过了姓氏这一列,索引顺序就完全帮不上忙。
1.2 误区二:SQL 里条件书写顺序必须和索引列顺序一致
有不少人以为 WHERE order_date = ? AND user_id = ? 这种写法走不了索引,因为“顺序不对”。这是对优化器工作机制的误解。MySQL 的优化器在处理等值条件时会进行条件重排,只要查询条件里包含索引的第一列且是等值或可匹配的形式,优化器就能调整到合适的顺序去利用索引。
实测下来,WHERE user_id = 1 AND order_date = '2024-03-01' 和 WHERE order_date = '2024-03-01' AND user_id = 1 的 explain 结果完全一致,走的都是同一个索引。所以别被“顺序”两个字忽悠了。真正影响能否走索引的,是条件里“有没有第一列”“有没有跳过某些列”“范围条件出现在哪里”。
1.3 误区三:范围条件后面的列就彻底没用了
这句话有一半是对的,但很多资料把它绝对化了。准确的说法是:范围条件右侧的列无法用于 B+ 树的定位匹配,但依然可能产生价值。以索引 (a, b, c) 为例,WHERE a = 1 AND b > 100 AND c = 5 中,a 用于等值定位,b 用于范围扫描,c 无法继续用于定位。但 MySQL 5.6 以后引入了 ICP(Index Condition Pushdown,索引条件下推),在 InnoDB 层遍历索引时,如果索引中包含了 c 的值,会在回表之前先用 c = 5 做一次过滤。索引层面就过滤掉了大量明显不满足条件的记录,减少回表次数。所以 c 不是“完全没用”,而是不能再用来缩小搜索范围。
理解到这层,再看最左前缀原则,就不再是背口诀,而是一个顺理成章的数据结构结论。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 联合索引的 B+ 树长什么样:底层机制才是记忆锚点
要真正搞清楚最左前缀原则,绕不开 B+ 树的存储结构。我尽量用大白话把这件事讲透。
2.1 联合索引是一棵“复合排序”树
InnoDB 的二级索引(Secondary Index)的叶子节点存的是“索引键值 + 主键值”。联合索引 (user_id, order_date, status) 的叶子节点在物理逻辑上这样排序:先按 user_id 升序;user_id 相同,按 order_date 升序;order_date 也相同,再按 status 升序。这就是一个复合排序键。
你可以把它理解成字典的排列方式:中文词典按拼音首字母排,首字母相同按第二个字母排,第二个字母再相同按第三个字母排。查词时,你先定位首字母,再在首字母区间内定位第二个字母,依次缩小范围。如果直接让你按第三个字母去查,词典的排列顺序帮不了你,你得翻遍整本词典——这就是跳过最左列后索引失效的原理。
2.2 为什么“连续匹配”是硬性要求
最左前缀原则的完整表述:联合索引 (a, b, c) 可以被用于以下查询匹配形式:
- 匹配 a
- 匹配 a、b 两列
- 匹配 a、b、c 三列
但下面的形式无法利用索引完成定位:
- 匹配 b
- 匹配 c
- 匹配 b、c
原因是,B+ 树在构建时只保证了“按照 a 组织、a 相同按 b 组织、a 和 b 相同按 c 组织”的全局顺序。单独拿 b 做条件时,B+ 树中 b 的分布是全局无序的。优化器如果硬要用这个索引,就得扫描所有叶子节点,成本接近全表扫描,甚至因为随机回表而更慢。所以优化器会直接选择全表扫描或别的索引。
有一类特殊场景需要提一下:MySQL 8.0 在特定情况下会尝试跳跃扫描(Skip Scan),也就是将 WHERE b = ? 自动改写成“遍历所有可能的 a 值,然后分别查 (a, b)”。优化器会根据 a 的区分度估算成本,如果 a 的枚举值很少,可能会采用这种做法。但它不是通用方案,不能把宝押在它身上。
2.3 回表与覆盖索引
联合索引的叶子节点存的是索引键和主键。查询如果只用到索引中的列,那么在这些叶子节点上就能拿到全部所需数据,整个过程不需要回表。一旦查询还涉及索引列之外的字段,就必须拿主键回聚簇索引取整行数据,这个过程叫回表。
回表不是坏东西,但回表次数会影响性能。索引设计的一个高级思路就是覆盖索引:让某条高频查询所需的列全部包含在某个索引里,直接从索引树拿结果。理解了这一点,也会更容易理解为什么有时候“宁可使用一个三列的联合索引,也不要三个单列索引”。
3. 实测 12 条 SQL:一条条看执行计划
光讲原理容易理解,但容易流于表面。我做了一张测试表,造了一些数据,把典型查询的执行计划拿出来逐条分析。建议你把这个过程在本地重现一遍,比看十篇文章都有用。
3.1 建表与造数据
sql复制CREATE TABLE `orders` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`order_date` date NOT NULL,
`status` tinyint NOT NULL,
`amount` decimal(10,2) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_date_status` (`user_id`, `order_date`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
往表里插入约 10 万行数据,user_id 分布在 1 到 5000,order_date 分布在近一年,status 取 0、1、2。
sql复制-- 用存储过程或程序批量插入,这里只给一个示意
INSERT INTO orders (user_id, order_date, status, amount)
SELECT
FLOOR(RAND() * 5000) + 1,
DATE('2024-01-01') + INTERVAL FLOOR(RAND() * 365) DAY,
FLOOR(RAND() * 3),
ROUND(RAND() * 1000, 2)
FROM information_schema.tables
LIMIT 100000;
3.2 等值查询的四种组合
sql复制-- Q1:只带第一列
EXPLAIN SELECT * FROM orders WHERE user_id = 123;
-- key: idx_user_date_status, key_len: 4, ref: const, rows 明显缩小
-- Q2:第一列 + 第二列
EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND order_date = '2024-05-01';
-- key 同上, key_len: 7
-- Q3:三列全等值
EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND order_date = '2024-05-01' AND status = 1;
-- key 同上, key_len: 8
-- Q4:跳过第一列,只用第二列和第三列
EXPLAIN SELECT * FROM orders WHERE order_date = '2024-05-01' AND status = 1;
-- type: ALL,没有使用这个索引
Q1 到 Q3 都能正常使用索引,区别体现在 key_len 上。第一列 user_id 是 int 类型,4 字节;加上第二列 order_date(date 类型 3 字节)后 key_len 变为 7;再加上第三列 status(tinyint 1 字节)后 key_len 为 8。key_len 是判断索引真正用到了哪一列的利器,后面我会专门展开。
Q4 则是典型的“跳过最左列”场景。虽然查询条件里有两列,但这两列中没有 user_id,联合索引帮不上定位,走了全表扫描。
3.3 范围查询的几种组合
sql复制-- Q5:第一列等值,第二列范围
EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND order_date > '2024-05-01';
-- key 命中,key_len: 7,type 为 range
-- Q6:第一列等值,第二列范围,第三列等值
EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND order_date > '2024-05-01' AND status = 1;
-- key 命中,key_len: 7,status 无法用于定位,但可能参与 ICP 过滤
-- Q7:第二列范围,不带第一列
EXPLAIN SELECT * FROM orders WHERE order_date > '2024-05-01';
-- type: ALL,无法用索引
-- Q8:第一列范围,第二列等值
EXPLAIN SELECT * FROM orders WHERE user_id > 123 AND order_date = '2024-05-01';
-- key 命中,key_len: 4,order_date 无法再用于定位
Q5 很好理解:第一列等值定位到一棵“子树”,第二列在这个子树内部有顺序,所以能范围扫描。
Q6 是误区三对应的真实场景。status 虽然没进入 key_len,但在 explain 的 Extra 列里通常会看到 Using index condition,说明 ICP 正在工作,存储引擎在索引层先拿 status 过滤了一遍,回表数据量比不带 status 条件时少得多。
Q8 要注意:第一列用了范围 user_id > 123,那么第二列 order_date 的等值条件就无法用来定位了。原因是 user_id 范围之后,order_date 只在每个 user_id 内部有序,跨多个 user_id 时全局无序,所以只能对满足 user_id 范围的所有索引项逐个检查 order_date。这条规则“范围列之后的列失效”,本质还是 B+ 树的有序性在起作用。
3.4 排序与分组场景
sql复制-- Q9:ORDER BY 使用索引前缀列
EXPLAIN SELECT * FROM orders WHERE user_id = 123 ORDER BY order_date;
-- Extra 中可以看到 Using index condition,没有 filesort
-- Q10:ORDER BY 没有包含最左列
EXPLAIN SELECT * FROM orders WHERE order_date = '2024-05-01' ORDER BY status;
-- 这条本身就是全表扫描,容易出现 filesort
-- Q11:混合排序方向
EXPLAIN SELECT * FROM orders WHERE user_id = 123 ORDER BY order_date DESC, status ASC;
-- 排序方向不一致时,索引也可能无法完全避免 filesort
-- Q12:GROUP BY
EXPLAIN SELECT user_id, order_date, COUNT(*) FROM orders GROUP BY user_id, order_date;
-- 如果查询列恰好与索引前缀匹配,可以避免临时表
排序场景容易忽略。联合索引本身是有序的,如果查询条件和 ORDER BY 的组合恰好能利用这个顺序,就不用额外的 filesort(文件排序)。例如 WHERE user_id = 123 ORDER BY order_date,它在索引中已经按 (user_id, order_date) 排好了序,直接顺序遍历即可。但如果 WHERE user_id = 123 ORDER BY status,由于跳过了 order_date,status 在索引树内部不是全局有序的,需要 filesort。分组类似,GROUP BY 使用的列如果正好是最左前缀的连续列,也可以利用索引避免临时表和文件排序。
4. 三个容易被忽略但非常关键的细节
原理和实测都有了,接下来这部分是工作几年之后才会注意到的细节,值得多花两分钟。
4.1 key_len:用数字验证最左前缀
explain 输出的 key_len 经常被人忽略,它是判断“索引实际用到了哪几列”的最直接证据。key_len 表示 MySQL 在索引中使用的字节数,把参与匹配的每一列的字节长度累加,再加上可空标志之类,就能反推优化器到底用了索引的哪些列。
以我的测试表为例:
- user_id int NOT NULL → 4 字节
- order_date date NOT NULL → 3 字节
- status tinyint NOT NULL → 1 字节
由于建表时三列都标了 NOT NULL,没有可空标志,所以 Q1 的 key_len 是 4,Q2 是 7,Q3 是 8。如果你的表列允许为空,需要额外加 1 字节。varchar 还要额外考虑变长字节和字符集编码。用这个办法核对线上慢查询时,能快速确认“你以为用了三列索引,实际只用了两列”的尴尬情况。
4.2 ICP:索引条件下推常被误解为“索引失效”
前文提到过 ICP。MySQL 5.6 引入后,对于 WHERE a = 1 AND b > 100 AND c = 5 这类条件,c 虽然不能参与定位,但存储引擎层在读取索引记录时,会用 c 条件对索引记录做判断,满足才回表。explain 的 Extra 列会显示 Using index condition。看到这个不是索引失效,恰恰说明 ICP 在起作用。
这对我们设计索引的启示是:把等值条件尽量放在前面,范围条件靠后;范围条件之后的列如果确实常见,保留在索引中仍然能利用 ICP 减少回表。但这属于锦上添花,不能指望它替代正确的列顺序。
4.3 覆盖索引带来的意外收益
SELECT user_id, order_date, status FROM orders WHERE user_id = 123 AND order_date = '2024-05-01' 这条查询,查询列正好是索引三列,explain 的 Extra 里会出现 Using index,意味着整个查询在索引树上就完成了,不需要回表。
设计联合索引时,除了考虑 WHERE 条件,还要把高频 SELECT 的列纳入考量。如果查询列少而精,可以构造一个覆盖索引,让查询完全绕开回表。代价是索引树更大、写入更慢,所以权衡点在读多写少的表上做覆盖索引,收益非常明显。
5. 从业务查询反推联合索引:一个真实优化案例
原理讲完,分享一个我经手的典型优化案例。有一张订单流水表,业务方经常查“某个用户最近 30 天状态为成功的订单列表”,SQL 大致是:
sql复制SELECT order_id, amount, create_time
FROM order_flow
WHERE user_id = ?
AND create_time >= ?
AND status = 1
ORDER BY create_time DESC
LIMIT 20;
5.1 第一版索引:只考虑“有什么条件”
有些同事设计索引时,会把常见条件列直接塞进一个索引,写成 (user_id, create_time, status, order_id, amount)。看着很全,但问题不少:
- 查询只带了 user_id、create_time、status 三个条件,索引后面的列对定位没用。
- ORDER BY create_time DESC,而索引默认是 ASC,范围条件之后再用排序,无法完全利用索引顺序。
- 如果把 amount 也塞进去,索引体积膨胀,写入性能下降。
5.2 第二版索引:按条件类型排布
我的调整思路:先放下所有条件,按“等值条件 → 范围条件 → 排序列 → 覆盖列”的顺序组织。
- 等值条件:user_id = ?、status = 1,两个等值条件顺序可以按区分度排。user_id 区分度更高,放前面。
- 范围条件:create_time >= ?,放等值条件后面。
- 排序列:ORDER BY create_time DESC,希望避免 filesort。
- 覆盖列:order_id、amount,查询需要返回,加进索引末尾实现覆盖。
于是索引设计为 (user_id, status, create_time, order_id, amount)。
这里有个值得说的地方:等值条件比范围条件放在更靠前,是因为只有等值条件才能精确缩小 B+ 树中的范围。范围条件一旦出现,它后面的索引列就无法继续用于定位,所以等值条件必须尽量排在范围条件之前。ORDER BY 的字段如果能和等值条件后的范围字段组合起来,天然有序,filesort 就有可能被消灭。索引实际运行后,key_len 精确用到了三列,排序也没出现 filesort,慢查询从几百毫秒降到了几个毫秒。
5.3 设计联合索引的实操清单
我平时会按下面这个顺序做索引设计,供你参考:
- 拿到业务 SQL 后,先把 WHERE 条件分成等值、范围、排序三类。
- 等值条件之间,按区分度从高到低排列。区分度可以简单理解为“这一列有多少种不同值”,用
COUNT(DISTINCT col) / COUNT(*)估算。 - 范围条件放在所有等值条件之后。
- 排序字段尽量放在范围条件之后,看能否利用索引天然有序。
- 如果高频查询的回表代价高,把 SELECT 需要的列补到索引尾部,构造覆盖索引。
- 同一列在不同 SQL 中的角色可能不同,优先服务最高频、最核心的查询。
6. 排查索引问题的完整链路:慢查询怎么一步步查到根因
最后分享一套我在线上排查慢查询的流程,每次都能用上。
6.1 第一步:开慢查询日志,定位问题 SQL
线上环境不一定方便直接做实验,先把慢查询日志打开,设置阈值,比如超过 200 毫秒的记录。
sql复制SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.2;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
注意这些设置重启后会失效,生产环境要写入配置文件。拿到慢 SQL 之后,先看执行计划。
6.2 第二步:用 explain 判断执行计划
sql复制EXPLAIN SELECT ...;
重点看几个字段:
- type:从好到差大致是 system > const > eq_ref > ref > range > index > ALL。看到 ALL 基本就是全表扫描。
- key:实际使用的索引名。
- key_len:确认索引真正用到了哪几列。
- rows:预估扫描行数,和表总行数对比。
- Extra:
Using filesort、Using temporary要警惕,Using index condition表示 ICP 正常工作,Using index表示覆盖索引。
6.3 第三步:用 optimizer trace 看优化器的内心戏
如果 explain 还不够,MySQL 还提供 optimizer trace,可以看到优化器评估每个索引成本的过程,以及为什么最终选择了某个方案。
sql复制SET optimizer_trace = "enabled=on";
SELECT /* 你要查的 SQL */ ...;
SELECT * FROM information_schema.OPTIMIZER_TRACE;
SET optimizer_trace = "enabled=off";
这里面的 rows_estimation 和 considered_execution_plans 会列出优化器看了哪些索引、估计了多少行、最终选用了哪个方案。看到实际成本数据,你就能理解为什么它没用你“认为应该用”的索引。
6.4 第四步:修索引、验证、对比
根据根因调整索引后,不要只看一次 explain,要做一个验证方案表,横向对比优化前后的执行计划、耗时、扫描行数。也可以顺手检查一下:
- 有没有针对同一字段的单列索引,完全被联合索引覆盖,该删的删掉。
- 有没有冗余索引,比如同时存在
(a, b)和(a, b, c),前者大概率是冗余的。 - 有没有低效的隐式转换,比如
WHERE user_id = '123'让字符串转成数字,可能触发索引失效。 - 有没有在索引列上做函数运算,比如
WHERE DATE(order_date) = '2024-05-01',改成WHERE order_date >= '2024-05-01' AND order_date < '2024-05-02'。
这套流程走下来,线上绝大多数“索引失效”问题都能定位到根,而且最后一般都会落到同一个结论:不是 MySQL 不给力,是联合索引列的顺序没设计对,或者 SQL 根本没按最左前缀来写。最左前缀原则不复杂,复杂的是把它落到每天实际写的 SQL 和每张表的索引设计里。我自己的体会是,多花十分钟看一次 explain 的 key_len 和 Extra,比盲目加索引有用得多。
