一、最左前缀原则
第一步:联合索引在 B+ 树里到底存了什么?
假设你有一张表:
CREATE TABLE tb_user ( id INT PRIMARY KEY, a INT, b INT, c INT, INDEX idx_abc (a, b, c) -- 联合索引 );你建了一个联合索引(a, b, c)。
MySQL 不会建三棵独立的树,而是只建一棵 B+ 树。
但这棵树的叶子节点存的键值不是单独的a、b或c,而是把三列的值拼在一起,当成一个整体键值:
索引键值格式:(a, b, c) 叶子节点实际存储的是: (1, 2, 3) (1, 2, 5) (1, 3, 1) (2, 1, 4) (2, 2, 6) (3, 1, 2) ...排序规则是:先按 a 排,a 相同再按 b 排,b 相同再按 c 排。
这和字典序排序一模一样:
先比较第一个字母
第一个相同,比较第二个
第二个相同,比较第三个
第二步:B+ 树的有序性决定了查找方式
B+ 树的核心特性是:节点内的所有键值是有序的,查找时必须利用这个有序性做二分查找或顺序扫描。
当你执行:
SELECT * FROM tb_user WHERE a = 1;MySQL 去idx_abc这棵 B+ 树里找(1, ?, ?)。
因为所有键值是先按a排序的,所以a = 1的记录一定连续地排在一起。B+ 树能快速定位到第一个(1, ...)的位置,然后顺着链表往后读,直到a不等于 1 为止。
这个过程能走索引,因为查询条件用到了排序的第一维。
第三步:为什么WHERE b = 2走不了索引?
现在看这条 SQL:
SELECT * FROM tb_user WHERE b = 2;MySQL 拿着b = 2去idx_abc这棵 B+ 树里找。
问题出现了:
在这棵树上,(a, b, c)是按a为第一优先级排序的。b的值是"散"在整个树里的:
(1, 2, 3) -- b=2 (1, 2, 5) -- b=2 (1, 3, 1) -- b=3 (2, 1, 4) -- b=1 (2, 2, 6) -- b=2 (3, 1, 2) -- b=1b = 2的记录出现在(1, 2, 3)、(1, 2, 5)、(2, 2, 6)这几个位置,它们在 B+ 树的叶子链表上不是连续的。
B+ 树没有办法直接跳到"所有b = 2的位置",因为它只能按完整的(a, b, c)键值排序查找。要找到所有b = 2的记录,只能遍历整棵树,逐个检查每个节点的b是不是 2。
遍历整棵树 = 全表扫描(索引扫描版),代价和全表扫描一样高,所以优化器会放弃这个索引,直接走全表扫描。
四:为什么WHERE a = 1 AND c = 3只能用到 a?
SELECT * FROM tb_user WHERE a = 1 AND c = 3;这个查询能用到索引,但只用到了a这一列,c用不上。
原因:
先通过
a = 1定位到 B+ 树上a = 1的连续区间:(1, 2, 3) (1, 2, 5) (1, 3, 1)在这个区间内,
b的值是2, 2, 3,是有序的。但c的值是3, 5, 1,因为b不同,所以c在这个区间内不是有序的。现在你要找
c = 3,但c在a = 1这个范围内是散落的(3, 5, 1),无法二分查找,只能在a = 1的所有记录里逐个检查c。
所以索引只帮你在第一步过滤了a = 1,第二步的c = 3还是要回表后逐行判断。
第五步:WHERE a = 1 AND b = 2 AND c = 3为什么能全走索引?
SELECT * FROM tb_user WHERE a = 1 AND b = 2 AND c = 3;B+ 树里的键值是(a, b, c),排序顺序是:
(1, 2, 3) (1, 2, 5) (1, 3, 1) (2, 1, 4) ...先找
a = 1:定位到(1, ...)开头的连续区间在这个区间内,
b是有序的,再找b = 2:定位到(1, 2, ...)的子区间在这个子区间内,
c是有序的,再找c = 3:精确命中(1, 2, 3)
每一层都利用了 B+ 树的有序性,每一层都能二分查找,所以三列都用上了索引。
第六步:范围查询为什么断尾?
SELECT * FROM tb_user WHERE a = 1 AND b > 2 AND c = 3;这个查询能用索引,但只用到a和b,c用不上。
原因:
a = 1:定位到a = 1的区间b > 2:在a = 1的区间内,b是有序的,可以找到第一个b > 2的位置,然后顺序往后读此时读取到的键值可能是:
(1, 3, 1) -- b=3, c=1 (1, 3, 5) -- b=3, c=5 (1, 4, 2) -- b=4, c=2现在你要找
c = 3,但在这个b > 2的范围内,c的值是1, 5, 2,不是有序的。因为
b已经是一个范围(> 2),b的值在变化(3, 3, 4...),导致c的值无法保证有序。
一旦某一列用了范围查询,它右边的列就无法再走索引的有序性了。
第七步:顺序无关——优化器会自动调整
WHERE b = 2 AND a = 1 AND c = 3这个 SQL 虽然写的顺序是b, a, c,但优化器会自动重排为a, b, c,然后走索引。
注意:这是"等值条件"的顺序重排,不是"列的使用顺序"。如果你写的是WHERE b = 2 AND c = 3,优化器不会凭空给你补一个a,依然走不了索引。
总结:最左前缀原则的本质
| 规则 | 原因 |
|---|---|
| 必须从最左列开始 | B+ 树按(a, b, c)整体排序,最左列是第一排序键 |
| 中间不能断 | 断了左边,右边列在树中不连续,无法二分 |
| 范围查询断尾 | 范围条件导致后续列在局部区间内无序 |
| 顺序可重排 | 优化器会重排等值条件的顺序,但不会补缺失的列 |
一句话记忆:
联合索引
(a, b, c)就是一棵按a → b → c优先级排序的 B+ 树。查询条件必须能按这个优先级一层层定位,才能利用索引的有序性。跳过了a,树就不知道从哪开始找;跳过了b,c在a的范围内就是乱的。
二、索引下推(ICP)
MySQL 5.6后,在二级索引遍历时就过滤条件,减少回表次数。
第一步:没有 ICP 时,MySQL 的查询流程是什么?
假设你有一张表:
CREATE TABLE tb_user ( id INT PRIMARY KEY, name VARCHAR(50), age INT, INDEX idx_name_age (name, age) -- 联合索引 );执行这条 SQL:
SELECT * FROM tb_user WHERE name LIKE '张%' AND age = 20;根据最左前缀原则,name LIKE '张%'可以用到idx_name_age索引,但age = 20是断尾的(因为name是范围条件),所以age本身无法利用 B+ 树的有序性来快速定位。
没有 ICP 时的执行流程:
存储引擎去
idx_name_age索引树,找到所有name LIKE '张%'的索引记录对每一条找到的索引记录,不管
age是多少,都拿着主键id去回表回表拿到完整的行数据后,交给MySQL Server 层
Server 层再检查
age = 20,把不符合条件的过滤掉
弊端暴露:
如果name LIKE '张%'匹配了 1000 条记录,但这 1000 条里只有 10 条的age = 20,那么:
发生了1000 次回表
其中990 次回表是白做的——回表后发现
age != 20,被 Server 层丢弃
回表需要查主键索引树,是磁盘 IO 操作。990 次无效回表 = 990 次无效磁盘 IO。
这就是没有 ICP 的核心问题:Server 层和存储引擎层之间职责划分太死板。
存储引擎只负责"用索引找到记录",找到就回表,把完整行交给 Server;Server 层负责"过滤条件"。
但存储引擎在遍历索引的时候,明明已经看到了age的值(因为idx_name_age的索引键是(name, age)),它却不判断,非要等回表后再让 Server 层判断。
第二步:ICP 的设计思路——把过滤条件下推
MySQL 5.6 引入 ICP(Index Condition Pushdown),设计思路是:
在存储引擎遍历二级索引的过程中,就把能在索引层面判断的条件提前过滤掉,只有满足条件的记录才回表。
为什么能这样做?
因为idx_name_age这棵索引树的叶子节点存储的是(name, age, id)。对于每一个索引条目,存储引擎在读取它的时候,已经同时拿到了name和age的值。
既然age的值就在索引条目里,为什么非要回表后再判断?直接在索引层判断age = 20,不满足的条目直接丢弃,不回表。
第三步:有 ICP 时的执行流程对比
同样的 SQL:
SELECT * FROM tb_user WHERE name LIKE '张%' AND age = 20;有 ICP 时的执行流程:
存储引擎去
idx_name_age索引树,找到第一条name LIKE '张%'的索引记录ICP 生效:存储引擎检查这条索引记录里的
age字段如果
age != 20:直接丢弃,不回表如果
age = 20:拿着主键id回表,查完整行数据返回给 Server 层
顺着索引链表继续找下一条
name LIKE '张%'的记录,重复步骤 2
结果对比:
| 阶段 | 没有 ICP | 有 ICP |
|---|---|---|
name LIKE '张%'匹配 1000 条 | 1000 次回表 | 只回表age = 20的那 10 条 |
age != 20的 990 条 | 回表后交给 Server 层丢弃 | 在索引层直接丢弃,零回表 |
| 磁盘 IO | 1000 次回表 IO | 10 次回表 IO |
第四步:在代码和 EXPLAIN 中怎么看 ICP?
EXPLAIN 中的标志
EXPLAIN SELECT * FROM tb_user WHERE name LIKE '张%' AND age = 20;如果 ICP 生效,在Extra列会看到:
Using index condition注意区分:
| Extra 值 | 含义 |
|---|---|
Using index | 覆盖索引,不需要回表 |
Using index condition | 使用了 ICP,需要回表,但回表前在索引层做了过滤 |
Using where | 没有 ICP,回表后在 Server 层过滤 |
关闭 ICP 做对比测试
-- 关闭 ICP(默认是开启的) SET optimizer_switch = 'index_condition_pushdown=off'; EXPLAIN SELECT * FROM tb_user WHERE name LIKE '张%' AND age = 20; -- Extra 显示:Using where(表示回表后 Server 层过滤) -- 开启 ICP SET optimizer_switch = 'index_condition_pushdown=on'; EXPLAIN SELECT * FROM tb_user WHERE name LIKE '张%' AND age = 20; -- Extra 显示:Using index condition第五步:ICP 的生效条件
ICP 不是万能的,它有以下限制:
1. 只对二级索引生效
主键索引(聚簇索引)的叶子节点本身就是完整数据,不存在"回表"这个概念,所以不需要 ICP。
2. 条件必须能在索引层判断
-- 能下推:age 在 idx_name_age 索引里 WHERE name LIKE '张%' AND age = 20 -- 不能下推:address 不在 idx_name_age 索引里 WHERE name LIKE '张%' AND address = '北京'address不在索引中,存储引擎在遍历idx_name_age时看不到address的值,所以address = '北京'无法下推,只能回表后由 Server 层判断。
3. 不能用于存储函数
WHERE name LIKE '张%' AND YEAR(created_at) = 2024如果created_at在索引中,但条件里包函数YEAR(),ICP 通常不会下推(因为存储引擎不一定能直接计算函数结果)。
总结逻辑链
| 阶段 | 问题/弊端 | 解决方案 |
|---|---|---|
| 没有 ICP | 存储引擎只负责索引定位,所有匹配记录都回表,Server 层再过滤 | 职责划分不合理,回表次数过多 |
| ICP 设计 | 索引条目里明明有age的值,却非要回表后再判断 | 把过滤条件下推到存储引擎层 |
| 有 ICP 后 | 存储引擎遍历索引时,先检查索引中的列条件,不满足直接丢弃 | 大幅减少无效回表,降低磁盘 IO |
| 限制 | 只对二级索引生效,条件列必须在索引中 | 主键索引不需要,非索引列无法下推 |