前言
本文将从事务的基础概念开始,介绍并发事务所面临的各种问题以及其对应的解决方案,并对可串行化快照隔离(Serializable Snapshot Isolation)的概念和实现进行详细的介绍和分析。
基础概念
数据库中的事务是一种将多个读写操作合并为一个逻辑单元的一种方式。一个事务要么成功提交(commit)要么中止(abort)或回滚(rollback)。事务的这种特性极大的简化了应用程序在与数据库进行交互时需要考虑的问题(e.g. 部分失败)。
事务所提供的安全保证通常用 ACID 来描述:
- 原子性(Atomicity)
- 一致性(Consistency)
- 隔离性(Isolation)
- 持久性(Durability)
我们重点关注隔离性的实现,隔离性描述了数据库在面临并发事务时的行为。在并发事务的情况下,数据库面临着脏读(dirty read),脏写(dirty write),不可重复读(nonrepeatable read)等问题,为了解决这些问题,不同的事务隔离级别提供了解决这些问题对应的保证。
-
脏读(Dirty Read):一个事务读取到了另一个并发事务未提交的修改;
-
脏写(Dirty Write):一个事务的修改覆盖了另一个并发事务未提交的修改;
-
读取偏差(Read Skew):在一个事务中的不同时间读取同一个值的结果不同;
-
丢失更新(Lost Update):在并发事务对同一条记录进行修改的情况下,前面事务(已提交)的修改可能会丢失,后面事务的修改 clobber 了前面的修改;
-
写入偏差(Write Skew):两个并发事务在读到相同的数据后各自进行更新,最终导致数据违反预期的一致性约束;
-
幻读(Phantom Read):一个事务中的写入改变另一个事务的对同一条件范围查询的结果;
脏写 是指一个事务覆盖了另一个未提交事务的修改;
丢失更新 是并发事务读取同一对象,基于旧数据分别对同一对象进行更新,导致更新丢失;
写入偏差 是并发事务读取相同的条件数据,基于读取结果对不同对象进行更新 ,虽然彼此不冲突,但可能导致业务约束被违反。
快照隔离
为了解决不可重复读(nonrepeatable read) 或者说 读取偏差(read skew) 问题,我们可以使用 快照隔离(snapshot isolation)。
如其名字所示,快照隔离意味着一个事务的可见范围是一个在事务开始时创建的对数据库的一致性快照,在事务的持续期间,其他并发事务对数据库的修改对当前事务来说是不可见的。
快照隔离的实现通常是和 MVCC(Multi-Version Concurrency Control) 挂钩的,因为不同的事务需要看到数据库在不同时间点的数据状态,所以我们需要对数据的多个版本进行保留和维护。
在 MySQL 使用的 InnoDB 存储引擎中,每个事务都会按事务创建时间被分配一个单调递增的唯一 ID(transaction id),为了实现 MVCC 机制,数据库存储的每个数据行都保存有多个版本(通过 undo log 实现),其中每个版本都会通过 transaction id 来标识这个数据行版本是由哪个事务创建的。
为了判断对于当前事务来说,哪些数据版本是可见的,哪些数据版本是不可见的,InnoDB 在创建事务的时候会构建一个一致性视图(read view),一致性视图具有四个重要的字段:
-
trx_ids:保存了构造一致性视图时所有 uncommitted 的事务的 ID。
-
up_limit_id:trx_ids 中最小的事务 ID,所有小于他的事务 ID 都已经提交;
-
low_limit_id:ReadView 创建时,系统中尚未分配的下一个事务 ID,所有;
-
creator_trx_id:创建该一致性事务的事务 ID;
我们可以将这个一致性视图可视为下面的样子:

