CMU-15445 2021 Project 4-Concurrency Control (并发控制) [完结撒花~]

本文详细介绍了CMU-15445项目4的并发控制实现,包括锁管理器的设计,如共享锁、排他锁的上锁与解锁策略,以及死锁预防的Would-Wait算法。此外,还涵盖了并发查询执行的处理,如不同隔离级别下的读写操作。文章最后分享了作者对此项目的感想和学习收获。
摘要由CSDN通过智能技术生成

CMU-15445 2021 Project 4-Concurrency Control (并发控制) [完结撒花~]

结结实实花了两天, 终于搞定了~~

这个好像没有LeaderBoard, 总之贴一下结果图吧~~
在这里插入图片描述

CMU禁止公开源代码哦~, 有问题欢迎私聊, 评论或者加群: 484589324交流~

Project4的内容是并发控制, 我们需要实现一个锁管理器, 并在我们Project3中的Executor中调用我们的锁管理器, 以实现各种隔离级别

一共有三个子实验, 其中前面两个是一体的, 难度**(1+2) > 3**

我认为你至少需要通过课程了解到2阶段锁(2PL), 各个隔离级别的理论, 这样才能顺利完成Project4

我们需要支持的隔离级别只有读未提交(RU), 读已提交(RR), 可重复度(RR)

由于bustub还没有类似MySQL间隙锁, 或者索引锁之类的工具, 所以串行化我们不需要实现

1. LOCK MANAGER (锁管理器)

1-1. 讲解一下需求

我们需要实现 共享锁(S锁), 排他锁(X锁), 将S锁升级为X锁, 解锁 四个函数

1-1-1. 上锁

所有的上锁函数在上锁前都需要:

  1. 检查一下事务是否已经是Shrinking状态, 如果已经Shrinking了, 这时上锁是不合法行为, 要将事务回滚

  2. 检查一下事务是否已经是Abort状态, 如果是, 直接return false即可

  3. 如果是RU隔离界别来上S锁, 那我们同样应该回滚事务, 这是因为RU的实现方式就是无锁读, 这样就能读到脏数据了, 如果上了S锁, RU将无法读到脏数据

  4. 检查是否已经上过同级或更高级的锁了, 如果已经上过了, 我们直接return true

其中前三点我们可以封装成一个函数 bool CheckLockValidity(Transaction *txn, LockMode mode)

第四点我们每次手动判断一下即可

而每个tuple可能有很多事务在对其加锁, 我们需要循环等待一个合法的时机, 才能给当前事务加锁

而这个合法的时机, 对于S锁来说是: 前面的请求都是S锁, 不管前面的请求有没有获得到S锁, 具体看代码

  auto judge_func = [&]() {
    for (const auto &req : rq.request_queue_) {
      if (txn->GetTransactionId() == req.txn_id_) {
        return true;
      }
      if (req.lock_mode_ != LockMode::SHARED) {
        return false;
      }

      // 和教科书上不一致, 不过确实这样更加公平
      // if (!req.granted_) {
      //   return false;
      // }
    }

    return true;
  };

书上要求S锁必须等待前面的事务都获取S锁之后才能获取, 这太严格了, Andy认为只要前面的请求都是S锁即可, 不需要等待前面的事务获取, 我个人比较认可Andy的观点. 同时这也是FairnessTest中最大的一个坑, 这也是我贴代码出来的原因(大家少踩点坑, hh)

对于X锁来说, 则仅当当前请求在请求队列首部时才合法, 这个不管是原因还是代码就都很简单了

而在循环等待时, 我们可能需要等待很久很久才能得到一个合法的时机(高并发场景), 这时如果我们用自旋就非常的不合理, 因此官方推荐我们使用条件变量cv, 我们用得到的是两个函数:

  1. cv.wait(mutex): wait干了三件事释放锁, 等待被唤醒, 唤醒时自动获取锁
  2. cv.notify_all(): 唤醒所有在wait的线程

