Files
cs-note/hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀.md
T
2026-05-24 11:42:38 +08:00

352 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MySQL, 联合索引, 最左前缀, 索引失效]
create time: 2026-05-16 00:00
---
# 联合索引与最左前缀
## 概述
联合索引(Composite Index)是将多个列放在同一个 B+ Tree 中组织的索引。它的核心法则是**最左前缀匹配原则**——理解这一点就能避开 80% 的索引设计失误。
## 从"字典查词"理解联合索引
翻开一本中文词典,里面的词条按照**拼音 → 笔画 → 部首**的顺序排列:
1. 先按拼音排序(a, b, c...)
2. 拼音相同时,按笔画排序
3. 笔画也相同时,按部首排序
你想查"爱"字,先翻到拼音 `ai` 的区域(第一列),再找 10 画的(第二列),最后精确定位(第三列)——这就是**最左前缀**的直觉。
反过来,如果有人问"10 画的字在第几页?"——你没法回答,因为 10 画的字**分散在每个拼音区**里,没有连续的区域可以翻到。
> [!QUESTION] 思考
> 联合索引 `idx(a, b, c)` 就像这本字典。条件必须从第一列开始依次匹配,不能"跳着查"。下面我们来看看它的具体结构。
## 联合索引的结构
叶子节点按 a 排序,a 相同时按 b 排序,a 和 b 都相同时按 c 排序:
| a | b | c | data |
|---|---|---|------|
| 1 | 10 | 3 | row1 |
| 1 | 10 | 7 | row2 |
| 1 | 20 | 1 | row3 |
| 1 | 20 | 9 | row4 |
| 2 | 5 | 2 | row5 |
| 2 | 15 | 4 | row6 |
| 3 | 10 | 1 | row7 |
| 3 | 10 | 8 | row8 |
| 3 | 30 | 5 | row9 |
> [!QUESTION] 这意味着什么?
> 联合索引的本质是**多级排序**。你可以把它想象成 SQL 的 `ORDER BY a, b, c`。索引中的数据已经按照这个顺序排好了。
## 最左前缀法则详解
```mermaid
flowchart TD
IDX["联合索引 idx(a, b, c)"]
IDX --> U1["✅ WHERE a=1"]
IDX --> U2["✅ WHERE a=1 AND b=2"]
IDX --> U3["✅ WHERE a=1 AND b=2 AND c=3"]
IDX --> X1["❌ WHERE b=2"]
IDX --> X2["❌ WHERE c=3"]
IDX --> X3["❌ WHERE b=2 AND c=3"]
IDX --> U4["⚠️ WHERE a=1 AND c=3"]
U1 -. "用 1 列" .-> _u1
U2 -. "用 2 列" .-> _u2
U3 -. "用全部" .-> _u3
X1 -. "无效" .-> _x1
X2 -. "无效" .-> _x2
X3 -. "无效" .-> _x3
U4 -. "仅用 a" .-> _u4
style U1 fill:#00D866,color:#fff
style U2 fill:#00D866,color:#fff
style U3 fill:#00D866,color:#fff
style X1 fill:#EE5A24,color:#fff
style X2 fill:#EE5A24,color:#fff
style X3 fill:#EE5A24,color:#fff
style U4 fill:#FF9F43,color:#000
```
### 为什么 b=2 单独查不了?
```mermaid
graph LR
subgraph SQ1["✅ WHERE a=1 二分查找定位"]
direction LR
A1["a=1 区域连续"] --> A2["直接定位起始行"] --> A3["顺序扫描即可"]
end
subgraph SQ2["❌ WHERE b=2 需全表扫描"]
direction LR
B1["b=10 在 a=1 下"] --> B4["b=10 在 a=3 下"] --> B2["b=2 散落各处"] --> B3["无法定位起点"]
end
style SQ1 fill:#E8F8F5,stroke:#00D866,stroke-width:2px
style SQ2 fill:#FDEDEC,stroke:#EE5A24,stroke-width:2px
style A1 fill:#00D866,color:#fff
style A2 fill:#00D866,color:#fff
style A3 fill:#00D866,color:#fff
style B1 fill:#EE5A24,color:#fff
style B2 fill:#EE5A24,color:#fff
style B3 fill:#EE5A24,color:#fff
style B4 fill:#EE5A24,color:#fff
```
B+ Tree 中的数据**先按 a 排序**,只有 a 相同时 b 才有序。单独查 `b=2` 时,满足条件的行分散在不同的 a 值下面,没有连续的 `b=2` 区域可供二分查找——只能全树扫描。
> [!QUESTION] 思考
> 规律是什么?只有匹配了索引的**第一列**,才能继续利用第二列;匹配了前两列,才能利用第三列。条件必须从索引的最左列开始,依次匹配,不能跳过中间列。
> 而对于 `WHERE a=1 AND c=3`,虽然 a 匹配了第一列,但跳过了 b,所以 c 无法走索引。不过别担心,MySQL 优化器足够聪明——它会自动调整 WHERE 条件的顺序来匹配索引,**SQL 中条件的书写顺序不影响索引的使用**。
## 范围查询后的断裂
联合索引遇到范围查询(`>`, `<`, `BETWEEN`, `LIKE 'prefix%'`)后,右侧列的索引失效。
```sql
-- 假设已有联合索引 idx(status, created_at, type)
-- ⚠️ 看起来像用上了 3 个列,实际上 type 不会走索引!
SELECT * FROM orders WHERE status = 'paid'
AND created_at > '2026-01-01' AND type = 'online';
-- 逐列分析:
-- status = 'paid' → ✅ 等值匹配,精确定位起始行
-- created_at > ... → ✅ 范围扫描,划定区间终点
-- type = 'online' → ❌ 区间内数据未按 type 排序 → 退化为内存过滤
```
> [!QUESTION] 为什么范围查询会打断后续列?
> 一个生活类比:联合索引就像一本按**省份 → 城市 → 区县**排序的通讯录。你可以先翻到"广东省"(等值),再在里面翻"深圳市"(等值),最后找"南山区"——很快。但如果你只知道"某个日期之后创建的订单"(范围),你得到的是一个连续但内部无序的数据段,就像拿到"2026 年之后的广东省所有城市"的名单——这个名单里的区县是乱序的,你没法再按区县快速定位。
>
> 简单说:**范围扫描得到的是一个"连续但内部无序"的区间**,后续列无法利用索引的有序性进行二分查找。
```mermaid
graph LR
A["等值列 = 定位起点"] -->|"精确找到起始位置"| B
B["范围列 = 确定终点"] -->|"划定扫描区间"| C
C["右侧列 = 失效"] -->|"区间内无序"| D["退化为 WHERE 过滤"]
style A fill:#00D866,color:#fff
style B fill:#00B6BC,color:#fff
style D fill:#EE5A24,color:#fff
```
### 实战:调整联合索引的顺序
```sql
-- 场景:订单表常用查询——按状态筛选 + 按时间范围 + 按类型过滤
-- ❌ 原始索引:范围列在中间,后续等值列无法使用
CREATE INDEX idx_sct ON orders (status, created_at, type);
-- 查询 A: WHERE status=? AND created_at>? AND type=?
-- → type 无法用索引(created_at 的范围扫描打断了后续列)
-- ✅ 优化方案 1:将等值列提前
CREATE INDEX idx_stc ON orders (status, type, created_at);
-- 查询 B: WHERE status=? AND type=? AND created_at>?
-- → 三个列全部用上!status 定位起点,type 进一步缩小,created_at 做范围
-- 💡 注意:即使 SQL 写的是 created_at 在前,优化器也会自动调整顺序匹配索引
-- ✅ 优化方案 2:如果两个查询频率差不多,拆成两个索引
CREATE INDEX idx_status ON orders (status);
CREATE INDEX idx_status_time ON orders (status, created_at);
-- 让每个索引专注于它的典型查询模式
```
### EXPLAIN 验证:从 range 到 ref
用 EXPLAIN 对比两种索引的效果:
```sql
-- ❌ 用 idx_sct(status, created_at, type)
EXPLAIN SELECT * FROM orders
WHERE status = 'paid' AND created_at > '2026-01-01' AND type = 'online';
```
```
+----+------+---------------+---------+-------+-----------------+------+----------+-------------+
| id | type | possible_keys | key | ref | key_len | rows | filtered | Extra |
+----+------+---------------+---------+-------+-----------------+------+----------+-------------+
| 1 | range| idx_sct | idx_sct | NULL | ... | 1280 | 33.33 | Using where |
+----+------+---------------+---------+-------+-----------------+------+----------+-------------+
-- type=range:范围扫描后,type='online' 需要逐行过滤(filtered=33.33%)
```
```sql
-- ✅ 用 idx_stc(status, type, created_at)
EXPLAIN SELECT * FROM orders
WHERE status = 'paid' AND type = 'online' AND created_at > '2026-01-01';
```
```
+----+------+---------------+---------+------------------+---------+------+----------+-------+
| id | type | possible_keys | key | ref | key_len | rows | filtered | Extra |
+----+------+---------------+---------+------------------+---------+------+----------+-------+
| 1 | ref | idx_stc | idx_stc | const,const | ... | 42 | 100.00 | NULL |
+----+------+---------------+---------+------------------+---------+------+----------+-------+
-- type=ref:等值列全部命中,范围扫描在最后一步,filtered=100%!
```
> [!TIP] 联合索引列序黄金法则
> 1. **等值优先于范围**:等值列放在前面
> 2. **选择性高的列靠前**:区分度大的列(如 user_id)比区分度小的(如 gender)靠前
> 3. **范围列放在最后**:让前面的等值列尽量多命中,范围列最后扫描
## 索引失效的典型场景
以下 5 个场景都会导致索引失效,逐一排查可解决大部分问题。
> [!WARNING] 场景 1:隐式类型转换
> ```sql
> ALTER TABLE users ADD INDEX idx_phone (phone); -- phone 是 VARCHAR 类型
> SELECT * FROM users WHERE phone = 13800138000; -- 传入 INT!
> ```
> MySQL 会把 varchar 列隐式转换为 int 再比较——函数作用于列,索引失效。
>
> **解决**:传入字符串 `WHERE phone = '13800138000'`。
> [!WARNING] 场景 2:函数/表达式包裹索引列
> ```sql
> SELECT * FROM users WHERE YEAR(created_at) = 2026;
> ```
> 索引列被函数包裹后,B+ Tree 无法直接定位。
>
> **解决**:改为范围查询 `WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'`。
> [!WARNING] 场景 3:LIKE 以通配符开头
> ```sql
> SELECT * FROM users WHERE name LIKE '%abc%';
> ```
> 前缀未知,无法利用索引的有序性定位。
>
> **解决**:使用前缀匹配 `LIKE 'abc%'`,或考虑全文索引。
> [!WARNING] 场景 4:OR 条件中有列没有索引
> ```sql
> SELECT * FROM users WHERE email = 'a@x.com' OR phone = '138...';
> ```
> 如果 email 有索引但 phone 没有,整个查询不走索引。
>
> **解决**:给 phone 加索引,或用 `UNION ALL` 拆分为两个独立查询。
> [!WARNING] 场景 5:字符集不一致导致隐式转换
> ```sql
> -- 表 charset=utf8mb4,客户端 charset=gbk → 自动转换
> ```
> 跨字符集连接时,MySQL 需要逐行转换再比较,索引失效。
>
> **解决**:确保连接字符集一致 `SET NAMES utf8mb4`。
### 索引失效决策图
```mermaid
flowchart TD
Q0{"查询条件是否包含索引最左列?"}
Q1{"最左列是否被函数/表达式包裹?"}
Q2{"列类型与传入值类型是否一致?"}
Q3{"OR 条件中所有列是否都有索引?"}
Q4{"字符集是否一致?"}
OK["✅ 索引生效"]
FAIL["❌ 索引失效,排查修复"]
Q0 -->|是| Q1
Q0 -->|否| FAIL
Q1 -->|否| Q2
Q1 -->|是| FAIL
Q2 -->|是| Q3
Q2 -->|否| FAIL
Q3 -->|是| Q4
Q3 -->|否| FAIL
Q4 -->|是| OK
Q4 -->|否| FAIL
style OK fill:#00D866,color:#fff
style FAIL fill:#EE5A24,color:#fff
```
## 最左前缀的灵活应用
一个设计良好的联合索引可以**同时服务 WHERE、ORDER BY、GROUP BY 三种需求**。
```sql
-- 建索引:idx(status, type, created_at)
CREATE INDEX idx_status_type_time ON orders (status, type, created_at);
-- ── 查询 1:等值 + 范围 ──
SELECT * FROM orders WHERE status = 'pending'
AND created_at > '2026-01-01';
-- → status 等值定位起点,created_at 范围扫描。type 虽在中间但没用到,不影响前两列生效
-- ── 查询 2:利用 ORDER BY ──
SELECT * FROM orders WHERE status = 'pending'
ORDER BY type ASC;
-- → WHERE 条件锁定了 status='pending' 的子区间,该区间内数据天然按 type 排序
-- → 无需额外 filesort!
-- ── 查询 3:利用 GROUP BY ──
SELECT type, COUNT(*) FROM orders WHERE status = 'pending'
GROUP BY type;
-- → 同上,子区间内 type 已有序,分组可以直接跳过
```
### Go 中利用索引的查询设计
在 Go 后端中,构建查询时应当有意识地按照索引列序组织条件:
```go
// 按索引列序拼接查询条件,确保每个条件都能命中 idx_stc(status, type, created_at)
func buildOrderQuery(status string, orderType string, since time.Time) (string, []any) {
conditions := []string{"1=1"}
args := []any{}
if status != "" {
conditions = append(conditions, "status = ?")
args = append(args, status)
}
if orderType != "" {
conditions = append(conditions, "type = ?")
args = append(args, orderType)
}
if !since.IsZero() {
conditions = append(conditions, "created_at > ?") // 范围列放最后
args = append(args, since)
}
query := "SELECT * FROM orders WHERE " + strings.Join(conditions, " AND ")
return query, args
}
```
> [!NOTE] 关键理解
> ORDER BY / GROUP BY 能复用联合索引,靠的不是"巧合",而是 B+ Tree **叶子节点本身有序**这一物理特性。只要 WHERE 过滤条件匹配了联合索引的左侧列,剩余列就是有序的——优化器只是利用了已有的顺序,并没有多做一次排序。
> [!TIP] 一索引多用
> 一个好的联合索引可以同时服务 WHERE、ORDER BY、GROUP BY 三种需求。在设计索引时要考虑查询的整体模式,而不是单一查询。
## 联合索引列序决策表
| 查询模式 | 列序建议 | 示例索引 | 说明 |
|----------|----------|----------|------|
| 等值 + 等值 | 高选择性列在前 | `idx(user_id, status)` | 两个等值条件都能走 ref |
| 等值 + 范围 | 等值列在前,范围列最后 | `idx(status, created_at)` | 等值定位起点,范围最后扫描 |
| 等值 + 等值 + 范围 | 等值列全在前,范围列最后 | `idx(status, type, created_at)` | 让尽量多的等值列命中 |
| 等值 + ORDER BY | 等值列 → 排序列 | `idx(status, type)` | 排序列可复用索引有序性 |
| 等值 + GROUP BY | 同 ORDER BY | `idx(status, type)` | 分组复用索引有序性 |
| 范围 + 范围 | 选择性高的范围列在前 | `idx(created_at, amount)` | 两个范围条件,只能一个走索引 |
> [!QUESTION] 什么时候该拆索引?
> 当两种查询模式的列序**互相矛盾**时(如一个需要 `(a, b, c)`,另一个需要 `(b, a, c)`),与其在同一个索引上做取舍,不如创建两个独立索引,让每个索引专注于它的典型查询模式。但要注意——索引越多,写入开销越大,需要在读写之间找到平衡。
## 关联笔记
- [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] — 聚簇索引本身就是特殊的联合索引 (PK)
- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 用 EXPLAIN 验证联合索引是否按预期生效
- [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — 如何从 slow log 中识别索引未命中
- [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] — 将失效的查询改写为可利用索引的形式