MIT6.824
  • 简介
  • Lecture 01 - Introduction
    • 1.1 分布式系统的驱动力和挑战(Drivens and Challenges)
    • 1.2 课程结构(Course Structure)
    • 1.3 分布式系统的抽象和实现工具(Abstraction and Implementation)
    • 1.4 可扩展性(Scalability)
    • 1.5 可用性(Availability)
    • 1.6 一致性(Consistency)
    • 1.7 MapReduce基本工作方式
    • 1.8 Map函数和Reduce函数
  • Lecture 03 - GFS
    • 3.1分布式存储系统的难点(Why Hard)
    • 3.2 错误的设计(Bad Design)
    • 3.3 GFS的设计目标
    • 3.4 GFS Master 节点
    • 3.5 GFS读文件(Read File)
    • 3.6 GFS写文件(Write File)(1)
    • 3.7 GFS写文件(Write File)(2)
    • 3.8 GFS的一致性
  • Lecture 04 - VMware FT
    • 4.1 复制(Replication)
    • 4.2 状态转移和复制状态机(State Transfer and Replicated State Machine)
    • 4.3 VMware FT 工作原理
    • 4.4 非确定性事件(Non-Deterministic Events)
    • 4.5 输出控制(Output Rule)
    • 4.6 重复输出(Duplicated Output)
    • 4.7 Test-and-Set 服务
  • Lecture 06 - Raft1
    • 6.1 脑裂(Split Brain)
    • 6.2 过半票决(Majority Vote)
    • 6.3 Raft 初探
    • 6.4 Log 同步时序
    • 6.5 日志(Raft Log)
    • 6.6 应用层接口
    • 6.7 Leader选举(Leader Election)
    • 6.8 选举定时器(Election Timer)
    • 6.9 可能的异常情况
  • Lecture 07 - Raft2
    • 7.1 日志恢复(Log Backup)
    • 7.2 选举约束(Election Restriction)
    • 7.3 快速恢复(Fast Backup)
    • 7.4 持久化(Persistence)
    • 7.5 日志快照(Log Snapshot)
    • 7.6 线性一致(Linearizability)
  • Lecture 08 - Zookeeper
    • 8.1 线性一致(Linearizability)(1)
    • 8.2 线性一致(Linearizability)(2)
    • 8.3 线性一致(Linearizability)(3)
    • 8.4 Zookeeper
    • 8.5 一致保证(Consistency Guarantees)
    • 8.6 同步操作(sync)
    • 8.7 就绪文件(Ready file/znode)
  • Lecture 09 - More Replication, CRAQ
    • 9.1 Zookeeper API
    • 9.2 使用Zookeeper实现计数器
    • 9.3 使用Zookeeper实现非扩展锁
    • 9.4 使用Zookeeper实现可扩展锁
    • 9.5 链复制(Chain Replication)
    • 9.6 链复制的故障恢复(Fail Recover)
    • 9.7 链复制的配置管理器(Configuration Manager)
  • Lecture 10 - Cloud Replicated DB, Aurora
    • 10.1 Aurora 背景历史
    • 10.2 故障可恢复事务(Crash Recoverable Transaction)
    • 10.3 关系型数据库(Amazon RDS)
    • 10.4 Aurora 初探
    • 10.5 Aurora存储服务器的容错目标(Fault-Tolerant Goals)
    • 10.6 Quorum 复制机制(Quorum Replication)
    • 10.7 Aurora读写存储服务器
    • 10.8 数据分片(Protection Group)
    • 10.9 只读数据库(Read-only Database)
  • Lecture 11 - Cache Consistency: Frangipani
    • 11.1 Frangipani 初探
    • 11.2 Frangipani的挑战(Challenges)
    • 11.3 Frangipani的锁服务(Lock Server)
    • 11.4 缓存一致性(Cache Coherence)
    • 11.5 原子性(Atomicity)
    • 11.6 Frangipani Log
    • 11.7 故障恢复(Crash Recovery)
    • 11.8 Frangipani总结
  • Lecture 12 - Distributed Transaction
    • 12.1 分布式事务初探(Distributed Transaction)
    • 12.2 并发控制(Concurrency Control)
    • 12.3 两阶段提交(Two-Phase Commit)
    • 12.4 故障恢复(Crash Recovery)
    • 12.5 总结
由 GitBook 提供支持
在本页

这有帮助吗?

  1. Lecture 12 - Distributed Transaction

12.2 并发控制(Concurrency Control)

上一页12.1 分布式事务初探(Distributed Transaction)下一页12.3 两阶段提交(Two-Phase Commit)

最后更新于4年前

这有帮助吗?

第一个要介绍的是并发控制(Concurrency Control)。在并发控制中,主要有两种策略,在这门课程中我都会介绍。

第一种主要策略是悲观并发控制(Pessimistic Concurrency Control)。

这里通常涉及到锁,我们在实验中的Go语言里面已经用过锁了。实际上,数据库的事务处理系统也会使用锁。这里的想法或许你已经非常熟悉了,那就是在事务使用任何数据之前,它需要获得数据的锁。如果一些其他的事务已经在使用这里的数据,锁会被它们持有,当前事务必须等待这些事务结束,之后当前事务才能获取到锁。在悲观系统中,如果有锁冲突,比如其他事务持有了锁,就会造成延时等待。所以这里需要为正确性而牺牲性能。

第二种主要策略是乐观并发控制(Optimistic Concurrency Control)。

