Files
autumn-recruitment/02.MySQL/index/B+树索引原理.md
T

6.4 KiB
Raw Blame History

tags, create time, update time
tags create time update time
mysql
bplus-tree
clustered-index
secondary-index
page-split
2026-08-08 18:00 2026-08-08 18:00

B+树索引原理

概述

InnoDB 存储引擎的默认索引结构是 B+树(Balanced Plus Tree),它是 MySQL 实现高效范围查询和精确匹配的核心数据结构。理解 B+树的设计哲学——为什么选它而不是 B 树或红黑树——是掌握 MySQL 查询性能底层逻辑的第一步。

核心原理

B 树与 B+树的本质区别

B+树并非独立发明,而是 B 树的演化版本。两者的核心差异在于数据存放位置:

  • B 树:每个节点都存储键值和对应数据记录
  • B+树:非叶子节点只存键(不存数据),所有数据统一在叶子节点

这意味着同样大小的页(通常 16KB),B+树的非叶子节点可以容纳更多键,从而降低树的高度。

对比维度 B 树 B+树
数据存储 所有节点均存数据 仅叶子节点存数据
非叶子节点作用 既做路由又存数据 纯路由,无数据负担
叶子节点连接 无连接 双向链表串联
范围查询 需中序遍历,效率低 直接遍历叶子链表,一步到位
磁盘 IO 次数 较高 更低(同等数据量下树更矮)
查询稳定性 不同路径长度可能不同 所有查询路径等长
graph TD
    subgraph "B 树 - 每个节点含数据"
        B_Root["根节点\nK1 D1"] -->|"≤K1"| B_L["左子节点\nK2 D2"]
        B_Root -->|"K1<K≤K2"| B_M["中间节点\nK3 D3"]
        B_Root -->|">K2"| B_R["右子节点\nK4 D4"]
    end

    subgraph "B+树 - 仅叶子节点含数据"
        N_Root["根节点\nK2"] -->|"≤K2"| N_Inter["内节点\nK1 K3"]
        N_Root -->|">K2"| N_Right["内节点\nK4 K5"]
        N_Inter -->|"≤K1"| L1["叶子 K1→D1"]
        N_Inter -->|"K1~K3"| L2["叶子 K2→D2"]
        N_Inter -->|"K3~K4"| L3["叶子 K3→D3"]
        N_Right -->|"K4~K5"| L4["叶子 K4→D4"]
        N_Right -->|">K5"| L5["叶子 K5→D5"]
        L1 -.->|"双向链表"| L2
        L2 -.-> L3
        L3 -.-> L4
        L4 -.-> L5
    end

[!NOTE] 关键直觉 树的高度每降低一层,意味着一次查询减少一次磁盘 IO。B+树通过让内节点只存索引而不存数据,让单个页能放下更多键,从而将 1 亿行数据的树高控制在 34 层,而 B 树可能需要 45 层。

页分裂与页合并机制

B+树的每个节点对应 InnoDB 的一个数据页(Page),默认大小为 16KB。当数据插入导致页满时,触发再平衡操作。

页分裂规则:

  • 原页保留最左侧约一半的记录,新页获得剩余记录
  • 父节点增加一个分割键(split key),指向新页
  • 如果父节点也满了,递归向上分裂,直到找到不满的父节点或到达根节点
  • 根节点分裂时,树的高度 +1

页合并规则:

  • 删除操作使页填充率低于一定阈值(默认约 50%,可通过 innodb_fill_factor 调整)时,尝试与相邻页合并
  • 合并方向优先选择右侧页;若右侧页也无法合并,则尝试左侧
  • 合并后,父节点中的分割键被移除

[!WARNING] 常见误区 很多人认为页分裂发生在"页使用率达到 100%"时,实际上 InnoDB 在预分页阶段就会考虑空间利用率。InnoDB 采用"首次分裂占 7/8,后续平分"的策略来预留碎片空间,避免频繁分裂。

聚簇索引与非聚簇索引

这是 InnoDB 索引体系中最核心的概念之一。

聚簇索引(Clustered Index):

  • InnoDB 的数据文件和索引文件是同一份——这就是"聚簇"的含义
  • 主键索引就是聚簇索引,叶子节点存储整行完整数据
  • 一张表有且仅有一个聚簇索引

二级索引(Secondary Index / Non-Clustered Index):

  • 除主键之外的所有索引都是二级索引
  • 叶子节点存储的是索引列的值 + 主键值,而非完整行数据
  • 通过二级索引查到主键后,再用主键回表查询完整数据
graph LR
    subgraph "聚簇索引(主键索引)叶子节点"
        CI1["PK=1 → 完整行"]
        CI2["PK=5 → 完整行"]
        CI3["PK=10 → 完整行"]
    end

    subgraph "二级索引(name字段)叶子节点"
        SI1["name='Alice' → PK=1"]
        SI2["name='Bob' → PK=5"]
        SI3["name='Charlie' → PK=10"]
    end

    SI1 --> CI1
    SI2 --> CI2
    SI3 --> CI3

[!TIP] 面试常考点 问:"为什么主键要尽量短?" 答案:因为所有二级索引叶子节点都存储了主键值,主键越长,二级索引越大,内存中能缓存的索引页数越少,命中率越低。这也是推荐使用自增 BIGINT 而非 UUID 作为主键的原因之一。

覆盖索引的起点

B+树的二级索引结构天然支持"覆盖索引"优化——当查询的列全部存在于某个二级索引中时,无需回表即可返回结果。这一话题将在 覆盖索引与回表优化 中详细展开。

代码示例

以下 SQL 演示如何通过执行计划观察聚簇索引与二级索引的使用差异:

-- 建表并建立索引
CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(64) NOT NULL,
    email VARCHAR(128) UNIQUE,
    age INT NOT NULL,
    INDEX idx_name_age (name, age)
);

-- 使用覆盖索引:不需要回表
EXPLAIN SELECT name FROM users WHERE name = 'Alice';
-- Extra: Using index (直接从 idx_name_age 二级索引获取 name)

-- 需要回表:二级索引查不到所需列
EXPLAIN SELECT * FROM users WHERE name = 'Alice';
-- Extra: Using where(先走 idx_name_age 拿到 id,再回聚簇索引取全行)

实践场景

场景一:选型主键

  • 优先使用自增 BIGINT 或 BIGINT UNSIGNED 作为主键,保持顺序写入减少页分裂
  • 绝对避免用 UUID 或随机字符串作为主键——乱序插入会导致频繁的页分裂和碎片

场景二:联合索引的顺序设计

  • 区分度高的列放前面(如 user_id 优于 status)
  • 等值查询列在前,范围查询列在后(范围查询会中断最左前缀匹配)

场景三:监控页分裂

-- 查看表中页的使用情况
SELECT table_name, data_length, index_length
FROM information_schema.tables
WHERE engine = 'InnoDB';

-- 填充率低说明页分裂频繁,考虑 OPTIMIZE TABLE

扩展阅读