Files
cs-note/hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理.md
T
2026-05-24 11:42:38 +08:00

455 lines
18 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, B+ Tree, 索引原理, Clustered Index]
create time: 2026-05-16 00:00
---
# B+ Tree 索引原理
## 概述
MySQL InnoDB 的索引默认使用 B+ Tree(Balance Plus Tree)。理解它的工作方式是掌握所有索引优化技巧的前提。
## 为什么用 B+ Tree?
```mermaid
flowchart LR
BT["B+ Tree<br/>多路平衡树"] --> W1["胜出的原因"]
BT2["B Tree<br/>传统平衡树"] --> W2["缺点"]
HT["Hash<br/>哈希表"] --> W3["缺陷"]
BST["RBTree<br/>二叉平衡树"] --> W4["不适合磁盘"]
W1 -.->|"范围扫描 + 磁盘友好 + 叶子链表串联"| WINNER["✅ B+ Tree 胜出"]
W2 -.->|"非叶子也存数据 → IO 更多"| REASON
W3 -.->|"无范围查询能力"| REASON
W4 -.->|"树太高 IO 频繁"| REASON
style WINNER fill:#00D866,color:#fff
style BT fill:#00D866,color:#fff
```
### 关键差异点
| 特性 | B+ Tree | B Tree | Hash | 红黑树 |
|------|---------|--------|------|--------|
| **范围查询** | ✅ 叶子节点链表遍历 | ❌ 需回溯祖先 | ❌ | ❌ |
| **全表扫描** | ✅ 顺序扫描叶子 | ❌ 需要层序遍历 | — | ❌ |
| **磁盘 IO 次数** | 低(阶数高,树矮) | 高 | O(1) 但仅等值 | 极高 |
| **插入删除稳定性** | ✅ 分裂/合并均衡 | ✅ | ❌ 缩容重哈希 | ✅ |
| **查询性能可预测** | ✅ 始终 O(logₘn) | ✅ | ⚠️ 冲突时退化 | ✅ |
## B+ Tree 结构
```mermaid
flowchart TD
ROOT["根节点<br/>键: 15, 30, 45"] --> N1["内部节点<br/>键: 5, 10"]
ROOT --> N2["内部节点<br/>键: 20, 25"]
ROOT --> N3["内部节点<br/>键: 35, 40"]
ROOT --> N4["内部节点<br/>键: 50, 55"]
N1 --> LEAF1["[1][3][5][8][10]<br/>叶子 · 存数据 + 双向链表"]
N2 --> LEAF2["[12][15][20][22][25]"]
N3 --> LEAF3["[28][32][35][40][45]"]
N4 --> LEAF4["[48][50][52][55][60]"]
LEAF1 -.-> LEAF2 -.-> LEAF3 -.-> LEAF4
style ROOT fill:#4FC08D,color:#fff
style N1 fill:#A0AEC0,color:#fff
style N2 fill:#A0AEC0,color:#fff
style N3 fill:#A0AEC0,color:#fff
style N4 fill:#A0AEC0,color:#fff
style LEAF1 fill:#00B6BC,color:#fff
style LEAF2 fill:#00B6BC,color:#fff
style LEAF3 fill:#00B6BC,color:#fff
style LEAF4 fill:#00B6BC,color:#fff
```
### 核心特征
1. **非叶子节点只存储键(Key)和指针**,不存储完整数据行 —— 一个页可以放更多键,树更矮
2. **所有数据存储在叶子节点**,形成有序链表 —— 支持范围扫描
3. **叶子节点之间通过双向链表连接** —— 相邻页面不用回溯父节点
4. **所有叶子节点在同一深度** —— 查询性能稳定
## 为什么树这么矮?
InnoDB 一页 16KB(可配置),扣除页头 / 页尾等元数据开销后,**实际可用空间约 14~15 KB**。假设:
- 主键 BIGINT = 8 bytes
- 指针(Page Pointer)= 6 bytes
- 每个内部节点 Entry(Key + Pointer + 冗余信息)≈ **16 bytes**
- 一页可存 ≈ 14.5 KB / 16 bytes ≈ **900 ~ 1170 个子节点**(取保守值 1170)
```mermaid
flowchart LR
D1["1层<br/>1,170 行"] --> D2["2层<br/>约 137 万行"]
D2 --> D3["3层<br/>约 16 亿行"]
D3 --> D4["4层<br/>约 1900 亿行"]
style D1 fill:#A0AEC0,color:#fff
style D2 fill:#FF9F43,color:#000
style D3 fill:#00D866,color:#fff
style D4 fill:#EE5A24,color:#fff
```
> [!TIP] 这意味着什么?
> 即使是一张有 1 亿行的表,查找任意一条记录也只需要 **3~4 次磁盘 IO**(每次读取一个页)。这就是 B+ Tree 在磁盘介质上无可替代的原因。
> [!QUESTION] 思考一下
> 如果每个内部节点只存一个键,那这棵树会变成什么样子?
> ——退化成一棵二叉树(红黑树的高度)。所以 B+ Tree 的**阶数越高,树越矮,IO 越少**。
>
> [!TIP] 直观感受
> 通过 `information_schema.statistics` 查看索引基数,关注 CARDINALITY(基数)——
> 基数接近总行数说明区分度高,索引效果好:
> ```sql
> -- 查看表的索引基数统计
> SELECT INDEX_NAME, NON_UNIQUE, CARDINALITY
> FROM information_schema.statistics
> WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users';
> ```
## 索引查找过程
```mermaid
sequenceDiagram
participant Q as "查询"
participant R as "根节点"
participant I as "内部节点"
participant L as "叶子节点"
participant D as "数据行"
Q->>R: "id > 30 → 右分支"
R->>I: "定位到第二层右子节点"
I->>L: "命中对应叶子节点"
L->>D: "读取完整行数据"
Note over Q,D:"总共 3 次随机 IO"
```
> [!QUESTION] 思考一下
> 如果二级索引也存整行数据,还需要回表吗?
> ——不需要了。这就是**覆盖索引(Covering Index)**的核心思想。
### 覆盖索引(Covering 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 (
id BIGINT PRIMARY KEY,
email VARCHAR(255),
name VARCHAR(100),
age INT,
INDEX idx_email(email)
);
-- ❌ 必须回表:email 在索引里,但 name 不在
SELECT name, email FROM users WHERE email = 'test@example.com';
-- ① 走 idx_email 找到 id → ② 回聚簇索引拿 name(随机 IO)
-- ✅ 覆盖索引:不需要回表!
SELECT email FROM users WHERE email = 'test@example.com';
-- 只需从 idx_email 叶子节点直接拿到 email,零回表
```
#### 用联合索引构造覆盖索引
实际业务中,单列索引很难做到覆盖——查询通常需要多个字段。更常见的做法是**通过联合索引把查询涉及的列"包"进去**:
```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-聚簇索引与二级索引]]
## 聚簇索引 vs 二级索引
这是本节最重要的概念延伸,也是理解后续所有索引优化的基础。
```mermaid
flowchart TB
subgraph "聚簇索引 = 数据本身"
C1["id=1 · 整行数据"]
C2["id=2 · 整行数据"]
C3["id=3 · 整行数据"]
end
subgraph "二级索引 idx_email"
S1["email='a' → id=1"]
S2["email='b' → id=2"]
S3["email='c' → id=3"]
end
S1 -.回表.-> C1
S2 -.回表.-> C2
S3 -.回表.-> C3
style C1 fill:#00D866,color:#fff
style C2 fill:#00D866,color:#fff
style C3 fill:#00D866,color:#fff
style S1 fill:#FF9F43,color:#000
style S2 fill:#FF9F43,color:#000
style S3 fill:#FF9F43,color:#000
```
> [!QUESTION] 什么是回表?
> 当用二级索引 `idx_email` 查 `WHERE email = 'a'` 时:
> 1. 先在二级索引中找到 `email='a'` → 得到主键 `id=1`
> 2. 再用 `id=1` 去聚簇索引中查找完整行数据
> 这个两步过程叫**回表(Table Lookup)**。
>
> **优化方向**:让查询条件直接走聚簇索引,或者用 Covering Index 避免回表。
## MySQL 索引类型总览
在 B+ Tree 的基础上,InnoDB 支持多种索引类型。理解它们的选择时机也是面试和实战的常考点。
| 索引类型 | 底层结构 | 适用场景 | 等值查找 | 范围查找 | 是否有序 |
|----------|----------|----------|----------|----------|----------|
| **聚簇索引 (Clustered)** | B+ Tree 叶子存整行数据 | 主键查询(默认自带) | ✅ O(log n) | ✅ 顺序扫描叶子 | ✅ |
| **二级索引 (Secondary)** | B+ Tree 叶子存 `索引列 + 主键` | 非主键字段查询 | ✅ O(log n) | ✅ | ✅ |
| **联合索引 (Composite)** | 单个 B+ Tree,多列组合排序 | 前缀匹配的多字段过滤 | ✅ | ✅ | ✅ |
| **唯一索引 (Unique)** | 基于 B+ Tree 加约束 | 保证列值唯一(如手机号) | ✅ | ✅ | ✅ |
| **前缀索引 (Prefix)** | B+ Tree 只取字符前 N 位 | 长字符串字段(如 URL) | ✅ | ⚠️ 精度降低 | ✅ |
| **全文索引 (Fulltext)** | InnoDB 使用倒排索引 | 文本搜索 (`MATCH...AGAINST`) | — | — | ❌ |
| **空间索引 (Spatial)** | R-Tree | GIS 地理空间数据 | — | — | ❌ |
> [!NOTE] 重点记忆
> 除 **全文索引** 和 **空间索引** 外,其余全部依赖 B+ Tree。所以本文的核心内容覆盖了 InnoDB 90% 以上的日常使用场景。
### 联合索引的最左前缀原则
联合索引 `(a, b, c)` 的本质是:**先按 a 排序,a 相同时按 b 排序,a、b 都相同时按 c 排序**。
```mermaid
graph TD
subgraph "B+ Tree 叶子节点中数据的实际存储顺序"
R1["(10, 'pending')"]
R2["(10, 'paid')"]
R3["(10, 'shipped')"]
R4["(20, 'pending')"]
R5["(20, 'paid')"]
R6["(30, 'pending')"]
end
Q1["WHERE user_id = 10<br/>✅ 精确匹配一层"] --> S1["命中: R1, R2, R3"]
Q2["WHERE user_id = 10 AND status = 'paid'<br/>✅ 精确匹配两层"] --> S2["命中: R2"]
Q3["WHERE status = 'paid'<br/>❌ 缺少最左列 user_id"] --> S3["全表扫描"]
Q4["WHERE user_id = 10 AND created_at > ...<br/>⚠️ 只能用到 user_id"] --> S4["R1,R2,R3 + 逐行过滤"]
style S1 fill:#00D866,color:#fff
style S2 fill:#00D866,color:#fff
style S3 fill:#EE5A24,color:#fff
style S4 fill:#FF9F43,color:#000
```
> [!NOTE] 核心直觉
> - 联合索引的 B+ Tree **不是按列独立排序**的,而是把所有列拼成一条记录整体排序。
> - 跳过最左列 → 相当于跳过了目录的第一层,直接翻到第二层找,找不到就退化全表扫描。
```sql
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id INT,
status VARCHAR(20),
created_at DATETIME,
INDEX idx_user_status(user_id, status) -- 联合索引
);
-- 以下可以命中索引
SELECT * FROM orders WHERE user_id = 1; -- ✅ (user_id)
SELECT * FROM orders WHERE user_id = 1 AND status = 'paid'; -- ✅ (user_id, status)
-- 以下无法完全命中联合索引
SELECT * FROM orders WHERE status = 'paid'; -- ❌ 缺少最左列 user_id
SELECT * FROM orders WHERE user_id = 1 AND created_at > ...; -- ⚠️ 只能用到 user_id
```
> [!TIP] 设计联合索引的黄金法则
> 1. **把区分度最高的列放在最左边**——能最大程度缩小搜索范围
> 2. **等值匹配的列排在范围查询之前**
> 3. **覆盖最常出现的查询模式**,而不是把所有可能的列堆在一起
## 实战常见陷阱:索引为什么不生效?
B+ Tree 再优秀,也用不好 SQL。以下是最常见的索引失效场景:
### 1. 函数 / 运算包裹了索引列
```sql
-- ❌ 索引失效 —— 每行都要计算 DATE(created_at)
SELECT * FROM orders WHERE DATE(created_at) = '2025-05-01';
-- ✅ 改用范围查询,利用 B+ Tree 的范围扫描能力
SELECT * FROM orders
WHERE created_at >= '2025-05-01 00:00:00'
AND created_at < '2025-05-02 00:00:00';
```
> [!NOTE] 原理
> B+ Tree 按原始值排序,对 `created_at` 建了索引但查的是 `DATE(created_at)`,相当于换了个"钥匙"去找"锁",树根目录里根本找不到这个新钥匙。
### 2. LIKE 以通配符开头
```sql
-- ❌ 走全表扫描
SELECT * FROM users WHERE name LIKE '%abc%';
-- ✅ 前缀匹配仍然走索引
SELECT * FROM users WHERE name LIKE 'abc%';
```
### 3. OR 条件中有一列无索引
```sql
-- ❌ 即使 id 有索引、email 也有索引,但一旦 email 没索引,整个 OR 就可能不走索引
SELECT * FROM users WHERE id = 1 OR email = 'test@x.com';
```
### 4. 隐式类型转换
```sql
-- 假设 phone 是 VARCHAR 类型并建有索引
-- ❌ 传入的是数字类型,MySQL 要对每行做 CAST(phone AS SIGNED)
SELECT * FROM users WHERE phone = 13800138000;
-- ✅ 保持类型一致
SELECT * FROM users WHERE phone = '13800138000';
```
### 5. 回表太多导致 Optimizer 选择全表扫描
```sql
-- 当二级索引需要回表的行数占总行数很大比例时(通常 > 20%~30%),
-- Optimizer 会认为走索引反而更慢(频繁随机 IO),主动选择全表扫描。
-- 这是正常行为,强行用 FORCE INDEX 往往适得其反。
```
> [!TIP] 怎么判断要不要加索引?
> - 先用 `EXPLAIN` 看执行计划
> - `type` 从 `ALL → index → range → ref → const` 依次越来越优
> - 如果已经是 `ref` 或 `range` 且 Extra 没有异常提示,说明索引已经在工作
## 索引的设计权衡
好索引带来快查询,但也带来慢写入。设计时需要权衡以下代价:
### 写入放大(Write Amplification)
聚簇索引只需维护一个 B+ Tree,但**每个二级索引都是独立的 B+ Tree**。每插入一行数据:
```sql
INSERT INTO users (id, email, name, age) VALUES (1, 'a@x.com', 'Tom', 25);
-- InnoDB 需要同时更新:
-- ① 聚簇索引 idx__PRIMARY → 1 次 IO(顺序插入在末尾)
-- ② 二级索引 idx_email → 1 次 IO(定位 + 可能的页分裂)
-- ③ 每多一个二级索引 → 各加 1 次 IO
-- 总写入成本 = 1(聚簇)+ N(N 个二级索引)
```
> [!NOTE] 直观理解
> 假设有 5 个二级索引,插入一条记录就要写 **6 棵 B+ Tree**。
> 删除、更新同理——所有相关索引都要同步修改。这就是为什么**索引越多,写越慢**。
### 空间占用
```sql
-- 一张表 100 GB,建了 4 个二级索引:
-- 聚簇索引 ≈ 100 GB(就是数据本身)
-- 二级索引 × 4 ≈ 30~50 GB(取决于索引列的宽度)
-- 总磁盘用量 ≈ 130~150 GB
```
> [!QUESTION] 思考一下
> 如果你有一张日增百万行的日志表,你会给它建几个二级索引?
> ——答案通常是:**少而精**。先分析最慢的几个查询,针对性加索引,而不是"以防万一全加上"。
### 维护开销
| 操作 | 聚簇索引影响 | 二级索引影响 |
|------|-------------|-------------|
| **INSERT** | 叶子节点末尾追加(顺序 IO) | 需定位正确位置 + 可能的页分裂(随机 IO) |
| **UPDATE** | 若主键不变则无影响;变化则删除+重建 | 所有涉及列变化的索引都需要更新 |
| **DELETE** | 标记删除或合并页 | 同上,且可能有页合并开销 |
| **页分裂** | 顺序写满一页后可能触发 | 频率更高(写入定位分散 + 热点行密集) |
> [!TIP] 经验法则
> - 写密集型系统(如日志、订单创建),索引数量控制在 **3 个以内**
> - 读密集型系统(如报表查询),可以放宽到 **5 个左右**
> - 超过 10 个索引几乎必然影响写入性能,应审慎评估
## 最佳实践清单
结合上述原理与实战经验,整理一份日常工作中可以直接使用的检查清单:
> [!CHECKLIST] 索引设计与审查清单
>
> **[建表阶段]**
> - [ ] 主键选择合理(自增 BIGINT / UUID 替代方案考虑过?)
> - [ ] 高频查询字段已建索引
> - [ ] 联合索引区分度最高的列放在最左
> - [ ] 避免过度索引(写多读少的表控制在 3 个以内)
>
> **[上线前]**
> - [ ] 所有慢查询 `EXPLAIN` 后 type 不是 `ALL`
> - [ ] 覆盖索引场景用 `Using index` 确认不走回表
> - [ ] 范围查询列不要和其他等值列反序排列
> - [ ] VARCHAR 长字符串考虑使用前缀索引
>
> **[线上巡检]**
> - [ ] `SHOW INDEXES` 检查 Cardinality 接近行数
> - [ ] 无用索引定期清理(使用 pt-duplicate-key-checker 等工具)
> - [ ] 大表 DDL 使用 `ALGORITHM=INPLACE, LOCK=NONE` 避免锁表
> - [ ] 监控慢查询日志,按需调整索引策略
## 关联笔记
- [[hhs/GORM/02-模型定义]] — GORM 创建索引的 struct tag 映射到 B+ Tree
- [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] — 深入解析聚簇索引的物理存储结构与二级索引的回表优化
- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 联合索引的设计原则与实践
- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — type=index / range 的物理含义与索引执行计划解读