大于等与 up_limit_id 不一定意味着这个事务还没有提交,up_limit_id 只能说明所有小于其值的事务都已经提交,不能说明所有大于等于其值的事务都未提交。
有了这个视图我们就可以通过对比行数据中的 transaction id 来判断这个数据行版本对于当前事务来说是否可见。
- 如果 txn_id 在绿色的部分(txn_id < up_limit_id),说明这个数据版本是由一个已经 committed 的事务生成的,数据可见;
- 如果 txn_id 在红色的部分(txn_id >= low_limit_id),说明这个数据版本是由还没有开始的事务生成的,不可见;
- 如果 txn_id 在橙色的部分(up_limit_id <= txn_id < low_limit_id),分为两种情况:
- txn_id 在 trx_ids 数组中,说明还未提交,不可见;
- txn_id 不在 trx_ids 数组中,说明已经提交,可见;
通过 Read View 和可见性规则,我们就可以确保一个事务在其持续期间始终看到一个数据库的一致性快照,实现了快照隔离事务隔离级别。
可串行化
快照隔离级别处理不了 写入偏差(write skew) 和 幻读(plantom read) 的现象,我们需要 可串行化(serializable) 来解决。
让我们先来考虑一个写入偏差的例子:

在这个例子中,我们需要确保至少有一位医生处于 on_call 状态,这时 Alice 和 Bob 医生同时发起请假请求(解除 on_call 状态),由于两个请求时同时开始的,他们会看到相同的一致性快照,这导致他们都认为现在系统中处于 on_call 状态的医生是多于一位的,所以他们都解除了自身的 on_call 状态,并成功提交了事务,这在快照隔离级别下是合理的,但这导致至少一位医生处于 on_call 状态的原则被违反。
在可串行化隔离级别下,这种情况是可以被避免的,这个例子中,根据实现方式的不同,一个并发事务会被阻塞或者中止等。
可串行化可以让多个事务看起来就像是串行执行的一样,即使它们实际是并发执行的,可串行化可以防止所有可能的竞争条件。为了实现可串行化,通常有三种方案:
-
真的串行执行:在单线程上执行事务,但使得数据库的事务吞吐量被限制为单机单核的速度,或者在数据集支持的情况下对数据进行分区(且不需要跨分区协调),这样每个分区可以利用自己独立的 CPU 核;
-
两阶段锁定(2PL, Two-Phase Locking):通过为数据对象加锁来实现,锁分为共享锁(Share mode)和独占锁(Exclusive mode),其中读取对象时必须获得共享锁,修改或写入对象时必须获得独占锁。共享锁和独占锁互斥,独占锁之间互斥。2PL 导致的死锁,锁等待等问题让其性能相比其他弱隔离级别要差得多;
Next-Key Lock,Record Lock 等都具有 Shared mode 和 Exclusive mode。
-
乐观并发控制(OCC, Optimistic Concurrency Control): 2PL 是一种悲观的并发控制机制,2PL 使用了锁,直到确保安全了之后才会释放锁,而可串行化快照隔离是一种基于乐观并发控制实现的机制,即使在事务执行过程中存在潜在的危险,我们也会直到事务提交时才去检查隔离原则是否被违反。在事务之间争用很少的情况下,乐观并发控制的性能表现很好;
可串行化(Serializable)隔离级别的目标是保证事务之间的执行效果等价于某个串行顺序,但它不保证每次读取的值都是最新的(最新写入的)。
可串行化快照隔离(SSI, Serializable Snapshot Isolation)
可串行化快照隔离提供了完整的可串行化隔离级别并且相比快照隔离只有很小的性能损失。可以将 SSI 的实现理解为在快照隔离的基础上添加了一个新的算法来检测写入之间的串行化冲突,所有的读取仍然是基于事务开始时创建的一致性快照的。
在快照隔离中导致写入偏差的原因是,事务是基于过时的一致性快照的读取结果做出的修改,因为通过一致性快照读取的数据可能已经被另一个已提交的并发事务修改了。
也就是说我们需要一种算法来检测我们在当前事务中读取的结果是否被一个并发事务进行了修改并先于我们提交(commit)。
这里的并发事务说的也就是我们在快照隔离中的可见性规则中讨论的两种不可见的情况,在这两种情况中我们都无法通过一致性快照看到这些事务的修改:
- 还未开始的事务;
- 已经开始但是未提交的事务;
让我们继续讨论之前的写入偏差的例子,我们需要考虑两种情况:
- 先写入,后读取