而这个mutex互斥量, 我们理论上应该做到一条记录一个锁, 但是如果我们一个表一个锁也是可以的, 因为我们的逻辑是循环等待, 无非也就是重新多wait一次, 当然, 生产环境肯定要一条记录一个锁提高吞吐, 循环等待的逻辑如下:

  std::unique_lock<std::mutex> u_latch(mutex);  
  // 循环等待事务被回滚或者情况合法
  while (!judge_func()) {
    // wait做三件事, 释放锁, 等待被唤醒, 唤醒时自动获取锁
    rq.cv_.wait(u_latch);
  }

下面我们讲解各种上锁的函数就很方便了:

  1. 首先自然是前面的四点校验
  2. 校验通过, 就获取锁, 在请求队列中新增一个请求, 注意lock_mode不要写错了
  3. judge_func, 然后循环等待
  4. 等待结束后我们将granted_置为true, 最后**txn->GetXXXXLockSet()->emplace(rid)**即可

之后是LockUpgrade, 官方保证了the RID should already be locked in shared mode by the requesting transaction, 因此一切都非常的简单了

我们先删除掉在队列中的S锁请求, 之后重新上一个X锁即可, 如果你会写S锁和X锁, 这个东西真的有手就行

1-1-2. 解锁

Unlock的逻辑自然是:

  1. 在请求队列和LockSet中删除对应的请求(不存在锁请求就return false), 并设置事务状态为Shrinking
  2. notify_all()通知其他请求可以尝试加锁了, 不是notify_one()而是all的原因是, 可能可以通知很多想要加S锁的请求, 这样他们就都能拿到锁了

需要注意的有两个坑点:

  1. 只有Growing时才设置txn的状态为Shrinking, 因为总不能Aborted的时候解个锁解成Shrinking了吧…

  2. 当lock_mode是S锁时, 只有RR才设置Shrinking, 因为RC是可以重复的加S锁/解S锁的, 这样他才可以在一个事务周期中读到不同的数据

额, 直接放代码出来帮大家避坑…

  if (txn->GetState() == TransactionState::GROWING) {
    if (txn->IsExclusiveLocked(rid)) {
      txn->SetState(TransactionState::SHRINKING);
    } else if (txn->IsSharedLocked(rid) && txn->GetIsolationLevel() == IsolationLevel::REPEATABLE_READ) {
      txn->SetState(TransactionState::SHRINKING);
    }
  }

2. DEADLOCK PREVENTION (死锁预防)

实验要求使用Would-Wait算法, 这个算法的思路非常的简单:

  1. 年轻事务如果和老事务有上锁冲突, 年轻事务需要等老事务解锁
  2. 老事务如果和年轻事务有上锁冲突, 老事务直接将年轻的事务统统杀掉(回滚), 非常的野蛮hh

而这种听起来很怪的方法work的原理是:

死锁必然是循环等待, 而我们现在只有单向的等待: 年轻事务等老事务, 这样可以百分百避免死锁

而所谓的上锁冲突, 我们自然需要考虑S锁和X锁:

  1. 如果老事务的S锁请求前面有年轻事务的S锁请求, 那这个年轻事务不需要回滚; 但如果年轻事务有X锁请求则需要回滚
  2. 老事务的X锁请求直接回滚任何前面的年轻事务

具体实现起来, 我们只需要在向队列中插入请求之前, 加一段杀掉年轻事务的代码即可(当然, 插入之后也可以, 看个人)

代码中, 我们:

  1. 遍历整个队列, 根据当前请求的lock_mode杀掉有上锁冲突的年轻事务**(就地删除该请求并设置状态为Aborted)**

  2. 遍历时记录一下有没有杀掉年轻事务, 如果确实杀掉了, 就需要立即notify_all()

    你可能会说暂时不notify_all(), 等当前事务解锁Unlock()的时候自动notify_all()不是也可以吗?

    这是因为高并发场景下内存比较珍贵, 尽可能早的杀掉没用的僵尸线程会好一些, 这一点在测试里也有测