这里的基本思想是,你不用担心其他的事务是否正在读写你要使用的数据,你直接继续执行你的读写操作,通常来说这些执行会在一些临时区域,只有在事务最后的时候,你再检查是不是有一些其他的事务干扰了你。如果没有这样的其他事务,那么你的事务就完成了,并且你也不需要承受锁带来的性能损耗,因为操作锁的代价一般都比较高;但是如果有一些其他的事务在同一时间修改了你关心的数据,并造成了冲突,那么你必须要Abort当前事务,并重试。这就是乐观并发控制。

实际,这两种策略哪个更好取决于不同的环境。如果冲突非常频繁,你或许会想要使用悲观并发控制,因为如果冲突非常频繁的话,在乐观并发控制中你会有大量的Abort操作。如果冲突非常少,那么乐观并发控制可以更快,因为它完全避免了锁带来的性能损耗。今天我们只会介绍悲观并发控制。几周之后的论文,我们会讨论一种乐观并发控制的方法。

所以,今天讨论悲观并发控制,这里涉及到的基本上就是锁机制。这里的锁是两阶段锁(Two-Phase Locking),这是一种最常见的锁。

对于两阶段锁来说,当事务需要使用一些数据记录时,例如前面例子中的X,Y,第一个规则是在使用任何数据之前,在执行任何数据的读写之前,先获取锁。

第二个对于事务的规则是,事务必须持有任何已经获得的锁,直到事务提交或者Abort,你不允许在事务的中间过程释放锁。你必须要持有所有的锁,并不断的累积你持有的锁,直到你的事务完成了。所以,这里的规则是,持有锁直到事务结束。

所以,这就是两阶段锁的两个阶段,第一个阶段获取锁,第二个阶段是在事务结束前一直持有锁。

为什么两阶段锁能起作用呢?虽然有很多的变种,在一个典型的锁系统中,每一个数据库中的记录(每个Table中的每一行)都有一个独立的锁(虽然实际中粒度可能更大)。一个事务,例如前面例子中的T1,最开始的时候不持有任何锁,当它第一次使用X记录时,在它真正使用数据前,它需要获得对于X的锁,这里或许需要等待。当它第一次使用Y记录时,它需要获取另一个对于Y的锁,当它结束之后,它会释放这两个锁。如果我们同时运行之前例子中的两个事务,它们会同时竞争对于X的锁。任何一个事务先获取了X的锁,它会继续执行,最后结束并提交。同时,另一个没有获得X的锁,它会等待锁,在对X进行任何修改之前,它需要先获取锁。所以,如果T2先获取了锁,它会获取X,Y的数值,打印,结束事务,之后释放锁。只有在这时,事务T1才能获得对于X的锁。

如你所见的,这里基本上迫使事务串行执行,在刚刚的例子中,两阶段锁迫使执行顺序是T2,T1。所以这里显式的迫使事务的执行遵循可序列化的定义,因为实际上就是T2完成之后,再执行T1。所以我们可以获得正确的执行结果。

这里有一个问题是,为什么需要在事务结束前一直持有锁?你或许会认为,你可以只在使用数据的时候持有锁,这样也会更有效率。在刚刚的例子中,或许只在T2获取记录X的数值时持有对X的锁,或许只在T1执行对X加1操作的时候持有对于X的锁,之后立即释放锁,虽然这样违反了两阶段锁的规则,但是如果立刻释放对于数据的锁,另一个事务可以早一点执行,我们就可以有更多的并发度,进而获得更高的性能。所以,两阶段锁必然对于性能来说很糟糕,所以我们才需要确认,它对于正确性来说是必要的。

如果事务尽可能早的释放锁,会发生什么呢?假设T2读取了X,然后立刻释放了锁,那么在这个位置,T2不持有任何锁,因为它刚刚释放了对于X的锁。

因为T2不持有任何锁,这意味着T1可以完全在这个位置执行。从前面的反例我们已经知道,这样的执行是错误的(因为T2会打印“10,9”),因为它没能生成正确结果。

类似的,如果T1在执行完对X加1之后,就释放了对X的锁,这会使得整个T2有可能在这个位置执行。

我们之前也看到了,这会导致非法的结果。

如果在修改完数据之后就释放锁,还会有额外的问题。如果T1在执行完对X加1之后释放锁,它允许T2看到修改之后的X,之后T2会打印出这个结果。但是如果T1之后Abort了,或许因为银行账户Y并不存在,或许账户Y存在,但是余额为0,而我们不允许对于余额为0的账户再做减法,这样会造成透支。所以T1有可能会修改X,然后Abort。Abort的一部分工作就是要撤回对于X的修改,这样才能维持原子性。这意味着,如果T1释放了对于X的锁,事务T2会看到X的虚假数值11,这个数值最终不存在,因为T1中途Abort了,T2会看到一个永远不曾存在的数值。T2的结果最好是看起来就像是T2自己在运行,并没有T1的存在。但是这里,T2会看到X加1,然后打印出11,这与数据库的任何状态都对应不上。

所以,使用了两阶段锁可以避免这两种违反可序列化特性的场景。

对于这些规则,还有一些需要知道的事情。首先是,这里非常容易产生死锁。例如我们有两个事务,T1读取记录X,之后再读取记录Y,T2读取记录Y,之后再读取记录X。如果它们同时运行,这里就是个死锁。

每个事务都获取了第一个读取数据的锁,直到事务结束了,它们都不会释放这个锁。所以接下来,它们都会等待另一个事务持有的锁,除非数据库足够聪明,这里会永远死锁。实际上,事务有各种各样的策略,包括了判断循环,超时来判断它们是不是陷入到这样一个场景中。如果是的话,数据库会Abort其中一个事务,撤回它所有的操作,并表现的像这个事务从来没有发生一样。

所以这就是使用两阶段锁的并发控制。这是一个完全标准的数据库行为,在一个单主机的数据库中是这样,在一个分布式数据库也是这样,不过会更加的有趣。