【work记录】计算机系统能力大赛数据库中的MVCC学习记录 第二弹

日期:2025.8.3(凌晨)

个人总结:

preface

经过了这一周痛苦的折磨,这个勾八MVCC总算过了。只能说sb操作眼瞎了这么久,一开始竟然还以为隔离级别是串行化,前几天才发现是快照隔离,改了很多很多个乱七八糟的版本才过的。

其实现在回过头来看这个MVCC,其实不难,但是总会被某些弱智地方给卡住了。

下面大概分享一下这个MVCC的实现经历:(另外,其实这个MVCC的test并不是很严格,某些谓词冲突其实并不会测,不实现也没有问题,只要把题干中给的冲突大概实现了就没事了,甚至有些test本身也不是很严谨。

另外,上一篇是基础的内容,大概讲的是undolog之类的内容,辅助理解MVCC的,本篇主要讲的冲突。

大概实现

其实很多东西上一次就已经差不多讲完了,但是有些细节大概讲一下,以及先叠一个甲,这篇只是针对于大赛题目的通过的大概讲述,某些部分确实还存有bug,仅仅提供一个思路作为参考。

关于上一篇遗留的问题:

关于冲突

其实冲突大体就是分为写写冲突,谓词冲突。

写写冲突就是像题干说的那样,要更新元组的时候,先找到对应的元祖的最新的undolog(可见的),先判断一下是否提交了,然后是对于时间戳的判断,从而判断是否会有写写冲突。

详细版本的就是:首先先找到是否存有版本链,如果没有版本链,说明是我们之前持久化过的数据了,那么肯定就不会有冲突,毕竟如果有dml语句操作过了,肯定会有版本链的,所以如果没有版本链,我们直接return false就好了。如果有的话,先判断一下UndoLink是否有效,这里的判断其实就和我们要找到可见性的record差不多了,一直沿着版本链跳,(这里我的undolog里面存了标识是否提交的变量,还有定义为时间戳的变量,如果是-1,就代表着回滚,就代表着这个undolog其实就是不可用的状态了。) 跳到的第一个有效的undolog,就根据是否提交的标识来进行if else判断就好了。

很勾八的是,我第一次写的时候,不得不说脑子缺根筋,如果时间戳是-1,竟然是直接return false,而不是往上跳,卡了好几天,deadlock测试点一直过不去,这里是重灾区。

判断是否冲突的代码如下:

// 事务A尝试更新一条元组时,发现该元组的最新时间戳属于另一个未提交的事务B。
/**
 * 首先是如果版本链不存在,那么肯定就是没有冲突
 * 然后去找到版本链的第一个undolog,如果事务id是自己,那么不会冲突。
 * 如果不是自己的话,就比较一下时间戳,如果对方事务开始的时间比自己的晚,
 *
 *
 * @param context
 * @param rid
 * @param tab_name
 * @return 如果存在冲突返回true,不存在冲突返回false
 */
auto TransactionManager::MVCCCheckConfilct(Context *context, const Rid &rid,
                                           const std::string &tab_name)
    -> bool {
  // return false;

  auto version_link_opt = GetVersionLink(tab_name, rid);
  if (!version_link_opt.has_value()) {
    return false;
  }
  auto version_link = version_link_opt.value();

  // new
  {
    UndoLink link = version_link.prev_;
    if (!link.IsValid()) {
      return false;
    }
    while (link.IsValid()) {
      auto log_opt = GetUndoLog(link);
      if (!log_opt.has_value()) {
        return false;
      }
      UndoLog log = log_opt.value();
      // 就代表着已经回滚过了,那么肯定就是不可见的
      if (log.ts_ == INVALID_TS) {
        link = log.prev_version_;
        continue;
      }

      if (link.prev_txn_ == context->txn_->get_transaction_id()) {
        return false;
      }

      if (log.is_commit_) {
        if (log.ts_ >= context->txn_->get_read_ts()) {
          return true;
        }
        return false;
      } else {
        // 到这里,说明没有提交,而且最新的时间戳不是自己
        return true;
      }
      break;
    }
    return false;
  }
}

谓词冲突

先来大概讲解一下这个冲突是干啥的,例如我们有

//A
begin;
delete from table where id = 1;
//B
begin;
insert into table values(1,1);//第一个列是id,第二个列是score
//A
commit;
//B
commit;

其实对于以上的内容中,并不会发生之前的写写冲突。毕竟delete的地方的rid和我们insert的rid也不一样,但是这个insert的数据是符合之前事务A的删除的要求的,那么这个B事务按道理来说也应该回滚。

至于回滚的原因,如果允许了这样的操作,那么会导致一种幻读,这个幻读不是说在事务的存活时期读数据的内容不变就好了,还需要保证在事务结束了之后,之前操作的数据都是合理的符合要求的,像我们在事务A里要删除所有id=1的东西,但是事务A结束了之后,竟然还有存在id=1的元组,那么这也算是一种幻读。

那么解决办法是什么呢?这边有一个比较弱智的

记录dml语句操作的那些condition有哪些,起初的想法是开一个vector(以下内容只拿delete条件去说),然后在insert算子里面去匹配我们存储的del的那些条件,如果匹配成功了,那么我们就进行回滚。

其实这个办法很有问题:首先,这个存储condition的vector就这样跟开了个全局的感觉似的,真的保证正确性吗?其实,在算子里面去匹配,然后如果有问题就回滚,也并不符合刚才的例子,因为像这种谓词冲突,明显我们是需要用在commit阶段有一个检测的过程,如果检测有问题,就抛出异常。

全局的condition的正确性

我们先说第一个问题,这个存储vector的conds我详细说一下,就是说我们在delete算子里面,当我们要执行删除操作的时候,我们把删除的条件存储起来,放到del_conds(在事务管理器里面),然后在insert算子里面我们会利用这个去判断(当然不止它去判断,这里简说)。那这个del_conds里的东西肯定我们要清掉,不然删除的条件一直push进去,肯定会有问题呢。那么什么时候清掉呢?我们要选择当有效的事务仅剩一个的时候清掉它。这样之前删除的条件就不会影响到那些开始时间戳比较靠后的事务了。

但是这个清空的方法很轻易就能够hack掉,例如如果有三个事务,他们开始到结束的时间分别是A(1,3),B(2,5),C(4,7),C只和B相交,那么A的删除条件不应该会作用到C上,但是我们刚才说的办法就会影响到C。

所以这里改良了一下,拿了一个map去存储每一个事务的开始时间戳和结束时间戳(后面简称区间),然后我们在算子里去匹配del_conds的时候,我们只匹配那些别的事务的区间和当前事务的区间相交的事务里的del_conds。(虽然这个做法貌似会导致空间有点爆炸),但是我们可以参考水印,去给他清掉,做垃圾回收。

大概就像这种:感觉透露太多了也不太好,所以展示一下伪代码了

struct WriteInGap {
  timestamp_t l_;
  timestamp_t r_;
//删除的条件,开了个vector维护
//更新的条件,开了个vector维护
//插入的数据,开了个vector维护
  WriteInGap() = default;

  WriteInGap(int l, int r = -1) : l_(l), r_(r) {
  }

//x和y是左端点和右端点,这个函数用来判断是否相交
  bool is_contact(timestamp_t x, timestamp_t y) {
    if (y == -1) {
      if (r_ == -1) {
        return true;
      } else {
        return (x <= r_);
      }
    } else {
      if (r_ == -1) {
        return (l_ <= y);
      } else {
        timestamp_t ll = std::max(l_, x);
        timestamp_t rr = std::min(r_, y);
        return (ll <= rr);
      }
    }
  }
};
在算子里抛异常

我们在算子里抛异常其实并不正确,毕竟谓词冲突是应该在提交阶段判断的,如果在算子里就抛异常,那么决定谁回滚的就不是提交时间戳,而是运行这个dml语句先后的时间戳了。那么如何在commit里检测呢?(虽然我还是直接在算子里面抛异常的,测试弱了)。其实只是稍微麻烦了一点点而已,就是把这些内容全部都挪到commit函数里,然后判断是否冲突的时候,我们要找到那些已经commit的并且和我们当前事务相交的事务,去匹配是否有没有发生谓词冲突。只是这样做的话,随之而来我们就要在commit的时候去做数据持久化,而不是直接在算子里就落盘了。

并不是太喜欢在commit的时候落盘是因为这个删除操作,我们要涉及到刚才的这个提到的类似于垃圾回收的地方,毕竟如果我们只要commit就去真的做删除的数据持久化,那么是有问题的,如果事务A是删除操作,事务B查询后,A再commit了,事务B再查询一次,会出现幻读情况,毕竟因为我们是直接删除了,扫描算子压根找不到对应的rid的数据,就算有undolog也没用。有些怕麻烦,也懒得思考了,就先不管了,日后再说(疯狂埋坑)。

关于数据持久化的问题

非常的抱歉,这个也是没有实现,但是实现起来其实并不是很困难,我们在淘汰那些时间戳过前的事务的时候,把他们的writerecord给落盘就好了,这边就懒得实现了,等到后面优化性能的时候再去考虑这些事情。主要麻烦的地方在上一条里也提过了。

deadlock测试点

有个测试点叫死锁,我其实一直很纳闷,为什么这个MVCC会出现死锁测试点。读数据是无锁的,写数据的时候只有dml的算子里面会涉及到。基本上就是在我们数据持久化的时候,要先上一下行级锁,然后最后再unlock就好了。

但是既然有死锁的情况,那么就写了一个死锁检测,(但是最后并没有用上啊,实际上是因为之前checkconflict函数写的有问题导致的这一切。

也算有意思,之前没有接触到过。

另外,这个行级锁,我们得实现lockManager里面的前两个函数以及最后的unlock函数。但本身并不困难。

这个就直接给了个申请行级写锁的代码吧,感觉没啥难度。里面用到了个wait_die策略。

/**
 * @description: 申请行级排他锁
 * @return {bool} 加锁是否成功
 * @param {Transaction*} txn 要申请锁的事务对象指针
 * @param {Rid&} rid 加锁的目标记录ID
 * @param {int} tab_fd 记录所在的表的fd
 */
bool LockManager::lock_exclusive_on_record(Context *context, const Rid &rid,
                                           int tab_fd) {
  std::unique_lock lock(latch_);
  Transaction *txn = context->txn_;

  if (txn->get_state() == TransactionState::ABORTED ||
      txn->get_state() == TransactionState::COMMITTED) {
    return false;
  }

  LockDataId lock_data_id(tab_fd, rid, LockDataType::RECORD);
  if (lock_table_.count(lock_data_id) == 0) {
    lock_table_.emplace(std::piecewise_construct,
                        std::forward_as_tuple(lock_data_id),
                        std::forward_as_tuple());
  }
  LockRequest request(txn->get_transaction_id(), LockMode::EXLUCSIVE);

  LockRequestQueue &lock_queue = lock_table_[lock_data_id];
  // 如果新等老,那么直接抛出异常
  // 如果新等老,那么直接抛出异常
  if (!lock_queue.request_queue_.empty()) {
    std::shared_lock lock(context->tx_manager_->txn_map_mutex_);
    auto one = TransactionManager::txn_map[lock_queue.oldest_txn_id_];
    if (one->get_start_ts() < txn->get_start_ts()) {
      throw TransactionAbortException(txn->get_transaction_id(),
                                      AbortReason::DEADLOCK_PREVENTION);
    }
  }
  // 检查自己是否已有锁
  for (auto request_one : lock_queue.request_queue_) {
    if (request_one.txn_id_ == txn->get_transaction_id()) {
      return true;
    }
  }
  if (lock_queue.group_lock_mode_ != GroupLockMode::NON_LOCK) {
    // 老事务等待
    lock_queue.cv_.wait(lock, [&]() {
      return ((lock_queue.group_lock_mode_ == GroupLockMode::S &&
               lock_queue.request_queue_.size() == 1) ||
              lock_queue.group_lock_mode_ == GroupLockMode::NON_LOCK);
    });
  }
  // 直接获得锁
  txn->get_lock_set()->emplace(lock_data_id);

  request.granted_ = true;
  lock_queue.group_lock_mode_ = GroupLockMode::X;
  lock_queue.request_queue_.emplace_back(request);
  // lock_queue.oldest_txn_id_ = std::min(lock_queue.oldest_txn_id_,
  // txn->get_start_ts()); 更新最老事务ID
  if (lock_queue.oldest_txn_id_ == INVALID_TXN_ID ||
      txn->get_transaction_id() < lock_queue.oldest_txn_id_) {
    lock_queue.oldest_txn_id_ = txn->get_transaction_id();
  }

  return true;
}

也是原谅我,在这最后的并发里才知道有这么个行级锁表级锁之类的东西,了解到了LockManager的作用。(bushi

postscript

其他的基本上就没有什么难度了,大都是关于undolog的内容,这个其实更多的是理解了就可以写出来了。上一篇里基本讲完了他们之间的关系,所以不懂的可以参考一下上一期。

可以睡一个好觉了,最近这几天写MVCC真的是精疲力尽。

其实这么看,初赛准备的时间太慢了,就写了半个月的时间,导致初赛的排名很落后,其实第八题的难度还好,拿个十多分还是不难的,只是当时一直被传说是第八题贼难,而且也完全不懂啥是MVCC给怯场了(虽然现在其实也不是太懂MVCC)。感觉自己的理论知识一塌糊涂啊,基本就是东拼一块西凑一块的,应该好好看看cmu15445是嘛?话说cmu好久没写了,一直在忙这个比赛,等比赛结束,就到它了。

posted @ 2025-08-03 06:38  AdviseDY  阅读(12)  评论(0)    收藏  举报