事务
在分布式数据系统中,任何问题都有可能发生:
- 系统侧:数据库系统和硬件系统任何时间都有可能发生故障
- 应用侧:使用数据库的应用程序任何时刻都有可能故障。
- 网络侧:应用程序和数据库间,数据库的多个节点间,随时都有可能断开连接。
- 多个客户端:并发写入时,可能会有竞态条件和相互覆盖。
- 半读:一个客户端可能会读到部分更新的数据库。
复杂度不会凭空消失,只会发生转移。如果数据库对这些故障不做任何处理,应用层就需要处理上述所有相关问题,会极大增加应用侧编程复杂度。事务就是数据库为了解决类似的问题,所提供的一种保证,以简化应用层的编程模型。
事务将应用程序的多个读、写操作捆绑在一起成为一个逻辑操作单元,并提供一种保证,这一组操作要么全部成功( commit,应用到数据库里),要么全部失败(被动 abort,或者主动 rollback),而不会存在只执行了一半的中间状态。此外,如果多个客户端的事务并发执行,会涉及到隔离性的问题,一般来说,数据库允许用户在隔离级别和性能之间做选择。
事务也可以从时间和空间两个角度来理解:
- 从生命周期(时间)来讲:事务要保证一组操作的整体性
- 从并发控制(空间)来讲:事务要做好多个事务间的并发控制
有了这种语义上的保证,用户在发现事务失败后,可以放心的进行重试,直到成功,就可以确定事务中的所有操作都生效了。但任何便利性都是有代价的,事务便是在一定程度上牺牲了性能和可用性。
几乎所有的关系型数据库和一些非关系型都支持事务处理,然而到了 21 世纪末,NoSQL 开始兴起,它们的目标是通过提供新的数据模型以及内置的复制和分区来改进传统的关系模型,然而事务却成为了这场变革的受害者:很多新一代的数据库完全放弃了事务支持,或者将其重新定义,即替换为比以前弱得多的保证。
很多数据库不再支持完整的事务,而是偷换概念,说成“xxx事务”,这其实是换个名词来骗外行。
有人认为大规模系统为了性能和高可用将不得不牺牲事务的支持,也有人坚持事务是保证高价值数据和关键应用的必备功能。其实这两个观点都有点夸大其词,应该根据实际情况来做 trade-off。
本章首先讨论事务的基本概念,然后针对隔离级别(并发控制)做了详细探讨,包括读已提交、快照隔离和可串行化。事务保证和是否分布式在概念上相对正交,但在实现上,分布式系统中事务的实现难度要大的多。
# 1. ACID 的含义
Theo Härder 和 Andreas Reuter 于 1983 提出的 ACID,它最初是为了数据库中的容错保证给出一种相对精确的描述,但实际上各家数据库的实现并不相同,现在的 ACID 更像是一个市场营销术语。
不符合 ACID 标准的系统有时被冠以“BASE”,是指基本可用性(Basically Available)、软状态(Soft state)和最终一致性(Eventual consistency)
下面,将逐一探究 Atomicity、Consistency、Isolation 和 Durability 的精确含义,以此来对事务所要做出的保证建立一个基本的认识。
# 1.1 原子性(Atomicity)
一句话:要么全成功,要么全失败
原子性:在单个客户端/线程内,一组操作可以被原子的执行,如果执行到一半失败,已经执行的操作可以被全部回滚。
因此,ACID 中原子性所提供的保证是:在发生错误时,会回滚该事务所有已经写入的变更。
这个保证很重要,否则用户在执行到一半出错时,很难知道哪些操作已经生效、哪些操作尚未生效。有了此保证,用户如果发现出错,可以安全的进行重试。
注意与并发编程中的“原子性”相区分,在并发编程中,原子性通常和可见性关联,即一个线程无法看到另一个线程执行的原子操作的中间结果。但 ACID 的原子性并不关心多个操作的并发性,它没有描述多个线程试图访问相同的数据会发生什么情况。
# 1.2 一致性(Consistency)
一致性:是指对某些不变性(invariants)的维持,所谓不变性,即某些约束条件。如,在银行账户中,在任何时刻,账户余额须等于收入减去支出。
不同于 ACID 中其他性质,一致性是需要应用侧和数据库侧共同维护的:
- 应用侧需要写入满足应用侧视角约束要求的数据。
- 数据库侧需要保证多次写入前后,尤其是遇到问题时,维持该约束。
因此,一致性可以表述为,应用侧依赖数据库提供的原子性、隔离性来实现一致性。可见,一致性并非数据库事务本身的一种特性,更多的是应用侧的一种属性。据此,乔・海勒斯坦(Joe Hellerstein)认为,在 Härder 与 Reuter 的论文中,“ACID 中的 C 是 用来凑数的”。
在不同的上下文中,“一致性”这个词有着不同的含义:
- 多副本:之前讨论了多副本一致性和最终一致性的问题
- 一致性哈希:一种分区和调度的方式
- CAP 定理:一致性指的是线性一致性,是多副本间一致性的一种特例
- ACID:数据库在应用程序的视角处于某种”一致性的状态“
# 1.3 隔离性(Isolation)
多个客户端并发访问数据库时,如果访问的数据没有交集,是可以随意并发的。但如果有交集,则会产生并发问题,或称竞态条件(race condition)。
设有一个计数器,且数据库没有内置原子的自增操作,有两个用户,各自读取当前值,加 1 后写回。如图,期望计数器由 42 变为 44,但由于并发问题,最终变成了 43 。
隔离性是指,每个事务的执行是互相隔离的,每个事务都可以认为自己是系统中唯一正在运行的事务,因此传统上,教科书将事务隔离形式称为:可串行化(Serializability)。
但在实践中,由于性能问题很少使用串行化隔离。实际上隔离性强弱类似于一个光谱,数据库系统提供商一般会实现其中几个,用户可以根据业务情况在隔离性和性能间进行选择。
隔离性是最重要、最难理解的一个概念,之后会讨论除了可串行化之外的几种弱隔离级别。
# 1.4 持久性(Durability)
持久性是一种保证:事务一旦提交,即使服务器宕机重启、甚至发生硬件故障,已写入的数据就不会丢失。
- 对于单节点数据库,持久性意味着数据已被写入非易失性存储设备,这可能是以 page 或 WAL 的形式写入的
- 对于多副本数据库,持久性意味着数据已成功复制到多个节点,因此为了实现这种保证,数据库必须等到复制完成之后才能报告事务成功提交
但,持久性都只能做到某种程度的保证,而非绝对保证,比如:
- 对于单机,可以容忍宕机。但磁盘坏了就完犊子。
- 对于多机,可以容忍少数副本损坏,但是多数副本完后也没辙。
所以我们也往往分“物理”和“抽象”两种视角来审视一个系统,这里所说的持久性更像是一个抽象层面的保证。
在实践中,要通过多种手段,比如强制刷盘、校验码、异地复制、定时备份等多种手段来保证数据的持久性,但也只能做到大概率的保证(比如五个九),而非绝对保证。现实就是没有哪一项技术可以提供绝对的持久性保证,这些都是降低风险的手段。
# 1.5 Summary
ACID | 描述 |
---|---|
原子性(Atomicity) | 一组操作,要么全成功,要么全失败 |
一致性(Consistency) | 对不变性的维持(用户 -50,商家 +50) |
隔离性(Isolation) | 多个事务的执行相互隔离 |
持久性(Durability) | 一旦提交,永不丢失 |
# 2. 单对象与多对象的事务操作
ACID 是数据库对同一事务中包含多个写操作时所提供的保证。相比单对象事务,这种多对象事务是一种更强的保证,且更常用,因为通常的多个写入不会只针对单个对象。
举一个例子,设有电子邮件情景,邮箱首页需要如下语句来展示未读邮件数:
SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
如果邮件过多,为了加快查询,可以使用额外字段将未读邮件数存储存储起来(术语:denormalization),但每次新增、读过邮件之后都要更新该计数值。这时可能会产生下面这种情况:如下图,用户 1 插入一封邮件,然后更新未读邮件数;用户 2 先读取读取邮件列表,后读取未读计数。但邮箱列表中显示有新邮件,但未读计数却显示 0 。
这种问题其实就是某个事务读取了另一个事务中尚未提交的写入(“脏读”),从而违反了隔离性。
这个例子是想说,多对象操作往往需要事务的保证来防止出现错误的现象。
在多对象事务中,一个关键点是如何确定多个操作是否属于同一事务:
- 从物理上来考虑:可以通过 TCP 连接来确定,在同一个连接中,
BEGIN TRANSACTION
和COMMIT
语句之间的所有内容,可以认为属于同一个事务。但会有一些 corner case,如在客户端提交请求后,服务器确认提交之前,网络中断,连接断开,此时客户端则无从得知事务是否被成功提交。 - 从逻辑上来考虑:使用事务管理器,为每个事务分配一个唯一标识符,从而对操作进行分组。
实际中基本上使用第二种方法。
警告
有一些非关系型数据库,虽然提供 Batch 操作接口(如批量更新),但它们并不一定有事务语义,即可能有些对象成功,另外一些对象操作却失败。
# 2.1 单对象写入
当只更改单个对象时,仍会面临原子性和隔离性的问题。假设,在文档数据库中,你正在写入一个 20 KB 的 JSON 文档:
- 如果发送了前 10 kb 数据后,网络断开,数据库是否已经存储了这不完整的 10k 数据?
- 如果该操作是正在覆盖一个老版本同 id 数据,覆盖一半时电源发生故障,数据库是否会存在一半旧值一半新值?
- 如果有另一个客户端同时在读取该文档,是否会看到半更新状态?
这些问题都很棘手,如果数据库不提供任何保证,用户侧得写很多的错误处理逻辑。因此,一般的数据库哪怕不支持完整的事务,也会提供针对单个对象的原子性和隔离性。比如,可以使用写前日志来保证原子性,使用锁来保证隔离性。
其他一些数据库,也会提供更复杂的原子支持,如原子的自增操作。另一种更泛化的原子性保证是提供单个对象上的 CAS 操作,允许用户原子的执行针对单个对象的 read-modify-write 操作。当然,如果咬文嚼字一下,原子自增(atomic increment),在 ACID 中应该是属于隔离性( Isolation )的范畴,此处的原子自增其实是多线程的概念。
有的 NoSQL 数据库将上述支持宣传为”轻量级事务“,甚而 PR 成 ”ACID”。但这是极其不负责任的,通常来说,事务是一种将针对多个对象的多个操作封装为一个执行单元的机制。
# 2.2 界定对多对象事务的需求
跨机器的多对象事务难以正确实现,且非常损失性能(可能在一个数量级),因此数据库大多将是否打开事务设置为一个开关。
因此,用户在数据库选型时,有必要审视一下是否真的需要多对象事务,普通的键值对模型和单对象事务是否能够满足需求。一些情况下,这是足够的,但更多的场景仍然需要协同操作多个对象:
- 在关系型数据库中,一些表通常会有一些外键。在更新时,需要进行同步更新。
- 在文档型数据库中,相关的数据通常会放到一个文档中,缺少 Join 支持的文档型数据库往往会滋生反规范化的数据,当更新这种数据时,就需要一次更新多个文档,这就涉及到多对象操作的问题了。
- 在支持次级索引的数据库中,数据和对应的多个索引需要进行同步更新。
非规范化的数据其实就是出现某些数据被冗余存储了,这是便产生了同步更新的需求。
如果没针对多对象事务,上述保证只能在应用侧实现,徒增复杂度,而且容易出错。
# 2.3 故障和终止
事务的一个重要特点是在执行到一半时,可以安全的终止并重试。事务的设计哲学是:当出现违反原子性、隔离性和持久性的危险时,就丢弃而非保留已经发生的修改。
但,另一些场景,如多副本中的无主模型,就采用了“尽力而为”的模型,即尽可能的保证完成任务,如不能完成,也不会回滚已经发生的修改。因此,应用侧需要处理这种半完成的状态。
事务的“重试中止”虽然是一个简单有效的错误处理机制,但并不完美:
- 事务已经被成功提交,但返回给用户时出错。用户如果简单重试,就会使该事务中的操作执行两次,从而造成数据冗余,除非用户在应用代码侧进行去重(如多次执行这些语句,效果一样)。
- 由于系统负载过高,而造成事务执行失败。如果无脑重试,会进一步加重系统负担。此时可以使用指数后退方式重试,并且限制最大重试次数。
- 一些临时错误,如死锁、异常、网络抖动和故障切换时,重试才有效;对于一些永久故障,重试是没有意义的。
- 某事务在数据库之外如有副作用,重试事务时,会造成副作用的多次发生。如果某个事务的副作用是发送邮件,则肯定不希望事务每次重试时都发送一次电子邮件。如果想进行多个系统间的协同,可以考虑两阶段提交。
- 如果客户端在写入数据时故障,则所有该客户端正在执行的事务所涉及的数据修改都会丢失。
# 3. 几种弱隔离性级别
如果两个事务修改的数据没有交集,则可以安全的并发;否则,就会出现竞态条件。一旦出现并发 BUG,通常很难复现和修复。单客户端的并发已经足够麻烦,多客户端并发访问更加剧了并发问题。
数据库试图通过事务隔离(transaction isolation)来给用户提供一种隔离保证,从而降低应用侧的编程复杂度。最强的隔离性,可串行化(Serializability),可以对用户提供一种保证:任意时刻,可以认为只有一个事务在运行。可串行化的隔离严重影响性能,因此实践中往往都是放松要求,采用弱隔离级别。
下面讲的其实也没有覆盖全部的弱隔离级别,而且现在的弱隔离级别的实现复杂度已经非常庞大了,进一步了解需要参考相关的论文了。
总览
初学者对几种隔离级别的递进关系通常难以理解,往往是找不到一个合适的角度。我的经验是,从实现的角度对几种隔离级别进行理解,会简单一些。如 ANSI SQL 定义的四种隔离级别:读未提交、读已提交、可重复读和可串行化,可以从使用锁实现事务的角度来理解。
- 最强的隔离性——可串行化,可以理解为全局一把大排它锁,每个事务在启动时获取,在提交、回滚或终止时释放,但无疑这种隔离级别性能最差.。
- 而其他几种弱隔离级别,可以理解为是为了提高性能,缩小了加锁的粒度、减小了加锁的时间,从而牺牲部分一致性换取性能。
- 从上锁的强弱考虑,我们有互斥锁(Mutex Lock,又称写锁)和共享锁(Shared Lock,又称读锁);
- 从上锁的长短来考虑,我们有长时锁(Long Period Lock,事务开始获取锁,到事务结束时释放)和短时锁(Short Period Lock,访问时获取,用完旋即释放);
- 从上锁的粗细来考虑,我们有对象锁(Row Lock,锁一行)和谓词锁(Predicate Lock,锁一个范围)。
但这没有覆盖到到另一个常见的隔离级别——快照隔离(Snapshot Isolation),因为它引出了另一种实现族——MVCC。由于属于不同的实现,快照隔离和可重复读在隔离级别的光谱上属于一个偏序关系,不能说谁强于谁,都是强于读已提交,但弱于可串行化。
接下来几个小节,将依次考察读已提交、快照隔离、可重复读三个隔离级别。以及隔离级别不够导致的几种现象——更新丢失(Lost Update)、写偏序(Write Skew)和幻读(Phantom Read)。
# 3.1 读已提交
读已提交(RC,Read Committed)是最基本的事务隔离级别,它提供了如下两个保证:
- 读数据库时,只能看到已成功提交的数据(防止“脏读”)
- 写数据库时,只能覆盖已成功提交的数据(防止“脏写”)
# 3.1.1 无脏读
脏读(dirty reads):如果一个事务 A 能够读到另一个未提交事务 B 的中间状态,则称有脏读。
下图展示了一个没有脏读的示例:
- 在上图中,在 User 1 的事务提交前,User 2 看到的 x 值一直是 2 。
当有以下需求时,需要防止脏读:
- 一个事务如果更新多个对象,另外的一个事务就可能看到中间状态,产生“脏读”
- 如果事务终止并回滚,允许脏读的系统可能会让其他的事务看到被回滚的数据
# 3.1.2 无脏写
脏写(dirty writes):如果一个事务 A 的中间状态被另一个事务 B 给修改了,那称有脏写。
下面是一个脏写的示例,在一个售车网站中,两个人试图购买同一辆车,而购车需要两步操作:更新买主和开发票,那就有可能 Alice 先更新了买主,还没开完发票就被 Bob 又给更新买主了:
用途和局限:当多个事务更新同一个对象时,防止脏写会防止带来非预期的结果。但防止脏写并不能防止图 7-1 所示的计数器的竞态条件,那是一种更新丢失的问题,两个事务都是读写的已提交的数据,但仍无法避免写入丢失的问题。
# 3.1.3 “读已提交”的实现
- 防止脏写的方法:最简单常见的是使用行锁,即针对单条数据的长时写锁(Long Period Write Lock)。当事务想要修改某行数据时,必须先获得这行的行锁,事务提交时释放。
- 防止脏读的方法:
- 一种方法可以使用读锁,读之前必须先申请锁,读完释放,读锁可以并发,但与写锁互斥。【性能差,可能“饿死”】
- 一种方法是数据库维护旧值和当前持锁事务将要设置的新值两个版本,在持有写锁的事务提交之前,数据库对外只返回旧值。【常用手段】
# 3.2 快照隔离与可重复读
“读已提交”的隔离性仍然会产生如下所述的一种并发问题:
如图,Alice 在两个账户各存了 500 块钱,但如果其两次分别查看两个账户期间,发生了一笔转账交易,则一次事务中的两次查看的余额加起来并不等于 1000:
这个并发问题让 Alice 感觉自己少了 100 元。这种在一个事务中观察到数据库处于不一致状态的现象就是不可重复读(nonrepeatable read)或读倾斜(read skew)。如果那单对象操作来举例的话,Alice 先查了第一个账户为 500,然后在同一个事务中稍后查询的结果变成了 600,出现了“同一个事务中两次读的结果不一样”,这也是不可重复读。
例子中的这种不一致情况只是暂时的,但在某些情况下,这种暂时的不一致却是不可接受的:
- 备份:备份可能需要花费很长时间,而备份过程中可能会有读写存在,从而造成备份时的不一致。如果之后再利用此备份进行恢复,则会造成永久的不一致。
- 分析型查询和完整性检查。这个操作和备份一样,耗时都会比较长,如果中间有其他事务并发导致出现不一致的现象,就会导致返回的结果有问题。
这里分析一下,出现这种并发的问题的原因在于,一次较长时间的事务在执行过程中,其他事务可能会改变数据库的状态,从而导致这种变化被前者读到,进而产生问题。那一种解决的想法就是在事物开始执行的时候,给数据库拍一个快照,之后这个事务的查询都是在这个快照上进行,这样就不会产生“不可重复读”的问题了。
快照隔离(snapshot isolation)级别能够解决上述问题,使用此级别,每个事务都可以取得一个某个时间点的一致性快照(consistent snapshot),在整个事务期间,读到的状态都是该时间点的快照。其他事务的修改并不会影响到该快照上。
快照隔离级别在数据库中很常用,PostgreSQL、使用 InnoDB 引擎的 MySQL、Oracle、SQL Server 等都支持。
区分:可重复读一种数据库的要求,而快照隔离是为了实现这个要求而采取的一个技术。
# 3.2.1 快照隔离的实现
和读已提交一样,快照隔离也使用加锁的方式来防止脏写,但在进行读取不使用锁。快照隔离的一个关键原则是“读不阻塞写,写不阻塞读”,从而允许用户在进行长时间查询时,不影响新的写入。
为了实现快照隔离,保证读不阻塞写,且避免脏读,数据库需要对同一个对象保留多个已提交的版本,我们称之为多版本并发控制(MVCC,multi-version concurrency control)。
要实现快照隔离级别,一般使用 MVCC。相对于锁,MVCC 是另一个进行事务实现的流派,而且近些年来更受欢迎。当然,MVCC 是一种思想或者协议,具体到实现,有 MVTO(Timestamp Ordering)、MVOCC(Optimistic Currency Control)、MV2PL(2 Phrase Lock),即基于多版本,加上一种避免写写冲突的方式。
具体来说,使用 MVCC 流派,也可以实现读未提交、读已提交、快照隔离、可串行化等隔离级别。
- 读已提交在查询语句粒度使用单独的快照,快照粒度更小,因此性能更好。
- 快照隔离在事务粒度使用相同的快照(主要解决不可重复读问题)
因此,“读已提交”级别的实现可以作为快照隔离级别的实现的特殊情况。
MVCC 的基本要点为:
- 每个事务开始时会获取一个自增的、唯一的事务 ID(txid),该 txid = max(existing tx id) + 1。
- 该事务在修改数据时,不会修改以前版本,而会新增一个具有 txid 版本的数据。
- 该事务只能访问到所有版本 ≤ txid 的数据。
- 在写入时,如果发现某个数据存在 > txid 的版本,则存在写写冲突。
这里不再讲 MVCC 的实现,具体可参考 MySQL 事务隔离 中所讲述的实现。最终实现的效果其实类似于在一个事务启动时,给数据库拍一张大的所有已完成事务所产生的结果的数据库的全库快照。
# 3.2.2 可见性规则
在事务中进行读取时,对于每个对象来说,需要控制其版本对事务的可见性,以保证该事务能够看到一致性的视图。
如果一个对象的版本:
- 在事务开始时,创建该版本的事务已经提交。
- 未被标记删除,或被标记删除的事务尚未提交。
则该对象版本对该事务可见。
# 3.3 索引和快照隔离
当数据有多个版本时,如何给数据建立索引?一种简单的方案是:将索引指向对象的所有版本,然后在查询时使用再进行版本过滤。当某个对象的所有版本对任何事务都不再可见时,相应的索引条目也可以被同时删除。
在实践中上面的方案有很多优化,但不需要深入了解。
CouchDB、Datomic 和 LMDB 中使用一种 仅追加 / 写时拷贝(append-only/copy-on-write)的 B 树变体,是一种多版本技术的变体。boltdb (opens new window) 参考了 LMDB,也可以归为此类,感兴趣可以读一下这篇文章。这种方法挺复杂的,可以先跳过。
# 3.4 可重复读与命名困惑
在 1975 年 System R 定义 ANSI SQL 标准的隔离级别时,只定义了 RU、RC、RR 和 Serializability。当时,快照隔离还没有被发明,但是上述四种级别汇总有一个和快照隔离类似的级别:RR、Repeatable Read、可重复读。
注意,可重复读与快照隔离虽然大体一样,但仍有很多细节上的差异。
因此,虽然快照隔离级别很有用,尤其是只读事务,但很多数据库虽然实现了快照隔离,但却另有称谓。比如 Oracle 将 SI 称为可串行化(Serializable), PostgreSQL 和 MySQL 将 SI 称为可重复读(repeatable read)。因为这样可以符合 SQL 标准要求,以号称兼容 SQL 标准。
出于商业上的考虑,这些隔离级别的同一名词在不同公司的不同时期往往有着不同的含义。
但严格来说, SQL 对隔离级别的定义是有问题的,比如标准依赖于实现、几个隔离级别不连续、模糊不精确。很多数据库都号称实现了可重复读级别,但它们提供的保证却存在着很大差异。虽然一些文献中有对可重复读进行了精确定义,但大部分实现并不严格满足此定义。
到最后,没有人知道可重复读的真正含义。
# 3.5 防止更新丢失
读已提交和快照隔离,只是定义了从只读事务的视角,在有并发写入时,哪些数据是可见的,即解决了读写冲突。但我们忽略了包含多个写事务并发的一些冲突情况,比较有名的是更新丢失(lost update),典型的例子如并发更新计数器(图 7-1)。
更新丢失常发生于这样的场景:两个事务中都有读后写序列(读取 - 修改 - 写入序列),即写依赖于之前的读。如果读到的内容被其他事务修改,则本事务稍后的依赖于此读的写就会发生问题。比如:
- 并发更新计数器和账户余额。
- 复合值的并发修改(如 json 文档中的列表字段,需要先读出,加一个字段后写回)。
- 两个用户同时修改 wiki 页面,并且都是修改后将页面完整覆写回。
可以看出,这是一个普遍存在的问题,因此也诞生了很多方案来解决此问题。下面介绍解决更新丢失的方法:
# 1)原子写
有些数据库提供针对单个对象的原子的 read-modify-write 操作,因此,如果应用层逻辑能用这个原子操作表达,就可以避免更新丢失。如果这种方式可行,那它就是推荐的最佳方式。
比如如下的 SQL 的原子操作就可以对计数器进行安全并发更新:
UPDATE counters SET value = value + 1 WHERE key = 'foo';
与关系数据库类似,
- 文档数据库如 MongoDB,提供对文档局部的原子更新操作。
- KV 存储如 Redis,支持对复合数据结构优先队列的原子更新。
原子操作的实现方式有:
- 方式 1:通过对读取的对象加一个独占锁来实现
- 方式 2:强制所有对同一个对象的操作都在单线程上执行
注意
ORM 框架很容易不使用原子操作来执行 read-modify-write 序列,常常会产生隐含的 bug。
# 2)显式加锁
这种方案是:由应用程序显式地锁定待更新的对象。这样其他事务尝试同时读取对象时,必须等待当前正在执行的序列全部完成。
“显式”是说数据库给应用层提供了一种手段,应用层可以使用这种手段来显式地给一些数据上锁。
SQL 中可以使用 FOR UPDATE 指令来显式加锁:
SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE;
2
3
但需要根据应用需求进行合理的加锁——不要过度、也不要忘记。
FOR UPDATE 指令加的是一种悲观锁:
- 如果查询条件用了索引/主键,那么 select … for update 就会进行行锁;
- 如果查询的是普通字段,那么 select … for update 就会进行锁表。
# 3)自动检测更新丢失
除了使用锁的(悲观)方式(在数据库层或应用层)强制 read-modify-write 原子的执行;还可以使用乐观方式,允许其并发执行,检测到更新丢失后进行重试。
在使用 SI 隔离级的基础上,可以高效的对更新丢失进行检测。事实上,PostgreSQL 的可重复读,Oracle 的可串行化和 SQL Server 的快照隔离级别,都能够自动检测更新丢失的冲突,并中止后面的事务。但 MySQL 的 InnoDB 的快照隔离级别并不检测是否有更新丢失,有些人认为,快照隔离级别需要检测更新丢失冲突,从这个角度来讲,MySQL 没有提供严格的快照隔离。
相对于应用层主动上锁来说,自动检测更新丢失可以减少很多心智负担,避免写出一些察觉不到的 bug。
# 4)Compare-and-set
在不提供事务的数据库中,有时候会支持 CAS 操作,前面单对象写入中提到了该概念。使用 CAS 操作也能避免更新丢失,保证 read-modify-write 的原子性。
例如,在文档数据库的维基百科场景中,可以使用数据库提供的 CAS 操作,来对 wiki 页面进行原子的更新,仅当发现内容没有被修改时,才写会修改后的内容:
-- This may or may not be safe, depending on the database implementation
UPDATE wiki_pages
SET content = 'new content'
WHERE id = 1234 AND content = 'old content';
2
3
4
对于上述语句,如果数据库支持从快照中读取数据,则仍然没有办法防止丢失更新。
CAS 操作
CAS(Compare And Swap,比较并交换),通常指的是这样一种原子操作:针对一个变量,首先比较它的内存值与某个期望值是否相同,如果相同,就给它赋一个新值。CAS 的逻辑用伪代码描述如下:
if (value == expectedValue) {
value = newValue;
}
2
3
# 5)多副本和冲突解决
在多副本数据库中,解决更新丢失问题会更难一些,尤其如果多个副本允许并发写入。
困难所在:在多主和无主模型中,允许数据进行并发的写入和异步的同步,因此难以保证所有的数据即时收敛成一致。之前提到的锁和 CAS 操作都是针对单份数据,因此在此情况下都不适用。
解决方法:
- 可以允许并发写入和异步更新,如果有冲突就用多版本来解决,最后使用用户代码或者特殊数据结构来合并冲突。
- 特殊的,当多个操作满足“交换律”时,原子操作可以在多副本数据中进行正常的工作,如计数器场景就满足交换律。Riak 2.0 之后就支持并发的更新,并且会自动合并结果,而不会有更新丢失。
后者胜(LWW,last write win)的冲突解决策略是会造成更新丢失的。不幸的是,很多多副本数据库默认使用 LWW 进行冲突解决。
# 3.6 写倾斜与幻读
之前的问题都是在多事务并发更新单个对象时产生的,除此之外,涉及到多个对象的访问也会有一些奇妙的冲突。这里介绍的写倾斜和幻读都是快照隔离级别所不能解决的问题。
🔶 考察一个医生值班的场景,医院通常会要求几名医生同时值班,即使有特殊情况,也要保证有不少于一名医生值班。假设在某天,轮到 Alice 和 Bob 两人值班,不巧的是,他们都感觉身体不适,并且恰好同时发起请假:
- 先查询上面的值班表
- 后修改下面的请假表
假定数据库运行在快照隔离级别下,Alice 和 Bob 同时查询了今天值班情况,发现有多于一人值班,然后先后提交了休假申请,并且都通过了。这并没有违反快照隔离级别,但确实造成了问题——今天没有人值班了。
# 3.6.1 写倾斜的特点
上述异常称为写偏序(write skew),因为这两个事务在更新不同的对象,这里的竞态条件稍微有点不明显,但的确存在竞态条件,因为如果顺序执行,不可能出现没人值班的后果。
还有一个经典的例子:黑白棋翻转:棋子的两面分别为黑面和白面,桌上有两颗棋子,分别黑面朝上和白面朝上,现在想让桌上只有一个颜色朝上,于是黑棋看到白棋朝上,白棋看到黑棋朝上,于是两者同时翻转,结果又是不符号要求的局面。
从单对象到多对象的角度来看,写偏序可以算作是更新丢失从单对象到多对象的一种泛化。写偏序本质也是 read-modify-write,虽然是涉及多个对象,但本质仍然是一个事务的写入会导致另外一个事务读取到的信息失效这种情况导致的。
补充一句,写偏序是由 MVCC 实现的快照隔离级别的特有的缺陷,它是由于读依赖同一个不变的快照引起的,基于锁实现的可重复读就没有这个问题。
解决更新丢失的很多手段,都难以直接用到解决写偏序上,因此具有种种限制:
- 由于涉及多个对象,针对单对象的原子操作不能使用。
- 在快照隔离中,想要真正避免写偏序需要真正的可串行化。
- 虽然有些数据库允许指定约束(constraints),但往往是单对象的简单约束,如唯一性、外键约束。当然,可以使用触发器来在应用层维护多对象约束,以解决上述问题。
- 如果没有办法使用可串行的化的隔离级别,还可以利用数据库提供的(for update)机制进行显式的加锁。
解决方式:一种解决的方式就是上面 4 所述的,利用数据库的 FOR UPDATE 机制进行显式加锁,比如图 7-8 所示的例子中,在第一步查询的过程中,对查询结果进行显式加锁,然后再操作第二步的请假,这样就可以让另一个事务无法完成请假。但这种方案存在一个问题:查询的结果可能为空,这个时候就加不了锁了。解决方法就是后面讲的“物化冲突”。
# 3.6.2 其他写倾斜的例子
写偏序初看起来不好理解,但只要把握住写偏序的特点:
- 涉及多个对象。
- 一个事务的写入会使得另外事务的读取失效,进而影响其写入决策。
就能发现很多写偏序的例子。这些例子都具有“先读,根据读的结果来做出决策”这样的过程特点。
- 会议室预订系统:难以避免多个用户并发预订时,可能会预订到同一个会议室的时段。
- 多人棋类游戏:对棋子对象加锁,虽然可以防止两个玩家同时移动同一个棋子,却不能避免两个玩家将不同棋子移到一个位置。
- 抢注用户名:在每个用户具有唯一用户名的网站上,两个用户可能会并发的尝试创建具有相同名字的账户。如果使用检查是否存在该名字→没有则注册该名字流程,在快照隔离级别下,是没法避免两个用户注册到相同用户名的。当然可以通过在数据库创建唯一性约束来保证这个特性。
- 防止一钱多花:允许用户花钱和点券的服务,通常会在用户消费时检查其没有透支,导致余额变为负数。
# 3.6.3 幻读会导致写倾斜
可以看到,上面的例子都有“先读,根据读的结果来做出决策”这样的过程特点,做出的决策会导致第一步读的结果失效,进而影响是否做出决策。
当然,这些步骤可能以不同的顺序发生,如可以首先写入,然后进行 select 查询,根据查询结果决定事务是否提交。
对于医生值班的例子,我们可以通过 for update 语句来锁住步骤 1 中查询到的结果;但对于其他例子,步骤 1 查询结果集为空,则无法锁住任何东西。
这种在一个事务中的写入改变了另一个事务的查询结果的现象,称为幻读。
取名“幻读”,是因为对于被影响的那个事务而言,就好像发生了幻觉一样,这个事务会困惑:”明明我查询的结果是让我做出这样的决策的,但怎么做完决策后这个局面告诉我当初我就不应该做出这个决策呢?我是当初读取结果的时候发生幻觉看错了吗?“
快照隔离能够避免只读事务中的幻读,但对于读写事务,就很可能出现由幻读引起的写偏序问题。
# 3.6.4 物化冲突
这个方法不是一个好方法,真要使用它,还不如直接上“可串行化”级别的隔离。
在 3.6.1 节中的解决方案中说到,有的场景在步骤 1 读不到任何对象来进行加锁。那很自然的一个想法就是,能不能手动引入一些对象槽来代表不存在的对象,从而使得加锁成为可能。
在预定会议室的例子中,可以创建一个会议室号+时间段表,比如每 15 分钟一个时间段。可以在该表中插入未来几个月中所有可预订的会议室号+时间段。如果现在一个事务想要预定某个会议室的某个时间段,便可在该表中将对应对象都锁住,然后执行预定的操作。
需要强调的是,该表只用于防止同时预定同一个会议室的同一个时间段,并不用来存储预定相关信息,可以理解为是个锁表,每一行都是一把锁。
这种方法称为物化冲突(materializing conflicts),因为它将幻读转化为数据库物理中实实在在的表和行。但如何对冲突进行合理的物化,很难且易出错。并且,此方法会将解决冲突的细节暴露给了应用层(因为应用层需要感知物化出来的表)。因此,这是最不得以的一种方法,如果数据库本就支持可串行化,则大多数情况下,可以直接使用可串行隔离级别。
# 4. 可串行化
“读已提交”可以避免脏写和脏读,“快照隔离”可以避免不可重复读,但仍存在更新丢失、写倾斜和幻读问题。而且,弱隔离级别有一些固有的问题:
- 从数据库侧,弱隔离级别的真正含义难以理解,且不同数据库产品实现的也千差万别。
- 从应用侧,很难判断当前代码在特定隔离级别上,是否会有竞态条件和并发问题。如果应用代码很复杂,更难看出问题。
- 从工具侧,没有比较好用的工具来帮助检测我们的代码在特定隔离级别的竞态条件。对有竞态条件的代码测试是非常难以编写,尤其对于很小概率出现的错误来说,更是难以复现。
解决这些问题,最容易想起的就是——使用最强隔离级别:可串行化。
可串行化隔离:它保证即使事务可能会并发执行,但最终的结果与每次一个即串行执行结果相同。
但可串行化有很多弱点,要了解这一点,需要逐一考察可串行化当前主要实现方法:
- 物理上真正的对所有事务进行串行的执行。
- 两阶段锁(2PL,two-phase locking),曾经几十年中唯一的可用选项。
- 乐观并发控制(OCC,Optimistic concurrency control),如可串行化的快照隔离。
本章主要针对单机数据库探讨上述实现,到第九章时,将会将这些理论扩展到多机。
# 4.1 物理上串行
虽然实现可串行化最直观的做法就是将所有事务串行的执行。但在过去几十年,单线程事务的性能基本是不可用的。直到 2007 年左右,一些软硬件的的发展,才促成了单线程事务的真正落地:
- RAM 足够大且便宜:像内存数据库将数据都存到内存中,因此性能还可以接受
- AP、TP 场景的界定和区分:人们意识到 OLTP 事务通常执行很快,只有少量的读写,而 OLAP 则通常只读,因此可以将耗时的 AP 事务在一致性快照上运行,而不影响串行主循环。
其实就是说,现在的应用环境已经与以前发生了很大改变,这使得单线程事务的落地成了可能。
VoltDB/H-Store, Redis, and Datomic 实现了物理上的串行执行事务。由于避免了多线程间用锁同步的开销,单线程的事务某些场景下可能性能更好,但在吞吐上可能受制于单核 CPU 的上限。此外,为了充分利用单核,相比传统形式,会对事务结构重新组织(如存储过程)。
# 4.1.1 将事务封装成存储过程
在数据库发展早期阶段,人们试图将数据库事务设计成为包含整个用户交互流程(比如从预定座位到付款)。如果整个交互流程都从属于一个事务,那么它们就可以被原子的提交,这么抽象看起来很干净。
但人的交互所引入延迟远大于计算机 CPU 时钟周期甚至 IO 延迟,因此 OLTP 型数据库多会避免在单个事务中包含人的交互,以求单个事务能够较快的执行结束。在 Web 上,这意味着,不能让单个事务跨多个请求。但如果只允许单次请求执行一个语句,一个完整流程通常会包含多个语句,从而包含多次 RPC\HTTP 请求,会在通信上耗费太多时间。
因此,单线程串行事务系统不允许交互式的多语句事务。用户需要提交整个事务代码并封装为存储过程一次性发给数据库。如果数据都在内存中,则存储过程可以被快速的执行。
- 上图的 Store procedure 示例中,用户把对值班表和请假表的操作封装为一个存储过程,来让数据库执行。
- 如上图,相比于之前的多次请求,数据库把事务所需的所有的数据全部加载到内存中,使得存储过程高效执行,而无需等待网络或磁盘 IO。
存储过程中包含 if、while 等流程控制语句判断分支,来依赖之前的结果进行决策,否则就只能实时交互。
这个技术其实是把标准 SQL 做不到的事引入 if、else 等流程控制语句来解决,它有点更像通用编程语言了。
# 4.1.2 存储过程的优缺点
存储过程从 1999 年就进了 SQL 标准(SQL/PSM),但由于种种原因,一直为人所诟病:
- 每个数据库厂商都有自己的存储过程语言支持(Oracle 有 PL/SQL,SQL Server 有 T-SQL,PostgreSQL 有 PL/pgSQL 等),且语法陈旧,迭代缓慢。
- 相比本地应用代码,存储过程运行在数据库服务器中,难以进行调试、测试和监控。
- 数据库通常对性能表现更敏感,一个写的不好的存储过程可能会拖累整个数据库的执行。
现代的存储过程放弃了 PL/SQL,转而使用通用编程语言:VoltDB 使用 Java 或 Groovy,Datomic 使用 Java 或 Clojure,而 Redis 使用 Lua。从而在某种程度上部分克服上述缺点。
存储过程与内存数据库存储使得单线程上执行所有事务变得可行:
- 内存数据库和存储过程避免了 IO
- 单线程避免了锁开销
值得一提的是,VoltDB 还使用存储过程进行跨节点的数据同步:不是将改动复制到多个节点上,而是在每个节点执行同样的存储过程。当然,这要求存储过程具有确定性:在不同节点的不同时间执行,需要产生相同的结果。如,在存储过程中,获取当前时间戳,就要用特殊 API。
# 4.1.3 对数据进行分区
单线程事务受限于单个 CPU 吞吐,为了提高写入吞吐,处理较大数据量,可以将数据进行分区。 VoltDB 支持对数据以某种方式(猜测是用户指定一个分区函数)对数据进行分区。
需要注意的是,分区方式要谨慎选择,以使绝大部分事务都局限于单个分区上。对于跨分区事务,由于需要进行额外协调(如上分布式锁),以串行执行。这会带来严重的性能损失,要尽量避免。
如,VoltDB 据称支持每秒 1000 的跨分区写入,比单机事务低几个数量级,且不能通过增加机器来平滑扩展。
能否对数据进行分区,取决于数据建模方式。如键值数据可以方便的进行分区,但具有多个次级索引的数据就很难,因为数据只能按照一种顺序来存储,而多个索引总会带来跨分区访问。
# 4.1.4 串行执行小结
在某些特定约束场景下,对事务进行真正物理上的串行执行,已经成为一种可串行化隔离级别的实现方案。这些约束包括:
- 所有事务都必须小(摸数据少)而快(延迟低)。因为只要有一个慢,就会拖累所有其他事务。
- 活跃数据能够全部装入内存,沉寂数据可以放在磁盘。总之,需要最少化 IO,以保证所有事务能够快速的执行。
- 单核 CPU 能够处理所有写入吞吐,或者,能够将事务局限在单个分区,不需要跨分区协调。
- 只允许有限的跨分区事务。
# 4.2 两阶段锁
数据库中最广泛使用的可串行化实现:两阶段锁(2PL,two phase locking)。
需要明确的是,本文所称的 2PL 是严格两阶段锁(SS2PL),以区别于其他 2PL 的变体。
另外,它虽然和“两阶段提交”(2PC)听起来很像,但是完全不同的两个东西。
2PL 分成两个阶段:
- 【扩张阶段】:事务的整个执行过程,只会申请锁,而且是按需申请
- 【收缩阶段】:事务的提交时,只会释放锁
易错:2PL 并非拿到所有的锁才开始进行读写操作,而是按需拿锁,提交时集中释放
2PL 的锁分成读锁和写锁:
- 如果所有事务都没有写入,允许多事务并发读取一个对象。【读锁的特点】
- 只要任何一个事务有写入,就会将其独占到事务结束,不允许其他任何事务读或写。【写锁的特点】
2PL 不允许读写并发、写写并发,而快照隔离却正好相反,即读写互相不阻塞。另一方面,2PL 通过阻止读写并发,可以避免更新丢失和写偏序等并发问题。
# 4.2.1 两阶段锁的实现
2PL 用于 MySQL(InnoDB)和 SQL Server 中实现可串行化隔离级别,以及 DB2 中实现可重复读隔离级别。
通过对每个对象进行加锁,可以实现单个对象的读写互斥。锁可以处于共享模式(shared mode)或者互斥模式(exclusive mode),具体来说:
- 如果某个事务想读取一个对象,需要首先获取该对象的共享锁。多个事务可以同时获取同一个对象的共享锁。但若某个事务持有该对象的互斥锁,则所有需要读写该对象的事务都得等待。
- 如果某个事务想写入一个对象,需要首先获取该对象的互斥锁。任何其他事务都不能同时持有该对象的任何种类的锁。因此,如果该对象上已经有锁,该事务必须先等待其释放。
- 如果某个事务要先读取,再写入某个对象,可以先获取其共享锁,然后将其升级为互斥锁。升级互斥锁和获取互斥锁的条件相同。
- 当某个事务获取锁之后,必须持有到事务结束(中止或者提交)。这也是上面两阶段定义的由来。
由于每个对象都要上锁,而一个事务通常会访问多个对象,因此很有可能造成死锁:多个事务持有锁,并且互相等待对方的锁。
# 4.2.2 两阶段锁的性能
两阶段锁的最大问题在于其性能差,这也是其没有被所有人都接受原因。两阶段锁的实现下,事务的吞吐要比其他弱隔离级别低的多。维护大量锁的开销是一个原因,更重要的原因是并发性的降低。
可能产生的问题有:
- 延迟不稳定。由于没有对等待时长进行限制,虽然你的事务很短,但系统中任何长事务都可能对你的执行造成影响。
- 死锁更加频繁。2PL 相比其他基于锁的隔离实现更容易发生死锁,从而造成额外的性能问题:检测死锁并重试。
# 4.2.3 谓词锁
前面在提到锁的时候遗漏了一个关键细节:锁的粒度。
之前讲过,幻读是一个事务改变另一个更事务的查询结果,而一个具有可串行化隔离级别的数据库,需要避免幻读。在会议室预定例子中,这意味着,在一个事务查询某个时间段可用会议室时,另外的事务不能更新该时间段的同会议室的使用情况。
如何实现这点呢?从概念上讲,我们需要一个谓词锁(predicate lock):它通常是共享模式,但粒度更大——不再限于单个对象,而需要囊括所有符合条件的查询结果。
事务使用谓词锁的方式如下(与共享锁类似,只不过粒度更大):
- 当某个事务需要读取匹配条件的所有对象时,需要获得该查询条件的共享谓词锁。如果有任何其他事务持有该范围内对象的互斥锁,则该事务需要等待其结束。
- 当某个事务想要写入(插入、更新或者删除)某个对象时,上互斥锁前,需要检查是否有其他事务持有包含该对象的谓词锁。如果有,则该事务需要等待其结束。
谓词锁的一个关键点是,可以锁住一个对象集合,该对象集中的对象甚至不必已存在,但将来可能会被添加。通过谓词锁,2PL 可以解决幻读问题。
# 4.2.4 索引范围锁
刚刚介绍的谓词锁的性能其实并不好:因为谓词锁所代表的的对象集合可能是离散的几个集合的并集,所以只能线性检查,因此非常耗时。
大多数 2PL 数据库使用了谓词锁的另一个近似——索引范围锁(index-range locking,也称为 next-key locking):通过适当放大锁住的对象集来简化谓词锁。
比如当有多个条件进行与的时候,只锁一个条件,而且是尽量锁带有索引的字段对应的那个条件。这样就可以转化为一个在索引上的范围锁,相比谓词锁可以更快地判断冲突。
相比谓词锁,索引范围锁虽然锁住的范围大,但实现开销较低。但谓词相关的索引并不总是能找到,此时可以简单的退化成整张表上的共享锁。这样做虽然有损性能,但是实现简单且安全。
# 4.3 可串行化的快照隔离
前面小节详细聊了下数据库中隔离级别的图景:
- 在光谱一侧,我们有很好的隔离级别——可串行化,但其实现要么性能差(两阶段锁),要么不可扩展(物理上串行执行)。
- 在光谱另一个侧,我们有一些相对较弱的隔离级别,它们性能较好,但会有各种竞态条件(更新丢失、写偏序、幻读等等)。
难道说强隔离级别和高性能两者不可得兼吗?
2008 年,Michael Cahill 在其博士论文中提出了一种新型的可串行化实现方案:可串行的快照隔离(SSI,serializable snapshot isolation)。今天,无论单机数据库(PostgreSQL 9.1+ 的可串行化隔离级别)和分布式数据库(FoundationDB 使用了类似算法)都有 SSI 的身影。相比其他实现方式,SSI 还相对不太成熟,但其表现出的性能优势,使其隐隐然有成为可串行化默认实现的趋势。
# 4.3.1 乐观与悲观
2PL 是一种悲观的并发控制机制,其背后哲学是:当可能有不好的事情(如并发)发生时,先悲观的等待到条件好转(其他事务释放锁),再进行执行。
物理上的串行执行,是将这种悲观哲学提升到了极致,等价于每个事务在执行时都持有了整个数据库级别的互斥锁。为了弥补这种悲观带来的性能损失,需要保证每个事务执行足够快。
SSI 是一种乐观的并发控制机制,其背后哲学是:当存在潜在危险时,仍然不做任何检查去大胆的执行。当事务提交时,再进行冲突检测,如果存在冲突,则回退重试。
将乐观发展到极致,则是不上任何锁,但为了给这种乐观进行兜底,需要在执行完后进行检查。
乐观并发控制的优缺点:
- 如果系统负载接近上限,且争用很多,乐观并发控制会导致事务大量中止和重试,从而进一步加重系统负载。
- 如果系统很空闲,且争用较少,乐观并发控制性能较好,因为其避免了锁的开销。此外,可以调换满足交换律的原子操作顺序,来减少争用。如并发增加的计数器场景。
SSI,顾名思义,基于快照隔离。即在 SSI 隔离级别中,所有的读取都针对一份一致性的快照,这是其区别于早期乐观并发控制之处。在快照隔离之上,增加写写冲突检测算法,以决定哪些事务需要中止重试,是为 SSI。
# 4.3.2 基于失效前提的决策
在之前讨论写偏差时,我们观察到一种一再发生的模式:读取-决策-写入。
- 读取:事务首先从数据库中读取到一些数据。
- 决策:考察读到的数据,做出某种决策。
- 写入:将对应决策造成结果写回数据库。
即,这里面存在一个因果关系,读为因,写为果。如果在提交时,发现决策的前提(premise,如:“今天有两名医生排到了值班”)不再满足,则后面写入失去意义。因此为了提供可串行化的隔离级别,需要识别这种因果关系,并且能够在提交时检测前提是否失效,以决定是否中止事务。
那如何检测前提是否失效呢?
- 在读取时,要检测读到的数据版本是否为最新版本。(读之前,可能有未提交的写入)
- 在写入时,要检测写入的数据是否覆盖了其他事务的读取。(读之后,可能发生了写入)
代入之前的例子,其实是从上述模式的不同阶段来考虑这个冲突的。
# 4.3.3 MVCC 读取的过时检测
快照隔离通常通过多版本并发控制(MVCC)来实现。当事务基于 MVCC 数据库中的某个一致性的快照进行读取时,会忽略其他事务潜在的任何修写入。
在下图中,事务 43 在查询时,认为 Alice on_call = true
,但在事务 43 提交时,事务 42 已经先一步提交,并且导致 Alice on_call = false
。也就是从 43 读取结果到提交决策这段时间里,它原本读取的结果的值发生了改变。
为了避免这种异常,数据库需要跟踪由于 MVCC 读所忽略的写入集合(读时发现有更新的未提交版本),如果在提交时检测到这些写入集存在已经提交的对象,则本事务必须终止。
延迟到提交时检测,而不是发现过时读取立即终止,是因为事务并不知道之后是否会发生基于这些读取的写入操作。
总结:读取时,检测写读冲突,延到提交时,看有冲突的写入是否已提交。
# 4.3.4 影响之前读取的写入检测
在一个事务写入某对象时,需要检测是否该数据被另一个事务读取过:
如上图,假设在班次编号 shift_id
上存在索引,事务 42、43 在读取了对应数据后,会在 shift_id = 1234
的索引条目上记下事务编号,并在事务和所有并发事务完成时,删除标记。当事务发生写入时,需要通知读过该索引的所有事务(通过标记可以知道):你读到的数据过期了。该过程类似于上锁,但并不真正的等待,而是简单通知。
如上图,事务 43 会在写入数据时,会通知事务 42 其所读取的数据过期;事务 42 在写入时,也会通知事务 43 。但事务 42 首先发起提交,尽管事务 43 的写入影响了 42, 但 43 未提交,此时 42 会提交成功。但 43 在提交时,发现收到通知的事务已经提交,则 43 只能中止,然后重试。
总结:在写入时,利用之前在对应索引范围记下的读取事务编号记录冲突,在提交时,看有冲突的读取是否已经提交。
# 4.3.5 可串行化快照隔离的
许多工程方面的细节会直接影响算法在实践中的效果,如事务读写跟踪粒度:
- 如果细粒度跟踪,虽然能精确的检测到真正的冲突,减少重试,但会有显著的记录开销。
- 如果粗粒度的跟踪,虽然性能会好,但会导致更多的冲突和重试。
和 2PL 相比,SSI 的最大优点是,不会通过锁来阻塞有依赖关系的事务并发执行。SSI 就想运行在快照隔离级别一样,读不阻塞写,写不阻塞读。只是追踪记录,在提交时决定是否提交或重试。这种设计是的查询延迟更可预测。尤其是,只读事务可以工作在一致性快照上,而不受影响,这对读负载很重的场景很有吸引力。
相比物理上的串行化,SSI 能够进行平滑扩展。如 FoundationDB 就可以利用多机并行进行冲突检测,从而通过加机器获取很高的吞吐。
事务的中止率会显著影响 SSI 性能。长时间的读写事务大概率会引起冲突,并重试。因此 SSI 要求读写事务尽可能的短。尽管如此,SSI 仍然比物理串行化以及两阶段锁对慢事务更友好。