Lec 13 乐观并发控制与快照隔离
阅读资料:
前面我们学习以两阶段锁(2PL)和基于时间戳顺序的并发控制,它们都属于悲观的并发控制。而这节Lec将学习另外一种隔离事务的方法——乐观并发控制(Opitimistic Concurrency Control, OCC)
思考题:
- 你认为在什么情况下,乐观并发控制会比基于锁的并发控制表现更好?
- 乐观并发控制会导致死锁吗?
- 你会如何在SimpleDB中实现乐观并发控制?
总览
- 乐观并发控制
- 多版本并发控制
- 论文阅读:
乐观并发控制
乐观并发控制(Opitimistic Concurrency Control, OCC), 是非锁的事务隔离性方案,”乐观“是指认为冲突很少,不加锁,让事务“乐观地”执行,到最后再验证是否出现了冲突。
💡关键方法:
- 每个事务有自己的写缓冲区,写操作先写在缓冲区中,不直接修改数据库
- 跟踪读写操作,分别组成读取和写入集合
- 提交时,检查是否与其他并发事务存在冲突
- 如果检测到冲突,事务会中止(abort),一般会重试。
- 如果验证通过(无冲突),则将写缓冲区的数据写入数据库(真正生效)
在乐观并发控制(OCC)中, 从不需要等待锁,因此没有死锁。但是...发生冲突的事务必须重新启动,因此事务可能会“饥饿”——例如,反复被重新启动,永远无法取得进展。权衡:当重启率较低时,OCC的表现会更好(即,争用较少)。
最近关于高性能事务处理的研究集中在OCC上,因为:OCC的检查可以在单个事务之间完成;不像全局共享的锁表那样复杂。 现代OCC系统能够实现极高的吞吐量(超过每秒1000万次操作)。
实现
事务的执行划分为三个阶段:
- 读取阶段:事务在数据库上执行操作,并存储局部状态。
- 验证阶段:事务检查它是否可以提交。
- 写入阶段:事务将状态写入数据库。
1. 读取阶段
- 事务正常执行,但所有修改只影响自己的本地数据副本
- 事务记录下它读了什么、改了什么(read set 和 write set)
def twrite(object, value):
if object not in write_set: # 从没写入,制作副本
m = read(object)
copies[object] = m
write_set = write_set U {object} # 加入新元素
write(copies[object, value])
def tread(object):
read_set = read_set U {object};
if object in write_set: # 这允许我们读取自己写入的内容
return read(copies[object]);
else:
return read(object);2. 验证阶段
需要有一套验证规则,让我们如何知道事务是否可以提交。但是首先……我们如何对事务进行排序?
验证规则
验证规则会保证可串行性,
在 开始读取阶段之前完成了其写入阶段,即 
在
开始读阶段时, 已经完成它的写阶段,他们没有任何重叠 与 不相交,并且 
原因:
与 相交,说明 读取了一部分 写入的内容; 与 相交,说明 写入了 写入的内容。这样保证了, 写的任何 已经写入的数据,后面会被安装,且 读的数据不会反映 的写入。 W(
) 与 R( )或W( ) 不相交, 
原因:
与 相交,说明 读取了一部分 写入的内容(但是 可能读取了。 没有读取或写入 写入的数据(但是 可能读取了 吸入 W(
) 与 R( )或W( ) 不相交,并且 W( ) 与 R( )不相交(即没有冲突)
3. 写入阶段
def twrite(object, value):
if object not in write_set: # 如果从未写过,制作副本
m = read(object)
copies[object] = m
write_set = write_set U {object}
write(copies[object], value)通过写入本地副本,我们确保了不干净的结果对其他并发事务不可见。
事务标识符分配
分配事务标识符 T1, … Tn,以确保这是与串行等效的顺序。
何时分配事务标识符?
a) 在事务开始时 b) 在验证阶段开始时 c) 在写入阶段开始时 d) 在写入阶段结束时
- 目标:分配事务标识符 T1, ... Tn,以确保这是与串行等效的顺序。
- 何时应该分配事务标识符?
- 在读取阶段开始时分配?
- 不行!这将是“悲观的”——不想在事务运行完成之前就预先分配事务顺序。
- 长时间运行的事务必须在之后的短事务之前提交。
- 在读取阶段结束时分配,就在验证开始之前。
多版本并发控制和快照隔离
概述
多版本并发控制 (MVCC) 是一个比并发控制协议更大的概念。它涉及DBMS设计和实现的各个方面。MVCC是DBMS使用最广泛的方案。过去10年内,几乎所有新开发的DBMS都采用了它。即使是不支持多语句事务的系统(例如NoSQL)也使用了它。
使用 MVCC,DBMS 会在数据库中维护单个逻辑对象的多个物理版本。当事务写入对象时,DBMS会创建该对象的新版本。当事务读取对象时,它会读取事务启动时存在的最新版本。MVCC 的基本概念/优点是写入操作不会阻塞读取操作,读取操作也不会阻塞写入操作。这意味着一个事务可以修改对象,而其他事务可以读取旧版本。但如果写入者正在写入同一对象,则由于与数据库对象相关的版本仍然处于上锁状态,因此其他写入者仍可能会被阻塞。
使用 MVCC 的一个优点是,只读事务可以读取数据库的一致快照(snapshot),而无需使用任何类型的锁。此外,多版本 DBMS 可以轻松支持时间旅行查询,即基于数据库在某个其他时间点的状态的查询(例如,对 3 小时前的数据库执行查询)。
典型的基于 MVCC 的数据库设计将:
- 有一个版本化存储,用于存储同一逻辑对象的不同版本。
- 当事务开始时,DBMS 会创建数据库快照(通过复制事务状态表)
- DBMS 使用快照来确定哪些版本的对象对事务可见
MVCC 设计有五个重要的决策:
- 并发控制协议
- 版本存储
- 垃圾回收
- 索引管理
- 删除
并发协议的选择取决于之前课程中讨论的方法(两阶段锁定、时间戳排序、乐观并发控制)。
快照隔离
快照隔离是指在事务启动时为事务提供一致的数据库快照。快照中的数据值仅包含已提交事务的值,并且该事务在完成之前完全与其他事务隔离运行。这对于只读事务非常理想,因为它们无需等待其他事务的写入。写入操作保存在事务的私有工作区中,或与事务元数据一起写入存储,并且只有在事务成功提交后才对数据库可见。 写入冲突:如果两个事务更新同一个对象,则第一个写入者获胜。 当两个并发事务修改不同的对象导致不可序列化的调度时,快照隔离中可能会发生写入倾斜异常。例如,如果一个事务将所有白色弹珠更改为黑色,而另一个事务将所有黑色弹珠更改为白色,则结果可能不符合任何可序列化的调度。
可重复读 vs 可串行化
一些系统,例如 PostgreSQL,通过一种基于事务开始时数据库快照的不同机制来实现可重复读 (REPEATABLE READ)。
- 这种机制被称为“多版本并发控制”(MVCC)——这是实现隔离性的一种方式!
这种机制除了幻影读问题外,还有其他问题——所谓的“读偏斜异常”(read skew anomalies)。
快照隔离
当一个事务 (TA) 开始时,它会接收到一个时间戳 T。
所有读取操作都是基于 T 时间戳的数据库版本进行的。
- 这意味着需要保留所有对象的历史版本!
所有写入操作都在一个单独的缓冲区中进行。
- 这些写入操作只有在提交后才会变得可见。
当事务提交时,数据库管理系统 (DBMS) 会检查是否存在冲突。
- 如果存在事务 TA2 满足以下条件,则中止事务 TA1(时间戳为 T1):
- TA2 在 T1 之后且在 TA1 之前提交。
- TA1 和 TA2 更新了相同的对象。
- 如果存在事务 TA2 满足以下条件,则中止事务 TA1(时间戳为 T1):
在快照隔离下, A) T2应该中止还是提交?
B)T3应该中止还是提交?
T1 T2 T3 R(Y) W(Y)
CommitStart
R(X)
R(Y)W(X)
W(Z)
Commit/AbortR(Z)
R(Y)
W(X)
Abort/Commit?
事务T1在使用快照隔离时:
- 在开始时获取已提交数据的快照
- 始终在自己的快照中读取/修改数据
- 并发事务的更新对T1不可见
- T1的写入操作在提交时完成
- 先提交者获胜规则
- 只有在没有其他并发事务已经写入T1打算写入的数据的情况下,T1才能提交
Solution: 对于T2来说
R(Z)->v0, 并发更新不可见 R(Y) ->v1, 自身更新可见 W(X:=v3): 非X的首个提交者 串行化失败,T2必须回滚
| T1 | T2 | T3 |
|---|---|---|
| R(Y)->v0 | ||
| W(Y := v1) Commit | ||
| Start R(X) -> v0 R(Y) -> v1 | ||
| W(X := v2) W(Z := v1) Commit | ||
| R(Z) -> v0 R(Y) -> v1 W(X:=v3) Abort |
对于什么类型的工作负载,你应该使用两阶段锁定(2PL),对于什么类型的工作负载你应该使用快照隔离(SI)?
创建一个现实世界的示例(例如,银行业务、酒店预订等),其中使用快照隔离保证执行的两个事务将违反可串行化。
快照隔离无法防止读写冲突,因为它不会检查是否读取了另一个事务写入的数据
这会导致所谓的写偏差
- 例子:
T1 T2 RX RY WY WX 两个事务都没有看到对方的写操作,这在串行化是无法被允许的
现实世界的例子:
- 员工 X 和员工 Y 正在报名值班。
- 他们互相厌恶,不想一起工作。
- 员工 Y 检查员工 X 是否没有值班(读取 X)。
- 员工 X 检查员工 Y 是否没有值班(读取 Y)。
- 两人都更新了他们的时间表,在同一天值班。
快照隔离有幻读问题吗?
在2PL下, 以下锁请求来自锁管理器,这里有死锁吗?你能想到创建或解决死锁的简单更改吗?
T1 T2 T3 RA RB RC WB RC RA
| 时间戳 | 类型 | TID |
|---|---|---|
| t1 | Req.ReadLock A | T1 |
| t2 | Req.ReadLock B | T2 |
| t3 | Req.ReadLock C | T3 |
| t4 | Req.ReadLock A | T3 |
| t5 | Req.WriteLock B | T1 |
| t6 | Req.ReadLock C | T2 |
事务隔离性级别
ANSI SQL定义
ANSI SQL 标准定义了四个“隔离等级”
- 未提交读(Read Uncommitted),一个事务可以读任何已提交或未提交的数据。
- 这可以通过“读操作不需要请求任何锁”来实现。
- 但是,为了防止其他事务看到脏数据,需要在事务的整个持续时间内持有写锁
- 应用场景,如果只是报告一些统计数据,如用户数量或浏览次数,这可能是可以接受的
- 读已提交(Read Committed), 一个事务可以读任何已提交的数据。对于同一个对象的重复读可能导致读到不同版本的数据。
- 如果所有其他事务都持有写锁(就如在 READ UNCOMMITTED 中),它将永远不会读取脏数据
- 实现方式是,读数据前必须首先获得一个读操作锁,一旦数据读取之后该锁被立即释放。
- 由于不在意总是读取相同的值,读取后可以释放锁
- 可重复读(Repeatable Read):一个事务只能读取一个已提交数据的一个版本;一旦该事务读取了一个对象,那么,它将只能读取该对象的同一个版本。
- 如果我们想总是读取相同的值,需要在事务持续时间内持有读锁, 因此实现方式是,事务在请求读数据 之前必须获得一个锁,并且保持该锁直到事务结束。
- 可串行化
- 与可重复读相比, 可串行化还需要防止幻读。
大部分DBMS默认是读已提交。
幻读和幽灵问题
乍一看可重复读似乎保证了完全的可串行化,但是,其实并不是这样。 在幽灵问题中,一个事务使用同样的谓词多次访问了同一个关系,但是,最近的访问却得到了最初访问时没有发现的新的“幽灵元组”。原因在于,元 组级的两段锁并不能阻止往表中插入元组。表级别的两段锁可以防止幽灵问题,但是,当事 务通过索引访问表中的几个元组时,表级别的两段锁是被限制的
其他隔离级别
快照隔离
这是 MVCC 在数据库产品中的主要应用之一。当事务开始时,它从一个单调递增的计数器中得到一个开始时间戳,当它成功提交时得到一个终止时间戳。对于一个事务 T 而言,只有当具有与 T 重叠的开始/结束时间戳的其他事务不去写事务 T 要写的数据时,事务 T 才会提交。这种隔离模型更依赖于多版本并发的实现,而不是锁机制。
当一个事务(TA)启动时,它会接收到一个时间戳 T。
所有读取操作都基于 T 时刻的数据库版本进行。
- 需要保留所有对象的历史版本!
所有写入操作都在一个单独的缓冲区中进行。
- 写入操作只有在提交后才会变得可见。
当事务提交时,DBMS会检查冲突。
- 如果存在事务 TA2 满足以下条件,则中止具有时间戳 T1 的事务 TA1:
- TA2 在 T1 之后且在 TA1 之前提交
- TA1 和 TA2 更新了相同的对象
- 如果存在事务 TA2 满足以下条件,则中止具有时间戳 T1 的事务 TA1:
例子

