CMU15-445 Project4 Concurrency Control心得

发布时间 2023-07-05 20:38:12作者: byFMH

一、概述

过瘾!过瘾!过瘾!P4 真过瘾!写 P3 的博客时我说过“感觉自己在数据库方面真正成长了”,但写完 P4 之后最大的感受就是,我终于理解了 andy 在第一课说过的“我只在乎两件事情,一个是我老婆,另一个是数据库。

从代码量、概念晦涩程度、思考深度等各方面综合考量,我认为 P4 是难于 P2 的,也可能我对手写 B+tree 所花(浪费)的时间太多而怨念太深哈哈哈。

最后这篇博客有两个目的

  1. 记录我的学习心得,这个 project 涉及到并发控制、二阶段锁、事务及其隔离级别、死锁及其检测/预防等概念,需要"学而时习之"。
  2. 记录我的思考过程,完成 P4 需要对二阶段锁、隔离级别、事务之间相互暴露可能发生的问题这三大块之间的关系有深入的思考和清晰的理解。

二、事务相关概念回顾

1 transaction 事务

事务是访问或更新数据项一系列操作的一个集合,简单来说就是一系列读写诶操作的集合。

transaction 的正确性标准称为 ACID

  • 原子性(Atomicity):事务中的所有操作要么都发生,要么都不发生。
  • 一致性(Consistency): 四个特性中最抽象的一个,一个容易理解的描述就是:数据库描述的状态要和真实的物理世界保持一致对数据的一组特定约束必须始终成立,比如转账事务T1 和查询两账户总额事务 T2,应该保证无论什么时候查询账户总额都是一样的。通过原子性、隔离性、持久性来维护这种一致的状态。
  • 隔离性(Isolation): 一个事务察觉不到他和其他事务是并发执行的。
  • 持久性(Durability):如果一个事务被 commit,则它对数据库的改变时永久的

2 如何确保四种特性

  • 原子性:几乎所有现代系统都使用 logging 日志方式,DBMS 在 log 中按顺序记录所有 操作 信息,如果事务被 abort 需要回滚,就可以利用日志中的信息来实现。
  • 一致性:通过原子性、隔离性、持久性来维护这种一致的状态。原子性,隔离性和持久性是数据库自身的属性,而 ACID 中的一致性更多是应用层的属性。
  • 隔离性:通过并发控制机制实现。
  • 持久性:和原子性一样,也是通过 logging 日志方式实现

3 多个事务交织的顺序-schedule

多个事务并发时,DBMS 执行操作的顺序被称为 执行调度 (execution schedule),简称 schedule。并发控制机制的目标是产生一个相当于串行执行 (serial execution) 的执行调度,也就是可串行化调度 (Serializable Schedule)

  • 串行调度 (Serial Schedule):一个事务执行完另一个事务执行,不存在事务之间的交织

  • 等效调度(Equivalent Schedule):两种调度的结果是一样的话,这两种调度就是的等效的

  • 可串行化的调度 (Serializable Schedule):当一种调度和串行调度是等效的,这种调度就是可串行化的调度

以下面为例,图 1 中 schedule1 和 schedule2 是两种执行调度,schedule2 明显是串行调度,不存在事务之间的交织。schedule1 的执行结果和 schedule2 是一样的,所以 schedule 1 与 schedule2 是等效调度。与串行调度 schedule2 等效,所以schedule1 是可串行化的调度

image-20230630202350412

冲突操作:如果两个操作位于不同的事务中,且操作同一个对象,且至少一个操作是写,那么这两个操作就是冲突操作,冲突操作包括:

  1. R-W 读写冲突 (导致 不可重复读):一个事务在多次读取同一对象时无法获得相同的值。
  2. W-R 写读冲突 (导致 脏读):一个事务在另一个的事务提交其更改之前就看到了该事务的写入效果。
  3. R-R 写写冲突(导致 丢失更新 或 脏写): 一个事务覆盖了另一个并发事务的未提交的数据。image-20230418171229787

识别冲突操作的意义在于,如果位于两个事务中的两个连续操作不是冲突操作,他们可以交换顺序!以产生等效调度!如下图中的最左边的 schedule 和最右边的 schedule 是等效调度

image-20230630204620390

  • 冲突等效:一个调度 S 通过一系列非冲突指令的交换而转换成另一个调度S',则这两个调度是冲突等效的
  • 冲突可串行化(Conflict Serializabile):如果一个调度 S 与一个串行化调度冲突等效的,则调度 S 被称为是冲突可串行化的。

所以上图最左边的schedule冲突可串行化的。

