This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/MySQL/16-B+Tree 索引原理.md
T
2026-05-17 00:06:11 +08:00

380 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, 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,假设:
- 主键 BIGINT = 8 bytes
- 指针 = 6 bytes
- 每个内部节点 Entry ≈ 14 bytes
- 一页可存 ≈ 16KB / 14 bytes ≈ **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] 直观感受
> 用 SHOW INDEXES 查看某个表的索引信息,关注 Cardinality(基数)——
> 基数接近总行数说明区分度高,索引效果好:
> ```sql
> -- 查看表索引及基数估计值
> SHOW INDEXES FROM users;
> ```
## 索引查找过程
```mermaid
sequenceDiagram
participant Q as "查询id等于42"
participant R as "根节点IO1"
participant I as "内部节点IO2"
participant L as "叶子节点IO3"
participant D as "数据行"
Q->>R: "id大于30走向右分支"
R->>I: "指向第二层右子节点"
I->>L: "命中对应叶子节点"
L->>D: "读取完整行数据"
Note over Q,D:"总共 3 次随机 IO"
```
> [!QUESTION] 思考一下
> 如果二级索引也存整行数据,还需要回表吗?
> ——不需要了。这就是**覆盖索引(Covering Index)**的核心思想。
### 覆盖索引(Covering Index)
当查询需要的数据全部存在于某个索引的 B+ Tree 中时,就不需要再走聚簇索引了——这叫 **Index Only Scan**,在 `EXPLAIN` 中显示为 `Using index`。
```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
-- ✅ 覆盖索引:不需要回表!
SELECT email FROM users WHERE email = 'test@example.com';
-- 只需从 idx_email 叶子节点直接拿到 email
```
> [!NOTE] Covering Index 的判断方法
> - `Extra = Using index`(无 `Using where`)→ 完整覆盖,零回表
> - `Extra = Using index condition` → 索引下推(ICP),部分过滤在索引层面完成
> - Extra 中**没有** `Using index` → 触发了回表
>
> 💡 更多细节(回表代价、ICP 原理、空间对比)详见 [[hhs/MySQL/17-聚簇索引与二级索引]]
## 聚簇索引 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 排序**。
```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
-- ③ 如果有更多二级索引... → N 次 IO
-- 总写入成本 = 1 + 二级索引数量
```
> [!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/17-聚簇索引与二级索引]] — 深入解析聚簇索引的物理存储结构与二级索引的回表优化
- [[hhs/MySQL/18-联合索引与最左前缀]] — 联合索引的设计原则与实践
- [[hhs/MySQL/19-EXPLAIN 完全指南]] — type=index / range 的物理含义与索引执行计划解读