T2 会提交还是中止?
T3 会提交还是中止?
读一致
这是 Oracle 定义的一种 MVCC 形式,它相对于快照隔离有一些不同。
游标稳定
这个等级是为了解决已提交读的更新丢失问题。假设有两个事务 T1 和 T2。T1 以“已提交读”模式运行,读取数据项 X(假设是银行账户值),记录这个 值,然后根据记录的值重写数据项 X(假设为原始账户增加¥100)。T2 同样读写了 X(假设从账户取走¥300)。如果 T2 的行为发生在 T1 的读和写之间,那么 T2 对于 账户的修改将丢失,即对于我们的例子而言,该账户最终将增加¥100 而不是减少 ¥200。游标稳定中的事务将根据查询游标在最近读取的数据项上加一个锁,当游 标移走(如数据被提取)或者事务中止时释放该锁。游标稳定允许事务对个别数据 项目按照“读—处理—写”的顺序来操作,其间避免了其他事务的更新干扰
论文阅读: 乐观并发控制
孔祥重,台湾人写的。OCC的奠基之作
摘要
大多数并发控制方法都依赖于对数据对象加锁实现,这论文介绍两种非锁的并发控制。所使用的方法是“乐观的”,因为它们主要依赖于事务回滚作为控制机制,并“希望”事务之间不会发生冲突。文中还讨论了这些方法在某些应用中可能比锁定更高效的情况。
1 介绍
我们假设某些特殊对象(称为root)总是存在的,且访问任何非根对象只能通过首先访问一个根对象,然后跟随指向该对象的指针来进行。任何保持数据完整性约束的数据库访问序列称为事务。
如果我们的目标是最大化对数据库的访问吞吐量,那么有两种典型场景尤其需要做高并发的访问,即同时允许多个事务或请求访问数据库系统:
- 即使是只读事务(如查询),理论上不会破坏数据完整性,但为了防止读取到正在被修改的数据,也需要加锁。
- 如果这时只允许低并发(比如一次只能有一个事务运行),那么 CPU 会等待磁盘 I/O,系统整体会处于低利用状态。
这两种情况系统的硬件资源(CPU和I/O等待)都会被浪费,从而导致数据库的访问吞吐量远低于理论上限。通常实现并发访问的方法是用锁,但有几个缺陷:
锁的维护开销大。即便是只读事务,也需要锁,即便并不影响数据的完整性;此外死锁检测也被认为是锁的维护开销的一部分;如果锁机制本身可能造成死锁,还要额外投入资源进行死锁检测,进一步增加开销。
没有通用的高并发无死锁的锁协议
事务在访问一个热点节点(如root),若此时该节点尚未从磁盘调入内存,会显著降低并发性
为了支持事务回滚,在事务完成或者中止之前,不能释放锁。进一步降低并发性
在很多场景中,锁其实不太需要。 (a) 数据库中节点非常多,但每次事务涉及的节点很少。(b) 热点节点(容易冲突的节点)被修改的概率很低。
当下述条件成立时,可以预期(5)的论点是成立的:图中的节点数量远大于在给定时间内所有正在运行的事务涉及的节点总数,修改热点节点的概率很小。
💡而在本文中,消除锁的乐观方法背后的想法非常简单:
- 由于从节点读取一个值或指针永远不会导致完整性丧失,因此读取是完全不受限制,然而,返回查询结果被认为等同于写入,因此要按照下面讨论的方式进行验证。
- 写入受到严格限制。要求任何事务由两个或三个阶段组成:读取阶段、验证阶段和可能的写入阶段(见图1)。在读取阶段,所有写入都在本地副本上进行修改。然后,如果在验证阶段能够确定事务所做的更改不会导致完整性丧失,则在写入阶段将本地副本变为全局副本。在查询的情况下,必须确定查询返回的结果确实是正确的。确定事务不会导致完整性丧失(或将返回正确结果)的步骤称为验证。

