325 lines
14 KiB
Markdown
325 lines
14 KiB
Markdown
|
|
---
|
|||
|
|
tags: [MySQL, 聚簇索引, 二级索引, Covering Index, 回表]
|
|||
|
|
create time: 2026-05-20 14:00
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 13-聚簇索引与二级索引
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
InnoDB 使用 B+ Tree 组织所有索引,但根据「索引叶子节点存什么」的不同,分为两类:**聚簇索引**和**二级索引**。
|
|||
|
|
|
|||
|
|
打个比方:如果把数据库表想象成一本通讯录——
|
|||
|
|
- **聚簇索引**就是通讯录正文本身:按姓名排序,每个条目后面直接写上了电话、地址等所有信息。
|
|||
|
|
- **二级索引**就像附录的索引卡片:只写了「姓名 → 第几页」,想看完整信息还得翻回正文。
|
|||
|
|
|
|||
|
|
理解这两者的差异,是你设计高效查询和执行计划的起点。
|
|||
|
|
|
|||
|
|
为了方便说明,本文档将始终使用以下 `users` 表作为示例:
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE users (
|
|||
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|||
|
|
name VARCHAR(64) NOT NULL,
|
|||
|
|
email VARCHAR(255) NOT NULL,
|
|||
|
|
status TINYINT NOT NULL DEFAULT 1,
|
|||
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
INDEX idx_email (email),
|
|||
|
|
INDEX idx_status_created (status, created_at)
|
|||
|
|
) ENGINE=InnoDB;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
这张表中:
|
|||
|
|
- **聚簇索引**:`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` 排序存储在磁盘上——
|
|||
|
|
> 现在要查 `WHERE email = 'alice@test.com'`,数据库需要做什么?
|
|||
|
|
> (提示:email 不是存储排序的依据。)
|
|||
|
|
|
|||
|
|
带着这个问题往下看。
|
|||
|
|
|
|||
|
|
## 一张图看懂索引全貌
|
|||
|
|
|
|||
|
|
在深入细节之前,先看这张示意图——它展示了同一个表上**聚簇索引**和**二级索引**如何共存:
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
block-beta
|
|||
|
|
columns 2
|
|||
|
|
|
|||
|
|
block:ClusteredIndex["聚簇索引 — idx_id (PK = id)"]
|
|||
|
|
columns 1
|
|||
|
|
CI_L1["枝节点: id=5 | id=18"]
|
|||
|
|
CI_L2["枝节点: id=12 | id=25"]
|
|||
|
|
CI_L3["叶子节点: 整行数据(id=5, name=Bob, email=bob@x.com, …)"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
block:SecondaryIndex["二级索引 — idx_email (email)"]
|
|||
|
|
columns 1
|
|||
|
|
SI_L1["枝节点: email < 'k'"]
|
|||
|
|
SI_L2["枝节点: email >= 'k'"]
|
|||
|
|
SI_L3["叶子节点: (email='alice@x.com' | PK=12)"]
|
|||
|
|
SI_L4["叶子节点: (email='bob@x.com' | PK=5)"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
style CI_L3 fill:#00B6BC,color:#fff
|
|||
|
|
style SI_L3 fill:#E8DFF5,color:#333
|
|||
|
|
style SI_L4 fill:#E8DFF5,color:#333
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!HIGHLIGHT] 核心区别一目了然
|
|||
|
|
> - **聚簇索引叶子层**存的是**完整行数据** → 查到就结束
|
|||
|
|
> - **二级索引叶子层**存的是**「索引列 + 主键」** → 拿到主键后还要去聚簇索引里再查一次(回表)
|
|||
|
|
> - 所有索引共享同一份数据(存储在主键聚簇索引中),二级索引只是"额外的查找路径"
|
|||
|
|
|
|||
|
|
## 聚簇索引(Clustered Index)
|
|||
|
|
|
|||
|
|
"聚簇"这个词听起来很抽象,但意思很简单:**数据按照索引的顺序"聚集"在一起存储**。在 InnoDB 里,聚簇索引的叶子节点不是"指向"数据,叶子节点**就是**数据——整行记录都直接存在里面。
|
|||
|
|
|
|||
|
|
正因为数据只有一份,所以一张表**只能有一个**聚簇索引。
|
|||
|
|
|
|||
|
|
### 聚簇索引的形成规则
|
|||
|
|
|
|||
|
|
| 优先级 | 条件 | 说明 |
|
|||
|
|
|--------|------|------|
|
|||
|
|
| ① | **PRIMARY KEY** | 如果有显式 PK,它自动成为聚簇索引 |
|
|||
|
|
| ② | **UNIQUE + NOT NULL** | 没有 PK 但有唯一非空列,用它 |
|
|||
|
|
| ③ | **隐藏 row_id** | 都没有时,InnoDB 自动生成隐藏的 6-byte row_id |
|
|||
|
|
|
|||
|
|
> [!WARNING] 隐藏的 row_id 是个坑
|
|||
|
|
> 如果你的表既没 PK 也没有 UNIQUE NOT NULL 列,InnoDB 会自动生成隐藏主键。这时:
|
|||
|
|
> - 你自定义的任何索引都会变成二级索引
|
|||
|
|
> - 二级索引回表时需要额外跳转 → 回表代价更高
|
|||
|
|
> - 外键引用不可靠(ID 对用户透明)
|
|||
|
|
>
|
|||
|
|
> **结论:每张表必须有显式 PRIMARY KEY。**
|
|||
|
|
|
|||
|
|
### 聚簇索引的特性
|
|||
|
|
|
|||
|
|
数据按主键顺序存储带来了两个直接好处:写入快(顺序追加)和范围查询快(连续扫描):
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart TD
|
|||
|
|
A["按主键排序存储数据"] --> B["数据页紧凑排列"]
|
|||
|
|
B --> C["连续主键 = 顺序写入"]
|
|||
|
|
C --> D["最小化页分裂"]
|
|||
|
|
D --> E["最佳写入性能"]
|
|||
|
|
|
|||
|
|
A --> F["范围查询极快<br/>WHERE id BETWEEN 100 AND 200<br/>只需扫描一段连续的叶子页"]
|
|||
|
|
F --> G["顺序 I/O 而非随机 I/O"]
|
|||
|
|
|
|||
|
|
style E fill:#00D866,color:#fff
|
|||
|
|
style G fill:#00B6BC,color:#fff
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
理解了聚簇索引为什么快,我们反过来想——如果不是按主键查,而是通过其他字段(比如 `email`)查询,又会发生什么?这就是二级索引的故事。
|
|||
|
|
|
|||
|
|
## 二级索引(Secondary Index)
|
|||
|
|
|
|||
|
|
除了聚簇索引外的所有索引都是二级索引。叶子节点存储的是:**索引列的值 + 主键值**。
|
|||
|
|
|
|||
|
|
> [!QUESTION] 为什么二级索引要存主键?
|
|||
|
|
> 因为二级索引本身没有完整数据,但它知道"这条记录在哪"——通过主键就能回到聚簇索引里找到完整行。主键就是二级索引回聚簇索引的"导航坐标"。
|
|||
|
|
>
|
|||
|
|
> 整个过程分两步:
|
|||
|
|
> 1. 在二级索引里找到匹配条件的记录,拿到主键 `id`
|
|||
|
|
> 2. 拿着 `id` 去聚簇索引里找完整行数据
|
|||
|
|
>
|
|||
|
|
> 这个"从二级索引跳回聚簇索引取数据"的过程,就叫 **回表**。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart TD
|
|||
|
|
subgraph "二级索引页<br/>idx_status_created = (status, created_at)"
|
|||
|
|
direction LR
|
|||
|
|
R1["status=1 \| created_at=01 → PK=5"]
|
|||
|
|
R2["status=1 \| created_at=02 → PK=12"]
|
|||
|
|
R3["status=1 \| created_at=03 → PK=18"]
|
|||
|
|
R4["status=0 \| created_at=01 → PK=3"]
|
|||
|
|
R5["status=0 \| created_at=04 → PK=25"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
style R1 fill:#E8DFF5,color:#333
|
|||
|
|
style R3 fill:#00B6BC,color:#fff
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!NOTE] 关键观察
|
|||
|
|
> - 每行都包含 **索引列值**(用于匹配查询条件)和 **主键值**(用于回表定位完整行数据)
|
|||
|
|
> - 二级索引本身是 B+ Tree,按索引列排序;叶子层每一行指向聚簇索引中的对应数据
|
|||
|
|
> - 如果只需要索引中的列(如 `SELECT status WHERE status=1`),就 **无需回表**——这就是 Covering Index
|
|||
|
|
|
|||
|
|
## 回表的代价
|
|||
|
|
|
|||
|
|
刚才说了回表是"跳回聚簇索引取数据",但这个跳转是有代价的——它意味着额外的磁盘 I/O。下面这张图展示了具体过程:
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
sequenceDiagram
|
|||
|
|
participant App as 应用层
|
|||
|
|
participant SI as 二级索引(idx_email)
|
|||
|
|
participant CI as 聚簇索引(PK=id)
|
|||
|
|
participant DB as 数据盘
|
|||
|
|
|
|||
|
|
App->>SI: 查找 email='alice@test.com'
|
|||
|
|
SI-->>App: 找到 → id=12345
|
|||
|
|
|
|||
|
|
App->>CI: 用 id=12345 查找
|
|||
|
|
CI->>DB: 定位叶子页 (随机 IO #1)
|
|||
|
|
DB-->>CI: 返回完整行
|
|||
|
|
|
|||
|
|
CI-->>App: 返回完整行
|
|||
|
|
|
|||
|
|
Note over SI,DB: 至少 2 次 IO:1次二级索引 + 1次回表
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 减少回表的策略(Covering Index)
|
|||
|
|
|
|||
|
|
回表既然是因为二级索引里"信息不够"才跳回聚簇索引,那最直接的解决办法就是:**让二级索引里包含查询需要的所有信息**,这样就不需要跳回去了。
|
|||
|
|
|
|||
|
|
这就是 Covering Index(覆盖索引)——索引"覆盖"了查询的全部需求,查完索引就够了,不用回表。
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- ❌ 差:Covering Index 未命中,需要回表拿 name 字段
|
|||
|
|
SELECT name, email FROM users WHERE email = 'alice@test.com';
|
|||
|
|
-- idx_email 能匹配 WHERE,但 name 不在索引里 → 需要回表
|
|||
|
|
EXPLAIN SELECT name, email FROM users WHERE email = 'alice@test.com'\G
|
|||
|
|
*************************** 1. row ***************************
|
|||
|
|
id: 1
|
|||
|
|
select_type: SIMPLE
|
|||
|
|
table: users
|
|||
|
|
type: ref ← 通过 idx_email 找到匹配行
|
|||
|
|
possible_keys: idx_email
|
|||
|
|
key: idx_email
|
|||
|
|
key_len: 769
|
|||
|
|
ref: const
|
|||
|
|
rows: 1
|
|||
|
|
filtered: 100.00
|
|||
|
|
Extra: Using where ← 注意:无 "Using index",说明走了回表
|
|||
|
|
|
|||
|
|
-- ✅ 好:覆盖索引,无需回表
|
|||
|
|
ALTER TABLE users ADD INDEX idx_email_name (email, name);
|
|||
|
|
EXPLAIN SELECT name, email FROM users WHERE email = 'alice@test.com'\G
|
|||
|
|
*************************** 1. row ***************************
|
|||
|
|
id: 1
|
|||
|
|
select_type: SIMPLE
|
|||
|
|
table: users
|
|||
|
|
type: ref
|
|||
|
|
possible_keys: idx_email,idx_email_name
|
|||
|
|
key: idx_email_name ← 优化器选择了更优的联合索引
|
|||
|
|
key_len: 769
|
|||
|
|
ref: const
|
|||
|
|
rows: 1
|
|||
|
|
filtered: 100.00
|
|||
|
|
Extra: Using index ← 完美!Index Only Scan,无需回表
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!SUCCESS] Covering Index 的黄金法则
|
|||
|
|
> **把 SELECT 中的列 + WHERE 中的列放到同一个联合索引中**,就能实现 Index Only Scan。
|
|||
|
|
> - 适合:高频查询、固定列选择
|
|||
|
|
> - 不适合:`SELECT *`(永远无法覆盖)、列变化频繁的查询
|
|||
|
|
|
|||
|
|
### 当 Covering Index 不够用时:索引下推(ICP)
|
|||
|
|
|
|||
|
|
并非所有查询都能被覆盖索引解决。比如 `SELECT *` 必须回表,或者部分过滤条件无法放入索引中。这时 MySQL 5.6 引入的 **索引下推**(Index Condition Pushdown)能进一步减少无效回表。
|
|||
|
|
|
|||
|
|
假设 `users` 表有联合索引 `idx_status_created (status, created_at)`:
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- 查询:WHERE status = 1 AND created_at > '2024-01-01'
|
|||
|
|
SELECT id, name FROM users WHERE status = 1 AND created_at > '2024-01-01';
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart TD
|
|||
|
|
N["无 ICP"] --> A["在 idx_status_created<br/>查到 1000 条 status=1 的记录"]
|
|||
|
|
A --> B["1000 次回表检查 created_at"]
|
|||
|
|
B --> C["最终只有 50 条满足时间条件"]
|
|||
|
|
|
|||
|
|
Y["有 ICP"] --> D["在二级索引中直接判断<br/>created_at > '2024-01-01'"]
|
|||
|
|
D --> E["1000 条中筛出 50 条"]
|
|||
|
|
E --> F["仅 50 次回表"]
|
|||
|
|
|
|||
|
|
style B fill:#EE5A24,color:#fff
|
|||
|
|
style F fill:#00D866,color:#fff
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!HIGHLIGHT] ICP 的本质
|
|||
|
|
> 用生活化的类比来说:以前的做法是,快递员(存储引擎)在仓库(二级索引)里找到 1000 个包裹,全部搬出来,再由前台(Server 层)一个一个拆开检查是不是客户要的。ICP 的做法是,直接告诉快递员"只搬 status=1 且 created_at 在这个日期之后的",在仓库里就筛掉了 950 个,只搬出 50 个。
|
|||
|
|
>
|
|||
|
|
> 技术上说,就是把过滤条件下推到存储引擎层,在读取二级索引页时就完成判断,命中了才回表。
|
|||
|
|
>
|
|||
|
|
> **适用条件:**
|
|||
|
|
> - 必须是二级索引(聚簇索引本身已经是完整数据,不存在"下推"的问题)
|
|||
|
|
> - 过滤条件必须能利用到索引列(否则在索引里也没法判断)
|
|||
|
|
> - MySQL 5.6+ 默认开启,无需额外配置
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- 验证 ICP 是否生效
|
|||
|
|
EXPLAIN SELECT * FROM users WHERE status = 1 AND created_at > '2024-01-01'\G
|
|||
|
|
*************************** 1. row ***************************
|
|||
|
|
id: 1
|
|||
|
|
select_type: SIMPLE
|
|||
|
|
table: users
|
|||
|
|
type: range
|
|||
|
|
possible_keys: idx_status_created
|
|||
|
|
key: idx_status_created
|
|||
|
|
key_len: 5 -- TINYINT(1) + DATETIME(5)
|
|||
|
|
ref: NULL
|
|||
|
|
rows: 1000
|
|||
|
|
filtered: 10.00
|
|||
|
|
Extra: Using where ← 注意这里
|
|||
|
|
-- 如果开启了 ICP,实际执行时会先判断 created_at,减少回表次数
|
|||
|
|
|
|||
|
|
-- 可以通过开关观察差异
|
|||
|
|
SET optimizer_switch = 'index_condition_pushdown=off';
|
|||
|
|
-- EXPLAIN Extra 仍为 "Using where",但执行计划变差(更多回表)
|
|||
|
|
SET optimizer_switch = 'index_condition_pushdown=on';
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 两种索引的空间对比
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph TB
|
|||
|
|
subgraph Clustered["聚簇索引 — 100 万行"]
|
|||
|
|
CI["每页约 200 行 | 16KB / 80 bytes"]
|
|||
|
|
CI2["总页数 ≈ 5000 页"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph Secondary["二级索引 — idx_email VARCHAR(255)"]
|
|||
|
|
SI["每页约 80 行 | 16KB / 200 bytes"]
|
|||
|
|
SI2["总页数 ≈ 12500 页"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
CI --> CI2
|
|||
|
|
SI --> SI2
|
|||
|
|
|
|||
|
|
style CI2 fill:#00B6BC,color:#fff
|
|||
|
|
style SI2 fill:#C44569,color:#fff
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!NOTE] 二级索引通常比聚簇索引大得多
|
|||
|
|
> 因为二级索引存的是「索引列 + 主键」,而聚簇索引存的是「整行」。如果索引列很长(如 VARCHAR(255)),二级索引会膨胀得很厉害。这也是为什么长字符串列做索引时要限制长度:`(name(50))`。
|
|||
|
|
|
|||
|
|
## 两种索引的对比总结
|
|||
|
|
|
|||
|
|
| 维度 | 聚簇索引 (Clustered) | 二级索引 (Secondary) |
|
|||
|
|
|------|---------------------|---------------------|
|
|||
|
|
| **数量** | 每张表仅一个 | 可以有多个 |
|
|||
|
|
| **叶子节点存储** | 整行数据 | 索引列值 + 主键值 |
|
|||
|
|
| **形成依据** | PK / 唯一非空列 / 隐藏 row_id | `CREATE INDEX` 或 `UNIQUE KEY` |
|
|||
|
|
| **范围查询** | 极快(顺序扫描连续页) | 需要回表,代价高 |
|
|||
|
|
| **覆盖索引** | 天然覆盖(本身就是数据) | 仅当所需列都在索引中时生效 |
|
|||
|
|
| **空间占用** | 基准大小 | 通常更大(多一列主键 + 膨胀风险) |
|
|||
|
|
| **写入代价** | 插入可能触发页分裂 | 更新索引列需改索引 + 回表改数据 |
|
|||
|
|
|
|||
|
|
> [!SUMMARY] 核心记忆点
|
|||
|
|
> 1. 聚簇索引 = 数据本身,**一张表只能有一个**
|
|||
|
|
> 2. 二级索引 = 「索引列 + 主键」,查数据要**回表**
|
|||
|
|
> 3. 能用 Covering Index 的场景永远优于回表
|
|||
|
|
> 4. ICP 是 MySQL 5.6 对回表的温和优化——能省则省
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — B+ Tree 作为底层数据结构的工作原理
|
|||
|
|
- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 联合索引的搜索模式对回表的影响
|
|||
|
|
- [[hhs/GORM/15-性能优化]] — GORM 场景下的 Covering Index 实践
|