--- 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-查询改写技巧]] — 将失效的查询改写为可利用索引的形式