CMU_15445_P4_Part3
Primary Key Index
BUSTUB 支持使用下面的方式创建主键索引
CREATE TABLE t1(v1 int PRIMARY KEY);
CREATE TABLE t1(v1 int, v2 int, PRIMARY KEY(v1, v2));
当创建一个表的时候如果确定了主键, 那么这张表的 is_primary_key 会被设置为 true. 由于在 P4 中添加了主键相关的信息, 并且 Projet4 中仅涉及主键的修改与更新, 因此前面的 Project3 中 Insert 与 Update 中非主键的修改会出现不一致的出入, 导致 P3 有些测试案例没有通过, 需要做一些调整.
Index Scan
多 MVCC 下的 Index Scan 与 SeqScan 是类似的, 因为都仅涉及到版本链的读, 而不涉及到版本链的修改, 不会涉及到多并发情况下修改版本链的问题, 按照 SeqScan 中事务的时间戳信息, 在正确的时间戳下读取版本链的信息即可.
Inserts
多事务并发主键 Insert
我们需要修改 Insert Executor 以支持插入主键索引, 同时需要考虑存在多个事务在不同的线程内同时插入相同主键索引的情况, 插入带有主键索引的 tuple 的步骤如下:
- 首先检查 Index 中是否已经存在这个 tuple, 也就是插入的这个 tuple 对应的检索是不是已经指向其他的 tuple 了, 如果已经存在, abort 当前事务. 但是这部分仅在 #Task4.1 生效, 因为在 #Task4.2 中检索 index 可能指向一个被删除的 tuple, 这种情况下, 事务可以继续 Insert, 不必 Abort. 在 #Task4 中, 对于冲突的情况, 我们仅需要设置事务的状态为
TAINTED.TAINTED状态表示这个事务将要 Abort, 但是还没有, 因此有可能 TableHeap 中已经插入数据了, 但是不必要清除它. 如果此时还有其他事务插入数据, 看到了 TableHeap 中还未清除的数据, 那么还是会被看作写写冲突. - 在 TableHeap 中创建 tuple.
- 创建 tuple 之后, 将这个 tuple 加入到检索中, 因为 BUSTUB 仅考虑主键索引, 主键索引具有唯一性, 如果此时已经有 tuple 加入到相同的检索中, 会发出冲突. 在顺序执行的情况下不会出现这种现象, 但是在并发场景下, 事务 Txn_A 执行完 2 之后, 事务 Txn_B 执行了 2,3 并且插入的主键相同, 此时会出现这种冲突.
我们用上面的例子说明冲突的情况, Txn9 依次插入 (A,4), (B,4), (C,4), 我们将第一列作为主键.
- 当插入 (A,4) 的时候, 由于 (A,4) 已经存在了, 所以存在写写冲突, Txn9 事务 Abort
- 插入 (B,4), 首先在 TableHeap 中创建一个 tuple, 然后将 RID 与新建的 tuple 加入到检索 Index 中.
- 当插入 (C,4) 的时候, Txn9 首先检测到没有写写冲突, 因此在 TableHeap 中创建了 tuple (C,4), 此时另一个事务 Txn10, 发现也没有检索存在冲突, 因此执行了上面的步骤 2,3, 创建了 tuple (C,5), 并且加入了检索. 执行完后, 检索 Index[C] 已经存储了 tuple, 因此事务 Txn9 在将 (C) 加入到检索时, 会发生冲突, 事务 Txn9 Abort.
冲突检查的实现
在本次的 BUSTUB 中, 索引表是使用 Project2 中实现的可扩展 HashTable 来实现的, 因此判断主键是否已经存在, 判断主键是否冲突也是通过可扩展 HashTable 中冲突的判断来实现的, 例如, 在 ScanKey 判断 index_tuple 对应的主键是否已经存在, 如果已经存在, 发生冲突.
if (index_info->is_primary_key_) {
std::vector<RID> result{};
index_info->index_->ScanKey(index_tuple, &result, exec_ctx_->GetTransaction());
/** if this Index have saved some RIDS, there is conflict */
if (!result.empty()) {
exec_ctx_->GetTransaction()->SetTainted();
throw ExecutionException(
"PRIMARYKEY INDEX CONFLICT IN INSERT-EXECUTOR, \nThe PrimaryKey Index of This Tuple Already Exists !!!");
}
if (!index_info->index_->InsertEntry(index_tuple, *rid, exec_ctx_->GetTransaction())) {
exec_ctx_->GetTransaction()->SetTainted();
throw ExecutionException(
"PRIMARYKEY INDEX CONFLICT IN INSERT-EXECUTOR, \nThe PrimaryKey Index of This Tuple Already Exists !!!");
};
}
Index Scan, Deletes and Updates
这个 Task 我们需要完成对 delete, update executor 的主键索引的支持. 这部分开始将会启用 multi-version index scan executor, 也就是多版本并发控制下的 index scan executor, 还要更新 insert, update, delete executor.
在 catalog_的一张表中, 索引和数据库的物理数据是分开存储的, 通过表号来建立关系. 并且在 MVCC 或者其他的并发控制中, 不会对一张表的索引或者表的数据加锁, 也就是不会加表级锁和索引级别的锁. 因为不会对索引表和数据表加锁, 所以同一时刻可能存在多个事务同时访问同一张表的物理数据, 以及访问索引中同一个表项的情况, 这时就会发生所谓的冲突.
为了合理的处理这些冲突, BUSTUB 数据库引擎需要检测并处理这些冲突, 检测与处理这些冲突的基本方式就是检测 TableHeap 中的 tuple 的 tuple_meta 信息, 即时间片与 is_deleted 删除信号信息.
Insert by Update
我们知道主键检索的功能是快速的构建一个 Index_Tuple, 然后通过索引表快速的定位到这个 Tuple 对应的 RID, 然后读取完整的 Tuple. 主键索引具有唯一性, 也就是说主键索引决定了这个 Tuple 的 PrimaryKey Column 的数据, 通过索引表决定了一个 Tuple 在内存中的位置(也就是 RID 信息). 因此即使这个 Tuple 在 TableHeap 中被删除了, 主键索引项也不会删除, 并且这个 Tuple 在 TableHeap 中只是标记为删除.
那么我们在使用 IndexScan 检索的时候, 就会出现, 通过索引项返回 rids 以及对应位置的 tuples, 被删除的 tuples 和对应的 rids 也会被返回. 如果在 TableHeap 中, 这个 tuple 被标记为删除, 索引表中仍然存储这个索引关系, 也就是这个检索仍然指向这个 RID, 因此不同于 Seqscan Executor 会跳过这个 tuple, IndexScan Executor 仍然会返回这个 RID.
这里会导致之前的 insert Executor 改变的情况是, 当 insert 到 TableHeap 中一个标记为 deleted 的 tuple 的时候, insert Executor 应该会更新这个 tuple, 而不是新创建一个. 因为索引项一旦创建, 总是指向相同的 RID, 此时会对我们前面的 Insert Executor 的插入过程产生下列的变化.
- 孩子节点 IndexScan Executor 返回需要插入的 rid, 如果这个 rid 对应的位置已经存储了一个 tuple, 有两种情况
- 这个 tuple 正在被其他事务修改, 或者被其他事务提交, 那么当前事务冲突, 应该 Abort
- 这个 tuple 的 deleted 标记位为 true, 并且已经被其他事务 committed, 那么可以在这个 tuple 上更新新插入的 tuple, 而不必 Aborted. 更新的过程是需要更新这个 tuple 的版本链的, 在 MVCC 中, 加入索引的时候, 更新版本链的时候, 在 update executor 和 delete executor 中是相同的, 我们后续合并在一起讨论.
- 其他情况和原来一致, 在 TableHeap 中插入这个 tuple
- 在 TableHeap 中插入这个 tuple
- 步骤 (3) 是向索引表中插入检索, 在插入索引之前需要检查该索引对应的 tuple 是否已经存在, 防止写写冲突.
下图是 Insert By Update 的过程, 这个过程十分详细, 因为其中包含了 MVCC 下多事务并发的时候版本链更新的问题, 当前主要看一下 Insert By Update 的过程就好.
这部分的具体实现实际上更多的是版本链(VersionUndoLink) 的更新与 TableHeap 中 Tuple 的更新的问题, 后续会在 Update 与 Delete 中解释.
MVCC 中并发带来的冲突问题
我们总结一下在 MVCC 中并发的事务是如何冲突的, 以及这些冲突的解决方式, 以及到目前位置 BUSTUB 中实现的 MVCC 解决了哪些冲突.
MVCC 中数据库的临界资源
我们知道冲突的本质是多个事务对同一个临界资源的修改, 导致最终结果不一致的情况, 那么在 MVCC 中有哪些临界资源呢, 以及如何管理呢?
- TableHeap: 这里并没有从 catalog_ 说起, 因为操作不同的表是不会冲突的. TableHeap 中存储着当前时刻该表存储的最新数据.
- VersionUndoLink: 每一个 tuple 都存在一个对应的版本链, 用于存储这个 tuple 的历史时间戳的数据.
- TableIndex: 数据库中的每张表的索引表 TableIndex 是单独存储的.
不可重复读和读写冲突
- MVCC 通过存储数据的多个版本以及快照隔离的机制避免了读写冲突, 在 SeqScan Executor 与 IndexScan Executor 通过版本时间戳与 VersionUndoLink 获取对应的版本链, 因此不会出现读写冲突与不可重复读的问题.
写写冲突
写写冲突出现在多个事务同时修改同一行数据的情况, 并发的情况下可以理解为多个事务同时修改上述的临界资源, 因此可能会出现下列的冲突情况:
- 多个事务同时访问索引表的同一个表项, 例如我们上述的 Insert 中出现的问题, 这样会导致索引表的重复插入, 造成写写冲突.
- 多个事务同时修改 TableHeap 中的数据, 我们应该检测以及避免这种冲突
- 多个事务同时修改一个 tuple 对应 VersionUndoLink, 这种情况发生在这些事务尝试同时修改这个 TableHeap 的基础上.
Write Skew(写偏差)
由于在 TableHeap 中 BUSTUB 是按照一个 tuple 的 timestamp 来控制版本的, 也就是说一个事务可以修改的数据库中最小的单元是 tuple, 那么写偏差往往会操作不同的 tuple, 例如黑白棋子问题. 两个事务分别读到的是黑子和白子, 因此最后导致错误的结果, 因此目前 BUSTUB 中的 MVCC 无法解决写偏差的冲突.
Phantom Read(幻读)
与写偏差类似, MVCC 无法解决幻读问题, 这是因为MVCC 通过 快照读(Snapshot Read) 确保事务只看到 在事务开始前已提交的数据,但无法阻止新数据插入,导致幻读. 例如 Txn_A 开始读数据之后, Txn_B 新插入了一行, 并且 Commit 了, 但是 Txn_A 再次读取时, 看不到 Txn_B 插入的这一行, 而是忽略了这一行.
冲突解决的方式
不可重复读与读写冲突
在 MVCC 中不可重复读与读写冲突可以使用时间戳与版本链控制事务读取到的版本信息的方式来避免冲突, 也就是我们在 SeqScan 和 IndexScan Executor 中实现的部分.
写写冲突解决
按照上述所列的, 在 MVCC 中存在三种写写冲突的情况, 冲突分别在索引表, TableHeap 以及 VersionUndoLink 中.
主键索引冲突解决
索引表中的冲突仅存在对主键的修改的过程中, 当前仅出现在 Insert Executor 中, 在插入索引表的时候检测冲突, 可以避免这种冲突
TableHeap 与 VersionUndoLink 冲突
在 BUSTUB 中实现的 Executor 中, 只有 Update Executor, Delete Executor, Insert Executor 中的 Insert By Update 部分会涉及并发的事务同时修改 TableHeap 中的数据以及版本链的情况, 这三部分修改 TableHeap 与版本链冲突的场景是相同的. 我们将这部分的处理步骤总结如下, 以 UpdateExecutor 为例:
- 当有多个事务并发的修改某个 tuple 的时候, 每个事务进入 Update Executor 时首先检测写写冲突, 是否有其他 uncommitted 事务正在使用这个 tuple, 或者这个 tuple 在当前事务读版本之后被更新.
- 当多个并发的事务同时检测到某个 tuple 的状态是可更新的情况, 例如当前 tuple_meta 的 timestamp 为 10, 表示在时间戳为 10 的时候被 committed, 此时 read_ts 为 13 的 Txn_A 检测到当前 tuple 可以修改, 对当前 tuple 进行 update 操作, 但是还没来得及修改当前 tuple 在 TableHeap 中的信息, 此时 read_ts 为 14 的 Txn_B 也检测到当前 tuple 可以修改(因为 tuplemeta 的 timestamp 为 10), 因此 Txn_A 和 Txn_B 都会尝试修改这个 tuple.
- 仅通过 tuple 的 tuplemeta 中的 timestamp 无法完全解决两个事务同时修改一个 tuple 的情况, 只能通过更加细粒度的互斥操作避免, 因此 BUSTUB 将 VersionUndoLink 设计为有状态的. 其中
in_progress表示当前 tuple 是否正在被处理. 每个事务在处理当前 rid 对应的 tuple 之前, 需要先检查当前 tuple 的 VersionUndoLink 的状态, 只有它的in_progress为 false 的情况下才可以获取修改这个 tuple 的权限, 同时, 一旦某个事务获取了该 tuple 的修改权限, 要将 VersionUndoLink 的in_progress状态设置为 true, 防止其他事务修改这个 tuple, 此时其他事务只能继续等待当前事务修改完这个 tuple, 当前事务完成修改之后将这个 tuple 的 VersionUndoLink 的in_progress设置为 false. 上述的 Insert By Update 的图片中的绿色箭头将in_progress的步骤可视化了. - 当一个事务 Txn_A 获取了 VersionUndoLink 的版本链之后, 还需要再次检查写写冲突, 这是因为, 另一个事务 Txn_B 释放了 VersionUndoLink, 并不代表该事务 Txn_B 已经 Committed 了, 或者 committed 的时候与事务 Txn_A 的 read_ts 的时间冲突. 或者可以理解为
in_progress虽然是更加细粒度的锁, 但是和 TableHeap 中的 timestamp 避免写写冲突的方式没有包含关系. 因此仍然会出现 tuplemeta 时间片中的写写冲突.
我们详细解释一下步骤 3 的原理与实现方式:
步骤 3 中我们设计了一个自旋锁, 防止多个事务同时修改一个 tuple.
- 如上图所示, 当多个事务准备修改 TablheHeap 中的一个 tuple 的时候, 发现没有冲突, 准备获取版本链的时候, 版本链有三种状态, 分别是
in_progress为 false,in_progress为 true, 以及这个 VersionUndoLink 不存在的状态. 每个事务首先获取当前版本链(VersionUndoLink) 的最新状态. - 如果当前 VersionUndoLink 不存在, 新建一个, 并且设置
in_progress为 true. 否则将current_version_undolink的in_progress设置为 true. - 使用
UpdateVersionLink函数不断的修改当前 tuple 的 VersionUndoLink, 并且传入 check 函数, check 函数的作用是, 只有当前 tuple 的最新的 VersionUndoLink 为空, 或者in_progress为 false, 才可以成功修改, 否则返回 false. 如果成功修改, 表示当前事务获取了 VersionUndoLink 的修改权限,in_progress被设置为 true, 其他事务无法获取当前 VersionUndoLink 的修改权限. 如果UpdateVersionLink返回 false, 表示当前 VersionUndoLink 正在被其他事务修改, 返回步骤 (1) 继续循环. - 为什么要设计一个自旋锁, 而不是出现冲突的时候直接 Abort 事务呢? 这是因为正在运行的时候可能 Abort, 那么后续等待的事务是可以运行的, 不应该直接 Abort.
/** get this rid's undolog version link */
std::optional<VersionUndoLink> current_version_undolink;
do {
/** get the latest VersionUndoLink of this tuple */
current_version_undolink = exec_ctx_->GetTransactionManager()->GetVersionLink(*rid);
/** if this RID haven't a version chain, we have to build one and set in_process as true */
if (!current_version_undolink.has_value()) {
/** generate a versionundolink for this RID, and set its in_process as true, and update the rid's version
* undolink */
current_version_undolink = VersionUndoLink{UndoLink{INVALID_TXN_ID}, true};
} else {
/**
* if the Version UndoLink is in processing, some other Txn is inserting into this tuple undolog,
* this txn has to wait other txn release the tuple.
* we can only update the tuple only when current_version_undolink is not in processing.
* use the check function check the in_process status of the rid's current tuple
*/
current_version_undolink->in_progress_ = true;
}
/** use a spinlock to avoid two txns process a tuple at the same time */
} while (!(exec_ctx_->GetTransactionManager()->UpdateVersionLink(
*rid, current_version_undolink, [](std::optional<VersionUndoLink> current_version_undolink) {
return current_version_undolink == std::nullopt || !current_version_undolink->in_progress_;
})));
上述的写写冲突解决的方式是在 Update Executor, Delete Executor, 以及 Insert Executor 均会使用到, 使用相同的方式检测与处理冲突, 当前目前我们还没有实现 Abort 的回退步骤, 届时还是需要重构代码.
涉及到主键索引的更新
在 Update 语句中可能涉及到更新主键, 在上面我们说过, 通常情况下, 只有 Insert Executor 会修改索引表, 实际如果 Update Executor 更新的是主键, 也会修改索引表, 涉及到主键的修改. 涉及到主键的 Update 的时候, 通常步骤如下:
- 在 Update Executor 的 Init() 阶段, 判断此次更新是否会修改主键, 也就是 Update Executor 会不会修改主键那一列, 判断的方式是通过
target_expression将旧的 tuple 构造出一个新的 tuple, 判断这两个 tuple 主键对应的 Value 是否修改, 如果修改说明更新了主键. - 如果是修改主键的 Update Executor, 由于之前已经确定 Update Executor 是 Pipeline Breaker, Update Executor 在修改主键的时候需要先将 TableHeap 中所有主键对应的 tuple 删除, 设置为删除标记. 但是仅设置 TableHeap 中的删除标志, 而不会修改索引表以及表项.
- 删除了所有的 tuple 之后, 再进行插入操作, 在 TableHeap 中新插入 tuple, 插入操作需要更新索引表项, 就是会插入索引项.
- 步骤 2,3 与之前的 Update 是完全独立与不同的, 因此是分开的步骤.
我们使用下图的例子说明这一过程, 假设我们执行的语句是 UPDATE table SET col1 = col1 + 1, 其中 col1 是主键.
-
在实际更新之前, 假设事务 txn9 首先在表中插入了 (2,B), 如下图所示:
-
现在我们还是更新
col1 = col1 + 1, 索引表中已经插入的表项是不会更新的, 可以看到我们索引表提供的功能只有InsertEntry,DeleteEntry以及ScanKey索引表自身是无法直接更新的 .因此第一步是将 TableHeap 中的 tuples 均设置为删除, 如下图所示:
-
在 TableHeap 中插入新的 tuples, 然后 commit.
BUG 记录
- 在 Insert Executor 中, 当已经存在检索的时候, 更新 TableHeap 中的 tuple 信息应该使用检索位置对应的 rid. 同时还需要修改 TableHeap 中 tuple 的状态从被删除到存在, 也就是 is_deleted 设置为 false.
- 需要注意新生成一个 undolog 的时候的 is_deleted_ 标志, 在 Update Executor 和 Delete Executor 中, 需要设置为原来的 TableHeap 中的状态, 最好不要直接设置为默认的 false, 最重要的是需要初始化, 否则可能随机玄学
- 在 MVCC 的 Update Executor 中, 当多个事务并发处理的时候, 例如语句
UPDATE Accounts SET Balance = Balance - 30 WHERE ID = 1;假设ID=1的有多行, Update Executor 现在是 Pipeline Break, 会先将需要修改的 RID 读取到一个数组中, 但是如果 Update Executor 的 Child Executor 发现, 这些所有需要 Update 的行都正在被其他事务修改, 因此 Update Executor 实际上不会更新任何一行.

浙公网安备 33010602011771号