在第2节中,我们将更详细地讨论事务的读取和写入阶段。在第3节中,我们将介绍一种特别强大的验证形式。第4节中的并发控制系列具有串行的最终验证步骤,而第5节中的并发控制具有完全并行的验证,但总成本更高。第六节将分析乐观方法在控制B树并发插入中的应用。第七节包含总结以及对未来研究的讨论。
2 读取和写入阶段
并发控制如何支持用户编程的事务的读写阶段(以对用户不可见的方式),以及如何高效地实现这一点。验证阶段将在接下来的三节中进行讨论。
我们假设一个底层系统提供了对各种类型对象的操作。为了简单起见,假设所有对象都是相同类型的。对象通过以下过程进行操作,其中n是对象的名称,i是传递给类型管理器的参数,v是任意类型的值(v可以是指针,即对象名称,或数据):
我们假设一个底层系统提供了对各种类型对象的操作。为了简单起见,假设所有对象都是相同类型的。对象通过以下过程进行操作:
create: 创建一个新对象并返回其名称。delete(n): 删除对象n。read(n, i): 读取对象n的项i并返回其值。write(n, i, v): 将v作为对象n的项i的值进行写入。
为了支持事务的读写阶段,我们还使用以下过程:
COPY(n): 创建一个对象n的副本并返回其名称。exchange(n1, n2): 交换对象n1和n2的名称。
并发控制对用户是不可见的;事务的编写好像直接使用了上述过程一样。然而,事务需要使用语法上相同的过程create, tdelete, tread, 和twrite。对于每个事务,并发控制维护了事务访问的对象名称集。这些集合通过tbegin调用被初始化为空。用户编写的事务主体实际上是前面提到的读阶段;后续的验证阶段直到tend调用之后才开始。
tcreate = (
n := create;
create_set := create_set ∪ {n}; # create set 用于跟踪当前事务中已经创建的所有对象
return n;
)
twrite(n, i, v) = (
if n ∊ create_set
then write(n, i, v);
else if n ∊ write_set
then write(copies[n], i, v);
else (
m := copy(n);
copies[n] := m;
write_set := write_set ∪ {n};
write(copies[n], i, v);
)
)
tread(n, i) = (
read_set := read_set ∪ {n};
if n ∊ write_set
then return read(copies[n], i);
else
return read(n,i);
)
tdelete(n) = (
delete_set := delete_set ∪ {n}
)我们可以看到,在读阶段,没有全局写入操作发生。相反,每当请求对某个对象进行第一次写入时,会创建一个副本,所有后续的写入都指向这个副本。这个副本可能是全局的,但在读阶段,根据我们的约定,由于所有节点只能通过从根节点指针访问,因此它对其他事务不可访问。如果该节点是根节点,由于名称错误,这个副本也是不可访问的(所有事务“知道”根节点的全局名称)。假设没有根节点被创建或删除,没有指向已删除节点的悬挂指针,且创建的节点通过写入新指针变得可访问(这些条件是每个事务需要单独维护的数据结构的完整性标准的一部分)。
当事务完成时,它将通过tend调用请求验证和写入阶段。如果验证成功,则事务进入写入阶段,这仅仅是:
for n ∈ write_set do exchange(n, copies[n])写入阶段之后,所有写入的值变为“全局的”,所有创建的节点变得可访问,所有删除的节点变得不可访问。当然,一些清理操作是必要的,我们不认为这属于写入阶段的一部分,因为它不与其他事务交互:
(for n ∈ delete_set do delete(n);
for n ∈ write_set do delete(copies[n]))如果事务中止,这些清理操作也是必要的。请注意,由于对象是虚拟的(对象通过名称引用,而不是通过物理地址),交换操作(以及写入阶段)可以非常快速:基本上只需要交换两个对象描述符的物理地址部分。
最后,我们注意到,两阶段事务的概念对于恢复操作非常有价值,因为在读阶段结束时,事务打算对数据结构进行的所有更改都已知晓。
3 验证阶段
首先,串行等价性是验证并发事务执行正确性的一种常用标准。假设多个事务 T1, T2, ..., Tn 并发执行,共享数据结构为 d,定义数据集为 D。每个事务
公式(1)定义了正确性条件:如果初始数据结构为 d_initial,最终数据结构为 d_final,则并发执行是正确的,当且仅当存在一个排列 π,使得事务按照 π 的顺序执行时,最终的数据结构仍然是 d_final。
这个正确性标准背后的思想是,每个事务都应该独立地维护共享数据结构的完整性。
3.1 串行等价性验证
串行等价性的验证直接基于公式(1),其中需要找到一个事务执行的排列 π。为了实现这一点,在事务执行过程中为每个事务分配一个唯一的整数事务编号 t(i)。在验证过程中,确保事务 t(i) < t(j)。这可以通过以下验证条件来保证:
的写阶段在 的读阶段开始之前完成。 的写集合与 的读集合没有交集,并且 在 开始写阶段之前完成写阶段。 的写集合与 的读集合或写集合没有交集,并且 的读阶段在 的读阶段完成之前完成。

