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. 上锁
所有的上锁函数在上锁前都需要:
-
检查一下事务是否已经是Shrinking状态, 如果已经Shrinking了, 这时上锁是不合法行为, 要将事务回滚
-
检查一下事务是否已经是Abort状态, 如果是, 直接return false即可
-
如果是RU隔离界别来上S锁, 那我们同样应该回滚事务, 这是因为RU的实现方式就是无锁读, 这样就能读到脏数据了, 如果上了S锁, RU将无法读到脏数据
-
检查是否已经上过同级或更高级的锁了, 如果已经上过了, 我们直接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, 我们用得到的是两个函数:
cv.wait(mutex)
: wait干了三件事–释放锁, 等待被唤醒, 唤醒时自动获取锁cv.notify_all()
: 唤醒所有在wait的线程
而这个mutex互斥量, 我们理论上应该做到一条记录一个锁, 但是如果我们一个表一个锁也是可以的, 因为我们的逻辑是循环等待, 无非也就是重新多wait一次, 当然, 生产环境肯定要一条记录一个锁提高吞吐, 循环等待的逻辑如下:
std::unique_lock<std::mutex> u_latch(mutex);
// 循环等待事务被回滚或者情况合法
while (!judge_func()) {
// wait做三件事, 释放锁, 等待被唤醒, 唤醒时自动获取锁
rq.cv_.wait(u_latch);
}
下面我们讲解各种上锁的函数就很方便了:
- 首先自然是前面的四点校验
- 校验通过, 就获取锁, 在请求队列中新增一个请求, 注意lock_mode不要写错了
- 写judge_func, 然后循环等待
- 等待结束后我们将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的逻辑自然是:
- 在请求队列和LockSet中删除对应的请求(不存在锁请求就return false), 并设置事务状态为Shrinking
- notify_all()通知其他请求可以尝试加锁了, 不是notify_one()而是all的原因是, 可能可以通知很多想要加S锁的请求, 这样他们就都能拿到锁了
需要注意的有两个坑点:
-
只有Growing时才设置txn的状态为Shrinking, 因为总不能Aborted的时候解个锁解成Shrinking了吧…
-
当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算法, 这个算法的思路非常的简单:
- 年轻事务如果和老事务有上锁冲突, 年轻事务需要等老事务解锁
- 老事务如果和年轻事务有上锁冲突, 老事务直接将年轻的事务统统杀掉(回滚), 非常的野蛮hh
而这种听起来很怪的方法work的原理是:
死锁必然是循环等待, 而我们现在只有单向的等待: 年轻事务等老事务, 这样可以百分百避免死锁
而所谓的上锁冲突, 我们自然需要考虑S锁和X锁:
- 如果老事务的S锁请求前面有年轻事务的S锁请求, 那这个年轻事务不需要回滚; 但如果年轻事务有X锁请求则需要回滚
- 老事务的X锁请求直接回滚任何前面的年轻事务
具体实现起来, 我们只需要在向队列中插入请求之前, 加一段杀掉年轻事务的代码即可(当然, 插入之后也可以, 看个人)
代码中, 我们:
-
遍历整个队列, 根据当前请求的lock_mode杀掉有上锁冲突的年轻事务**(就地删除该请求并设置状态为Aborted)**
-
遍历时记录一下有没有杀掉年轻事务, 如果确实杀掉了, 就需要立即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, 能够感受到应该也会是和这门课一样高质量的神课
看到这里的同学们应该也都已经通关或者即将通关了, 在这里也恭喜大家, 做完了这样一件高投入高回报的事情, 这是这个时代绝大部分人不愿意做的事情