vault backup: 2026-05-21 12:20:54
This commit is contained in:
@@ -132,7 +132,31 @@ sequenceDiagram
|
||||
|
||||
### 覆盖索引(Covering Index)
|
||||
|
||||
当查询需要的数据全部存在于某个索引的 B+ Tree 中时,就不需要再走聚簇索引了——这叫 **Index Only Scan**,在 `EXPLAIN` 中显示为 `Using index`。
|
||||
什么是**回表**?二级索引的叶子节点只存了**索引列 + 主键**,不存完整行数据。当查询需要的列不在索引中时,MySQL 必须拿着主键再去聚簇索引里找完整行——这个"再跳一次"的过程就叫回表(详见下方 [[#聚簇索引 vs 二级索引]])。每回表一次就是一次**随机 IO**,匹配 1000 行就是 1000 次随机跳转,代价非常高。
|
||||
|
||||
**覆盖索引的核心思想**:如果查询所需的**所有列**都存在于某个索引的叶子节点中,就省掉回表这一步——直接从二级索引中把数据读完。这在 `EXPLAIN` 中显示为 `Using index`,也叫 **Index Only Scan**。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "回表路径(普通查询)"
|
||||
A1["二级索引<br/>叶子节点"] -->|"拿到主键 id"| A2["聚簇索引<br/>再次定位"]
|
||||
A2 -->|"随机 IO"| A3["完整行数据"]
|
||||
end
|
||||
|
||||
subgraph "覆盖索引路径(Index Only Scan)"
|
||||
B1["二级索引<br/>叶子节点"] -->|"所有列都在这里"| B2["直接返回"]
|
||||
end
|
||||
|
||||
style A1 fill:#FF9F43,color:#000
|
||||
style A2 fill:#EE5A24,color:#fff
|
||||
style A3 fill:#FF9F43,color:#000
|
||||
style B1 fill:#00D866,color:#fff
|
||||
style B2 fill:#00D866,color:#fff
|
||||
```
|
||||
|
||||
> [!QUESTION] 为什么避免回表这么重要?
|
||||
> 回表是**随机 IO**,而顺序 IO 和随机 IO 的性能差距是 **百倍到千倍** 级别(机械磁盘尤其明显,SSD 也有 10~50 倍差距)。
|
||||
> 当匹配行数较多时,覆盖索引的收益会非常显著。
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
@@ -145,16 +169,39 @@ CREATE TABLE users (
|
||||
|
||||
-- ❌ 必须回表:email 在索引里,但 name 不在
|
||||
SELECT name, email FROM users WHERE email = 'test@example.com';
|
||||
-- ① 走 idx_email 找到 id → ② 回聚簇索引拿 name
|
||||
-- ① 走 idx_email 找到 id → ② 回聚簇索引拿 name(随机 IO)
|
||||
|
||||
-- ✅ 覆盖索引:不需要回表!
|
||||
SELECT email FROM users WHERE email = 'test@example.com';
|
||||
-- 只需从 idx_email 叶子节点直接拿到 email
|
||||
-- 只需从 idx_email 叶子节点直接拿到 email,零回表
|
||||
```
|
||||
|
||||
> [!NOTE] Covering Index 的判断方法
|
||||
> - `Extra = Using index`(无 `Using where`)→ 完整覆盖,零回表
|
||||
> - `Extra = Using index condition` → 索引下推(ICP),部分过滤在索引层面完成
|
||||
#### 用联合索引构造覆盖索引
|
||||
|
||||
实际业务中,单列索引很难做到覆盖——查询通常需要多个字段。更常见的做法是**通过联合索引把查询涉及的列"包"进去**:
|
||||
|
||||
```sql
|
||||
-- 高频查询:根据 user_id 查该用户已完成的订单金额
|
||||
SELECT order_id, amount
|
||||
FROM orders
|
||||
WHERE user_id = 100 AND status = 'paid';
|
||||
|
||||
-- 方案 A:索引只有 (user_id) → 必须回表拿 status、order_id、amount
|
||||
-- 方案 B:联合索引 (user_id, status, order_id, amount)
|
||||
-- 叶子节点存了全部所需列 → 零回表 ✅
|
||||
ALTER TABLE orders ADD INDEX idx_user_cover(user_id, status, order_id, amount);
|
||||
```
|
||||
|
||||
> [!NOTE] 设计覆盖索引的思路
|
||||
> 1. **先看高频查询**:`SELECT` 了哪些列?`WHERE` 和 `ORDER BY` 涉及哪些列?
|
||||
> 2. **把 WHERE + ORDER BY + SELECT 涉及的列**按合理顺序放入联合索引
|
||||
> 3. 等值列在前、范围列在后(最左前缀原则),`SELECT` 中无法参与过滤的列放最后
|
||||
>
|
||||
> 代价是索引更宽、占用更多空间、写入维护成本更高——需要权衡。
|
||||
|
||||
> [!NOTE] EXPLAIN 中怎么判断覆盖索引?
|
||||
> - `Extra = Using index` → 完整覆盖,零回表
|
||||
> - `Extra = Using index condition` → 索引下推(ICP),部分过滤在索引层面完成,但仍需回表
|
||||
> - Extra 中**没有** `Using index` → 触发了回表
|
||||
>
|
||||
> 💡 更多细节(回表代价、ICP 原理、空间对比)详见 [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]]
|
||||
|
||||
@@ -7,7 +7,13 @@ create time: 2026-05-20 14:00
|
||||
|
||||
## 概述
|
||||
|
||||
InnoDB 使用 B+ Tree 组织所有索引,但根据「索引叶子节点存什么」的不同,分为两类:**聚簇索引**和**二级索引**。理解这两者的差异,是你设计高效查询和执行计划的起点。
|
||||
InnoDB 使用 B+ Tree 组织所有索引,但根据「索引叶子节点存什么」的不同,分为两类:**聚簇索引**和**二级索引**。
|
||||
|
||||
打个比方:如果把数据库表想象成一本通讯录——
|
||||
- **聚簇索引**就是通讯录正文本身:按姓名排序,每个条目后面直接写上了电话、地址等所有信息。
|
||||
- **二级索引**就像附录的索引卡片:只写了「姓名 → 第几页」,想看完整信息还得翻回正文。
|
||||
|
||||
理解这两者的差异,是你设计高效查询和执行计划的起点。
|
||||
|
||||
为了方便说明,本文档将始终使用以下 `users` 表作为示例:
|
||||
|
||||
@@ -24,8 +30,8 @@ CREATE TABLE users (
|
||||
```
|
||||
|
||||
这张表中:
|
||||
- **聚簇索引**:`id`(PRIMARY KEY)
|
||||
- **二级索引**:`idx_email`(email)、`idx_status_created`(status, created_at)
|
||||
- **聚簇索引**:`id`(PRIMARY KEY)—— 因为 InnoDB 的规则是:谁是 PRIMARY KEY,谁就是聚簇索引。你可以这样理解:InnoDB **根本不单独存"表数据"**,它把整张表的数据直接塞进了以 `id` 为键的 B+ Tree 的叶子节点里。所以 `id` 这棵 B+ Tree 既是索引,也是数据本身。(后面会详细展开这条规则。)
|
||||
- **二级索引**:`idx_email`(email)、`idx_status_created`(status, created_at) —— 这两个索引的叶子节点里不存完整行数据,只存「索引列的值 + `id`」,所以叫"二级":它们是依附于聚簇索引的"小抄",查到 `id` 后还得回聚簇索引里拿完整数据。
|
||||
|
||||
> [!QUESTION] 思考
|
||||
> 假设一张用户表按 `id` 排序存储在磁盘上——
|
||||
@@ -69,7 +75,9 @@ block-beta
|
||||
|
||||
## 聚簇索引(Clustered Index)
|
||||
|
||||
聚簇索引就是数据本身。InnoDB 表中**只能有一个聚簇索引**。
|
||||
"聚簇"这个词听起来很抽象,但意思很简单:**数据按照索引的顺序"聚集"在一起存储**。在 InnoDB 里,聚簇索引的叶子节点不是"指向"数据,叶子节点**就是**数据——整行记录都直接存在里面。
|
||||
|
||||
正因为数据只有一份,所以一张表**只能有一个**聚簇索引。
|
||||
|
||||
### 聚簇索引的形成规则
|
||||
|
||||
@@ -89,6 +97,8 @@ block-beta
|
||||
|
||||
### 聚簇索引的特性
|
||||
|
||||
数据按主键顺序存储带来了两个直接好处:写入快(顺序追加)和范围查询快(连续扫描):
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["按主键排序存储数据"] --> B["数据页紧凑排列"]
|
||||
@@ -109,6 +119,15 @@ flowchart TD
|
||||
|
||||
除了聚簇索引外的所有索引都是二级索引。叶子节点存储的是:**索引列的值 + 主键值**。
|
||||
|
||||
> [!QUESTION] 为什么二级索引要存主键?
|
||||
> 因为二级索引本身没有完整数据,但它知道"这条记录在哪"——通过主键就能回到聚簇索引里找到完整行。主键就是二级索引回聚簇索引的"导航坐标"。
|
||||
>
|
||||
> 整个过程分两步:
|
||||
> 1. 在二级索引里找到匹配条件的记录,拿到主键 `id`
|
||||
> 2. 拿着 `id` 去聚簇索引里找完整行数据
|
||||
>
|
||||
> 这个"从二级索引跳回聚簇索引取数据"的过程,就叫 **回表**。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "二级索引页<br/>idx_status_created = (status, created_at)"
|
||||
@@ -131,6 +150,8 @@ flowchart TD
|
||||
|
||||
## 回表的代价
|
||||
|
||||
刚才说了回表是"跳回聚簇索引取数据",但这个跳转是有代价的——它意味着额外的磁盘 I/O。下面这张图展示了具体过程:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as 应用层
|
||||
@@ -152,7 +173,9 @@ sequenceDiagram
|
||||
|
||||
### 减少回表的策略(Covering Index)
|
||||
|
||||
要减少回表,最直接的方法是让查询**完全在二级索引中完成**——这就是 Covering Index。
|
||||
回表既然是因为二级索引里"信息不够"才跳回聚簇索引,那最直接的解决办法就是:**让二级索引里包含查询需要的所有信息**,这样就不需要跳回去了。
|
||||
|
||||
这就是 Covering Index(覆盖索引)——索引"覆盖"了查询的全部需求,查完索引就够了,不用回表。
|
||||
|
||||
```sql
|
||||
-- ❌ 差:Covering Index 未命中,需要回表拿 name 字段
|
||||
@@ -220,11 +243,13 @@ flowchart TD
|
||||
```
|
||||
|
||||
> [!HIGHLIGHT] ICP 的本质
|
||||
> 把 **Server 层**的过滤条件(如 `created_at > ...`)**下推到 Handler 层**,在读取二级索引页时就完成判断,命中了才回表。
|
||||
> 用生活化的类比来说:以前的做法是,快递员(存储引擎)在仓库(二级索引)里找到 1000 个包裹,全部搬出来,再由前台(Server 层)一个一个拆开检查是不是客户要的。ICP 的做法是,直接告诉快递员"只搬 status=1 且 created_at 在这个日期之后的",在仓库里就筛掉了 950 个,只搬出 50 个。
|
||||
>
|
||||
> 技术上说,就是把过滤条件下推到存储引擎层,在读取二级索引页时就完成判断,命中了才回表。
|
||||
>
|
||||
> **适用条件:**
|
||||
> - 必须是二级索引(聚簇索引不走 ICP)
|
||||
> - 过滤条件中能利用到的部分必须在索引列范围内
|
||||
> - 必须是二级索引(聚簇索引本身已经是完整数据,不存在"下推"的问题)
|
||||
> - 过滤条件必须能利用到索引列(否则在索引里也没法判断)
|
||||
> - MySQL 5.6+ 默认开启,无需额外配置
|
||||
|
||||
```sql
|
||||
|
||||
@@ -9,23 +9,36 @@ create time: 2026-05-16 00:00
|
||||
|
||||
联合索引(Composite Index)是将多个列放在同一个 B+ Tree 中组织的索引。它的核心法则是**最左前缀匹配原则**——理解这一点就能避开 80% 的索引设计失误。
|
||||
|
||||
## 联合索引的结构
|
||||
## 从"字典查词"理解联合索引
|
||||
|
||||
```
|
||||
联合索引 idx(a, b, c) 的 B+ Tree 结构:
|
||||
翻开一本中文词典,里面的词条按照**拼音 → 笔画 → 部首**的顺序排列:
|
||||
|
||||
1. 先按拼音排序(a, b, c...)
|
||||
2. 拼音相同时,按笔画排序
|
||||
3. 笔画也相同时,按部首排序
|
||||
|
||||
你想查"爱"字,先翻到拼音 `ai` 的区域(第一列),再找 10 画的(第二列),最后精确定位(第三列)——这就是**最左前缀**的直觉。
|
||||
|
||||
反过来,如果有人问"10 画的字在第几页?"——你没法回答,因为 10 画的字**分散在每个拼音区**里,没有连续的区域可以翻到。
|
||||
|
||||
> [!QUESTION] 思考
|
||||
> 联合索引 `idx(a, b, c)` 就像这本字典。条件必须从第一列开始依次匹配,不能"跳着查"。下面我们来看看它的具体结构。
|
||||
|
||||
## 联合索引的结构
|
||||
|
||||
叶子节点按 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
|
||||
```
|
||||
| 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`。索引中的数据已经按照这个顺序排好了。
|
||||
@@ -63,16 +76,35 @@ flowchart TD
|
||||
|
||||
### 为什么 b=2 单独查不了?
|
||||
|
||||
```
|
||||
查询 WHERE b=2 时,B+ Tree 的搜索路径是什么样的?
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph SQ1["✅ WHERE a=1 二分查找定位"]
|
||||
direction LR
|
||||
A1["a=1 区域连续"] --> A2["直接定位起始行"] --> A3["顺序扫描即可"]
|
||||
end
|
||||
|
||||
[b=20, c=1] 的位置在 [b=10, c=7] 之后,但它们之间穿插着 [b=5, c=2]...
|
||||
数据在整个树中分散存放,没有连续的 b=2 区域 → 需要全树扫描
|
||||
subgraph SQ2["❌ WHERE b=2 需全表扫描"]
|
||||
direction LR
|
||||
B1["b=10 在 a=1 下"] --> B4["b=10 在 a=3 下"] --> B2["b=2 散落各处"] --> B3["无法定位起点"]
|
||||
end
|
||||
|
||||
而 WHERE a=1 则不同:
|
||||
[a=1, ...] 的所有记录都在树的左侧一段连续区域内 → 二分查找即可定位
|
||||
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%'`)后,右侧列的索引失效。
|
||||
@@ -91,14 +123,16 @@ SELECT * FROM orders WHERE status = 'paid'
|
||||
```
|
||||
|
||||
> [!QUESTION] 为什么范围查询会打断后续列?
|
||||
> 想象一下,当 `status='paid'` 锁定一个子树后,该子树内部按 `created_at` 升序排列。一旦用 `>` 做了范围扫描,得到的是一个 `created_at` 连续递增的数据段——这个段内的 `type` 值是乱序穿插的,无法再用二分查找定位 `type='online'`。
|
||||
> 一个生活类比:联合索引就像一本按**省份 → 城市 → 区县**排序的通讯录。你可以先翻到"广东省"(等值),再在里面翻"深圳市"(等值),最后找"南山区"——很快。但如果你只知道"某个日期之后创建的订单"(范围),你得到的是一个连续但内部无序的数据段,就像拿到"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
|
||||
@@ -126,36 +160,115 @@ 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. **前缀长度要短**:如果需要 LIKE,尽量用较短的前缀
|
||||
> 3. **范围列放在最后**:让前面的等值列尽量多命中,范围列最后扫描
|
||||
|
||||
## 索引失效的典型场景
|
||||
|
||||
```sql
|
||||
-- ❌ 场景 1:隐式类型转换
|
||||
ALTER TABLE users ADD INDEX idx_phone (phone);
|
||||
-- phone 是 VARCHAR 类型
|
||||
SELECT * FROM users WHERE phone = 13800138000; -- 传入 INT!
|
||||
-- MySQL 会把 varchar 转成 int 再比较 → 函数作用于列 → 索引失效
|
||||
以下 5 个场景都会导致索引失效,逐一排查可解决大部分问题。
|
||||
|
||||
-- ❌ 场景 2:函数/表达式包裹索引列
|
||||
SELECT * FROM users WHERE YEAR(created_at) = 2026;
|
||||
-- 应对:改为范围查询 WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'
|
||||
> [!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'`。
|
||||
|
||||
-- ❌ 场景 3:LIKE 以通配符开头
|
||||
SELECT * FROM users WHERE name LIKE '%abc%';
|
||||
-- 应对:考虑全文索引 FT
|
||||
> [!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'`。
|
||||
|
||||
-- ❌ 场景 4:OR 条件中有列没有索引
|
||||
SELECT * FROM users WHERE email = 'a@x.com' OR phone = '138...';
|
||||
-- 如果 email 有索引但 phone 没有,整个查询不走索引
|
||||
-- 应对:给 phone 加索引,或用 UNION ALL 拆分
|
||||
> [!WARNING] 场景 3:LIKE 以通配符开头
|
||||
> ```sql
|
||||
> SELECT * FROM users WHERE name LIKE '%abc%';
|
||||
> ```
|
||||
> 前缀未知,无法利用索引的有序性定位。
|
||||
>
|
||||
> **解决**:使用前缀匹配 `LIKE 'abc%'`,或考虑全文索引。
|
||||
|
||||
-- ❌ 场景 5:字符集不一致导致隐式转换
|
||||
-- 表 charset=utf8mb4,客户端 charset=gbk → 自动转换
|
||||
-- 应对:确保连接字符集一致 SET NAMES utf8mb4
|
||||
> [!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
|
||||
```
|
||||
|
||||
## 最左前缀的灵活应用
|
||||
@@ -183,12 +296,53 @@ 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)
|
||||
|
||||
@@ -12,6 +12,17 @@ create time: 2026-05-16 00:00
|
||||
> [!QUESTION] 为什么不能直接靠"写了索引就一定能用到"?
|
||||
> 因为 MySQL 使用的是 **Cost-Based Optimizer (CBO)**:优化器会根据统计信息(行数、页大小、聚簇因子等)自行决定最优执行路径。有时优化器认为全表扫描比走索引更快(比如要查整张表 80% 的数据),这时即使有索引也不会使用。`EXPLAIN` 就是用来观察和优化器"想法"是否一致的镜子。
|
||||
|
||||
> [!TIP] EXPLAIN 输出的阅读顺序
|
||||
> 面对一屏 EXPLAIN 输出,可以按"从大到小"的优先级快速判断:
|
||||
> 1. **type** — 最快判断索引使用情况(ALL = 🔴 全表扫描)
|
||||
> 2. **key** — 确认实际命中的索引名
|
||||
> 3. **rows** — 预估扫描行数,决定是否需要优化
|
||||
> 4. **Extra** — 有没有 filesort / temporary 等额外开销
|
||||
> 5. **key_len** — 联合索引用了几列?只用了前缀?
|
||||
> 6. **ref** — 索引在跟什么做比较?
|
||||
>
|
||||
> 先扫 type + key 就能判断"这 SQL 有没有大问题",再看 rows + Extra 判断"问题有多严重"。
|
||||
|
||||
## 基本用法
|
||||
|
||||
```sql
|
||||
@@ -27,15 +38,65 @@ EXPLAIN FORMAT=JSON SELECT * FROM users WHERE email = 'a@x.com';
|
||||
EXPLAIN ANALYZE SELECT ...; -- MySQL 8.0.18+
|
||||
```
|
||||
|
||||
## 关键字段速查
|
||||
## 输出字段全览
|
||||
|
||||
| 字段 | 含义 | 关注点 |
|
||||
|------|------|--------|
|
||||
| **type** | JOIN 类型 | 是否走索引(ref/range 优,ALL 差)|
|
||||
| **key** | 实际使用的索引 | NULL = 没用索引 |
|
||||
| **rows** | 预估扫描行数 | 越小越好 |
|
||||
| **Extra** | 附加信息 | 重点关注 Using where/index/filesort/temporary |
|
||||
| **cost** | 预估成本(JSON 格式) | optimizer_cost 越低越好 |
|
||||
> [!NOTE] 核心思路
|
||||
> EXPLAIN 输出的每一行对应一张表的访问方式。多表 JOIN 时会输出多行,**id 相同时,行号越小(越靠上)越先执行**——第一行是驱动表,后续行是被驱动表。
|
||||
|
||||
| 字段 | 含义 | 重要程度 | 关注点 |
|
||||
|------|------|:--------:|--------|
|
||||
| **id** | 查询编号 | ⚪ 了解 | 相同 id = 同层查询,不同 id = 嵌套子查询 |
|
||||
| **select_type** | 查询类型 | 🟡 辅助 | SIMPLE/PRIMARY/SUBQUERY/DERIVED,判断复杂度 |
|
||||
| **table** | 访问的表 | ⚪ 了解 | 可能是别名或 `<derivedN>` 物化表 |
|
||||
| **type** | 访问类型 | 🔴 必看 | **索引使用的第一指标**(const > ref > range > ALL) |
|
||||
| **possible_keys** | 可能用到的索引 | 🟡 辅助 | NULL = 没有候选索引,需创建 |
|
||||
| **key** | 实际使用的索引 | 🔴 必看 | NULL = 优化器放弃索引,选了全表扫描 |
|
||||
| **key_len** | 索引使用字节数 | 🟡 辅助 | 判断联合索引是否被充分利用 |
|
||||
| **ref** | 索引比较对象 | 🟡 辅助 | const/列名/func,理解 JOIN 驱动关系 |
|
||||
| **rows** | 预估扫描行数 | 🔴 必看 | 越小越好,`rows × filtered%` ≈ 进入下一步的行数 |
|
||||
| **filtered** | WHERE 过滤率 (%) | 🟡 辅助 | 100% = 索引完全满足条件,越低说明回表后还要过滤 |
|
||||
| **Extra** | 附加信息 | 🔴 必看 | filesort/temporary = 🔴;Using index = 🟢 覆盖索引 |
|
||||
|
||||
## select_type:查询层级识别
|
||||
|
||||
`select_type` 告诉你这一行代表的是什么级别的查询——最外层、子查询、还是 UNION?
|
||||
|
||||
| select_type | 含义 | 典型场景 |
|
||||
|-------------|------|----------|
|
||||
| **SIMPLE** | 简单查询(无子查询/UNION) | `SELECT * FROM t WHERE id = 1` |
|
||||
| **PRIMARY** | 最外层查询 | 包含子查询时,外层标记为 PRIMARY |
|
||||
| **SUBQUERY** | SELECT 列表或 WHERE 中的子查询 | `WHERE id IN (SELECT ...)` |
|
||||
| **DEPENDENT SUBQUERY** | 依赖外层结果的关联子查询 | `WHERE EXISTS (SELECT ... FROM t2 WHERE t2.id = t1.id)` |
|
||||
| **DERIVED** | FROM 子句中的派生表 | `FROM (SELECT ...) AS tmp` |
|
||||
| **UNION** | UNION 中第二个及之后的 SELECT | `SELECT ... UNION SELECT ...` |
|
||||
| **MATERIALIZED** | 物化子查询(8.0+) | 优化器将子查询结果存入临时表 |
|
||||
|
||||
```sql
|
||||
-- 一个包含子查询的查询
|
||||
EXPLAIN
|
||||
SELECT u.username, order_count
|
||||
FROM users u
|
||||
JOIN (
|
||||
SELECT user_id, COUNT(*) AS order_count
|
||||
FROM orders
|
||||
GROUP BY user_id
|
||||
) AS oc ON u.id = oc.user_id
|
||||
WHERE u.id IN (SELECT user_id FROM vip_users);
|
||||
```
|
||||
|
||||
```
|
||||
+----+-------------+------------+--------+---------------+---------+---------+------+------+-------+
|
||||
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
|
||||
+----+-------------+------------+--------+---------------+---------+---------+------+------+-------+
|
||||
| 1 | PRIMARY | <derived2> | ALL | NULL | NULL | NULL | NULL | 1000 | | ← FROM 子句的派生表
|
||||
| 1 | PRIMARY | u | eq_ref | PRIMARY | PRIMARY | 4 | oc.user_id | 1 | |
|
||||
| 2 | DERIVED | orders | index | idx_user_id | idx_user_id | 4 | NULL | 100K | Using index |
|
||||
| 3 | SUBQUERY | vip_users | ALL | NULL | NULL | NULL | NULL | 500 | |
|
||||
+----+-------------+------------+--------+---------------+---------+---------+------+------+-------+
|
||||
```
|
||||
|
||||
> [!QUESTION] 为什么 DERIVED 表的 rows 是 1000?
|
||||
> 因为派生表 `oc` 是先物化到临时表的,MySQL 对物化表的行数统计通常不精确。这也是为什么**尽量避免在大表上使用 FROM 子查询**——物化过程本身就有开销。MySQL 8.0 的 Merged Derived Table 优化可以将简单的派生表"合并"回外层查询,减少物化。
|
||||
|
||||
## type 详解(最重要)
|
||||
|
||||
@@ -72,6 +133,90 @@ flowchart LR
|
||||
> [!WARNING] 不要盲目追求 ref 以上级别
|
||||
> `range` 在某些场景(如范围不大)完全可以接受。关键是看 `rows` 字段和 `Extra` 的组合。
|
||||
|
||||
## key_len:索引使用了多少?
|
||||
|
||||
`key_len` 表示 MySQL 在索引中实际使用的**字节数**。对于联合索引,它是判断"索引被用了几列"的最直接证据。
|
||||
|
||||
> [!QUESTION] 联合索引 `idx_a_b_c (a, b, c)`,EXPLAIN 显示 key_len = 8,用了几列?
|
||||
> 需要知道各列的数据类型才能算出来。key_len 的计算规则如下:
|
||||
|
||||
### 计算规则
|
||||
|
||||
| 类型 | 字节数 | 说明 |
|
||||
|------|--------|------|
|
||||
| `INT` | 4 | 固定 4 字节 |
|
||||
| `BIGINT` | 8 | 固定 8 字节 |
|
||||
| `CHAR(n)` | n × 字符集字节数 | utf8mb4 = 每字符 4 字节 |
|
||||
| `VARCHAR(n)` | n × 字符集字节数 + **2** | 额外 2 字节存长度 |
|
||||
| `DATE` | 3 | 固定 3 字节 |
|
||||
| `TIMESTAMP/DATETIME` | 5/8 | 固定长度 |
|
||||
| **NULLABLE 列** | +1 | 所有类型额外 +1 字节标记 NULL |
|
||||
|
||||
> [!TIP] 速算公式
|
||||
> `key_len = 各列字节数之和`,其中每列 = **类型基础长度 + (VARCHAR 额外 2) + (可 NULL 额外 1)**
|
||||
>
|
||||
> 反过来看:如果 key_len 比你预期的短,说明联合索引**只用到了前缀几列**。
|
||||
|
||||
### 实战计算
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id BIGINT PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL, -- utf8mb4
|
||||
age INT,
|
||||
email VARCHAR(128) DEFAULT NULL,
|
||||
INDEX idx_name_age_email (name, age, email)
|
||||
);
|
||||
```
|
||||
|
||||
| 使用方式 | key_len 计算 | 说明 |
|
||||
|----------|-------------|------|
|
||||
| `WHERE name = 'Tom'` | 64 × 4 + 2 = **258** | VARCHAR(64) utf8mb4,NOT NULL 无额外字节 |
|
||||
| `WHERE name = 'Tom' AND age = 20` | 258 + 4 = **262** | age 是 INT,NOT NULL |
|
||||
| `WHERE name = 'Tom' AND age = 20 AND email = 'a@b.c'` | 262 + 128 × 4 + 2 + 1 = **777** | email 可 NULL,额外 +1 |
|
||||
|
||||
```sql
|
||||
EXPLAIN SELECT * FROM users WHERE name = 'Tom' AND age = 20;
|
||||
-- key: idx_name_age_email, key_len: 262
|
||||
-- → 说明索引只用到了前 2 列(name + age),没用到 email
|
||||
```
|
||||
|
||||
> [!NOTE] 核心用法
|
||||
> 看到 `key_len` 后,对照表结构算一下"如果用满所有列应该是多少字节"。差得远 = 索引没充分利用,可能需要调整查询条件或索引顺序。
|
||||
|
||||
## ref 字段:索引在跟谁比?
|
||||
|
||||
`ref` 显示索引的查找值来自哪里——是常量、函数还是另一张表的列。
|
||||
|
||||
| ref 值 | 含义 | 示例 |
|
||||
|--------|------|------|
|
||||
| **const** | 与常量比较 | `WHERE id = 1` |
|
||||
| **func** | 与函数结果比较 | `WHERE created_at = NOW()` |
|
||||
| **db.table.column** | 与另一张表的列比较(JOIN) | `ON u.id = o.user_id` → ref 显示 `o.user_id` |
|
||||
| **NULL** | 无法使用等值比较 | range 扫描(如 `WHERE id > 100`)|
|
||||
|
||||
```sql
|
||||
EXPLAIN SELECT u.username, o.amount
|
||||
FROM users u
|
||||
JOIN orders o ON u.id = o.user_id
|
||||
WHERE o.status = 'pending';
|
||||
```
|
||||
|
||||
```
|
||||
+----+-------------+-------+------+---------------+--------------+---------+------------------+------+-------+
|
||||
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
|
||||
+----+-------------+-------+------+---------------+--------------+---------+------------------+------+-------+
|
||||
| 1 | SIMPLE | o | ref | idx_status | idx_status | 130 | const | 5000 | Using where |
|
||||
| 1 | SIMPLE | u | eq_ref | PRIMARY | PRIMARY | 8 | test.o.user_id | 1 | |
|
||||
+----+-------------+-------+------+---------------+--------------+---------+------------------+------+-------+
|
||||
```
|
||||
|
||||
- 第一行:`o` 表用 `idx_status` 索引,ref = `const`(`status = 'pending'` 是常量)
|
||||
- 第二行:`u` 表用主键,ref = `test.o.user_id`(被驱动表的列来自 `o.user_id`)
|
||||
|
||||
> [!TIP] ref 帮你理解 JOIN 驱动关系
|
||||
> 当 ref 显示另一张表的列名时,说明当前表是**被驱动表**。驱动表的行数(rows)越小,被驱动表的回表次数就越少——这就是为什么**小表驱动大表**的原则。
|
||||
|
||||
## Extra 关键字解读
|
||||
|
||||
> [!SUCCESS] Using index — 覆盖索引(Index Only Scan)
|
||||
@@ -85,6 +230,35 @@ flowchart LR
|
||||
> [!TIP] Using index condition — 索引下推(ICP)
|
||||
> MySQL 5.6+ 引入,在存储引擎层预过滤,减少回表次数 ✅
|
||||
|
||||
#### ICP 深入理解:下推 vs 不下推
|
||||
|
||||
```sql
|
||||
-- 假设有联合索引 idx_status_city (status, city)
|
||||
EXPLAIN SELECT * FROM users
|
||||
WHERE status = 'active' AND city LIKE '北京%';
|
||||
```
|
||||
|
||||
**Without ICP(MySQL 5.6 之前)**:存储引擎只用 `status` 过滤,拿到所有 active 用户的主键 → 逐行回表 → Server 层再过滤 `city LIKE '北京%'`。假设 active 用户有 10 万,就要回表 10 万次。
|
||||
|
||||
**With ICP(MySQL 5.6+)**:存储引擎在索引层**直接过滤** `city LIKE '北京%'`,只把满足两个条件的行回表。可能只回表 2000 次。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["存储引擎层<br/>idx_status_city"] --> B{"ICP 可用?"}
|
||||
|
||||
B -->|No ICP| C["只用 status 定位<br/>回表 100K 行"]
|
||||
C --> D["Server 层过滤 city<br/>丢弃 98K 行 ⚠️"]
|
||||
|
||||
B -->|With ICP| E["status 定位 + city 预过滤<br/>索引层过滤后只剩 2K 行"]
|
||||
E --> F["只回表 2K 行 ✅"]
|
||||
|
||||
style D fill:#EE5A24,color:#fff
|
||||
style F fill:#00D866,color:#fff
|
||||
```
|
||||
|
||||
> [!NOTE] ICP 的适用条件
|
||||
> ICP 只在**联合索引**且 WHERE 条件包含**索引列但不满足最左前缀**时才有意义。比如 `idx(a, b)`,`WHERE a > 1 AND b = 2` 中 `b = 2` 就是 ICP 下推的候选条件。如果只查 `WHERE a = 1`,ICP 无从发挥。
|
||||
|
||||
### 其他 Extra 信息速查
|
||||
|
||||
| 关键词 | 含义 | 处理 |
|
||||
@@ -151,37 +325,115 @@ ORDER BY created_at DESC;
|
||||
|
||||
## 实战案例分析
|
||||
|
||||
下面用一个完整的调优案例,把前面所有知识点串起来。
|
||||
|
||||
### 场景:订单列表页越来越慢
|
||||
|
||||
```sql
|
||||
-- 原始查询(慢)
|
||||
EXPLAIN SELECT u.username, o.amount
|
||||
FROM users u
|
||||
JOIN orders o ON u.id = o.user_id
|
||||
WHERE o.created_at >= '2026-01-01'
|
||||
ORDER BY o.created_at DESC
|
||||
-- 表结构
|
||||
CREATE TABLE orders (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
amount DECIMAL(10,2),
|
||||
created_at DATETIME NOT NULL,
|
||||
INDEX idx_user_id (user_id)
|
||||
) ENGINE=InnoDB;
|
||||
-- 表中有 100 万行数据
|
||||
|
||||
-- 慢查询:某用户最近的已支付订单
|
||||
EXPLAIN SELECT *
|
||||
FROM orders
|
||||
WHERE user_id = 42
|
||||
AND status = 'paid'
|
||||
AND created_at >= '2026-01-01'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
-- 典型 bad result:
|
||||
-- type: ALL, rows: 1000000, Extra: Using where; Using filesort; Using temporary
|
||||
-- → 全表扫描 + 临时表 + 文件排序
|
||||
### Step 1:看 type + key — 全表扫描?
|
||||
|
||||
-- 修复方案
|
||||
-- 1. 创建复合索引
|
||||
ALTER TABLE orders ADD INDEX idx_created_user (created_at, user_id);
|
||||
```
|
||||
+----+-------------+--------+------+---------------+------------+---------+-------+--------+---------------------------+
|
||||
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
|
||||
+----+-------------+--------+------+---------------+------------+---------+-------+--------+---------------------------+
|
||||
| 1 | SIMPLE | orders | ref | idx_user_id | idx_user_id| 8 | const | 98000 | Using where; Using filesort |
|
||||
+----+-------------+--------+------+---------------+------------+---------+-------+--------+---------------------------+
|
||||
```
|
||||
|
||||
-- 2. 查询改写(反向排序优化)
|
||||
-- 先用索引定位 top 20 的 pk,再 JOIN 拿数据
|
||||
SELECT u.username, o.amount
|
||||
FROM orders o
|
||||
JOIN users u ON u.id = o.user_id
|
||||
WHERE o.created_at >= '2026-01-01'
|
||||
ORDER BY o.created_at DESC
|
||||
- type = ref(走了索引 ✅),但 rows = 98000(user_id=42 有 9.8 万条订单,太多)
|
||||
- Extra = `Using where; Using filesort` 🔴 — 回表后还要过滤 status + created_at,再排序
|
||||
|
||||
> [!QUESTION] 问题出在哪?
|
||||
> `idx_user_id` 是单列索引,只帮你定位 user_id。`status` 和 `created_at` 的过滤都在回表后才做,9.8 万次回表 + filesort = 慢。
|
||||
|
||||
### Step 2:创建联合索引 — 关注 key_len
|
||||
|
||||
```sql
|
||||
ALTER TABLE orders ADD INDEX idx_uid_status_created (user_id, status, created_at);
|
||||
```
|
||||
|
||||
```sql
|
||||
EXPLAIN SELECT *
|
||||
FROM orders
|
||||
WHERE user_id = 42
|
||||
AND status = 'paid'
|
||||
AND created_at >= '2026-01-01'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
-- 新的 EXPLAIN:
|
||||
-- type: range on orders, ref on users
|
||||
-- key: idx_created_user
|
||||
-- Extra: Using index condition; Using where
|
||||
-- → 索引范围扫描 + 快速消除 filesort
|
||||
```
|
||||
+----+-------------+--------+-------+---------------------------------------------+-------------------------+---------+------+------+----+
|
||||
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
|
||||
+----+-------------+--------+-------+---------------------------------------------+-------------------------+---------+------+------+----+
|
||||
| 1 | SIMPLE | orders | range | idx_user_id, idx_uid_status_created | idx_uid_status_created | 97 | NULL | 1200 | Using where |
|
||||
+----+-------------+--------+-------+---------------------------------------------+-------------------------+---------+------+------+----+
|
||||
```
|
||||
|
||||
**逐字段分析**:
|
||||
|
||||
| 字段 | 变化 | 说明 |
|
||||
|------|------|------|
|
||||
| **type** | ref → range | 用 created_at 范围扫描,精确了 |
|
||||
| **key** | idx_user_id → idx_uid_status_created | 用上了新索引 ✅ |
|
||||
| **key_len** | 8 → 97 | 8 = 只用了 user_id(BIGINT);97 = user_id(8) + status(VARCHAR(20)×4+2+1=83) + created_at(DATETIME=5+1=6) → **三列全用上** ✅ |
|
||||
| **rows** | 98000 → 1200 | 索引直接过滤到 1200 行,减少了 **98.8%** |
|
||||
| **Extra** | Using filesort 消失 ✅ | created_at 在索引末尾,DESC 排序直接倒序扫描 |
|
||||
|
||||
> [!NOTE] 关键决策点
|
||||
> 这里 ORDER BY created_at DESC 恰好可以用到索引的倒序扫描(InnoDB 支持 Reverse Index Scan),所以 **filesort 消失了**。如果 ORDER BY 的列不在索引中,filesort 仍会出现。
|
||||
|
||||
### Step 3:EXPLAIN ANALYZE 确认
|
||||
|
||||
```sql
|
||||
EXPLAIN ANALYZE
|
||||
SELECT *
|
||||
FROM orders
|
||||
WHERE user_id = 42
|
||||
AND status = 'paid'
|
||||
AND created_at >= '2026-01-01'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
```
|
||||
-> Limit: 20 row(s)
|
||||
-> Index range scan on orders using idx_uid_status_created
|
||||
(actual time=0.089..0.142 rows=20 loops=1)
|
||||
```
|
||||
|
||||
- **rows=20**:只扫了 20 行就满足 LIMIT,配合索引的有序性,几乎零开销
|
||||
- **actual time < 0.2ms**:从秒级优化到了亚毫秒级 🚀
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Step 1<br/>rows: 98K<br/>filesort 🔴"] --> B["Step 2<br/>rows: 1.2K<br/>无 filesort ✅"]
|
||||
B --> C["Step 3<br/>rows: 20<br/>0.14ms 🚀"]
|
||||
|
||||
style A fill:#EE5A24,color:#fff
|
||||
style B fill:#FF9F43,color:#000
|
||||
style C fill:#00D866,color:#fff
|
||||
```
|
||||
|
||||
## EXPLAIN FORMAT=JSON 精读
|
||||
@@ -244,6 +496,39 @@ flowchart LR
|
||||
style D fill:#C44569,color:#fff
|
||||
```
|
||||
|
||||
## FORMAT=TREE:现代可读格式
|
||||
|
||||
MySQL 8.0.16+ 引入了 `FORMAT=TREE`,输出**缩进树状结构**,比传统表格和 JSON 都更直观。
|
||||
|
||||
```sql
|
||||
EXPLAIN FORMAT=TREE
|
||||
SELECT u.username, o.amount
|
||||
FROM users u
|
||||
JOIN orders o ON u.id = o.user_id
|
||||
WHERE o.created_at >= '2026-01-01'
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
```
|
||||
-> Limit: 20 row(s)
|
||||
-> Nested loop inner join (cost=530 rows=20)
|
||||
-> Index range scan on o using idx_created_user (cost=530 rows=5000)
|
||||
-> Single-row index lookup on u using PRIMARY (id=o.user_id) (cost=0.25 rows=1)
|
||||
```
|
||||
|
||||
### 三种格式怎么选?
|
||||
|
||||
| 格式 | 适用场景 | 优点 | 缺点 |
|
||||
|------|----------|------|------|
|
||||
| **默认表格** | 快速筛查 | 一行一表,一目了然 | 缺少 cost、缺少嵌套信息 |
|
||||
| **FORMAT=JSON** | 精细分析 | 信息最全,可编程解析 | 冗长,层级关系不直观 |
|
||||
| **FORMAT=TREE** | 日常推荐 | **层级清晰 + 含 cost + 易读** | 无法看到 possible_keys |
|
||||
| **ANALYZE** | 深度诊断 | 含真实执行时间 | 会实际执行 SQL |
|
||||
|
||||
> [!TIP] 推荐工作流
|
||||
> 日常排查先用 `FORMAT=TREE` 快速定位瓶颈(哪一步 cost 最高),需要细节时切到 `FORMAT=JSON` 看 key_len、possible_keys 等。
|
||||
|
||||
## EXPLAIN ANALYZE:看到真实执行代价
|
||||
|
||||
`EXPLAIN ANALYZE` 是 MySQL 8.0.18+ 引入的功能——它会**真正执行一次 SQL**,然后返回实际运行时间、每行的实际扫描数等统计信息。
|
||||
@@ -321,8 +606,28 @@ ANALYZE TABLE orders;
|
||||
SET GLOBAL innodb_stats_auto_recalc = ON;
|
||||
```
|
||||
|
||||
## 核心记忆点
|
||||
|
||||
> [!SUMMARY] EXPLAIN 六字口诀:型键行推排临
|
||||
> 1. **型**(type):判断索引使用等级,ALL 是底线问题
|
||||
> 2. **键**(key):确认命中哪个索引,NULL 需要警觉
|
||||
> 3. **行**(rows):预估扫描行数,rows × filtered 决定实际负载
|
||||
> 4. **推**(ICP):Using index condition = 存储引擎层预过滤,减少回表
|
||||
> 5. **排**(filesort):ORDER BY 无法利用索引排序时触发
|
||||
> 6. **临**(temporary):GROUP BY / DISTINCT 产生临时表
|
||||
|
||||
> [!CHECKLIST] EXPLAIN 审查清单
|
||||
> - [ ] `type` 不是 ALL(或 rows 可接受)
|
||||
> - [ ] `key` 不是 NULL(有索引可用)
|
||||
> - [ ] `key_len` 覆盖了联合索引的关键列
|
||||
> - [ ] `Extra` 没有 filesort(或 ORDER BY 有索引支撑)
|
||||
> - [ ] `Extra` 没有 temporary(或 GROUP BY 走了索引)
|
||||
> - [ ] `rows` × `filtered%` 在可接受范围
|
||||
> - [ ] 预估 rows 与实际偏差不大(必要时 `ANALYZE TABLE`)
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — type=ALL vs type=range 的底层差异
|
||||
- [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — 如何用 pt-query-digest 配合 EXPLAIN
|
||||
- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 索引设计不当导致的 EXPLAIN 问题
|
||||
- [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] — 回表原理与 Extra 中 Using index 的底层原因
|
||||
|
||||
Reference in New Issue
Block a user