知识全景图/ 软件工程与系统/ 如何设计一个数据库/ 如何设计 B+Tree 索引

如何设计 B+Tree 索引

从 MiniDB 的全表扫描失败开始,逐步长出有序叶子页、内部节点、分裂、范围扫描、回表和维护成本

阅读定位: 这页只讲数据库索引里最经典的一条线:B+Tree 为什么会从“按 key 查太慢”这个问题里长出来。它不讲全文索引、向量索引、LSM Tree 和复杂优化器,只把 MiniDB 的主键索引设计透。

一句话原则: B+Tree 不是“树结构知识点”,而是一条磁盘友好的访问路径:用少量页跳转找到目标叶子页,并且让叶子页保持有序,方便范围查询。
一、第一版:没有索引,按 id 查也要扫全表

MiniDB 已经有 heap file 和 RID 了。现在执行 WHERE id = 10086,如果没有索引,只能从第一页扫到最后一页。

select * from entries where id = 10086 SeqScan: for each page in entries.heap: for each slot in page: if row.id == 10086: return row
它能工作为什么不够下一步需要什么
小表能查到正确结果表越大越慢,查询时间和数据量线性相关从 key 直接找到 RID 的访问路径
不需要额外维护结构每次查询都重复做无用扫描用空间和写入成本换读取速度
阶段验收: 你能说清:索引不是为了让数据库“更高级”,而是为了避免每次查询都重新发现数据在哪。
二、第二版:用一个内存 Map 做索引

最直觉的索引是 Map<id, RID>。插入一行时,把 id 和 RID 记进 Map;查询时直接查 Map。

index: 1 -> RID(page=42, slot=0) 10086 -> RID(page=811, slot=4)
解决了什么马上撞上的问题为什么会引出磁盘索引
按 id 查非常快重启后 Map 没了,必须重扫表重建索引本身也要持久化
实现简单数据多了,Map 可能放不进内存索引不能假设全在内存
等值查询好用按时间范围查不方便索引最好保持 key 有序
三、第三版:用有序数组,范围查方便了,插入坏了

如果把索引项按 key 排序,范围查询就很自然:找到起点后一路往后扫。但数组插入中间位置要搬动大量元素。

操作有序数组表现问题
等值查找二分查找很快如果数组在磁盘上,随机访问很多页仍然麻烦
范围查询找到起点后顺序扫描这点很好,应保留
插入新 key可能要移动后面大量索引项写入成本太高,不适合频繁插入
页满数组没有天然页分裂机制需要可局部分裂的数据结构
这一步的教训: 一个数据库索引不能只服务查询,也要承受持续插入、删除和更新。
四、第四版:把有序数组切成叶子页

现在我们不维护一整条巨大数组,而是维护很多有序的 leaf page。每个 leaf page 里有一段连续 key,叶子页之间用 next 指针连起来。

Leaf Page 10: keys: 1, 2, 5, 8 values: RID... next: page 11 Leaf Page 11: keys: 13, 21, 34, 55 values: RID... next: page 12
设计变化解决什么新的问题
叶子页内部有序页内可二分查找要先知道应该去哪个叶子页
叶子页之间有 next 指针范围扫描可以跨页向右走插入分裂时要维护链表
页满时分裂不用搬动整棵索引分裂后父节点也要更新
五、第五版:用内部节点找到叶子页

现在问题变成:给定 key=10086,怎么知道该去哪个 leaf page?于是 B+Tree 长出 internal page。内部节点不存行地址,只存“key 范围 → 子页”。

Root: key < 100 -> page 10 100..999 -> page 24 1000..9999 -> page 81 key >= 10000 -> page 130 Leaf page 130: 10001 -> RID(...) 10086 -> RID(page=811, slot=4)
为什么内部节点只做路由为什么所有数据都放叶子验收问题
内部节点越小,能容纳越多 key,树越矮叶子页顺序连接,范围扫描更自然查找 key 是否总能走到唯一叶子页
内部节点不直接返回业务记录索引结构和表记录位置分离更新 RID 时,叶子值是否同步变化
阶段验收: 你应该能画出一条查询路径:root page → internal page → leaf page → RID → heap page。
六、第六版:叶子页满了,必须分裂

持续插入后,某个 leaf page 会满。不能整棵树重建,B+Tree 的关键动作是局部分裂。

