Files
cs-note/hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引.md
T
2026-05-24 11:42:38 +08:00

325 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 实践