而被唤醒之后之后, 年轻事务要如何感知到自己被死锁预防杀掉了呢?

看来Task1的循环等待逻辑要改造一下, 加一个check abort的逻辑:

  // 循环等待事务被回滚或者情况合法
  while (txn->GetState() != TransactionState::ABORTED && !judge_func()) {
    // wait做三件事, 释放锁, 等待被唤醒, 唤醒时自动获取锁
    rq.cv_.wait(u_latch);
  }

  if (txn->GetState() == TransactionState::ABORTED) {
    // std::cout << txn->GetTransactionId() << " Killed For DeadLock Prevention!\n";
    return false;
  }

3. CONCURRENT QUERY EXECUTION (并发的请求执行)

在Project3中我们没有考虑并发的请求执行, 现在有了lock_manager之后我们有能力针对不同的隔离级别进行上锁和解锁了

事务回滚的时候我们需要利用一些元信息, 因此我们需要在执行时记录一下这些信息

理论上我们需要维护的是txn的WriteSet和IndexWriteSet, 但其中WriteSet已经由TableHeap维护了, 因此我们只要记录索引的更新即可

SEQUENTIAL SCAN

读元组的时候, 读之前自然要上S锁, 别忘了RU是不用上S锁的, 当然, 如果已经上过S锁或X锁了也不需要重复上锁(其实我们之前的lock_manager中已经保证过这一点了, 但是多一点判断总是好的)

读完之后要不要解锁呢?

  • RC肯定是要解锁的, 这样后面才能读到不同的数据, 而且事务不会因此变成Shrinking

  • RR不能解锁, 这会让他直接变成Shrinking状态, 后面想读元组都上不了锁了

  • RU… 给你个眼神自己体会

DELETE/UPDATE

上过X锁就不用管了, 没上过任何锁就直接上X锁, 上过S锁了就将S锁升级为X锁即可

当然了, 肯定不能解锁(要不然直接变成Shrinking了)

最后别忘了维护txn->IndexWriteSet

INSERT

插入之前我们没办法加锁, 插入之后我们立刻上一个写锁, 这样RU可以读到, RC和RR读不到

然后我们再更新索引, 这样能保证其他事务通过索引查到这条记录时, 该记录已经被锁上了

当然, 别忘了维护txn->IndexWriteSet哦~~~

感想

(●’◡’●) 至此, 对卡内基梅隆大学15445/645数据库课程的学习, 2021版就全部完结了 ╰(°▽°)╯

距离Project1的博客发布正好一个月, 虽然中间很多摆烂很多琐事, 最后还是顺利通关了

期间光是加我QQ好友的就有十几个同学, 以他们的进度, 想必很快也会看到这段文字了hh, 要是再很多人加我打算直接拉个群了hh

这真的是收益非常大的一门课(废话, hh)

看不见摸不到的bpm, 索引, 隔离级别, 在实验中全部都会被落实到代码, 这真的很棒

之前背的一些面试八股文现在想想真是可笑, 学完了这门课, 数据库方面直接降维打击面试, 我认为没有问题

国内外的计算机教学差距真的太大了, 凭良心说, 国内真的教材教材不行(豆瓣评分都能看出来爆炸), 实验实验不行(增删改查你实验个啥呢…), 有能力的同学真的墙裂建议快点加入到这门课的学习中吧ヾ(≧▽≦*)o

最近快要期末了, 虽然但是, 还是要稍微应付一下的

下一步我的打算是MIT 6.824, 早些时间写完了Lab1, 能够感受到应该也会是和这门课一样高质量的神课

看到这里的同学们应该也都已经通关或者即将通关了, 在这里也恭喜大家, 做完了这样一件高投入高回报的事情, 这是这个时代绝大部分人不愿意做的事情

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值