以上是2 个事务的 schedule 可以使用交换顺序来识别一个 schedule 是否是冲突可串行化的,如果是多个事务,这样的交换会很复杂,所以 检测多个 transactions 构成的 schedule 是否 serializable 需要使用依赖图 (dependence graphs) / 优先图(precedence graph)

  • 每个事务表示一个节点
  • 若事务 ?? 和 ?? 存在冲突操作 ?? 和 ??,?? 早于 ?? 发生则添加 ?? 指向 ?? 的箭头,表示 ?? 必须出现在 ?? 之前

如果一个 schedule 的依赖图无环的,则它是冲突可串行化的,如下图:

image-20230630212412530

三、并发控制机制-2PL

上一部分介绍了通过依赖图或者“交换”来判断一个 schedule 是否是可串行化 (serializable) 的方法,前提是已经有了 schedule,再用这些方法去判断这个 schedule 是否为可串行化的,但真实的数据库使用场景如下:

  1. 任何时刻都可能有事务在开启、中止和提交
  2. 显式事务中,客户端不会一次性告诉数据库所有操作

这就使得 DBMS 无法获得一个完整的 schdule,更不用说进一步去判断是不是冲突可串行化的,唯一的办法就是兵来将挡,水来土掩,不管哪个事务的哪个操作来了,只要有一个合理的加锁策略,最终生成的这个 schedule 一定是可串行化的,这种策略就是 2PL

1. 2PL 是什么

两阶段封锁协议 (2PL) 是一种悲观的并发控制协议,它使用锁来帮助数据库在运行过程中决定某个事务是否可以访问某条数据,并且 2PL 的正常工作并不需要提前知道所有事务的执行内容,仅仅依靠已知的信息即可。

数据库中的每一个数据对象都有两种锁:*(S)hared lock* 和 *e(X)clusive lock*。正如字面意思,shared lock允许多个锁并存;exclusive lock具有排它性。两种锁之间的 兼容性 参考下表:

image-20230701164723596

2PL 有两个阶段:

Phase #1– Growing: 在该阶段,事务可按需(事务需要读数据就申请读锁,需要写数据就申请写锁)获取某条数据的锁,lock manager 授予/拒绝这些锁请求。但是该阶段不能释放锁

Phase #2– Shrinking: 在该阶段,事务只能释放之前获取的锁,不能申请新锁。即一旦开始释放锁进入 Shrinking 阶段,之后就只能释放锁。

image-20230418204027064

2PL 解决的问题:对并发执行的多个事务产生可串行化的 schedule

2PL 存在的问题:

  • 可能出现级联回滚 —— 解决方案:严格二阶段封锁协议 (Strict 2PL, S-2PL)
  • 可能出现脏读 —— 解决方案:强二阶段封锁协议 (Strong Strict 2PL, SS-2PL,又称 Rigorous 2PL)
  • 可能导致死锁 —— 解决方案:死锁检测和预防

2. 证明 2PL 一定能产生可串行化的调度

假设 2PL 不能保证可串行化,那么假设一组事务{T0~T(n-1)}存在一种调度,遵循 2PL 但是产生一种不可串行化的调度,一个不可串行化的调度意味着优先图中有一个环,我们将证明2PL不能产生这样的环:

假设在优先图中存在以下循环:

T0->T1->T2->...->T(N-1)->T0

优先图中 Ti->Tj 表示在(等价的)串行化调度 S'中, Ti 必须出现在 Tj 之前,那么 Ti 的某一个解锁操作一定在 Tj 的某一个加锁操作之前,由于遵循 2PL,所以 Ti 一旦解锁一定处于 shrinking 阶段,Tj 一旦加锁必定处于 growing 阶段,即: Ti 一定在 Tj 完成growing phase之前就进入了shrinking phase

举例:下图中间的 schedule 遵循 2PL,存在冲突T1: W(A) T2: R(A),所以优先图中必存在 T1->T2,由于冲突的存在, T2 要想获得lock(A),必须等待 T1 unlock(A),由于遵循 2PL,T1 unlock说明进入 shrinking,所以T1 一定在 T2 完成growing phase之前进入了shrinking phase

image-20230418212946321

设 ti 是 Ti 获得其最后一个锁的时间 ( 即T的封锁点-lock point ) 。刚刚已经证明,如果优先图中 Ti -> Tj,那么 Ti 一定在 Tj 完成 growing phase之前进入了shrinking phase,而:

  1. ti < Ti 进入 shrinking 的时间点
  2. tj >= Tj 完成 growing 的时间点
  3. Ti 进入 shrinking 的时间点 < Tj 完成 growing 的时间点

根据以上三点可以得出结论:ti < tj

所以最终结论就是,如果优先图中 Ti -> Tj,那么 ti < tj

所以 T0->T1->T2->...->T(N-1)->T0 可以推出 t0 < t1 < t2 <...<t(n-2) < t0 ,其中 t0< t0 矛盾,反证成立, "2PL 不能保证可串行化"为假

