联合索引最左前缀原则深度解析

候选人小吴参加京东 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 中,以下条件属于范围查询,会中断索引匹配:

查询类型是否中断索引
=, IN (等值)✅ 不中断
>, >=, <, <=⚠️ 中断后续列
BETWEEN ... AND ...⚠️ 等价于范围,中断
LIKE 'abc%' (前缀匹配)✅ 不中断
LIKE '%abc%' (中间匹配)❌ 无法使用索引
!=, <>⚠️ 中断

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);
维度三个单列索引联合索引
查询覆盖每个查询独立选一个索引需要匹配最左前缀
写入开销3 个索引的维护1 个索引的维护
空间开销索引分散,占用更多索引紧凑,占用更少
适用场景查询条件各不相同查询有共同前缀
⚠️

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 索引数量的控制

表规模推荐索引数理由
< 1万行3~5 个数据量小,索引优势不明显
1万~100万行5~8 个按需建索引,注意冗余
100万~1亿行5~10 个严格控制,写入性能敏感
> 1亿行视查询特征考虑分区/分表
💡

一个实用的经验法则:如果一个索引的 cardinality(基数)低于总行数的 5%,考虑删除它。这样的索引区分度太低,优化器大概率不会使用,反而徒增维护开销。