分裂步骤为什么这样做容易错在哪里
把满叶子页分成左右两个页让两个页都留出空间给未来插入左右页 key 范围必须保持有序
维护 leaf next 指针范围扫描不能断链忘记更新 next 会让范围查询漏数据
把分裂边界 key 推给父节点父节点需要知道新叶子页的路由范围父节点也可能满,于是继续向上分裂
根节点满时新建根树高度增加root page id 必须更新到 catalog / metadata
before leaf: [10, 20, 30, 40] insert 25 after split: left: [10, 20, 25] right: [30, 40] promote separator key: 30 to parent
七、第七版:删除不是简单擦掉 key

删除 key 后,叶子页可能太空。如果完全不管,索引会越来越稀疏;如果每次都合并,又会频繁调整树结构。

选择适合什么代价
先只标记删除第一版 MiniDB,简化实现空间膨胀,需要后续 vacuum / rebuild
向兄弟页借 key兄弟页较满时要更新父节点边界 key
和兄弟页合并两个页都较空时父节点也要删除指针,可能级联调整
后台整理减少前台删除成本实现复杂,需要和并发控制配合
设计取舍: 第一版可以先不追求删除后的完美平衡,但必须承认这会带来空间膨胀和扫描成本。
八、第八版:索引和表必须一起对

插入一条记录时,存储引擎先拿到 RID,再把 key → RID 写进索引。这里最危险的是表和索引只成功一边。

失败场景后果需要谁解决
表页写了,索引页没写全表扫能找到,按索引查不到事务与 WAL
索引页写了,表页没写索引指向不存在 RID事务与 WAL
叶子分裂写了一半宕机树结构可能断裂页日志、恢复和一致性检查
并发插入同一范围两个事务可能同时分裂同一页Latch / Lock 协议
阶段验收: 这页不要求完整实现事务,但要求你能指出:B+Tree 不是孤立结构,它的每次修改都要和表页、Buffer Pool、WAL 协同。
九、第九版:覆盖索引和回表

叶子页里只存 key → RID 时,查到 RID 后还要回表读 heap page。如果查询只需要索引里已有的字段,就可以不回表。

索引叶子值查询表现代价
id -> RID主键查到 RID 后回表索引小,但多一次 heap page 读取
id -> amount查 amount 可直接从索引返回索引更大,更新 amount 要维护索引
(category, occurred_on) -> RID支持分类 + 日期范围查询联合 key 的顺序会影响可用查询
不要误读: 覆盖索引不是“索引越多越好”。它是在读性能、写入成本、空间成本、维护复杂度之间做选择。
十、把 B+Tree 设计线收起来
阶段遇到的问题长出的设计真正吸收点
SeqScan按 key 查太慢key → RID索引是访问路径
内存 Map重启丢失,放不下,范围查弱持久有序索引索引本身也要落盘
有序数组插入搬动太多有序叶子页局部修改比全局移动更适合数据库
叶子页不知道去哪个叶子内部节点树高换查询跳转次数
页满插不进去分裂和提升 separator keyB+Tree 的生命在于维护平衡
索引更新表和索引可能不一致事务 / WAL / Latch索引正确性不是单页问题
十一、练习:用失败驱动实现
练习故意制造什么失败通过标准
实现单 leaf page随机插入 key,检查页内顺序页内始终有序,查找能二分
实现 leaf split插满后继续插入左右页有序,next 指针不断,范围扫描不漏
实现两层树多个 leaf split 后查随机 keyroot 能路由到正确 leaf
实现根分裂持续插入直到 root 满树高增加,所有旧 key 仍可查
实现删除简化版删除大量 key 后范围扫描不返回已删除 key,并能说明空间膨胀问题
实现索引一致性测试插入时模拟表写成功、索引写失败能指出必须交给事务 / WAL 修复
十二、回到主线
如何设计一个数据库
如果你想回到 MiniDB 总主线,继续看 如何设计一个数据库
如何设计存储引擎
如果你还没理解 RID、Heap File、Page、Buffer Pool 和脏页刷盘,先回到 如何设计存储引擎
如何设计 WAL 和崩溃恢复
当 B+Tree 分裂、表页插入和索引页更新需要一起成功时,下一步看 如何设计 WAL 和崩溃恢复,把跨页一致性、commit、Redo 和 Undo 讲清楚。
总结: B+Tree 索引不是树的名词解释,而是从失败里长出来的访问路径:全表扫描太慢,于是需要 key → RID;内存 Map 不持久,于是索引要落盘;有序数组插入太贵,于是切成叶子页;叶子页太多找不到,于是长出内部节点;页满了,于是分裂;表和索引可能不一致,于是必须接事务和 WAL。