Bustub重点解析
mvcc解析
主流程
task1: watermark
Watermark::AddTxn 事务开启的时候,记录读时间戳
Watermark::RemoveTxn 事件提交或者回滚的时候,更新watermark
task2: 重建元组 和 扫描算子
- 重建元组
示例 1:正常更新链
1 | 初始版本 (t=0): {id:1, name:"Alice", age:25} |
示例 2:删除与恢复
1 | t=0: 插入 {id:1, name:"Alice", age:25} |
UndoLink结构体解析
1 | 当前元组 (最新版本) |
- 扫描算子
1 | 1、获取 tuple |
task3: 增删改算子
insert
路径 1: UPDATE 路径 (rid_opt.has_value() == true)
当检测到主键冲突且需要更新时,执行以下步骤:
- 获取旧元组信息
1 | auto tuple_link_info = GetTupleAndUndoLink(txn_mgr, table_info->table_.get(), rid_opt.value()); |
- 写-写冲突检测
1 | if (IsWriteWriteConflict(txn, cur_ts)) { |
- 检查当前事务是否与持有该元组的事务存在写-写冲突
- 如果冲突,标记事务为脏并抛出异常
- 处理已删除元组
1 | auto tuple_ptr = &old_tuple; |
- 生成或查找 Undo Link
1 | auto target_undo_link = GenerateOrFindUndoLink( |
- 这是 MVCC 的核心:创建 undo 记录以便回滚
- 保存旧版本信息,允许事务回滚时恢复
- 更新元组
1 | auto check = [cur_ts](const TupleMeta &meta, const Tuple &tuple, RID rid, std::optional<UndoLink> undo_link) { |
- 使用乐观锁检查,确保更新是原子的
- 如果更新失败(元组被其他事务修改),抛出异常
路径 2: INSERT 路径 (rid_opt.has_value() == false)
纯插入操作:
- 插入元组到表
1 | auto tuple_meta = TupleMeta{txn->GetTransactionTempTs(), false}; |
- 创建新的元组元数据(时间戳 = 事务临时时间戳,未删除)
- 调用表的
InsertTuple方法分配新的 RID - 使用锁管理器确保并发安全
- 更新索引(只更新主键索引)
1 | for (const auto &index : indexes) { |
- 提取元组的主键值
- 插入到 B+ 树索引中
- 如果插入失败(重复键),抛出异常
GenerateNewUndoLog and GenerateUpdatedUndoLog
GenerateNewUndoLog
- INSERT 操作:
base_tuple == nullptr
1 | if (base_tuple == nullptr) { |
场景:插入新元组
base_tuple为空 → 修改前没有数据- 返回的Undo日志:
is_deleted_ = true(回滚时删除该元组) modified_fields_和tuple_为空(不需要保存旧值)
示例:
1 | INSERT INTO users (id, name) VALUES (1, 'Alice'); |
Undo Log:{is_deleted: true, modified: [], tuple: []}
回滚时:删除RID对应的元组
- DELETE 操作:
target_tuple == nullptr
1 | if (target_tuple == nullptr) { |
场景:删除已有元组
target_tuple为空 → 修改后数据消失- 标记所有字段为已修改(
modified_fields全为true) - 保存完整的原始元组(
original_tuple) is_deleted_ = false(回滚时恢复元组,不是删除)
示例:
1 | DELETE FROM users WHERE id = 1; |
Undo Log:{is_deleted: false, modified: [true,true,…], tuple: (1, ‘Alice’, 25)}
回滚时:在RID位置插入保存的完整元组
- UPDATE 操作:两者都不为空
1 | std::vector<bool> modified_fields; |
场景:更新已有元组的部分字段
- 逐字段比较
base_tuple和target_tuple - 只记录发生变化的字段
- 节省存储空间(只保存修改字段的旧值)
示例:
1 | -- 假设原数据: (1, 'Alice', 25) |
比较结果:
- id: 1 vs 1 → 未变化,跳过
- name: ‘Alice’ vs ‘Bob’ → 变化,记录旧值 ‘Alice’
- age: 25 vs 26 → 变化,记录旧值 25
Undo Log:{is_deleted: false, modified: [false, true, true], tuple: (1, ‘Alice’, 25)}
创建部分Schema
1 | Schema modified_schema(modified_columns); // 只包含被修改的列 |
4.执行流程图
1 | GenerateNewUndoLog() |
GenerateUpdatedUndoLog
第一部分:核心逻辑
- 处理已删除的元组
1 | if (log.is_deleted_) { |
- 没有base_tuple的情况
1 | if (base_tuple == nullptr) { |
- 场景:元组之前不存在,这是第一次插入
- 行为:生成新的Undo日志,记录从”空”到”新元组”的变化
- 回滚时,将元组删除即可
- 有base_tuple的情况(完整合并)
Step 1: 重建原始元组
1 | auto original_tuple = ReconstructTuple(schema, *base_tuple, {0, false}, {log}); |
- 使用当前的base_tuple和Undo log,重建修改前的原始元组
- 这是通过应用Undo log中的修改字段来实现的
Step 2: 生成当前的变更日志
1 | auto cur_log = GenerateNewUndoLog(schema, base_tuple, target_tuple, log.ts_, log.prev_version_); |
- 记录从base_tuple到target_tuple的变更
- 即当前事务这次修改了哪些字段
Step 3: 合并两个Undo日志
1 | UndoLog combined_log; |
- 创建合并后的日志
- 继承旧日志的元数据(时间戳、上一版本等)
Step 4: 合并修改字段
1 | std::vector<Value> values; |
- 如果该字段在当前修改或历史修改中被修改过,标记为已修改
- 保存该字段的原始值(用于回滚)
Step 5: 构造合并后的元组
1 | auto temp_schema = GetUndoLogSchema(schema, combined_log); |
- 创建一个只包含修改字段的临时schema
- 用保存的原始值构造元组
第二部分:可视化示例
假设表有3个字段:(id, name, age)
场景:事务T1连续修改同一个元组
1 | 初始状态: (1, "Alice", 25) |
合并过程
1 | base_tuple = 当前数据库值 (1, "Bob", 30) |
update and detelte
delete_executor
- 执行删除
阶段一:收集待删除元组并检测冲突
1 | std::vector<Tuple> tuples; |
关键点:
- 从子执行器(通常是 SeqScan/IndexScan)逐行读取要删除的元组
- 对每个元组检查 写-写冲突(Write-Write Conflict)
- 将所有待删除元组暂存到
tuples向量中
为什么要暂存? 为了符合 MVCC 的”先收集后操作”模式,避免在遍历过程中因为删除操作影响扫描结果。
阶段二:逐个执行删除
1 | for (auto &tuple : tuples) { |
- 流程图
1 | 开始 |
删除示例
事务 T1(txn_id = 200)执行删除:
1
DELETE FROM users WHERE id = 1;
执行流程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
181. 扫描找到 RID=101 的元组
├─ tuple_meta.ts_ = 100, is_deleted_ = false
├─ 检查写-写冲突(无冲突)
└─ 暂存到 tuples 列表
2. 生成 Undo Log
├─ base_tuple = {id:1, name:"Alice", age:25}
├─ target_tuple = nullptr (删除操作)
└─ UndoLog {is_deleted_: false, modified_fields: [true,true,true],
tuple: {id:1, name:"Alice", age:25},
ts: 100, prev_version: null}
3. 更新元组元数据
├─ tuple_meta.ts_ = 200 (临时时间戳)
└─ tuple_meta.is_deleted_ = true
4. 写入写集
└─ AppendWriteSet(table_oid, RID=101)
update_executor
- 核心设计思想
更新操作分为两种类型:
| 更新类型 | 处理方式 | 说明 |
|---|---|---|
| 主键不变 | 原地更新(In-place Update) | 直接修改元组数据,生成 Undo Log 保存旧版本 |
| 主键变更 | 删除 + 插入(Delete + Insert) | 先逻辑删除旧元组,再插入新元组 |
阶段一:收集待更新的元组
1 | std::vector<RID> tuple_rids; |
关键点:
- 先扫描所有待更新元组,暂存到 vector 中(避免遍历时数据变化影响结果)
- 对每个元组计算新值(
target_expressions_对应 SET 子句) - 检查写-写冲突
阶段二:判断主键是否变更
1 | bool primary_key_equal = true; |
示例:
sql
1 | -- 主键不变(原地更新) |
分支一:主键不变 → 原地更新
1 | if (primary_key_equal) { |
Undo Log 生成:
1 | // GenerateNewUndoLog 中的更新路径 |
示例数据:
| 操作前 | 操作后 | Undo Log 内容 |
|---|---|---|
{id:1, name:"Alice", age:25} |
{id:1, name:"Alice", age:26} |
modified_fields: [F,F,T], {age:25} |
分支二:主键变更 → 删除 + 插入
步骤 1:逻辑删除旧元组
1 | // 生成 Undo Log(删除操作,保存完整旧元组) |
步骤 2:检查新主键是否冲突
1 | bool key_exist = false; |
CheckKeyIfExistInIndex 的返回值:
ret.first = true:键冲突(已被其他事务使用)ret.second:可复用的 RID(被删除标记的旧元组位置)
步骤 3:插入新元组
1 | InsertTupleAndIndexKey(&new_tuples[i], |
InsertTupleAndIndexKey 的逻辑(你之前看过的):
- 如果
rid_opt.has_value() == true:复用被删除标记的位置(更新模式) - 如果
rid_opt.has_value() == false:插入到新位置(插入模式)
阶段三:处理第一次失败的插入
1 | // 4. try to insert for previous not updated |
为什么需要重试?
- 第一次循环中可能遇到键冲突(
key_exist = true),跳过该元组 - 第二次循环时,其他元组的插入可能已经完成,冲突可能已解决
- 如果仍然冲突,则抛出异常
- 完整执行流程图
1 | 开始 |
- 示例数据演示
1 | -- 初始数据 |
这种设计完美地平衡了性能(原地更新)和灵活性(主键变更),同时通过 MVCC 保证了读一致性和事务回滚能力。
垃圾回收
- 整体架构
1 | GarbageCollection() |
阶段一:收集所有事务的写集
1 | auto water_mark = running_txns_.GetWatermark(); |
关键点:
| 变量 | 含义 |
|---|---|
water_mark |
水位线:当前所有活跃事务中最小的读时间戳。任何 ts <= water_mark 的版本都对所有活跃事务不可见。 |
tuple_infos |
所有已提交/中止事务修改过的 RID 列表(从写集中收集) |
txn_map_ |
存储所有事务(包括活跃和已完成的) |
为什么需要停止世界(Stop-the-World)?
- 需要获取所有事务的快照(
water_mark) - 在 GC 期间,不能有新事务开始或旧事务提交,否则水位线会变化
- 这就是为什么注释说 “Will be called only when all transactions are not accessing the table heap”
阶段二:标记不可见的页面版本
1 | { |
判断逻辑:
1 | 如果 meta.ts_ <= water_mark |
示例:
| 时间戳场景 | water_mark | meta.ts_ | 是否不可见 |
|---|---|---|---|
| 活跃事务最小 ts=100 | 100 | 50 | ✅ 不可见(旧版本) |
| 活跃事务最小 ts=100 | 100 | 150 | ❌ 仍可见(新版本) |
| 活跃事务最小 ts=100 | 100 | 100 | ✅ 不可见(边界值) |
阶段三:统计不可见的 Undo Log
这是最复杂的部分,需要遍历每个 RID 的 Undo Log 链:
1 | { |
核心算法解析
数据结构:
version_info_[page_id]:每个页面的版本信息prev_link_[slot_num]:每个槽位的 Undo Log 链表头- Undo Log 通过
prev_version_链接成链表(从新到旧)
遍历逻辑:
1 | Undo Log 链表示例(从新到旧): |
两种情况:
| 情况 | meet_first 初始值 |
行为 |
|---|---|---|
| 最新版本已被删除 | true(RID 在 invisible_page_versions 中) |
所有 Undo Log 都标记为不可见 |
| 最新版本仍可见 | false |
只标记 ts <= water_mark 的旧版本 |
阶段四:清理已完成的事务
1 | std::unique_lock<std::shared_mutex> lck(txn_map_mutex_); |
清理条件:
1 | 条件1:事务已完成(COMMITTED 或 ABORTED) |
- 完整执行示例
场景设置
1 | 时间线: |
数据状态
| 版本 | 内容 | ts | prev_version |
|---|---|---|---|
| 最新(元组) | 已删除 | 400 | → UndoLog3 |
| UndoLog3 | Alice3 | 300 | → UndoLog2 |
| UndoLog2 | Alice2 | 200 | → UndoLog1 |
| UndoLog1 | Alice | 100 | null |
GC 执行
1 | // 阶段一:收集 RID |
清理结果
| 事务 | Undo Log | 是否可见 | 是否清理 |
|---|---|---|---|
| T1 | 无 | N/A | ✅ 清理 |
| T2 | UndoLog1 | ❌ 不可见 | ✅ 清理 |
| T3 | UndoLog2 | ❌ 不可见 | ✅ 清理 |
| T4 | UndoLog3 | ✅ 可见(T5需要) | ❌ 保留 |
| T5 | 无 | N/A | ❌ 活跃事务 |
task4: 索引扫描
Init() 方法解析
Init() 负责初始化扫描状态,根据 plan_->filter_predicate_(过滤谓词)决定扫描策略:
- 无过滤条件(全索引扫描)
1 | if (plan_->filter_predicate_ == nullptr) { |
- 从索引的起始位置开始遍历所有记录
- 点查找(Point Lookup) - 两种场景
场景 A:通过析取条件匹配
1 | if (FindDisjunctiveIndexConditions(plan_->filter_predicate_, index_info.get(), point_lookups)) |
- 将谓词拆解为多个析取子句(OR 连接)
- 每个子句作为一次独立的等值查询
场景 B:索引前缀完全匹配
1 | if (match_result.equality_condition_count_ == index_info->index_->GetKeyAttrs().size()) |
- 所有索引列都有等值条件
- 执行精确查找
- 范围查找(Range Scan)
1 | // 匹配索引前缀条件 |
- 提取索引前缀的等值和范围条件
- 构建起始键(
start_partial_tuple_) - 使用
GetBeginIterator(start_key)定位起始位置 - 处理
>和>=的边界情况(跳过等于起始键的记录)
- 全表/全索引扫描
- 如果没有匹配任何优化路径,退化为全索引扫描
- 所有过滤条件转为
remaining_conds_在Next()中逐条检查
Next() 方法解析
Next() 负责获取下一条满足条件的记录,返回 (tuple, rid):
- 点查找分支
1 | if (is_point_lookup_) { |
- 遍历所有点查找键
- 使用
ScanKey获取 RID - MVCC 处理:通过
GetTupleAndUndoLink+CollectUndoLogs+ReconstructTuple获取当前事务可见的版本
- 范围/全扫描分支
1 | while (!index_iterator_.IsEnd()) { |
- 迭代索引条目(
(key, rid)) - 对每个 RID 重建可见元组
- 依次应用前缀条件和剩余条件
- 前缀条件失败时提前终止(利用索引有序性)
示例数据
假设有一个学生表 student:
1 | CREATE TABLE student ( |
表数据:
1 | (1, 'Alice', 20, 90) RID=100 |
索引结构(B+树):
1 | 键值: (1,'Alice',20) -> RID=100 |
示例1:无过滤条件(全索引扫描)
SQL
1 | SELECT * FROM student; |
执行流程
Init() 阶段:
1 | // plan_->filter_predicate_ == nullptr |
Next() 阶段:
1 | // 第1次调用 Next() |
输出顺序:按索引键排序,即 (1,2,3,4,5)
示例2:点查找(完整索引匹配)
SQL
1 | SELECT * FROM student WHERE id = 3 AND name = 'Charlie' AND age = 21; |
Init() 阶段
步骤1:分解谓词
1 | // plan_->filter_predicate_ = (id=3) AND (name='Charlie') AND (age=21) |
步骤2:匹配索引
1 | MatchIndexWithPreds(predicates, index_info.get(), match_result); |
步骤3:构建查找键
1 | if (match_result.equality_condition_count_ == index_info->index_->GetKeyAttrs().size()) { |
结合后面的分析,这里的代码是错的。
Next() 阶段
1 | // 第1次调用 Next() |
示例3:点查找(析取条件 OR)
SQL
1 | SELECT * FROM student WHERE (id = 1 AND name = 'Alice') OR (id = 5 AND name = 'Eve'); |
Init() 阶段
1 | FindDisjunctiveIndexConditions(plan_->filter_predicate_, index_info.get(), point_lookups); |
Next() 阶段
1 | // 第1次 Next() |
关键点:即使没有 age 条件,仍能利用索引前缀进行查找
示例4:范围查找
SQL
1 | SELECT * FROM student WHERE id >= 2 AND id < 4; |
Init() 阶段
步骤1:匹配索引
1 | MatchIndexWithPreds(predicates, index_info.get(), match_result); |
步骤2:构建范围
1 | // start_key_exprs = [2] |
Next() 阶段
1 | // 第1次 Next() |
性能优势:不需要扫描 id=4 和 id=5 的记录
示例5:前缀匹配 + 剩余条件
SQL
1 | SELECT * FROM student WHERE id = 2 AND score > 80; |
Init() 阶段
1 | MatchIndexWithPreds(predicates, index_info.get(), match_result); |
Next() 阶段
1 | // 第1次 Next() |
关键点:即使 id=2 后面还有记录(如 (2,'Charlie',20)),但我们的前缀条件是 id=2,如果遇到 id=3,说明 id=2 的所有记录都已遍历完,可以提前终止。
示例6:MVCC 可见性处理
场景
事务 T1 将 id=3 的 score 从 88 改为 90(未提交)
SQL
1 | SELECT * FROM student WHERE id = 3; |
Next() 执行过程
1 | // 1. 索引查找得到 RID=102 |
没有剩余条件的点查找(即索引点查)-Init()函数
- 核心函数
FindDisjunctiveIndexConditions
这个函数的作用是从过滤谓词中提取可以用于点查找的析取条件。
处理逻辑:
场景 A:简单点查找
1 | -- SQL |
场景 B:OR 条件(析取)
1 | -- SQL |
场景 C:部分索引前缀
1 | -- SQL |
场景 D:无法使用索引
1 | -- SQL |
1 | is_point_lookup_ = true; |
- 设置执行模式
- 标记当前为点查找模式
- 在
Next()方法中会走点查找分支
1 | point_lookup_partial_tuples_.reserve(point_lookups.size()); |
- 预分配内存
reserve()预先分配容量,避免动态扩容- 优化性能,减少内存分配次数
1 | for (auto &constant_exprs : point_lookups) { |
- 构建查找键
BuildTupleFromIndexPrefixExprs 函数解析:
1 | void BuildTupleFromIndexPrefixExprs(Tuple *tuple, |
示例:
1 | // 索引有3列:(id, name, age) |
- 完整的执行流程示例
示例1:单值点查找
1 | SELECT * FROM student WHERE id = 3; |
代码执行:*
1 | // 1. FindDisjunctiveIndexConditions 解析 |
示例2:OR 条件
1 | SELECT * FROM student WHERE id = 3 OR id = 5; |
代码执行:
1 | // 1. FindDisjunctiveIndexConditions 解析 |
示例3:多列 OR
1 | SELECT * FROM student |
代码执行:
1 | // 1. FindDisjunctiveIndexConditions 解析 |
示例4:复杂条件(函数返回 false)
1 | SELECT * FROM student |
代码执行:
1 | // FindDisjunctiveIndexConditions 解析: |
带剩余条件的点查找-Init()函数
- 分解合取条件
1 | // 3. point lookup with remaining conditions / range lookup with specific prefix |
DecomposeConjunction 函数: 将 AND 连接的条件拆分成独立的谓词列表。
示例:
1 | -- SQL |
- 匹配索引条件
1 | IndexMatchResult match_result; |
MatchIndexWithPreds 函数: 分析哪些谓词可以用索引处理。
数据结构:
1 | struct IndexMatchResult { |
处理逻辑示例:
1 | -- 索引列: (id, name, age) |
关键规则:
- 等值条件:必须按索引列顺序出现
- 范围条件:只能有一个(且必须在最后一个等值条件之后)
- 不等值/非索引列:全部放入 `remaining_conditions_
- 保存剩余条件
1 | if (match_result.is_valid_) { |
将非索引列的条件保存下来,在 Next() 中回表后过滤。
- 判断是否为点查找
1 | // 3.1 point lookup |
条件: 等值条件的数量 == 索引列的总数
含义: 索引的每一列都有一个等值匹配,可以精确定位到唯一/少数记录。
示例对比:
1 | -- 索引: (id, name, age) |
- 构建点查找键
1 | is_point_lookup_ = true; |
注意: 这里有个看似奇怪的地方:
1 | BuildTupleFromIndexPrefixExprs(&tuple, {cond.constant_value_}, index_info); |
为什么只传 {cond.constant_value_} 而不是所有 index_conditions_?
原因分析:
match_result.index_conditions_ 中存储的是所有索引条件,但 BuildTupleFromIndexPrefixExprs 期望的是按顺序的常量表达式列表。
问题出在哪里?
1 | // 假设 match_result.index_conditions_ = [ |
这是代码的 BUG 还是设计?
实际上,这是一个索引扫描的范围查找优化:
深入理解:这不是真正的点查找!
1 | // 正确的点查找应该用 ScanKey 精确匹配所有列 |
完整执行流程示例
示例1:点查找(所有列等值)
1 | SELECT * FROM student |
执行过程:
1 | // 1. 分解条件 |
实际执行(Next()):
1 | // 点查找循环 |
这里存在严重的逻辑错误! 🐛
1 | // 正确的点查找键构建 |
范围查找-Init()函数
代码整体结构
这段代码处理的是:利用索引前缀进行范围查找,例如 WHERE id = 3 AND name = 'Charlie' AND age > 20。
逐行详细解析
- 变量声明
1 | // 3.2 range lookup with specific prefix |
start_key_exprs:存储构建起始键的常量表达式列表last_comparison_type:记录最后一个比较操作的类型(用于边界处理)
- 构建前缀条件和起始键
1 | for (auto &cond : match_result.index_conditions_) { |
循环处理每个索引条件:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 更新 last_comparison_type |
记录最后一个条件类型 |
| 2 | 加入 prefix_preds_ |
保存为过滤条件(在 Next() 中检查) |
| 3 | 加入 start_key_exprs |
构建起始键的常量值 |
示例:
1 | // 索引条件: [id=3(EQ), name='Charlie'(EQ), age>20(GT)] |
- 构建起始元组
1 | BuildTupleFromIndexPrefixExprs(&start_partial_tuple_, start_key_exprs, index_info); |
BuildTupleFromIndexPrefixExprs 的作用:
1 | // 索引列: (id, name, age) |
如果有缺失列:
1 | // 索引列: (id, name, age) |
- 定位到起始位置
1 | IntegerKeyType_BTree start_key; |
GetBeginIterator(start_key) 的作用:
- 返回第一个 >= start_key 的索引条目
- 对于 B+树,这是范围扫描的起点
示例:
1 | // start_key = (3, 'Charlie', 20) |
- 边界处理:跳过起始键(重要!)
1 | // skip the included start key if the last comparison is greater than |
问题: 当最后一个条件是 >(大于)而不是 >=(大于等于)时,需要跳过等于起始键的记录。
为什么需要这个处理?
1 | -- SQL |
详细处理流程:
步骤1:检查条件
1 | if (!index_iterator_.IsEnd() && last_comparison_type == ComparisonType::GreaterThan) |
- 迭代器有效(不是结束)
- 最后一个比较是
>(而不是>=)
步骤2:获取当前键
1 | auto [cur_key, _] = *index_iterator_; |
步骤3:构建比较模式
1 | std::vector<uint32_t> attrs; |
步骤4:比较键值
1 | IntegerComparatorType_BTree comparator(&key_schema); |
完整的执行示例
示例1:> 条件(需要跳过)
1 | SELECT * FROM student |
数据:
1 | 索引键值: |
执行过程:
1 | // 1. 构建起始键 |
示例2:>= 条件(不需要跳过)
1 | SELECT * FROM student |
执行过程:
1 | // 1. 构建起始键 |
示例3:前缀不足(缺失列)
1 | SELECT * FROM student |
执行过程:
1 | // 1. 匹配索引 |
示例4:复杂条件组合
1 | SELECT * FROM student |
执行过程:
1 | // 1. 匹配索引 |
点查找 - Next函数
代码整体结构
这段代码处理两种扫描模式:
点查找模式 (
is_point_lookup_ == true)范围扫描模式(后续代码,这里暂不解析)
快速失败检查
1 | if (IsPredicateFalse(plan_->filter_predicate_)) { |
IsPredicateFalse 函数: 检查谓词是否恒为 FALSE。
1 | // 示例: |
优化目的: 避免执行无意义查询。
- 过滤函数定义
1 | auto select_func = [this](const Tuple &cur_tuple, const std::vector<AbstractExpressionRef> &preds) { |
select_func Lambda: 检查元组是否满足所有谓词条件。
执行逻辑:
1 | for (auto &pred : preds) { |
示例:
1 | // remaining_conds_ = [score > 80] |
注意: 这里使用 plan_->OutputSchema() 作为求值上下文,而不是表 schema。这意味着输出列可能被重命名或投影。
- 点查找主循环
1 | // 1. point lookup |
无限循环,直到找到一条满足条件的记录或遍历完所有查找键。
- 检查是否还有查找键
1 | if (point_lookup_idx_ >= point_lookup_partial_tuples_.size()) { |
point_lookup_idx_: 当前处理的查找键索引point_lookup_partial_tuples_: 所有查找键列表
1 | // 示例: |
- 执行索引查找
1 | auto &cur_tuple = point_lookup_partial_tuples_[point_lookup_idx_++]; |
ScanKey 函数: 在 B+树中查找匹配的键,返回所有对应的 RID。
1 | // 示例: |
为什么用 std::vector?
- 虽然点查找通常返回唯一记录,但索引可能不唯一
- 也可能存在重复键(B+树支持重复键)
- 获取元组和 Undo 链接
1 | if (!rid_ret.empty()) { |
GetTupleAndUndoLink 函数: 从表中读取指定 RID 的元组及其版本链信息。
返回三元组:
tuple_meta_:元组元数据(is_deleted, timestamp 等)tuple_:当前版本的元组数据undo_link:指向 undo 日志的链接(用于 MVCC)
MVCC 背景:
1 | 表存储: |
- 收集 Undo 日志
1 | auto undo_logs = CollectUndoLogs(rid_ret[0], tuple_meta_, tuple_, undo_link, |
CollectUndoLogs 函数: 收集当前事务需要应用的 undo 日志列表。
判断逻辑:
1 | // 当前事务 T2 |
为什么 continue?
- 如果记录在当前事务看来不存在(已删除)
- 跳过这条记录,尝试下一个查找键
- 重建可见元组
1 | auto rebuild_tuple = ReconstructTuple(&GetOutputSchema(), tuple_, tuple_meta_, undo_logs.value()); |
ReconstructTuple 函数: 应用 undo 日志,重建当前事务可见的元组版本。
重建过程:
1 | // 原始 tuple_ = (3, 'Charlie', 21, 90) // T1 修改后 |
- 应用剩余条件并返回
1 | if (select_func(rebuild_tuple.value(), remaining_conds_)) { |
执行步骤:
- 应用剩余条件:
1 | // remaining_conds_ = [score > 80] |
- 设置 RID:
1 | rebuild_tuple->SetRid(rid_ret[0]); // 设置 RID=102 |
- 返回结果:
1 | *tuple = rebuild_tuple.value(); // 输出元组 |
- 循环控制
1 | } |
如果当前查找键没有匹配:
rid_ret为空 → 继续while循环- 尝试下一个查找键
如果所有查找键都处理完:
point_lookup_idx_超出范围 → 返回false
完整的执行流程图
1 | 开始 Next() |
综合示例
场景:点查找 + 剩余条件
初始状态:
1 | -- 表 student(id, name, age, score) |
SQL:
1 | SELECT * FROM student |
执行过程:
cpp
1 | // Init() 阶段: |
范围查找-Next函数
代码整体结构
这段代码处理的是:从索引的某个起始位置开始,顺序扫描直到满足条件或到达末尾。
- 检查迭代器状态
1 | // 2. range lookup |
index_iterator_:指向当前要扫描的索引条目
1 | // 如果迭代器已经到达末尾 |
- 获取输出模式
1 | auto schema = plan_->OutputSchema(); |
注意: 这里声明了 schema 但实际上没有使用!
1 | auto schema = plan_->OutputSchema(); // 未使用,可能是冗余代码 |
这可能是遗留代码或准备用于未来扩展。
- 主循环
1 | while (true) { |
无限循环,直到找到一条满足条件的记录或到达索引末尾。
- 获取当前索引条目
1 | auto [cur_key, cur_rid] = *index_iterator_; |
解引用迭代器返回:
1 | // B+树迭代器返回 pair<KeyType, RID> |
- 读取元组和 Undo 链接
1 | auto [tuple_meta, tuple_, undo_link] = |
与点查找相同的 MVCC 处理:
1 | // 从表中读取 RID=102 的记录 |
- 收集 Undo 日志
1 | auto undo_logs = CollectUndoLogs(cur_rid, tuple_meta, tuple_, undo_link, |
示例场景:
1 | // 记录被其他事务删除且已提交 |
- 重建可见元组
1 | auto rebuild_tuple = ReconstructTuple(&GetOutputSchema(), tuple_, tuple_meta, undo_logs.value()); |
示例:
1 | // 应用 undo 日志,得到当前事务可见的版本 |
- 应用前缀条件(关键!)
1 | if (!select_func(rebuild_tuple.value(), prefix_preds_)) { |
prefix_preds_:从 Init() 中保存的索引前缀条件
1 | // 示例: |
为什么提前终止?
因为索引是有序的,prefix_preds_ 是连续的前缀条件:
1 | // 索引顺序:(id, name, age) |
这是一个重要的性能优化!
- 应用剩余条件
1 | if (!select_func(rebuild_tuple.value(), remaining_conds_)) { |
remaining_conds_:非索引列的条件(如 score > 80)
1 | // remaining_conds_ = [score > 80] |
为什么不能提前终止?
1 | // 索引顺序:(id, name, age) |
- 返回结果
1 | rebuild_tuple->SetRid(cur_rid); |
找到一条满足所有条件的记录:
1 | // 1. 设置 RID |
- 返回成功
1 | return true; |
表示找到了一条记录,调用者可以继续调用 Next() 获取更多记录。
完整的执行流程图
1 | 开始 Next() |
综合示例
场景:范围查找 + 前缀条件 + 剩余条件
初始状态:
sql
1 | -- 表 student(id, name, age, score) |
SQL:
1 | SELECT * FROM student |
Init() 阶段:
1 | // 匹配索引: |
Next() 执行过程:
1 | // ===== 第1次 Next() ===== |
关键点: 当遇到 id=4 时,由于索引有序,后面所有记录的 id 都 > 3,所以 id=3 的条件永远不会再满足,可以安全地提前终止!
b+树解析
Insert
我来逐行解析这段 B+树插入 的代码。这是 BusTub 数据库系统中 B+树索引的核心插入逻辑。
整体结构
这段代码包含两个函数:
Insert- 对外的公开接口(处理事务参数)InsertHelper- 内部的核心插入逻辑(支持两种锁模式)
一、Insert 函数解析
1 | auto BPLUSTREE_TYPE::Insert(const KeyType &key, const ValueType &value, Transaction *transaction) -> bool { |
- 公开的插入接口,返回
bool表示插入是否成功 - 参数:要插入的键值对
(key, value),以及当前事务
1 | bool dummy_used = false; |
- 事务兼容性处理:如果外部没有传入事务(
nullptr),创建一个虚拟事务对象 dummy_used标记这个事务是临时创建的,后面需要释放
1 | bool success = InsertHelper(key, value, transaction, LatchMode::OPTIMIZE); |
- 调用核心插入函数,初始使用
OPTIMIZE模式 OPTIMIZE模式是乐观锁模式(性能更好,但可能在页面满时失败)
1 | if (dummy_used) { |
- 如果是临时创建的事务,释放内存
- 返回插入结果
二、InsertHelper 函数解析
1 | auto BPlusTree<KeyType, ValueType, KeyComparator>::InsertHelper(const KeyType &key, const ValueType &value, |
- 内部核心插入函数
- 多了一个
mode参数:OPTIMIZE(乐观)或INSERT(保守/悲观)
第一阶段:初始化变量和加根页锁
1 | int dirty_height = 0; // 记录脏页高度(用于释放锁时判断哪些页被修改了) |
1 | if (IsEmpty()) { // B+树为空 |
逻辑说明:
- 树为空时,插入操作将创建第一个节点
- 如果是
OPTIMIZE模式:释放锁,改用INSERT模式重新调用(悲观模式下做更多准备工作) - 如果是
INSERT模式:直接调用InitBPlusTree()初始化树,释放锁,返回true
第二阶段:查找叶子节点
1 | auto [raw_leaf_page, leaf_page] = FindLeafPage(key, transaction, mode); |
- 从根节点开始查找,定位到应该插入的叶子节点
FindLeafPage会沿途加锁(根据mode决定加锁策略)- 返回叶子页面的原始指针和包装对象
1 | if ((1 + leaf_page->GetSize()) == leaf_page->GetMaxSize() && mode == LatchMode::OPTIMIZE) { |
- 乐观锁失败条件:如果叶子节点已满(插入后需要分裂),且当前是
OPTIMIZE模式 - 因为乐观模式下没有对父节点加锁,无法安全地处理分裂
- 所以:释放所有锁,改用
INSERT模式重新调用
第三阶段:插入键值对
1 | bool no_duplicate = leaf_page->Insert(key, value, comparator_); |
- 尝试在叶子节点中插入
(key, value) - 如果返回
false,说明键已存在(B+树不允许重复键) - 释放锁,返回
false
1 | // 没有重复 |
第四阶段:处理分裂
1 | if (leaf_page->GetSize() == leaf_page->GetMaxSize()) { |
- 如果叶子节点满了,执行分裂操作:
- 创建新的叶子页面
- 将原叶子页面的后半部分移动到新页面
- 新页面的父节点与原页面相同
- 取新页面的第一个键作为向上的键
- 调用
InsertInParent()在父节点中插入该键(可能递归分裂) UnpinPage释放新页面(true表示页面已修改,需要刷盘)
第五阶段:清理并返回
1 | ReleaseAllLatches(transaction, mode, dirty_height); |
- 释放所有持有的锁
- 返回
true表示插入成功
三、两种锁模式对比
| 模式 | 说明 | 使用场景 |
|---|---|---|
| OPTIMIZE(乐观) | 只对路径上的页面加读锁,效率高 | 树结构变化可能性小时使用 |
| INSERT(悲观/保守) | 对路径上的页面加写锁,允许安全修改 | OPTIMIZE 失败后回退使用 |
设计思想:
- 先用乐观模式尝试,如果发现需要分裂(结构会变化),则释放锁,改用悲观模式重新执行
- 这样在大多数情况下(叶子节点未满),性能最优
四、关键辅助函数
| 函数 | 作用 |
|---|---|
LatchRootPageId() |
对根页面加锁 |
FindLeafPage() |
查找键应该落入的叶子节点 |
ReleaseAllLatches() |
释放当前事务持有的所有页面锁 |
InitBPlusTree() |
初始化一棵只有根节点的B+树 |
InsertInParent() |
在父节点中插入键,必要时递归分裂 |
五、执行流程图
1 | Insert(key, value) |
Search
我来逐行解析这段 B+树查询(GetValue) 的代码。这是 BusTub 数据库系统中 B+树索引的查找操作。
整体功能
这段代码实现了 B+树的 键值查找 功能:给定一个键 key,在 B+树中查找所有匹配的值,并将结果存入 result 向量中。
逐行解析
第一部分:事务处理
1 | auto BPLUSTREE_TYPE::GetValue(const KeyType &key, std::vector<ValueType> *result, Transaction *transaction) -> bool { |
- 函数签名:B+树的查询接口
- 参数:
key:要查找的键result:输出参数,存储查找到的所有值transaction:当前事务
- 返回值:
bool,表示是否找到至少一个匹配的值
1 | bool dummy_used = false; |
- 事务兼容处理:如果没有传入事务,创建一个临时虚拟事务
dummy_used标记是否需要释放这个临时事务
第二部分:根页面加锁与空树检查
1 | LatchRootPageId(transaction, LatchMode::READ); |
- 对根页面加读锁(
READ模式) - 因为这是查询操作,不会修改树结构,所以只需要共享锁(读锁)
- 多个读操作可以并发执行
1 | if (IsEmpty()) { |
- 空树检查:如果 B+树为空(没有根节点)
- 释放所有锁,清理临时事务,返回
false(未找到)
第三部分:查找叶子节点
1 | bool found = false; |
- 定位叶子节点:从根节点开始,根据键值查找应该存储该键的叶子节点
FindLeafPage会沿着路径逐层加读锁- 使用 C++17 的结构化绑定,返回两个对象:
raw_leaf_page:原始页面指针leaf_page:包装后的叶子页面对象
第四部分:在叶子节点中进行二分查找
1 | auto left = 0; |
- 初始化二分查找边界
GetSize()返回叶子节点中键值对的数量left指向第一个元素,right指向最后一个元素
1 | while (left <= right) { |
- 二分查找循环,条件是
left <= right mid计算中间位置(避免整数溢出)
1 | auto comp_result = comparator_(key, leaf_page->KeyAt(mid)); |
- 比较键值:将目标键与叶子节点中
mid位置的键进行比较 comparator_是键的比较器(通常是std::less或自定义比较函数)- 返回值:
0:相等< 0:目标键小于中间键> 0:目标键大于中间键
1 | if (comp_result == 0) { |
- 找到匹配:如果键相等
- 将对应的值(
ValueAt(mid))添加到结果向量中 - 标记
found = true,跳出循环
1 | if (comp_result < 0) { |
- 二分查找的常规移动:
- 如果目标键小于中间键,在左半部分继续查找(
right = mid - 1) - 如果目标键大于中间键,在右半部分继续查找(
left = mid + 1)
- 如果目标键小于中间键,在左半部分继续查找(
第五部分:清理并返回
1 | // clear up all the latches held in this transaction |
- 释放所有锁:释放当前事务持有的所有页面读锁
- 包括根页面和路径上所有页面的锁
1 | if (dummy_used) { |
- 如果是临时事务,释放内存
- 返回查找结果(
true表示找到,false表示未找到)
关键设计思想
- 读锁(共享锁)机制
1 | LatchRootPageId(transaction, LatchMode::READ); |
- 查询操作只加读锁,不阻塞其他读操作
- 多个查询可以并发执行,提高并发性能
- 与插入操作的写锁(
OPTIMIZE/INSERT)互斥
- 二分查找优化
- 叶子节点内部使用二分查找,时间复杂度 O(log N)
- 比线性扫描更高效,特别是叶子节点包含大量键时
- 事务一致性
- 即使没有外部事务,也会创建临时事务来管理锁
- 确保所有操作都在事务上下文中,便于统一管理锁的获取和释放
与插入操作的对比
| 特性 | GetValue(查询) |
Insert(插入) |
|---|---|---|
| 锁模式 | READ(读锁/共享锁) |
OPTIMIZE / INSERT(写锁/排他锁) |
| 锁粒度 | 路径上的页面都加读锁 | 路径上的页面加写锁 |
| 是否修改树 | 否(只读) | 是(可能分裂) |
| 并发性 | 高(多读并发) | 低(互斥) |
| 二分查找 | 在叶子节点中进行 | 插入前需要先定位位置 |
执行流程图
1 | GetValue(key, result, transaction) |
Delete
整体功能
这段代码实现了 B+树的 键值删除 功能:给定一个键 key,从 B+树中删除对应的键值对。与插入操作类似,也采用了 乐观/悲观 双模式策略。
一、Remove 函数解析
1 | INDEX_TEMPLATE_ARGUMENTS |
- 公开的删除接口,无返回值(
void) - 参数:要删除的键
key,以及当前事务
1 | bool dummy_used = false; |
- 事务兼容处理:与插入/查询一样,如果没有事务则创建临时事务
dummy_used标记临时事务
1 | RemoveHelper(key, transaction, LatchMode::OPTIMIZE); |
- 调用核心删除函数,初始使用
OPTIMIZE模式(乐观锁) - 乐观模式性能更好,但可能在需要合并/重分配时失败
1 | if (dummy_used) { |
- 释放临时事务
二、RemoveHelper 函数解析
第一部分:初始化与空树检查
1 | INDEX_TEMPLATE_ARGUMENTS |
- 内部核心删除函数
dirty_height:记录脏页高度,用于释放锁时判断哪些页被修改了mode:OPTIMIZE(乐观)或DELETE(悲观/保守)
1 | LatchRootPageId(transaction, mode); |
- 对根页面加锁:根据
mode决定加读锁还是写锁
1 | if (IsEmpty()) { |
- 空树检查:如果树为空,释放锁后直接返回(无需删除)
第二部分:定位叶子节点
1 | auto [raw_leaf_page, leaf_page] = FindLeafPage(key, transaction, mode); |
- 查找包含该键的叶子节点
- 沿着路径逐层加锁,返回叶子页面
第三部分:乐观模式失败条件检查
1 | if ((leaf_page->GetSize() - 1) < leaf_page->GetMinSize() && mode == LatchMode::OPTIMIZE) { |
- 核心判断:如果删除后叶子节点的大小小于最小容量,且当前是乐观模式
leaf_page->GetSize() - 1:删除后的预期大小leaf_page->GetMinSize():叶子节点的最小容量(通常是max_size / 2)
这意味着删除后叶子节点会下溢出(Underflow),需要通过合并(Merge)或重分配(Redistribute)来修复
1 | auto is_root = leaf_page->IsRootPage(); |
- 获取当前页面的属性:是否为根节点、是否为叶子节点、是否为内部节点
1 | auto fail_condition1 = !is_root; |
- 三个失败条件(任何一条满足,乐观模式都会失败):
| 条件 | 说明 | 为什么需要特殊处理 |
|---|---|---|
fail_condition1 |
不是根节点 | 删除后需要从兄弟节点借元素或合并,需要修改父节点,乐观模式下父节点可能没有写锁 |
fail_condition2 |
是叶子根节点,且删除后大小为0 | 树可能变为空树,需要特殊处理 |
fail_condition3 |
是内部根节点,且删除后大小为1 | 根节点可能只需要一个键,需要特殊处理 |
cpp
1 | if (fail_condition1 || fail_condition2 || fail_condition3) { |
- 如果满足任一失败条件:释放所有锁,使用
DELETE模式(悲观模式)重新调用 - 悲观模式下会对路径上的页面加写锁,允许安全地修改父节点
第四部分:执行删除
1 | RemoveEntry(leaf_page, key, dirty_height); |
- 实际执行删除操作:在叶子节点中移除键值对
dirty_height会递增,标记页面已被修改
1 | ReleaseAllLatches(transaction, mode, dirty_height); |
- 释放所有持有的锁(包括页面锁)
三、乐观 vs 悲观模式对比
乐观模式 (OPTIMIZE)
- 加锁方式:沿路径加读锁
- 适用场景:删除后叶子节点大小 >= 最小容量(不会下溢出)
- 优点:并发性能好,多个删除/查询可以并发
- 缺点:如果叶子节点下溢出,无法安全处理(需要修改父节点)
悲观模式 (DELETE)
- 加锁方式:沿路径加写锁(排他锁)
- 适用场景:删除后叶子节点下溢出,需要合并或重分配
- 优点:可以安全地修改树结构
- 缺点:并发性能差,阻塞其他操作
四、B+树删除的核心概念
下溢出(Underflow)
- 定义:叶子节点删除后,键值对数量小于
GetMinSize() - 最小容量:通常是
GetMaxSize() / 2(根节点除外) - 修复方式:
- 重分配(Redistribute):从兄弟节点借一个元素
- 合并(Merge):将当前节点与兄弟节点合并
根节点特殊规则
- 根节点没有最小容量限制(可以为空)
- 如果根节点变成空,需要:
- 如果是叶子根节点,树变为空树
- 如果是内部根节点,树高度降低
五、执行流程图
1 | Remove(key, transaction) |
RemoveEntry
我来逐行解析这段 B+树删除操作的完整实现,包括删除条目、重分配、合并等核心逻辑。
整体架构
这段代码实现了 B+树删除的四个核心函数:
RemoveEntry- 删除入口,处理下溢出RemoveDependingOnType- 根据页面类型执行删除TryRedistribute- 尝试从兄弟节点借元素TryMerge- 尝试与兄弟节点合并Redistribute- 实际执行重分配Merge- 实际执行合并RefreshParentPointer/RefreshAllParentPointer- 更新父指针
执行流程图
1 | RemoveHelper (悲观模式) |
一、示例 B+树结构
假设我们有一个阶数(Order)为 3 的 B+树(每个节点最多 3 个键,最少 1 个键),当前状态如下:
1 | [4, 7] ← 根节点(内部节点) |
- 根节点:内部节点,存储键
[4, 7],有 3 个子节点指针 - 叶子 A:存储
[1, 2, 3] - 叶子 B:存储
[4, 5, 6] - 叶子 C:存储
[7, 8, 9]
二、RemoveEntry - 删除入口函数
1 | void BPlusTree<KeyType, ValueType, KeyComparator>::RemoveEntry(BPlusTreePage *base_page, const KeyType &key, |
场景:删除键 1
base_page→ 叶子 A([1, 2, 3])key→1dirty_height→ 初始为 0
1 | auto delete_success = RemoveDependingOnType(base_page, key); |
步骤 1:调用 RemoveDependingOnType 在叶子 A 中删除键 1
1 | 叶子 A:[1, 2, 3] → 删除 1 → [2, 3] |
1 | if (base_page->GetSize() < base_page->GetMinSize()) { |
步骤 2:检查是否下溢出
1 | 叶子 A 当前大小 = 2 |
跳过修复逻辑,函数返回
✅ 删除完成,树结构保持不变:
1 | [4, 7] |
三、继续删除键 2
场景:删除键 2
1 | 叶子 A:[2, 3] → 删除 2 → [3] |
检查下溢出:
1 | 叶子 A 大小 = 1 |
四、继续删除键 3 - 触发修复
场景:删除键 3
1 | 叶子 A:[3] → 删除 3 → [] (空!) |
检查下溢出:
1 | 叶子 A 大小 = 0 |
根节点判断
1 | if (base_page->IsRootPage()) |
1 | 叶子 A 是根节点吗?→ false(根节点是内部节点) |
进入非根节点修复分支:
执行修复
1 | auto redistribute_success = TryRedistribute(base_page, key); |
当前树结构:
1 | [4, 7] ← 根节点 |
五、TryRedistribute - 尝试重分配
1 | auto BPlusTree<KeyType, ValueType, KeyComparator>::TryRedistribute(BPlusTreePage *base_page, const KeyType &key) |
断言:base_page 不是根节点 ✅
1 | auto parent_page_id = base_page->GetParentPageId(); |
步骤 1:获取父节点
1 | 叶子 A 的父节点 → 根节点 [4, 7] |
1 | auto underfull_index = parent_page->SearchJumpIdx(key, comparator_); |
步骤 2:找到 base_page 在父节点中的索引
1 | 在父节点 [4, 7] 中查找键 3 |
父节点结构:
1 | 索引: 0 1 2 |
尝试从右兄弟借
1 | if (underfull_index < parent_page->GetSize() - 1) { |
1 | parent_page->GetSize() = 2(键的数量) |
1 | auto [sibling_raw_page, sibling_page] = FetchBPlusTreePage(parent_page->ValueAt(underfull_index + 1)); |
步骤 3:获取右兄弟
1 | underfull_index + 1 = 1 |
1 | if ((sibling_page->GetSize() - 1) >= sibling_page->GetMinSize()) { |
步骤 4:检查右兄弟借出后是否下溢出
1 | 右兄弟大小 = 3 |
执行重分配:调用 Redistribute
1 | sibling_raw_page->WUnlatch(); |
解锁并释放右兄弟页面
1 | if (!redistribute_success && underfull_index > 0) { |
跳过(没有左兄弟)
1 | buffer_pool_manager_->UnpinPage(parent_page->GetPageId(), redistribute_success); |
释放父节点,返回 true(重分配成功)
六、Redistribute - 实际执行重分配
1 | void BPlusTree<KeyType, ValueType, KeyComparator>::Redistribute( |
参数:
base= 叶子A(下溢出,空)sibling= 叶子B([4, 5, 6])parent= 根节点([4, 7])base_index= 0sibling_on_left= false(右兄弟)
1 | if (base->IsLeafPage()) { |
判断:base 是叶子节点 ✅
1 | if (sibling_on_left) { |
执行右兄弟重分配:
步骤 1:将右兄弟的第一个元素移到 base
1 | 叶子B:[4, 5, 6] → 移除第一个元素 4 → [5, 6] |
步骤 2:更新父节点的分隔键
1 | 原来的分隔键在索引 1(KeyAt(1))= 4 |
删除完成
1 | [5, 7] ← 根节点更新 |
✅ 通过重分配修复完成
七、继续删除键 4 - 触发合并
场景:删除键 4
1 | 叶子A:[4] → 删除 4 → [] (空!) |
检查下溢出:0 < 1 ⚠️
进入 TryRedistribute
TryRedistribute - 重分配失败
当前树结构:
1 | [5, 7] |
尝试从右兄弟借:
1 | 右兄弟 = 叶子B [5, 6] |
执行重分配:
1 | 叶子B → [5, 6] 移除第一个元素 5 → [6] |
删除完成
1 | [6, 7] |
八、继续删除键 5 - 合并
场景:删除键 5
1 | 叶子A:[5] → 删除 5 → [] (空!) |
TryRedistribute:
当前树结构:
1 | [6, 7] |
尝试从右兄弟借:
1 | 右兄弟 = 叶子B [6] |
尝试从左兄弟借:不存在
redistribute_success = false
进入 TryMerge
1 | auto TryMerge(...) -> bool { |
获取父节点和索引:
1 | 父节点 = [6, 7] |
1 | if (underfull_index < parent_page->GetSize() - 1) { |
与右兄弟合并:
1 | 右兄弟 = 叶子B [6] |
九、Merge - 实际执行合并
1 | void BPlusTree<KeyType, ValueType, KeyComparator>::Merge( |
参数:
base= 叶子A(空)sibling= 叶子B([6])parent= 根节点([6, 7])base_index= 0sibling_on_left= false(右兄弟)
1 | if (base->IsLeafPage()) { |
执行合并:
步骤 1:获取分隔键
1 | parent->KeyAt(base_index + 1) = parent->KeyAt(1) = 6 |
步骤 2:将右兄弟所有元素移到 base
1 | 叶子B:[6] → 移动到 叶子A |
步骤 3:更新叶子节点链表
1 | 叶子A 原来 → 叶子B(next) |
步骤 4:标记叶子B为无效
1 | 叶子B->SetParentPageId(INVALID_PAGE_ID) |
步骤 5:从父节点删除分隔键
1 | RemoveEntry(父节点, 6, dirty_height) |
十、递归调用 RemoveEntry 删除父节点中的键
场景:从父节点删除键 6
1 | RemoveEntry(parent, key_in_between, dirty_height) |
步骤 1:在父节点中删除键
1 | 父节点 = [6, 7] |
步骤 2:检查父节点是否下溢出
1 | 父节点大小 = 1 |
父节点保留为 [7]
合并完成
1 | [7] ← 根节点(只有1个键) |
十一、继续删除键 6 - 树高度降低
场景:删除键 6
1 | 叶子A:[6] → 删除 6 → [] |
TryRedistribute:
1 | 右兄弟 = 叶子C [7, 8, 9] |
执行重分配:
1 | 叶子C 移除第一个元素 7 → [8, 9] |
最终状态
1 | [8] ← 根节点 |
十二、总结:删除操作的核心逻辑
| 函数 | 职责 | 关键操作 |
|---|---|---|
RemoveEntry |
删除入口 | 删除键 → 检查下溢出 → 调用修复 |
TryRedistribute |
尝试重分配 | 从右→左尝试借元素 |
TryMerge |
尝试合并 | 从右→左尝试合并 |
Redistribute |
执行重分配 | 移动 1 个元素,更新分隔键 |
Merge |
执行合并 | 移动所有元素,递归删除父节点键 |