3.2 事务编号的分配
设计分配事务编号的并发控制机制时,首先要考虑如何分配事务编号。显然,事务编号应该按顺序分配,因为如果 t(i) < t(j)。文中建议的简单方案是维护一个全局整数计数器 tnc(事务编号计数器);每当需要事务编号时,递增计数器并返回结果值。
为了优化事务的响应时间,作者建议在读阶段结束时分配事务编号,这样可以尽可能立即验证事务,而无需等待其他事务的完成。
3.3 实际考虑
给定这种分配方法,处理读阶段特别长的事务的情况, 当验证这种事务时,必须检查在其读阶段开始之前完成读阶段但尚未完成写阶段的所有事务的写集合。为了避免无限增长的写集合,文中建议只维护有限数量的最近写集合。如果无法获取旧的写集合,验证失败,事务回滚并重新执行。
最后,文中讨论了验证失败时的处理方法。通常情况下,事务会被中止并重新开始,分配一个新的事务编号。然而,如果验证多次失败,可能会导致事务“饥饿”。文中提出了一种简单的解决方案:在 tend 过程中进入短暂的关键区,如果并发控制检测到“饥饿”事务,它将在不释放关键区信号量的情况下重新启动事务,相当于锁定整个数据库,确保该事务能顺利完成。
4 串行验证OCC
我们将介绍一系列并发控制,它们是第 3.1 节中验证条件 (1) 和 (2) 的实现。由于我们不使用条件 (3),因此条件 (2) 的最后一部分意味着写入阶段必须是串行的。实现这一点的最简单方法是将事务号的分配、验证以及随后的写入阶段都放在一个临界区中(即在事务执行后期(tend 阶段),将“验证 + 写入 + 事务编号分配”封装在一个临界区里,串行完成)。在下文中,我们将临界区用"<>"括起来。
tbegin = (
create set := empty;
read set := empty;
write set := empty;
delete set := empty;
start tn := tnc; // 记录当前事务开始时的最大事务编号
)
tend = (
<finish tn := tnc; // 暂时设为当前最大编号
valid := true;
// 检查【之后开始、但还没写入】的事务中是否有写了我读的东西
for t from start tn + 1 to finish tn do
if (关于事务t的写set与读set有交叉)
then valid := false;
if valid:
then
(write phase);
tnc := tnc + 1;
tn := tnc)
>;
if valid
then ( cleanup ) // 清理临时数据
else (backup) // 放弃修改重新开始
)5 并行验证
本节介绍的并发控制使用了3.1节所有三个验证条件, 从而允许更高的并发度。我们保留上一节的优化,仅在验证成功的情况下,在写入阶段之后分配事务号。