结论:一种调度,只要遵循 2PL, 一定可以保证可串行化

image-20230701151948814

课本上 S-2PL 解决级联回滚、SS-2PL 可以按照其提交(commit)的次序串行化,2PL 是按照封锁点顺序串行化

如果排他锁直到事务结束才释放,,那么调度就是无级联的(S-2PL 解决级联回滚问题)

3. 两阶段锁是如何实现的

2PL 算法很简单,只需要遵守以下规则:

  1. 如果一个transaction想要读取 数据项 x ,那它必须获取 x 上的shared lock;如果一个transaction想要写入 数据项 x ,那它必须获取 x 上的exclusive lock

  2. 如果一个transaction释放了它所持有的任意一个锁,那它就再也不能获取任何锁

  3. Strict 2PL 是 2PL 的变体,不仅要求封锁是两阶段的,还要求事务持有的排他锁必须在事务提交后才能一次性释放。

    Strong Strict 2PL 是 2PL 的变体,不仅要求封锁是两阶段的,还要求事务持有的所有锁必须在事务提交后才能一次性释放。

对应到 bustub 的 project,在 prohect3 :query execution 中,直接和 row data 打交道的是 seq_scan_executor、insert_executor、delete_executor,其中seq_scan_executor 就是负责读取数据项的,所以在 seq_scan_executor 的 Next() 函数中,每读取一个 row 之前,都需要对 row 上 S 锁;同理,在insert_executor 和 delete_executor 中,负责写入数据,需要对插入、删除的行加 X 锁

事务的初始状态是 growing,如果涉及到解锁操作,需要将事务的状态修改为 shrinking。

以上只是笼统的流程,project4 中,基于 2PL 实现了 3 种隔离级别:

  • 读未提交:写时加X 锁,读时不加锁,commit 释放 X 锁
  • 读提交:写时加X 锁,读时加 S 锁,读完立即释放,commit 释放 X 锁
  • 可重复读:写时加 X 锁,读时加 S 锁,commit 释放S、 X 锁

可以看出来,project4 中的可重复读就是 SS2PL,而读提交和读未提交只是有一个 2PL 的“形式”,因为“读未提交”没有使用 S 锁,“读提交”解锁 S 锁后并不影响事务状态,所以这两种隔离级别只是是 2PL 的“魔改”,放宽了加锁的限制条件,换来了更大的并发。

4 为什么这样加解锁就可以实现对应的隔离级别?

这是一个很重要的思考,project4 中 lock_manger.h 给出了实现相应隔离级别的加解锁方案,但是却没有解释为什么要这样加解锁,我整整想了 2 天,终于想通了,以下是我的思考结论:

首先,隔离级别是为了解决相应的数据不一致问题,具体来说有:脏写、脏读、不可重复读、幻读(可能还有更多,只说这几种主流的)

定义:长时锁(Long Period Lock,事务开始获取锁,到事务结束时释放)和短时锁(Short Period Lock,访问数据时获取,用完旋即释放);

定义:互斥锁(Mutex Lock,又称写锁、独占锁、排它锁)和共享锁(Shared Lock,又称读锁);

为了避免脏写,可以给要写的数据项长时写锁,但读数据时并不加锁,这样其他事务要写的话,就不能获取到这个数据项的写锁了,但是由于读数据和锁无关,可以随便读,就会读到未提交的数据,此时的隔离级别称为读未提交(RU,Read Uncommitted)。

为了避免脏读,可以对要读的数据项短时读锁,因为要写的数据已经加了长时写锁,所以这个短时读锁会等待,不会读到未提交的数据,之所以这个读锁是短时的,是为了尽快放锁,增加并发,此时的隔离级别是读已提交(RC,Read Committed)。

为了解决不可重复读,可以对要读的数据项长时读锁。这样其他事务要修改这个数据,就无法获取这个数据项的写锁了,所以不管什么时候读,读到的数据都是一指的,解决了不可重复读的隔离级别称为可重复读(RR,Repeatable Read)。

到可重复读级别,都是针对单条数据上锁。在此隔离级别下,如果一个事务两次进行范围查询,比如说执行两次 select count(x) where x > 5;另一个事务在两次查询中间插入了一条 x = 6,这两次读到的结果并不一样,这种现象称为幻读(Phantom Read)。为了解决幻读问题,需要针对范围查询语句长时谓词读锁。解决了幻读的隔离级别,我们称为可串行化(Serializability)

画了个小图帮助理解:

image-20230705195035169

5. project4 的实现心得

5.1 lock manager

锁管理器,用来处理事务的加锁、解锁请求。锁管理器的基本思想是,它维护一个关于事务当前持有的锁的数据结构,事务在访问数据项之前需要向LM发出锁请求,LM将根据情况决定锁授予该事务、阻止该事务还是终止该事务。

