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 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.
Index Scan, Deletes and Updates
这个 Task 我们需要完成对 delete, update executor 的主键索引的支持. 这部分开始将会启用 multi-version index scan executor
, 也就是多版本并发控制下的 index scan executor
, 还要更新 insert, update, delete executor.
检索的功能是指向指定的 RID, 读取这个 RID 对应的 tuple, 一旦一个 tuple 在索引表中创建了一个检索, 即使这个 tuple 被删除了, 在 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 是否已经存在, 如果已经存在, 检查是否标记为删除, 如果标记为删除, 不冲突, 使用
Index Scan
在加入主键索引后, 同时加入多线程与多事务并发引来的新的冲突的问题:
在 catalog_的一张表中, 索引和数据库的物理数据是分开存储的, 通过表号来建立关系. 并且在 MVCC 或者其他的并发控制中, 不会对一张表的索引或者表的数据加锁, 也就是不会加表级锁和索引级别的锁. 因为不会对索引表和数据表加锁, 所以同一时刻可能存在多个事务同时访问同一张表的物理数据, 以及访问索引中同一个表项的情况, 这时就会发生所谓的冲突.
为了合理的处理这些冲突, BUSTUB 数据库引擎需要检测并处理这些冲突, 检测与处理这些冲突的基本方式就是检测 TableHeap 中的 tuple 的 tuple_meta 信息, 即时间片与 is_deleted 删除信号信息.
可能出现的冲突汇总
- MVCC 通过多版本的控制避免了读写冲突, 因此在 SeqScan Executor 与 IndexScan Executor 中不会存在读写冲突导致事务 Aborted, 而是通过时间戳的方式使得事务访问到正确的版本. 需要特别注意的情况是当一个 tuple 正在被其他事务修改的时候, 需要特别注意版本链的状态, 有可能存在TableHeap 中的 tuple 与版本链中的第一个 undolog 的时间戳相同的情况, 需要特别注意.
- IndexScan Executor 的特殊性: 由于一张表的索引表是单独存储的, 并且仅存储检索与 RID 的键值对, 即使 RID 对应位置的 tuple 被删除, 索引表的表项也不会删除, 而是会返回对应位置的 RID, 并且 tuple 的标识 is_deleted_ 为 true.
- 多并发下多个事务同时修改同一个 tuple 的写写冲突: 不同于 Task3 中检测写写冲突, 当一个事务尝试修改一个 tuple 的时候检测到另一个事务正在修改这个 tuple 的, 会出现写写冲突. 在多事务并发的情况下会出现的问题是, 有可能两个事务同时检测一个 tuple, 都发现这个 tuple 是可以访问的, 因此两个事务同时处理一个 tuple. 或者一个 tuple 修改了数据, 还没来得及修改 tuple_meta 的 timestamp, 此时也会出现冲突.
- 在 update 与 delete executor 中不存在两个事务同时检测到同一个 tuple 分别被这两个事务正在修改, 也就是说
tuple_meta.ts_
只能等于两个事务其中的一个. - update 和 delete Executor 读取 TableHeap 中的 tuple 一定会尝试修改这个 tuple, 而 IndexScan 和 SeqScan Executor 只会读这个 tuple.
- 由于插入到索引表中的键值对不会被删除, 因此只有 Insert Executor 会插入键值对, 也就是索引项. update 和 delete Executor 会修改 TableHeap 中的 tuple, 但是不会修改索引项中的内容.
冲突解决的方式
- IndexScan Executor 与 SeqScan Executor 的实现方式中不会存在这个问题, IndexScan Executor 中读取 tuple 版本的判断使用与 SeqScan Executor 一样的方式即可.
- 在 Insert Executor 中, 我们会检查某一个索引项是不是已经存在对应的 RID, 如果已经存在, 还需要检查此时 TableHeap 中这个 tuple 的状态是否为已经删除, 如果为已经删除, 那么不会产生冲突, 应该更新 TableHeap 中这个 tuple 的内容为最新的状态.
- 当多个事务同时检查到可以更新某个 tuple 的时候, 在创建一个 undolog 之后, 尝试修改这个 tuple 的版本链的时候, 应该首先检查这个版本链是否已经处于被修改的状态, 即
in_progress_
是否为true
.
UPDATE Executor 中并发问题的解决
- Update Executor 每次会遍历所有 TableHeap 中的所有的 tuple 对应的 rids, 然后更新这些 rids 在 TableHeap 中对应的 tuple 中的数据, Update Executor 是 Pipeline Breaker, 因此会先读取整个 tuples 中的列表.
- 会有多个事务同时进入 Update Executor, 因此第一步是检测当前 tuple 是否正在被其他 uncommitted 的事务修改, 如果存在, 那么是写写冲突, 应该 Aborted 当前事务
- 如果这个 tuple 正在被当前事务修改过, 那么不会有冲突, 因为当前事务正在修改 tuple, 不可能有另一个事务也同时在修改 tuple
- 如果多个事务同时进入 Update Executor, 并且检测到这个 tuple 的状态是已经被 committed, 可以被修改, 那么会有多个事务去尝试修改这个 tuple.
- 尝试修改一个 tuple 首先要获取这个 tuple 的版本链, 当一个事务 Txn_A 获取版本链之后, 在第 4 步, 一同进入的后续的事务应该无法再获取版本链, 这里应该设计成一个自旋锁, 为什么后续事务不会直接 Aborted, 而是选择自旋锁不断地获取版本链呢, 这是因为当前事务有可能 Aborted, 导致回退, 这种情况后续的事务就应该获取到版本链.
涉及到主键索引的更新
在 Update 语句中可能涉及到更新主键, 在上面我们说过, 通常情况下, 只有 Insert Executor 会修改索引表, 实际如果 Update Executor 更新的是主键, 也会修改索引表, 涉及到主键的修改. 涉及到主键的 Update 的时候, 通常步骤如下:
- 在 Update Executor 的 Init() 阶段, 判断此次更新是否会修改主键, 也就是 Update Executor 会不会修改主键那一列, 判断的方式是通过 UpdatePlanNode 中的 target_expressions_ 来判断的, 因为其中不修改的 column 的类型一定是
ColumnValueExpression
. 否则就是修改的 column. - 如果是修改主键的 Update Executor, 由于之前已经确定 Update Executor 是 Pipeline Breaker, Update Executor 在修改主键的时候需要先将 TableHeap 中所有主键对应的 tuple 删除, 设置为删除标记. 但是仅设置 TableHeap 中的删除标志, 而不会修改索引表以及表项.
- 删除了所有的 tuple 之后, 再进行插入操作, 在 TableHeap 中新插入 tuple, 插入操作需要更新索引表项, 就是会插入索引项.
- 步骤 2,3 与之前的 Update 是完全独立与不同的, 因此是分开的步骤.
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 实际上不会更新任何一行.