MySQL B+Tree、B-Tree 与数据库索引家族
很多资料会把这个问题讲得很乱:
MySQL 用 B+Tree,Oracle、PostgreSQL、SQL Server 用 B-Tree。
这句话容易误导初学者。更准确的说法是:MySQL InnoDB 的索引按 B+Tree 理解最合适;Oracle、PostgreSQL、SQL Server 文档里常说 B-tree index,但这里的 B-tree 往往是数据库索引家族名,工程实现通常是适合页式存储、范围扫描和高并发的 B-Tree/B+Tree/B*Tree 变体。
这一页把 B+Tree 为什么适合 MySQL、B-Tree 和 B+Tree 的区别、其他数据库为什么叫 B-Tree 讲清楚。
这一页解决这些问题
| 问题 | 要掌握到什么程度 |
|---|---|
| B+Tree 是什么 | 知道根页、内部页、叶子页、叶子链表 |
| 为什么不用 B-Tree | 知道内部节点存数据会降低扇出,范围扫描不如叶子链表 |
| 为什么不用 Hash、红黑树 | 知道数据库索引核心目标是减少页 IO |
| 聚簇索引和二级索引怎么组织 | 知道主键叶子是整行,二级索引叶子是索引列加主键 |
| Oracle/PG/SQL Server 为什么叫 B-Tree | 知道这是数据库索引家族名,不要死扣教材定义 |
| 商业项目怎么用 | 能解释联合索引、范围查询、分页、页分裂和主键设计 |
数据库索引首先要围绕“页”理解
InnoDB 不是一行一行从磁盘读取数据。它以页为基本单位管理数据,默认页大小通常是 16KB。
flowchart TD
A["表空间 Tablespace"] --> B["段 Segment"]
B --> C["区 Extent"]
C --> D["页 Page,默认 16KB"]
D --> E["行 Record"]为什么这点重要?
| 事实 | 对索引设计的影响 |
|---|---|
| 磁盘和 Buffer Pool 以页为单位读写 | 索引节点最好能放进页里 |
| 一页能放的 key 越多 | 树的分叉越多,高度越低 |
| 树越矮 | 查询需要的随机 IO 越少 |
| 叶子页有序 | 范围查询和排序可以顺序扫描 |
所以数据库索引最核心的问题不是“比较次数少不少”,而是:一次查询要读多少页,随机 IO 多不多,能不能顺序扫描。
B+Tree 长什么样
flowchart TD
A["根页<br/>key + 子页指针"] --> B["内部页 1<br/>key + 子页指针"]
A --> C["内部页 2<br/>key + 子页指针"]
B --> D["叶子页 1<br/>有序 key + 数据"]
B --> E["叶子页 2<br/>有序 key + 数据"]
C --> F["叶子页 3<br/>有序 key + 数据"]
C --> G["叶子页 4<br/>有序 key + 数据"]
D <--> E
E <--> F
F <--> GB+Tree 的关键特征:
| 特征 | 解释 |
|---|---|
| 根节点和内部节点像目录 | 主要保存 key 和子页指针 |
| 真实数据在叶子层 | InnoDB 聚簇索引叶子保存整行数据 |
| 叶子节点按 key 有序 | 支持范围、排序、分页 |
| 叶子节点之间有链表 | 找到范围起点后可以继续向后扫 |
| 高扇出 | 一个页能放很多目录项,树高通常很低 |
为什么 B+Tree 查询 IO 少
假设一个索引页能放几百到上千个目录项,树高为 3 或 4 时就能管理大量数据。查询一条记录大致是:
flowchart TD
A["查 id=1001"] --> B["读根页"]
B --> C["读内部页"]
C --> D["读叶子页"]
D --> E["找到记录"]如果根页和部分内部页常驻 Buffer Pool,实际磁盘 IO 可能更少。
这也是为什么数据库不用普通二叉树。二叉树每个节点最多两个分支,数据量大时树高很高。树高越高,随机 IO 越多。
| 数据结构 | 分叉数量 | 是否适合磁盘页 | 范围查询 | 适合作为 InnoDB 主力索引吗 |
|---|---|---|---|---|
| 二叉搜索树 | 2 | 不适合 | 一般 | 不适合 |
| 红黑树 | 2 | 不适合 | 一般 | 不适合 |
| Hash | 很高 | 部分场景 | 不支持 | 不适合作主力 |
| B-Tree 家族 | 高 | 适合 | 支持 | 适合 |
| B+Tree | 高 | 很适合 | 很强 | InnoDB 主力 |
B-Tree 和 B+Tree 的核心区别
教材里的 B-Tree 和 B+Tree 可以这样区分:
| 对比 | B-Tree | B+Tree |
|---|---|---|
| 数据保存位置 | 内部节点和叶子节点都可能保存数据 | 数据统一在叶子节点 |
| 内部节点容量 | 因为保存数据,能放的 key 和指针更少 | 只做目录,能放更多 key 和指针 |
| 树高 | 相对可能更高 | 通常更矮 |
| 单点查询 | 可能在内部节点提前命中 | 一般走到叶子节点 |
| 范围查询 | 需要中序遍历或复杂跳转 | 找到起点后沿叶子链表顺序扫描 |
| 适合数据库页式存储 | 可以,但不是最优表达 | 更适合 OLTP 常见范围查询 |
B-Tree 示意:
flowchart TD
A["根节点<br/>key + data + pointer"] --> B["内部节点<br/>key + data + pointer"]
A --> C["内部节点<br/>key + data + pointer"]
B --> D["叶子节点<br/>key + data"]
B --> E["叶子节点<br/>key + data"]B+Tree 示意:
flowchart TD
A["根节点<br/>key + pointer"] --> B["内部节点<br/>key + pointer"]
A --> C["内部节点<br/>key + pointer"]
B --> D["叶子节点<br/>key + data"]
B --> E["叶子节点<br/>key + data"]
D <--> E为什么 InnoDB 更适合 B+Tree
InnoDB 的典型业务查询很多都不是单点查询,而是范围、排序、分页组合:
select id, order_no, amount
from orders
where user_id = 1001
and created_at >= '2026-07-01'
and created_at < '2026-08-01'
order by created_at desc
limit 20;如果有联合索引:
create index idx_user_time on orders(user_id, created_at);B+Tree 可以这样执行:
flowchart TD
A["联合索引 user_id, created_at"] --> B["先定位 user_id=1001"]
B --> C["再定位 created_at 范围起点"]
C --> D["沿叶子页链表顺序扫描"]
D --> E["按索引顺序返回前 20 条"]这比扫描整张表再排序要稳定得多。
InnoDB 使用 B+Tree 的收益:
| 收益 | 原理 |
|---|---|
| 单点查询快 | 从根到叶少量页访问 |
| 范围查询快 | 叶子页有序并链表连接 |
| 排序友好 | 索引顺序可以避免额外 filesort |
| 分页友好 | where + order by + limit 可沿索引取前 N |
| Buffer Pool 友好 | 热点根页、内部页、叶子页可缓存 |
| 写读平衡 | 插入、删除、页分裂、页合并都有成熟机制 |
聚簇索引和二级索引里的 B+Tree
InnoDB 中主键索引是聚簇索引。叶子节点保存整行数据。
flowchart TD
A["主键 B+Tree"] --> B["叶子页"]
B --> C["整行数据<br/>id、user_id、amount、status、created_at"]二级索引叶子节点保存的是索引列和主键值。
flowchart TD
A["二级索引 idx_user_time"] --> B["叶子页"]
B --> C["user_id、created_at、主键 id"]
C --> D["如果还需要其他列,回到主键 B+Tree"]这解释了回表:
select amount
from orders
where user_id = 1001
order by created_at desc
limit 20;如果 amount 不在 idx_user_time 中,二级索引先找到主键 id,再回聚簇索引读取 amount。如果查询字段都在索引中,就形成覆盖索引,不需要回表。
页分裂为什么影响写入
B+Tree 叶子页容量有限。插入新 key 时,如果目标页已满,就要分裂。
flowchart TD
A["叶子页已满"] --> B["插入新 key"]
B --> C{"是否追加到最右侧"}
C -- "是" --> D["更可能顺序写入"]
C -- "否" --> E["拆分页面"]
E --> F["移动部分记录"]
F --> G["更新父节点指针"]
G --> H["产生碎片和额外 IO"]这就是为什么 InnoDB 常推荐主键短、稳定、趋势递增。
| 主键类型 | 对 B+Tree 的影响 |
|---|---|
| 自增 bigint | 大多追加到右侧,页分裂较少 |
| 雪花 ID | 趋势递增,分布式友好 |
| 随机 UUID | 插入位置随机,页分裂和碎片更多 |
| 很长字符串主键 | 聚簇索引和所有二级索引都变大 |
不是说 UUID 一定不能用,而是要知道代价。如果必须用 UUID,商业项目常见做法是另设自增或趋势递增主键,把 UUID 做唯一业务键。
为什么不用 Hash 做所有索引
Hash 等值查询很快:
where id = 1001但商业系统大量查询不是单纯等值:
where created_at >= '2026-07-01'
order by created_at
where name like 'tom%'
where user_id = 1001
order by created_at desc
limit 20Hash 的问题:
| 问题 | 后果 |
|---|---|
| 无序 | 不能天然支持范围查询 |
| 无序 | 不能利用索引顺序排序 |
| 不支持最左前缀 | 联合条件能力弱 |
| Hash 冲突 | 还要处理冲突链 |
所以 Hash 可以作为某些内存结构或特定引擎的补充,但不适合作 InnoDB 主力索引。
Oracle、PostgreSQL、SQL Server 为什么说 B-Tree
很多数据库文档会把默认索引叫 B-tree index。这里不要机械理解成“它们一定使用教材里的 B-Tree,而 MySQL 才用 B+Tree”。
更准确的理解:
flowchart TD
A["数据库工程里的 B-Tree family"] --> B["B-Tree"]
A --> C["B+Tree"]
A --> D["B*Tree"]
A --> E["各数据库工程变体"]
E --> F["高扇出"]
E --> G["页式存储"]
E --> H["有序扫描"]
E --> I["并发控制和日志恢复"]不同数据库的叫法和实现:
| 数据库 | 文档常见叫法 | 工程上怎么理解 |
|---|---|---|
| MySQL InnoDB | B+Tree | 聚簇索引叶子保存整行,二级索引叶子保存主键 |
| PostgreSQL | B-tree index | 默认通用索引类型,支持等值、范围、排序,是 B-Tree 家族工程实现 |
| Oracle | B-tree index | root/branch/leaf block,leaf 保存 key 和 ROWID,适合范围扫描 |
| SQL Server | B-tree structure | root/intermediate/leaf level,聚集索引叶子是数据行,非聚集索引叶子是行定位器 |
| SQLite | B-tree | 表和索引都基于页式 B-tree 结构 |
它们共同选择 B-Tree 家族,是因为:
- 高扇出降低树高,减少随机 IO。
- key 有序,支持范围、排序、分页、
min/max。 - 页式结构适合 Buffer Cache、预读和刷盘。
- 插入、删除、分裂、合并有成熟算法。
- 能和锁、MVCC、日志恢复结合。
“使用 B-Tree 的数据库有哪些”怎么答
面试可以这样答:
严格说,很多关系型数据库文档会把默认索引称为 B-tree index,例如 PostgreSQL、Oracle、SQL Server、SQLite。MySQL InnoDB 通常明确按 B+Tree 理解。这里的 B-tree 不一定是教材里内部节点也保存完整数据的纯 B-Tree,而是数据库工程里的 B-Tree family,包括 B+Tree、B*Tree 和各种页式存储变体。它们共同特点是高扇出、树高低、key 有序、适合范围查询和排序,也方便数据库按 page/block 做缓存、预读、并发控制和崩溃恢复。不要这样答:
Oracle、PostgreSQL、SQL Server 用 B-Tree,所以它们范围查询不如 MySQL。这个说法不严谨。它们的 B-tree index 同样支持范围扫描,只是页格式、行定位、聚簇组织、MVCC、锁和优化器实现不同。
商业场景:订单表索引怎么设计
订单列表常见查询:
select id, order_no, amount, status, created_at
from orders
where user_id = 1001
and status in (1, 2)
order by created_at desc
limit 20;可能索引:
create index idx_user_status_time
on orders(user_id, status, created_at);为什么这样设计:
| 条件 | 作用 |
|---|---|
user_id | 等值条件,先缩小到某个用户 |
status | 继续过滤订单状态 |
created_at | 服务排序和范围 |
但要注意:status in (1,2) 可能让排序利用变复杂,真实是否合适要看数据分布和 EXPLAIN。如果查询更多是“用户最近订单”,也可能设计:
create index idx_user_time
on orders(user_id, created_at);索引设计不是背公式,而是从真实 SQL、选择性、排序、分页、回表成本一起判断。
可运行 Demo:观察 B+Tree 索引效果
建表:
create table btree_demo_order (
id bigint primary key auto_increment,
user_id bigint not null,
status tinyint not null,
amount decimal(10, 2) not null,
created_at datetime not null,
key idx_user_time (user_id, created_at),
key idx_status_time (status, created_at)
) engine = InnoDB default charset = utf8mb4;查询用户最近订单:
explain
select id, amount, created_at
from btree_demo_order
where user_id = 1001
order by created_at desc
limit 20;观察:
| 字段 | 期望 |
|---|---|
key | 使用 idx_user_time |
type | ref 或 range |
rows | 明显小于全表 |
Extra | 尽量避免大范围 Using filesort |
再观察覆盖索引:
explain
select user_id, created_at
from btree_demo_order
where user_id = 1001
order by created_at desc
limit 20;如果查询字段都在 idx_user_time 里,Extra 可能出现 Using index,表示覆盖索引,不需要回表。
常见坑
| 坑 | 为什么错 | 正确理解 |
|---|---|---|
| B+Tree 查询快只是因为二分查找 | 核心是减少页 IO,不只是 CPU 比较 | 围绕页、树高、顺序扫描理解 |
| B-Tree 一定比 B+Tree 差 | 工程实现有很多变体 | 比较时要看数据库具体实现 |
| Oracle/PG/SQL Server 用纯 B-Tree | 文档叫法常是家族名 | 不要死扣教材定义 |
| 随机 UUID 主键没问题 | 会导致页分裂、二级索引膨胀 | 尽量短、稳定、趋势递增 |
| 覆盖索引万能 | 只能减少回表,不能减少大范围扫描 | 还要控制扫描范围和排序 |
面试标准回答
InnoDB 使用 B+Tree,核心原因是数据库以页为单位读写,索引要尽量减少随机 IO。B+Tree 的内部节点只保存 key 和页指针,一页能放更多目录项,树高更低;真实数据在叶子层,叶子页按 key 有序并通过链表连接,范围查询、排序和分页都更高效。B-Tree 的内部节点也可能保存数据,扇出相对更低,范围扫描通常不如 B+Tree 的叶子链表直接。Hash 等值查询快,但不支持范围、排序和最左前缀;红黑树分叉太少,树高太高,不适合磁盘页式索引。
Oracle、PostgreSQL、SQL Server 文档常说 B-tree index,但这里的 B-tree 更像数据库索引家族名,不一定是教材里的纯 B-Tree。它们的实现也是围绕页、块、高扇出、有序扫描、并发控制和日志恢复设计的工程变体。所以更准确的表达是:现代关系型数据库主流索引都属于 B-Tree family,MySQL InnoDB 按 B+Tree 理解最清楚。关联知识点
| 知识点 | 说明 |
|---|---|
| 存储结构 | InnoDB 页、行、表空间、聚簇索引 |
| 索引知识点 | 最左前缀、覆盖索引、索引失效 |
| EXPLAIN 执行计划 | 验证索引是否被优化器选择 |
| 大表覆盖索引仍然慢 | 覆盖索引解决回表,不解决扫描大 |
| SQL 执行全过程 | 查询如何通过 B+Tree 和 Buffer Pool 读页 |
