MySQL 联合索引最左匹配原则

一、先看一个真实的慢查询事故

某电商公司订单表 `order_tab`,数据量 800 万行。有个常见查询:按**用户 ID + 下单时间**查订单列表。

```sql

SELECT * FROM order_tab

WHERE user_id = 10086

ORDER BY create_time DESC

LIMIT 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_id,create_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 秒的根源。


五、不只是方向问题,还有"范围查询"这个大坑

再看一个更隐蔽的场景:

```sql

SELECT * FROM order_tab

WHERE user_id = 10086

AND create_time > '2026-01-01'

ORDER BY create_time;

```

这次方向对了,但 `create_time` 用了**范围查询**(`>`)。

索引 `(user_id, create_time)` 是怎么走的?

  • `user_id = 10086` → 精准匹配,走索引,没问题

  • `create_time > '2026-01-01'` → 范围扫描

**关键在于:一旦某一列用了范围查询,该列之后的索引列就"失效"了。**

假设索引是 `(user_id, create_time, order_status)`,查询是:

```sql

WHERE user_id = 10086

AND create_time > '2026-01-01'

AND 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)`**

```sql

ALTER 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_tab

WHERE user_id = 10086

ORDER BY create_time ASC

LIMIT 10;

```

然后业务代码里反转结果集。缺点是 LIMIT 10 取的是最早的 10 条,不是最新的 10 条,语义不对。此方案仅适用于特定场景。

方案三(最优):**索引 + 覆盖索引 + 延迟关联**

如果只需要返回 10 条,先只查主键 ID,再回表:

```sql

SELECT t.* FROM order_tab t

INNER JOIN (

SELECT id FROM order_tab

WHERE user_id = 10086

ORDER BY create_time DESC

LIMIT 10

) tmp ON t.id = tmp.id

ORDER 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)`:

  • INT:4 字节

  • DATETIME:5 字节(MySQL 5.6+)

  • TINYINT:1 字节

  • 再加上变长字段长度标记和 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 列失效

✅ 全用上 (索引到此阻断)

```---