LM 本质就是一个 map,key 是表 ID(或者行 ID: RID),value 是 LockRequestQueue,记录了针对该表(行)的当前所有的锁请求,下图是针对表的锁管理器,我画了个小图帮助理解:

image-20230701160645599

我们拿出map 中的第一条 KV 对的 value 细看,如下图,可以看出:事务 T32 和 T5 已经在表 22 上获取了 S 锁,事务 T14 和 T7 在等待获取 X 锁,uograding 表示目前 LR-Queue 中没有正在升级的锁。

image-20230701161733855

以上图为例,加锁操作流程是这样的:

  • 如果事务 T99 请求对表 22 加 S 锁,直接把这个 request 添加到队列末尾
  • 如果事务 T5 请求对标 22 加 X 锁,触发锁升级,把队列中原元素删除,并且将升级的锁请求插入到第一个未授予的位置,并重新检查该请求能否成功上锁。

解锁流程更简单的:(以解表锁为例)

  • 解锁条件检查:解表锁之前行锁是否清空,LQ-Queue 有无该数据项的授权记录等

  • 直接删除队列中的的 request

  • 根据隔离级别更新事务状态

    可重复读:
                解开 s/x锁都应该将事务状态设置为  SHRINKING
    读提交:
                解锁 × 锁应将事务状态设置为 SHRINKING
                解锁 s 锁不影响事务状态。
    读未提交:
                解锁 X 锁事务状态设置为 SHRINKING
                不允许使用 S 锁
                在此隔离级别下解锁S锁的行为未定义
    

5.2 死锁检测算法

死锁检测算法是后台一个进程,定期运行,主要流程为:

  1. 遍历锁表中所有的 LR_Queue建立依赖图(将“未授予锁”的事务指向“已授予锁”的事务)
  2. 利用环检测算法,从 ID 最小的事务开始DFS 遍历,直到发现有环(这是为了保证DFS循环检测算法是确定性的,在探索 node 的邻居时,也是按从低到高的排序顺序探索它们)
  3. 将环中 ID 最大的事务状态标记为中止,然后从图中删除这个 node 以及其出边和入边
  4. 如果被 中止 的是等待(未授权)事务,拿到其对应的 table_id,再拿到 LQ_Queue,通知其他线程:cv_.notify_all();
  5. 接着遍历,直到无环,结束死锁检测算法(死锁检测算法每运行一次,都要确保系统中没有死锁才会提出

有几点需要说明:

  • 依赖图可以用 map 结构来表示,我画了个小图,方便理解,比如右边的依赖图在代码中就是左边的map结构

    image-20230705200910836

  • 环检测算法是使用 DFS,从最小 ID 开始遍历,比如图中的 T5 开始,T5 发现自己没有“邻居”(本node出边指向的 node),无环,结束针对 T5 的 DFS;然后 T7 开始 DFS,T7->T5,这条路线结束,T7->T32->T7,路线中发现重复项,说明有环,结束遍历,将环中 ID 最大的事务(T32)状态标记为中止,然后删除 node 和 node 相连的出边、入边,我画了个小图帮助理解:
    image-20230705201428329

5.3 并发查询

两阶段锁是如何实现的 这一节中已经讲了如何实现三种隔离级别:

  • 读未提交:写时加X 锁,读时不加锁,commit 释放 X 锁
  • 读提交:写时加X 锁,读时加 S 锁,读完立即释放,commit 释放 X 锁
  • 可重复读:写时加 X 锁,读时加 S 锁,commit 释放S、 X 锁

对照这个内容,以 seq_scan 算子为例,因为这个算子相当于简单模型中读数据,的如果是读未提交,不用加锁;如果是读提交,则给行加 S 锁(别忘了先给表 IS 锁,再加行 S 锁),读完之后立即释放 S 锁;如果是可重复读,则给行加 S 锁就不用管了,abort 或者 commit 会自动解锁的。

四、最后

放一张满分通关截图吧(忽略我小李飞刀的花名哈哈),也算成为全球 220 个通关的人之一了哈哈哈(截止到今天2023 年 7 月 5 日),可以看到本来在 6 月 29 日我已经满分通关了,但是这篇博客我写了整整一周,因为写完 project 之后,我一直在思考为什么要这么实现隔离级别 以及 总结这个 project 中的各种复杂概念还有画各种小图哈哈哈

image-20230705202356623

转眼间已经 gap10 个月了,这期间去尽兴玩了两个月,之后专心学完了 CSAPP、MIT6.s081、CS144、CMU15-445 这四门大课,还有若干门小课,只剩下最后一门 MIT6.824 分布式系统了,同时也该着手准备工作了,希望这一次我能做一些有意义的东西,make the world a little better.