在这个场景中,事务 43 和事务 42 是基于相同的一致性快照的,事务 43 在读取时事务 42 的修改已经发生(虽然还未提交),事务 42 先于事务 43 提交,这意味着事务 43 已经读取到了陈旧的数据,在事务 43 尝试提交时被中止(abort)。
为什么要等到提交时才中止?
- 42 可能先中止,修改为提交;
- 42 可能后提交;
- 43 可能是只读事务,不会导致写入偏差;
- 先读取,都写入

在这个场景中,事务 43 和事务 42 是基于相同的一致性快照的,事务 43 的读取先于事务 42 的修改发生,但事务 42 先于事务 43 提交导致 43 读取的数据变为陈旧的,在事务 43 尝试提交时被中止(abort)。
为了实现这种算法,我们需要在当前事务提交时通知内些并发的读取到受到我们修改的数据的事务,或者记录并发事务修改过的数据并在事务提交时检查当前事务是否对这些数据进行过读取。
实现
在这一节中我们将自己实现可串行化快照隔离,为数据库提供事务支持。但我们这里基于的是一个使用 LSM-Tree 存储引擎的键值数据库(NoSQL),不是更为常见的关系型数据库(RDBMS)。
但在实现 SSI 的思路上两者是共通的,为键值数据库提供 SSI 支持相对来说更加简单。
在概念介绍部分我们已经提到了,SSI 是基于快照隔离事务隔离级别的,这意味着我们需要先实现快照隔离,然后在其基础上实现检测并发事务间串行化冲突的算法。
在实现部分我们不会具体的代码过分深入,而是专注于各个组件的搭配与使用,完整的代码请参考 github.com/B1NARY-GR0U... 中的实现。
事务的开始与提交时机
我们要解决的第一个问题是,如何确定事务开始和提交的时机?
- 事务开始的时机用于我们确定这个事务可以看到的一致性快照的范围;
- 事务提交的时机用于在 SSI 中确定并发事务中先提交的事务;
通过对其他事务的提交时机和当前事务开始时机的比较可以让我们判断这两个事务之间是否可能存在串行化冲突,并且事务的提交时机可以用作我们存储数据的版本标识,通过和事务开始时机的搭配使用就可以实现快照隔离的效果。
为了方便,我们使用 readTs
来表示一个事务的开始时机,使用 commitTs
来表示一个事务的提交时机, readTs
和 commitTs
的运行规则如下:
readTs
和commitTs
都是一个从 0 开始的整数时间戳(timestamp);- 每个事务提交时都会分配一个单调递增的
commitTs
; - 每个事务开始时都会分配一个
readTs
,其值等于全局最大的commitTs
,并且事务开始前会等待所有commitTs
小于等于这个值的事务提交或者中止; - 一个事务可以看到的数据版本的值小于等于
readTs
;
让我们通过下面这个简单的例子来进一步说明:

