联合索引最左前缀原则深度解析
候选人小吴参加京东 P6 面试,面试官问:
"有一个联合索引 (a, b, c),查询条件是 WHERE b = 1 AND c = 2,能用到这个索引吗?"
小吴说:"不能,因为 b 不在最左边。"面试官追问:"那如果查询是 WHERE a = 1 AND c = 2 呢?"
小吴说:"能用一部分,用到 a 这一列。"面试官:"为什么 a 和 c 之间夹了个 b,但 c 还是用不到索引?"
小吴开始语无伦次。
【面试官心理】
最左前缀原则是 MySQL 索引中最容易被误解的知识点。90% 的候选人知道"要从左边开始用",但能解释清楚"为什么联合索引是 B+Tree 结构、字段顺序怎么决定排序、范围查询会中断索引使用"的不到 10%。
一、联合索引的存储结构 🔴
1.1 联合索引是一颗 B+Tree
理解最左前缀原则的关键在于:联合索引不是多个索引,而是一颗 B+Tree。
-- 创建联合索引
CREATE INDEX idx_a_b_c ON users (a, b, c);
这颗 B+Tree 的排序规则是:先按 a 排序,a 相同时按 b 排序,b 相同时按 c 排序。
graph TD
subgraph 联合索引 B+Tree (a, b, c)
Root[根节点]
Root --> L1A[(a=1)]
Root --> L1B[(a=5)]
Root --> L1C[(a=10)]
L1A --> L2A1[(a=1, b=1)]
L1A --> L2A2[(a=1, b=3)]
L1A --> L2A3[(a=1, b=8)]
L1B --> L2B1[(a=5, b=2)]
L1B --> L2B2[(a=5, b=6)]
L2A1 --> Leaf1[/完整行数据或主键/]
L2A1 --> Leaf2[/完整行数据或主键/]
end
关键点:叶子节点按照 (a, b, c) 三列的复合排序键排列。这就是为什么索引顺序如此重要。
1.2 最左前缀匹配原理
-- 索引 (a, b, c) 下,以下查询能使用索引:
-- 1. 精确查 a (最左前缀)
WHERE a = 1 -- ✅ 使用索引
-- 2. 精确查 a + b (左前缀)
WHERE a = 1 AND b = 2 -- ✅ 使用索引
-- 3. 精确查 a + b + c (完整左前缀)
WHERE a = 1 AND b = 2 AND c = 3 -- ✅ 使用索引
-- 4. 范围查 a + 精确查 b (a 是前缀,b 精确)
WHERE a > 1 AND b = 2 -- ⚠️ a 范围查询,b 只能用一部分
-- 5. 跳过 a,查 b (没有最左前缀)
WHERE b = 2 -- ❌ 无法使用索引
-- 6. 跳过 a 和 b,查 c (没有最左前缀)
WHERE c = 3 -- ❌ 无法使用索引
-- 7. 精确查 a + 范围查 c (a 精确,c 范围,b 断开了)
WHERE a = 1 AND c > 3 -- ⚠️ 只能用到 a,c 用不到
1.3 ❌ 错误示范
候选人原话:"联合索引 (a, b, c),查询 WHERE a = 1 AND c = 3 用不到索引,因为 b 不在查询条件里。"
问题诊断:
- 混淆了"字段在不在查询条件里"和"索引能不能用"
- 联合索引用 a 列时,b 列即使不在查询条件里也不影响 a 的使用
- 但 c 列因为中间断了 b,确实用不到索引
面试官内心 OS:这个候选人没有真正理解 B+Tree 的排序机制。b 列不在查询条件里不影响 a 的使用,但如果 b 和 c 之间断了,后面的列就用不到了。
⚠️
最左前缀原则的真正含义是:索引的匹配是从左到右、不能中断的。不查询 b 列没关系(b 列的值在索引中存在),但不能跳过一个列去匹配后面的列。
二、为什么联合索引要按这个顺序 🟡
2.1 字段顺序决定查询能力
联合索引的字段顺序是使用场景决定的,不是拍脑袋的。
-- 场景 1: 用户表,查询以登录为主
-- 查询: WHERE email = ? 和 WHERE phone = ? 和 WHERE name = ?
-- 结论: 这三个查询是独立的,无法用一个联合索引覆盖,应该建三个独立索引
-- 场景 2: 订单表,查询以用户和状态为主
-- 查询: WHERE user_id = ? 和 WHERE user_id = ? AND status = ?
-- 结论: 可以建联合索引 (user_id, status),覆盖两个查询
-- 场景 3: 报表查询
-- 查询: WHERE DATE(create_time) = ? (对日期函数处理后的值查询)
-- 结论: 无法使用 create_time 的索引,因为对列做了函数处理
2.2 区分度原则
区分度高的字段放前面。
-- ❌ 差的设计
CREATE INDEX idx_status_user ON orders (status, user_id);
-- status 只有 3 种值 (pending, paid, shipped),区分度极低
-- 99% 的查询会走 user_id 过滤,status 索引几乎没用
-- ✅ 好的设计
CREATE INDEX idx_user_status ON orders (user_id, status);
-- user_id 区分度极高,先用 user_id 过滤,数据量骤减
-- 验证区分度
SELECT status, COUNT(*) as cnt FROM orders GROUP BY status;
-- 结果: pending=1000, paid=50000, shipped=100000
-- status 区分度 = 3 / 总行数 ≈ 0.00001(极低)
2.3 常见错误:把等值查询字段放在范围查询字段后面
-- ❌ 错误设计
CREATE INDEX idx_age_status ON users (age, status);
-- 查询: WHERE age BETWEEN 18 AND 30 AND status = 'active'
-- age 是范围查询,只能用到 age 一列
-- status 因为范围查询中断,用不到
-- ✅ 正确设计
CREATE INDEX idx_status_age ON users (status, age);
-- 查询: WHERE status = 'active' AND age BETWEEN 18 AND 30
-- status 是等值查询,可以完全匹配
-- age 是范围查询,status 之后可以用到
【面试官心理】
这道题我会连续追问三层:第一层问能不能用,第二层问为什么,第三层让候选人设计字段顺序。能说出"等值字段优先于范围字段"的人,基本是有实战优化经验的。
三、范围查询中断索引 🟡
3.1 什么算"范围查询"
在 MySQL 中,以下条件属于范围查询,会中断索引匹配:
3.2 IN 的特殊性
-- IN 在某些情况下可以等价于多个等值查询,不中断索引
CREATE INDEX idx_a_b ON t (a, b);
-- 查询: WHERE a = 1 AND b IN (2, 3)
-- 等价于 a=1 AND b=2 OR a=1 AND b=3
-- MySQL 5.6+ 可以使用索引
-- 但: WHERE a IN (1, 2) AND b = 3
-- 只能用到 a 列,b 用不到(因为 a 是范围)
💡
IN 在 MySQL 优化器中可能被改写成 OR 的形式。对于 (a, b) 索引,a IN (1, 2) 会被视为两次范围查询,会中断 b 的使用。但如果 IN 的值列表很短(小于 200),优化器有时会改写为多个等值查询。
3.3 LIKE 前缀匹配的边界
-- LIKE 'abc%' 是前缀匹配,在 B+Tree 中可以按序遍历
CREATE INDEX idx_name ON users (name);
SELECT * FROM users WHERE name LIKE '张%';
-- ✅ 可以使用 idx_name 索引,按字典序扫描所有 '张' 开头的记录
SELECT * FROM users WHERE name LIKE '%张%';
-- ❌ 无法使用索引,必须全表扫描
四、生产避坑 🔴
4.1 联合索引 vs 多个单列索引
-- 方案 A: 三个单列索引
INDEX idx_a ON t(a);
INDEX idx_b ON t(b);
INDEX idx_c ON t(c);
-- 方案 B: 一个联合索引
INDEX idx_a_b_c ON t(a, b, c);
⚠️
MySQL 5.6+ 有索引合并(Index Merge)功能,当单个查询条件没有合适的联合索引时,优化器会尝试合并多个单列索引的结果。但索引合并的开销很大(交集/并集计算),有时不如建一个联合索引。
4.2 索引失效的隐蔽场景
-- 场景 1: 函数处理导致索引失效
SELECT * FROM orders WHERE YEAR(create_time) = 2024;
-- ❌ YEAR() 函数导致无法使用 create_time 索引
-- ✅ 正确写法
SELECT * FROM orders WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';
-- 场景 2: 类型转换导致索引失效
CREATE TABLE users (user_id VARCHAR(20)); -- 字符串类型
SELECT * FROM users WHERE user_id = 123456; -- 数值 vs 字符串
-- ❌ 隐式类型转换导致索引失效
-- 场景 3: OR 条件导致索引失效
SELECT * FROM users WHERE name = '张三' OR age = 25;
-- 如果 age 上没有索引,OR 会导致整个查询无法使用 name 的索引
4.3 如何验证索引使用情况
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'paid';
-- 关键字段解读:
-- type: ref (使用了索引的等值匹配) / ALL (全表扫描)
-- key: idx_user_status (实际使用的索引)
-- key_len: 索引使用到的字节数
-- - user_id BIGINT(8字节): key_len = 8
-- - user_id + status VARCHAR(50)*3 + 2: key_len = 8 + 152 + 2 = 162
-- rows: 预计扫描的行数
-- Extra: Using index condition (索引下推) / Using index (覆盖索引) / Using filesort (需排序)
五、工程选型
5.1 联合索引设计公式
设计联合索引时,遵循以下顺序:
(等值/IN) > (区分度高的字段) > (范围查询字段) > (区分度低的字段)
-- 常见订单查询场景
-- 查询1: WHERE user_id = ? AND status = ?
-- 查询2: WHERE user_id = ? AND status = ? AND create_time > ?
-- 查询3: WHERE user_id = ?
-- 索引设计: (user_id, status, create_time)
-- 完美覆盖三个查询
5.2 索引数量的控制
💡
一个实用的经验法则:如果一个索引的 cardinality(基数)低于总行数的 5%,考虑删除它。这样的索引区分度太低,优化器大概率不会使用,反而徒增维护开销。