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

14 KiB
Raw Blame History

tags, create time
tags create time
MySQL
B+ Tree
索引原理
Clustered Index
2026-05-16 00:00

B+ Tree 索引原理

概述

MySQL InnoDB 的索引默认使用 B+ Tree(Balance Plus Tree)。理解它的工作方式是掌握所有索引优化技巧的前提。

为什么用 B+ Tree?

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 结构

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 个子节点
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(基数)—— 基数接近总行数说明区分度高,索引效果好:

-- 查看表索引及基数估计值
SHOW INDEXES FROM users;

索引查找过程

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。

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 二级索引

这是本节最重要的概念延伸,也是理解后续所有索引优化的基础。

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 排序。

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. 函数 / 运算包裹了索引列

-- ❌ 索引失效 —— 每行都要计算 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 以通配符开头

-- ❌ 走全表扫描
SELECT * FROM users WHERE name LIKE '%abc%';

-- ✅ 前缀匹配仍然走索引
SELECT * FROM users WHERE name LIKE 'abc%';

3. OR 条件中有一列无索引

-- ❌ 即使 id 有索引、email 也有索引,但一旦 email 没索引,整个 OR 就可能不走索引
SELECT * FROM users WHERE id = 1 OR email = 'test@x.com';

4. 隐式类型转换

-- 假设 phone 是 VARCHAR 类型并建有索引
-- ❌ 传入的是数字类型,MySQL 要对每行做 CAST(phone AS SIGNED)
SELECT * FROM users WHERE phone = 13800138000;

-- ✅ 保持类型一致
SELECT * FROM users WHERE phone = '13800138000';

5. 回表太多导致 Optimizer 选择全表扫描

-- 当二级索引需要回表的行数占总行数很大比例时(通常 > 20%~30%),
-- Optimizer 会认为走索引反而更慢(频繁随机 IO),主动选择全表扫描。
-- 这是正常行为,强行用 FORCE INDEX 往往适得其反。

[!TIP] 怎么判断要不要加索引?

  • 先用 EXPLAIN 看执行计划
  • type 从 ALL → index → range → ref → const 依次越来越优
  • 如果已经是 ref 或 range 且 Extra 没有异常提示,说明索引已经在工作

索引的设计权衡

好索引带来快查询,但也带来慢写入。设计时需要权衡以下代价:

写入放大(Write Amplification)

聚簇索引只需维护一个 B+ Tree,但每个二级索引都是独立的 B+ Tree。每插入一行数据:

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。 删除、更新同理——所有相关索引都要同步修改。这就是为什么索引越多,写越慢。

空间占用

-- 一张表 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 避免锁表
  • 监控慢查询日志,按需调整索引策略

关联笔记