一、先看一个真实的慢查询事故
某电商公司订单表 `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)` 时,流程是这样的:
-
MySQL 通过 B+ 树定位到 `user_id = 10086` 的起始位置
-
在 `user_id = 10086` 这个范围内,`create_time` 是**升序**排列的(1月1日 → 1月5日 → 1月10日)
-
但查询要求 `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
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 列失效
✅ 全用上 (索引到此阻断)
```---