--- tags: [MySQL, 联合索引, 最左前缀, 索引失效] create time: 2026-05-16 00:00 --- # 联合索引与最左前缀 ## 概述 联合索引(Composite Index)是将多个列放在同一个 B+ Tree 中组织的索引。它的核心法则是**最左前缀匹配原则**——理解这一点就能避开 80% 的索引设计失误。 ## 联合索引的结构 ``` 联合索引 idx(a, b, c) 的 B+ Tree 结构: 叶子节点按 a 排序,a 相同时按 b 排序,a 和 b 都相同时按 c 排序: [a=1, b=10, c=3] → data [a=1, b=10, c=7] → data [a=1, b=20, c=1] → data [a=1, b=20, c=9] → data [a=2, b=5, c=2] → data [a=2, b=15, c=4] → data [a=3, b=10, c=1] → data [a=3, b=10, c=8] → data [a=3, b=30, c=5] → data ``` > [!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 单独查不了? ``` 查询 WHERE b=2 时,B+ Tree 的搜索路径是什么样的? [b=20, c=1] 的位置在 [b=10, c=7] 之后,但它们之间穿插着 [b=5, c=2]... 数据在整个树中分散存放,没有连续的 b=2 区域 → 需要全树扫描 而 WHERE a=1 则不同: [a=1, ...] 的所有记录都在树的左侧一段连续区域内 → 二分查找即可定位 ``` ## 范围查询后的断裂 联合索引遇到范围查询(`>`, `<`, `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] 为什么范围查询会打断后续列? > 想象一下,当 `status='paid'` 锁定一个子树后,该子树内部按 `created_at` 升序排列。一旦用 `>` 做了范围扫描,得到的是一个 `created_at` 连续递增的数据段——这个段内的 `type` 值是乱序穿插的,无法再用二分查找定位 `type='online'`。 ```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); -- 让每个索引专注于它的典型查询模式 ``` > [!TIP] 联合索引列序黄金法则 > 1. **等值优先于范围**:等值列放在前面 > 2. **选择性高的列靠前**:区分度大的列(如 user_id)比区分度小的(如 gender)靠前 > 3. **前缀长度要短**:如果需要 LIKE,尽量用较短的前缀 ## 索引失效的典型场景 ```sql -- ❌ 场景 1:隐式类型转换 ALTER TABLE users ADD INDEX idx_phone (phone); -- phone 是 VARCHAR 类型 SELECT * FROM users WHERE phone = 13800138000; -- 传入 INT! -- MySQL 会把 varchar 转成 int 再比较 → 函数作用于列 → 索引失效 -- ❌ 场景 2:函数/表达式包裹索引列 SELECT * FROM users WHERE YEAR(created_at) = 2026; -- 应对:改为范围查询 WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01' -- ❌ 场景 3:LIKE 以通配符开头 SELECT * FROM users WHERE name LIKE '%abc%'; -- 应对:考虑全文索引 FT -- ❌ 场景 4:OR 条件中有列没有索引 SELECT * FROM users WHERE email = 'a@x.com' OR phone = '138...'; -- 如果 email 有索引但 phone 没有,整个查询不走索引 -- 应对:给 phone 加索引,或用 UNION ALL 拆分 -- ❌ 场景 5:字符集不一致导致隐式转换 -- 表 charset=utf8mb4,客户端 charset=gbk → 自动转换 -- 应对:确保连接字符集一致 SET NAMES utf8mb4 ``` ## 最左前缀的灵活应用 一个设计良好的联合索引可以**同时服务 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 已有序,分组可以直接跳过 ``` > [!NOTE] 关键理解 > ORDER BY / GROUP BY 能复用联合索引,靠的不是"巧合",而是 B+ Tree **叶子节点本身有序**这一物理特性。只要 WHERE 过滤条件匹配了联合索引的左侧列,剩余列就是有序的——优化器只是利用了已有的顺序,并没有多做一次排序。 > [!TIP] 一索引多用 > 一个好的联合索引可以同时服务 WHERE、ORDER BY、GROUP BY 三种需求。在设计索引时要考虑查询的整体模式,而不是单一查询。 ## 关联笔记 - [[hhs/MySQL/17-聚簇索引与二级索引]] — 聚簇索引本身就是特殊的联合索引 (PK) - [[hhs/MySQL/19-EXPLAIN 完全指南]] — 用 EXPLAIN 验证联合索引是否按预期生效 - [[hhs/MySQL/20-慢查询日志分析]] — 如何从 slow log 中识别索引未命中 - [[hhs/MySQL/21-查询改写技巧]] — 将失效的查询改写为可利用索引的形式