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 | ❌ 活跃事务 |