CMU_15445_Project4_BonusTask_Serializable_Verification

Serializable Verification

我们知道 MVCC 并不能解决幻读以及写偏差的问题, 仅通过 MVCC 的事务调度是无法保证数据库引擎的 ACID 原则的, 那么为了保证数据库的 ACID 原则, 即使在调度的过程中无法保证, 可以通过在 Commit 的时候, 通过验证, Abort 可能造成写偏差于幻读的事务, 从而避免这些冲突, 完成事务执行的序列化隔离(serializable isolation level), 请注意这并不是冲突可序列化, 而是在事务提交的时候验证删选掉冲突的事务, 保证数据库从结果上来说是 ACID.

OCC (Optimistic Concurrency Control)

BUSTUB 使用的是 OCC backward validation 序列化验证方法. 由于 BUSTUB 是动态数据库, 允许增删改查, 允许各种操作, 因此需要考虑插入, 删除, 以及更新的操作. 因此对于每一个将要提交的事务, 需要存储其 Scan Fliter 与 Write Set. 当前有了所有信息(Read Set, Write Set, 版本链等), 我们可以通过检查扫描谓词(Read Set)是否与当前事务开始后其他已提交事务的 Write Set 存在交集, 从而进行可序列化的验证. 每次事务 Txn_A 将要提交的时候, 需要进行以下的验证过程:

  1. 如果 Txn_A 是只读事务, 无需验证, 在 BUSTUB 中仅验证, UPDATE, DELETE, 和 INSERT, 因为只读事务不会对版本链造成修改, 从 OCC 的角度来说, 不会影响事务的隔离性.
  2. 遍历在 Txn_A 的 read timestamp 时间戳后 Commit 的所有事务, 这些事务的集合为 "conflict transactions", 表示可能存在冲突的事务
  3. 收集 "conflict transactions" 修改的 tuples 的 RIDS.
  4. 对于步骤 3 中的 RIDS 集合, 遍历每一个对应的 tuple 的版本链, 判断与当前将要提交的事务 Txn_A 之间是否存在幻读. 这个过程需要从当前时候想 Txn_A 的 read timestamp 开始遍历版本链, 构造出一个 undologs SET 集合 回放其他事务的更新流程, 检查是否存在冲突.
    我们详细解释一下上述的步骤 4 可能存在冲突, 从而 Abort 事务 Txn_A 的情况:
    我们遍历 Write Set 的 tuples 中的每一个 tuple 的版本链
  • 如果一个 tuple 是新插入的, 需要判断这个新插入的 tuple 是否满足当前事务的 Scan Fliter, 我们可以用下图来解释这种情况.
    img
    可以看到, 图中的 Scan Fliter 是 col2=8, Txn1 的 read_ts 为 2, 它开始的时候, 是不会读取到这个 tuple 的, 但是 Txn2 插入了 (B,8), 并且成功 Commit 了, 按照事务的序列化执行标准, 事务 Txn1 Commit 的时候应该读取这个 (B,8) 并且修改为 (A,4), 但是按照 MVCC 版本链的控制, 是不会读到新插入的 Tuple 的, 因此当前事务 Txn1 Abort.
  • 如果这个一个 tuple 的版本链中有删除的操作, 并且这个删除的 Tuple 满足当前事务的 Scan Fliter, 那么当前事务 Txn_A Abort. 这个过程与上述图中的类似.
  • 边界情况: 如果一个已经 Commit 的事务, 先 Insert 了一个 tuple, 然后又删除了这个 tuple, 这个 tuple 会被写入这个事务的 Write Set, 但是实际这个事务并没有影响这个 tuple 的版本链, 这种情况不看作冲突, Txn_A 不会 Abort.
  • 如果一个 tuple 的 undolog 中记录的是更新步骤, 那么需要判断更新前后的 tuple 是否满足当前事务的 Scan Fliter, 如果其中一个满足, 都需要 Abort 当前事务. Update 的示例如下:
    img
    图中可以看到, 更新前是满足 Txn_1 的 Scan Fliter 的, 但是更新后不满足, 这种情况需要 Abort. 同样, 如果更新前不满足, 更新后满足, 也是需要 Abort.
  • 特殊情况1: 如果版本链中, 一个事务 Txn_B 将一个 tuple 的 Value 从 X 修改为 Y, 然后又将 Y 修改为 X, 这种情况如果 X 或者 Y 满足 Txn_A 的 Scan Fliter, 都视作冲突, 这是因为 Txn_B 修改了版本链
  • 特殊情况2: 如果这个 tuple 的版本链中, 这个 tuple 被多个事务修改, Txn_B 将 tuple 中的 Value X 修改为 Y, 然后 Txn_C 将 Value Y 修改为 X, 那么如果 Txn_A 开始于 Txn_B 之前, 并且在 Txn_C Commit 之后才 Commit, 那么 Txn_A 也视作冲突, 应该 Abort.

如果事务由于 OCC backward validation 序列化验证导致事务需要 Abort, 那么 BUSTUB 应该自动执行 Abort 步骤, 并且将事务的状态设置为 ABORTED, 而不是 TAINTED.

实现要点

