MySQL 联合索引最左匹配原则

发布时间:2026/6/27 2:52:08
MySQL 联合索引最左匹配原则 ## 一、先看一个真实的慢查询事故某电商公司订单表 order_tab数据量 800 万行。有个常见查询按**用户 ID 下单时间**查订单列表。sqlSELECT * FROM order_tabWHERE user_id 10086ORDER BY create_time DESCLIMIT 10;DBA 建了联合索引 (user_id, create_time)自认为完美。结果大促期间这条 SQL 执行耗时 **3.8 秒**数据库 CPU 飙到 90%。EXPLAIN 一看**Using filesort**。DBA 懵了“不是最左匹配了吗user_id 相等create_time 有序怎么还 filesort”**问题出在哪——他对“最左匹配”的理解只有 50 分。**---## 二、90% 的人对“最左匹配”的认知只到第一层绝大多数人对最左匹配原则的理解是 “查询条件必须从联合索引的最左列开始不能跳过中间的列。”于是遇到 WHERE user_id ? ORDER BY create_time 时心想user_id 在最左边create_time 在第二位没跳过索引肯定用上了。**但这个理解太粗了。** 最左匹配原则的精髓在于 **索引的有序性是“从左到右”层层递进的。** 只有在最左列确定的情况下第二列才是有序的如果最左列都不确定第二列的顺序对数据库来说毫无意义。我们来拆解 B 树结构。---## 三、B 树里到底怎么排序的联合索引 (user_id, create_time) 在 B 树中的排序规则是**先按 user_id 升序排列 → 同一 user_id 内再按 create_time 升序排列。**用伪数据表示索引键的排列顺序(1, 2026-01-01)(1, 2026-01-05)(1, 2026-01-10)(2, 2026-01-02)(2, 2026-01-08)(2, 2026-01-15)(3, 2026-01-03)(3, 2026-01-07)看到了吗**create_time 的有序性是建立在“user_id 相同”这个前提下的。** 一旦跨 user_idcreate_time 就乱序了1 的 1 月 10 日后面是 2 的 1 月 2 日时间倒退了。---## 四、回到事故为什么 filesort 了查询是 WHERE user_id 10086 ORDER BY create_time DESC。走索引 (user_id, create_time) 时流程是这样的1. MySQL 通过 B 树定位到 user_id 10086 的起始位置2. 在 user_id 10086 这个范围内create_time 是**升序**排列的1月1日 → 1月5日 → 1月10日3. 但查询要求 ORDER BY create_time DESC也就是**降序**问题来了**索引能正向遍历但反向遍历的性能很差。**InnoDB 的 B 树叶子节点是**双向链表**理论上可以反向遍历。但这里真正的性能杀手是 如果 ORDER BY 的方向和索引排序方向不一致MySQL 会把所有满足 user_id 10086 的数据全部读出来在内存中重新排序filesort然后取前 10 条。注意**如果 user_id 10086 有 10 万条订单就要先读 10 万条排序再取 10 条。** 这就是 3.8 秒的根源。---## 五、不只是方向问题还有“范围查询”这个大坑再看一个更隐蔽的场景sqlSELECT * FROM order_tabWHERE user_id 10086AND create_time 2026-01-01ORDER BY create_time;这次方向对了但 create_time 用了**范围查询**。索引 (user_id, create_time) 是怎么走的- user_id 10086 → 精准匹配走索引没问题- create_time 2026-01-01 → 范围扫描**关键在于一旦某一列用了范围查询该列之后的索引列就“失效”了。**假设索引是 (user_id, create_time, order_status)查询是sqlWHERE user_id 10086AND create_time 2026-01-01AND order_status 1索引能用到哪一列| 索引列 | 能否用上 | 原因 ||--------|---------|------|| user_id | ✅ 精确匹配 | 最左列等值查询 || create_time | ⚠️ 部分用到 | 范围条件能过滤但消耗了“有序性” || order_status | ❌ 失效 | 前一列是范围查询order_status 在索引中不再有序 |这就是所谓的**“范围查询阻断后续索引列”**。---## 六、那到底怎么设计联合索引### 核心原则等值查询放左边范围查询放右边| 查询类型 | 索引列顺序建议 ||---------|--------------|| WHERE a ? AND b ? AND c ? | (a, b, c) —— a、b 等值放前c 范围放后 || WHERE a ? AND b ? AND c ? | (a, c, b) —— 把 c 移到 b 前面因为 b 是范围 |### 排序方向要一致| 查询 | 索引 ||------|------|| ORDER BY a ASC, b ASC | (a ASC, b ASC) 或 (a, b) || ORDER BY a ASC, b DESC | (a ASC, b DESC) —— 在 MySQL 8.0 开始支持降序索引 || WHERE a ? ORDER BY b DESC | (a, b DESC) —— 降序索引可以避免 filesort |### 优先考虑“过滤性”高的列放在最左索引最左列的选择直接影响索引的筛选效率。比如 user_id 的区分度远高于 status就把 user_id 放最左。---## 七、回到事故怎么修方案一**改索引为 (user_id, create_time DESC)**sqlALTER TABLE order_tab ADD INDEX idx_user_time_desc (user_id, create_time DESC);MySQL 8.0 支持降序索引后ORDER BY create_time DESC 可以直接用索引顺序无需 filesort。方案二**不改索引改 SQL**sql-- 改成升序取业务层再反转SELECT * FROM order_tabWHERE user_id 10086ORDER BY create_time ASCLIMIT 10;然后业务代码里反转结果集。缺点是 LIMIT 10 取的是最早的 10 条不是最新的 10 条语义不对。此方案仅适用于特定场景。方案三最优**索引 覆盖索引 延迟关联**如果只需要返回 10 条先只查主键 ID再回表sqlSELECT t.* FROM order_tab tINNER JOIN (SELECT id FROM order_tabWHERE user_id 10086ORDER BY create_time DESCLIMIT 10) tmp ON t.id tmp.idORDER BY t.create_time DESC;配合索引 (user_id, create_time, id) 形成覆盖索引子查询在索引中完成避免回表和 filesort。最终采用方案一 方案三组合优化后查询耗时从 **3.8 秒降到 15 毫秒**。---## 八、验证方法EXPLAIN 怎么看执行 EXPLAIN 后重点关注三个字段| 字段 | 期望值 | 含义 ||------|--------|------|| key | 你建的索引名 | 表示走没走索引 || key_len | 尽可能大 | 算出实际用了几列索引详见下文 || Extra | 无 Using filesort、无 Using temporary | 额外开销标识 |### key_len 推算索引使用列数比如索引 (user_id INT, create_time DATETIME, status TINYINT)- INT4 字节- DATETIME5 字节MySQL 5.6- TINYINT1 字节- 再加上变长字段长度标记和 NULL 标记如果 key_len 4说明只用到了 user_id 一列。如果 key_len 9说明用到了 user_id create_time 两列。通过 key_len 可以精准判断索引到底用了几列验证最左匹配是否真正生效。---## 九、一张图总结全文联合索引 (a, b, c)│▼┌──────────────────────┐│ a 列全局有序 ││ b 列在 a 确定时有序 ││ c 列在 a,b 确定时有续 │└──────────────────────┘│┌─────────────────┼─────────────────┐▼ ▼ ▼a 等值 a 范围 a 等值b 等值 b 列失效 b 范围c 等值 (索引到此阻断) c 列失效✅ 全用上 (索引到此阻断)---