在 TxnA 和 TxnB 开始前,我们的数据库中已经存储了三个键值对,其中 @
后面的值表示这个键的版本,例如 k:a@2 v:a2
表示键为 a 值为 a2 版本为 2 的键值对。TxnA 和 TxnB 开始事务,此时数据库中已经没有活跃的事务,他们的 readTs
都为 2(即当前数据库中最大的 commitTs
),TxnA 先提交了新的键 a 的值并 commit,commitTs=3 单调递增。TxnB 在事务中获取键 a 的值,由于其 readTs
为 2,所以看不到 TxnA 刚刚提交的版本 3 的键 a,最终读取到键 a 的值为 a2,Txn 还设置了新的键 b 的值并 commit,commitTs=4
单调递增。
冲突检测
第二个我们需要关注的问题是,如何检测并发事务导致的串行化冲突?
在之前的讨论中我们考虑了导致写入偏差的两种情况,并分析出检测的关键是判断当前事务中读取的结果是否被一个并发事务进行了修改并先于当前事务提交。我们这里使用的检测逻辑非常简单直接,我们只需要记录当前事务读取的键以及并发事务已提交的修改中的键,并检查他们是否存在重叠即可。
我们先来看一下具体的执行流程,再考虑冲突检测的细节:
- 记录一个事务的写入和读取的键;
- 在 commit 时进行冲突检测,存在冲突直接返回;
- 如果没有冲突就将这个事务写入的键和其被分配的
commitTs
记录到一个全局维护的数组中;
这个全局维护的数组就是我们用来实现冲突检测的主要方法,在这个数组中记录所有可能与当前事务发生冲突的事务所修改过的键,下面是我们冲突检测方法的实现:
go
func (o *oracle) hasConflict(txn *Txn) bool {
if len(txn.readsFp) == 0 {
return false
}
for _, ct := range o.committedTxns {
if ct.ts <= txn.readTs {
continue
}
for _, fp := range txn.readsFp {
// a conflict occurred when curr txn read a key that be modified by a committed txn
if _, ok := ct.writesFp[fp]; ok {
return true
}
}
}
return false
}
其中 committedTxns
就是这个全局数组,我们先选出数组中在当前事务创建之后 commit 的事务(> txn.readTs
),再检查当前事务读到的键是否和这些事务存在重叠,如果存在重叠则说明当前事务通过一致性快照读取的数据已经被另一个已提交的并发事务修改了,返回 true
。
还有一个需要注意的点是,我们不能让 committedTxns
这个数组一直增长,我们需要定时清理数组中不再被需要的已提交事务,即 commitTs
在系统中最早的活跃事务之前的所有事务,他们不再是任何其他事务的并发事务了。
现在我们再通过之前讨论过的 先写入,后读取 的例子,完整的走一遍流程,看看我们的实现是否可以应对写入偏差的问题:

如图所示,committedTxns
初始为空因为系统中没有 commitTs > 1
的事务,TxnA 先于 TxnB 修改键的值并提交,此时 committedTxns
已经记录了 TxnA 所修改过的键,在 TxnB 尝试提交时,TxnA 的 commitTs
大于 TxnB 的 readTs
并且 TxnB 读到了 TxnA 修改后的键,存在冲突,中止 TxnB。
丢弃的数据版本
需要丢弃的数据版本是我们在这里需要考虑的最后一个问题,如果不定期对数据版本进行清理,数据库最终会积累大量不再需要的旧版本数据,浪费存储空间,拖累数据库的整体性能。
对于清理旧数据版本的时机和频率,不同的数据库都有各自的考量和实现,我们这里主要关注清理旧数据版本的范围,即哪些数据版本是我们可以安全进行清理的。
我们得到的结论是,对于每一个键值对,我们保留所有版本号大于等于系统中最早的活跃事务的 readTs
的版本,对于版本号小于系统中最早的活跃事务的 readTs
的所有版本,我们只保留最新的版本。
这里的清理规则与 InnoDB 的清理规则类似,InnoDB 会清理所有 transaction id 小于当前最早的活跃事务的 transaction id 的数据版本 undo log,由于最新的版本在主表上,所以不会被清理。
理解了清理规则之后,我们要关注的问题是,如何获取系统中最早的活跃事务的 readTs
的版本,这个值是如何维护的?我们提到 InnoDB 中的每个事务维护了一个创建事务时活跃的事务 ID 列表,而这个列表是在事务创建时从一个全局的活跃事务列表生成的,通过这个全局的列表,InnoDB 就可以获取到系统中最早的活跃事务的 ID 从而执行清理等操作。
我们在实现时可以采用类似的思路,使用一个结构来对系统中事务的活跃情况进行全局的追踪,最终我们实现这个结构叫做 WaterMark
。
WaterMark
的主要方法如下:
Begin
:开始对一个时间戳的追踪;Done
:结束对一个时间戳的追踪;DoneUntil
:获取一个值,小于等于此值的时间戳都已经结束;
通过这三个主要方法,我们就可以对系统中活跃的事务进行追踪,在事务开始时追踪这个事务的 readTs
,事务提交或者中止后结束对这个 readTs
的追踪,再通过 DoneUntil
,我们即可以得到数据版本的清理范围。
总结
我们从事务的概念出发,了解并发事务会导致的各种问题,最后完成对可串行化快照隔离实现的分析,希望可以帮助你对这些内容有更加深入的了解。
以上就是本篇文章的所有内容了,如果文章有误或者存在问题,欢迎私信或者在评论区指出。