直接按照上述的流程实现 OCC 的序列化验证效率是极其低下的, 这是因为:

  1. 每次只有一个事务可以进行 OCC 序列化验证.
  2. 需要遍历所有 "conflict transactions" 的 Write Sets 并且检查是否存在冲突, 最好能够并行化的实现, 或者使用更加细粒度的锁(attribute-level).

多线程序列化验证

在得到所有的 conflict transactions 的 Write Sets 之后, 从 Write Sets 回放所有的 undologs 的时候, 实际上是仅读的, 由于 write sets 中的 RIDS 很多, 可以使用多个线程同时处理这些 RIDS, 多线程处理的步骤为:

  1. 遍历 txn_map_, 获取所有在当前事务 Txn_A 的 read_ts 之后, 当前时间片之前的所有的可能冲突的事务, 将这些事务修改的 Write Sets 合并成一个集合
  2. 使用多线程处理这个集合, 我的多线程的处理思路是将 Write Sets 集合中的所有 tuples 按照 RID 进行 Hash 分组, 每一个线程处理一个分组. 每个分组内, 使用当前线程的 Scan Fliter 验证是否存在冲突. 这里实际上有一个 trick, 在 BUSTUB 的测试中只有一张表, 所以我是对 WriteSets 进行分组的, 如果这些并行的事务同时修改了多张表, 每张表的 Schema 不同, 最好还是按照表进行分组比较好, 每个线程处理一张表.
  3. 当某个线程检测到冲突, 立即返回, 当前事务 Abort.

我冲突检测的实现过程如下:

auto TransactionManager::OCCConflictDetect(
    Transaction *txn, const std::unordered_map<table_oid_t, std::vector<AbstractExpressionRef>> &scan_predicates,
    const std::unordered_map<table_oid_t, std::unordered_set<RID>> &write_set) -> bool {
  /** if found conflict in the Serializable Verification */
  std::atomic<bool> conflict_found(false);
  /** the number of threads are the number of the cores of the CPU or the wirte_set size */
  const size_t max_threads =
      std::min(static_cast<size_t>(write_set.size()), static_cast<size_t>(std::thread::hardware_concurrency()));

  /** get the reture value from different threads */
  std::vector<std::future<bool>> futures;
  /**
   * 将需要验证的 write_set 使用 Hash 分区, 分成共计 max_threads 个区, 然后每个区使用一个线程进行验证
   * 使用 rid_partitions 记录每个线程需要处理的 RID 的集合
   *  */
  std::vector<std::unordered_set<RID>> rid_partitions(max_threads);
  for (const auto &[table_id, rids] : write_set) {
    for (const auto &rid : rids) {
      size_t partition_id = std::hash<RID>{}(rid) % max_threads;
      rid_partitions[partition_id].insert(rid);
    }
  }
  /** use multi threads to detect conflicts */
  for (size_t i = 0; i < max_threads; ++i) {
    /** use lambda funciton in multi-threads to process the write_sets */
    futures.emplace_back(std::async(std::launch::async, [&, i]() {
      /** for each Scan Fliter, detect if there are some conflicts */
      for (const auto &[table_id, predicates] : scan_predicates) {
        auto table_info = catalog_->GetTable(table_id);
        for (const auto &predicate : predicates) {
          /** In a thread and its corresponding rid_partitions detect if there is a conflict */
          for (const auto &rid : rid_partitions[i]) {
            /** detect if there is a conflict between this predicate and this tuple */
            if (ConflictMeet(txn, predicate, rid, table_info)) {
              conflict_found.store(true, std::memory_order_relaxed);
              return true;
            }
          }
        }
      }
      return false;
    }));
  }

  for (auto &f : futures) {
    if (f.get()) {
      return true;
    }
  }
  return false;
}

冲突步骤仅验证 Scan Fliter 的行

常规的做法:

  1. 遍历 write_set 以及对应的 undologs, 然后回放的过程会生成很多很多的 tuples, 对每一个 tuples, 都执行 Scan Fliter 的 Execute() 函数, 根据执行结果判断是否存在冲突, 如果存在冲突, 需要 Abort() 当前事务.

优化思路

  1. 按照 Scan Fliter 的 AbstractExpressionRef 判断修改的 column, 后续重放一个 tuple 的 undologs 版本链的时候, 仅需要考虑修 column 的 undologs 即可, 但是这种方式实现起来太过于复杂了, 因为需要重新实现表达式那部分内容, 抽取专门的列. 因此放弃, 仅使用多线程的优化方案.

总结

序列化验证看起来比较难, 实际上一点也不简单, 哈哈哈哈. 其实也还好, 前面想清楚了思路就好了, 这里好在终于不用再重构 UPDATE, DELETE, INSERT 的代码了, 只需要在 IndexScan Fliter 和 SeqScan Fliter 中添加 Scan Predicate 的记录即可, 使用多线程的思路那里也比较好想, 最好的是 BUSTUB 的测试案例多个并行是事务仅修改单张表. 最后终于完成了, 贴一个战绩:

img

posted @ 2025-03-22 11:27  虾野百鹤  阅读(44)  评论(0)    收藏  举报