在一个苛刻的数据存储环境中。会有许多可能出错的情况。为了系统高可靠的目标,我们必须处理好这些问题,万一发生类似情况确保不会导致系统级的失效。然而,完善的容错机制需要大量的工作,要仔细考虑各种可能出错的可能,并进行充分的测试才能确保方案切实可靠。
事务技术一直是简化这些问题的首选机制。事务将应用程序的多个读、写操作捆绑在一起成为一个逻辑操作单元。即事务中的所有读写是一个执行的整体,整个事务要么成功、要么失败。如果失败,应用程序可以安全地重试。这样,由于不需要担心部分失败的情况,应用层的错误处理就变得简单很多。
深入理解事务
与其他技术一样,事务有其优势,也有其自身的局限性。为了更好地理解事务设计的权衡之道,让我们考虑正常运行和各种极端情况,详细分析事务可以为我们提供哪些保证。
ACID 的含义
原子性
原子性其实描述了客户端发起一个包含多个写操作的请求时可能发生的情况,例如在完成了一部分写入之后,系统发生了故障,包括进程崩溃,网络中断,磁盘变满或者违反了某种完整性约束等。把多个写操作纳入到一个原子事务,万一出现了上述故障而导致没法完成最终提交时,则事务会中止,并且数据库须丢弃或撤销那些局部完成的更改。
ACID 中原子性所定义的特征是:在出错时中止事务,并将部分完成的写入全部丢弃。也许可中止性比原子性更为准确,不过我们还是沿用原子性这个惯用术语。
一致性
ACID 中的一致性的主要是指对数据有特定的预期状态,任何数据更改必须满足这些状态约束(或者恒等条件)。例如,对于一个账单系统,账户的贷款余额应和借款余额保持平衡。如果某事务从一个有效的状态开始,并且事务中任何更新操作都没有违背约束,那么最后的结果依然符合有效状态。
这种一致性本质上要求应用层来维护状态一致,应用程序有责任正确地定义事务来保持一致性。这不是数据库可以保证的事情:即如果提供的数据修改违背了恒等条件,数据库很难检测进而阻止该操作(数据库可以完成针对某些特定类型的恒等约束检查,例如使用外键约束或唯一性约束。但通常主要靠应用程序来定义数据的有效/无效状态,数据库主要负责存储)。
原子性,隔离性和持久性是数据库自身的属性,而 ACID 中的一致性更多是应用层的属性。应用程序可能借助数据库提供的原子性和隔离性,以达到一致性,但一致性本身并不源于数据库。因此,字母 C 其实并不应该属干 ACID。
隔离性
ACID 语义中的隔离性意味着并发执行的多个事务相互隔离,它们不能互相交叉。经典的数据库教材把隔离定义为可串行化,这意味着可以假装它是数据库上运行的唯一事务。虽然实际上它们可能同时运行,但数据库系统要确保当事务提交时,其结果与串行执行完全相同。
持久性
数据库系统本质上是提供一个安全可靠的地方来存储数据而不用担心数据丢失等。持久性就是这样的承诺,它保证一旦事务提交成功,即使存在硬件故障或数据库崩溃,事务所写入的任何数据也不会消失。
对于单节点数据库,持久性通常意味着数据已被写入非易失性存储设备,如硬盘或SSD。在写入执行过程中,通常还涉及预写日志等,这样万一磁盘数据损坏可以进行恢复。而对于支持远程复制的数据库,持久性则意味着数据已成功复制到多个节点。为了实现持久性的保证,数据库必须等到这些写入或复制完成之后才能报告事务成功提交。
现实情况是,没有哪一项技术可以提供绝对的持久性保证。应该组合使用持久化的手段,包括写入磁盘、复制到远程机器以及备份等。因此对任何理论上的“保证”一定要谨慎对待。
单对象与多对象事务操作
单对象写入
存储引擎几乎必备的设计就是在单节点、单个对象层面上提供原子性和隔离性。例如,出现宕机时,基于日志恢复来实现原子性,对每个对象采用加锁的方式来实现隔离。
多对象事务的必要性
是否可能只用键-值数据模型和单对象操作就可以满足应用需求?的确有一些情况,只进行单个对象的插入、更新和删除就足够了。但是,还有许多其他情况要求写入多个不同的对象并进行协调:
- 对于关系数据模型,表中的某行可能是另一个表中的外键。类似地,在图数据模型中,顶点具有多个边链接到其他的顶点。
- 对于文档数据模型,如果待更新的字段都在同一个文档中,则可视为单个对象,此时不需要多对象事务。但是,缺少 join 支持的文档数据库往往会滋生反规范化,当更新这种非规范化数据时,就需要一次更新多个文档。
- 对于带有二级索引的数据库,每次更改值时都需要同步更新索引。从事务角度来看,这些索引是不同的数据库对象。
即使没有事务支持,或许上层应用依然可以工作,然而在没有原子性保证时,错误处理就会异常复杂,而缺乏隔离性则容易出现并发性方面的各种奇怪问题。
弱隔离级别
从理论上讲,隔离是假装没有发生并发,可串行化隔离意味着数据库保证事务的最终执行结果与串行执行结果相同。
实现隔离绝不是想象的那么简单。可串行化的隔离会严重影响性能,而许多数据库却不愿意牺牲性能,因而更多倾向于采用较弱的隔离级别,它可以防止某些但并非全部的并发问题。这些弱隔离级别理解起来更为困难,甚至可能会带来一些难以捉摸的隐患,但在实践中还是被广泛使用。
已提交读
防止脏读
假定某个事务已经完成部分数据写入,但事务尚未提交,此时另一个事务是否可以看到尚未提交的数据呢?如果是的话,那就是脏读。
已提交读级别的事务隔离必须做到防止发生脏读。这意味着事务的任何写入只有在成功提交之后,才能被其他事务观察到。
防止脏写
如果两个事务同时尝试更新相同的对象,我们不清楚写入的顺序,但可以想象后写的操作会覆盖较早的写入。如果先前的写入是尚未提交事务的一部分,是否还是被覆盖?如果是,那就是脏写。
已提交读隔离级别下所提交的事务可以防止脏写,通常的方式是推迟第二个写请求,直到前面的事务完成提交或回滚。
实现
数据库通常采用行级锁来防止脏写:当事务想修改某个对象时,它必须首先获得该对象的锁,然后一直持有锁直到事务提交或回滚。给定时刻,只有一个事务可以拿到特定对象的锁,如果有另一个事务尝试更新同一个对象,则必须等待,直到前面的事务完成了提交或回滚后,才能获得锁并继续。这种锁定是由处于已提交读模式(或更强的隔离级别)数据库自动完成的。
那如何防止脏读呢?一种选择是使用相同的锁,所有试图读取该对象的事务必须先申请锁,事务完成后释放锁。从而确保不会发生读取一个脏的、未提交的值。
然而,读锁的方式在实际中并不可行,大多数数据库采用了以下的方法来防止脏读:对于每个待更新的对象,数据库都会维护其旧值和当前持锁事务将要设置的新值两个版本。在事务提交之前,所有其他读操作都读取旧值;仅当写事务提交之后,才会切换到读取新值。
快照级别隔离与可重复读
在使用可重复读隔离级别时,仍然有很多场景可能导致并发错误。
假设 Alice 在银行有 1000 美元的存款,分为两个账户,每个 500 美元。现在有这样一笔转账交易从账户 1 转 100 美元到账户 2。如果在她提交转账请求之后而银行数据库系统执行转账的过程中间,来查看两个账户的余额,她有可能会看到账号 2 在收到转账之前的余额(500 美元),和账户 1 在完成转账之后的余额(400 美元)。对于 Alice 来说,貌似她的账户总共只有 900 美元,有 100 美元消失了。
这种异常现象被称为不可重复读。如果 Alice 在交易结束时再次读取账户 1 的余额,她将看到不同的值(600 美元)。不可重复读在已提交读隔离语义下是可以接受的,Alice 所看到的账户余额的确都是账户当时的最新值。
对于这个例子,暂时的不一致不会导致严重的问题,但是有些场景则不能容忍这种暂时的不一致:
备份
备份任务要复制整个数据库,这可能需要数小时才能完成。在备份过程中,可以继续写入数据库。因此,得到镜像里可能包含部分旧版本数据和部分新版本数据。如果从这样的备份进行恢复,最终就导致了永久性的不一致。
分析查询与完整性检查
有时查询可能会扫描几乎大半个数据库。这类查询在分析业务中很常见,亦或定期的数据完整性检查。如果这些查询在不同时间点观察数据库,可能会返回无意义的结果。
快照级别隔离是解决上述问题最常见的手段。总体想法是,每个事务都从数据库的一致性快照中读取,事务一开始所看到是最近提交的数据,即使数据随后可能被另一个事务更改,但保证每个事务都只看到该特定时间点的旧数据。
实现
与已提交读隔离类似,快照级别隔离的实现通常采用写锁来防止脏写,但是读取则不需要加锁。这使得数据库可以在处理正常写入的同时,在一致性快照上执行长时间的只读查询,且两者之间没有任何锁的竞争。
考虑到多个正在进行的事务可能会在不同的时间点查看数据库状态,所以数据库保留了对象多个不同的提交版本,这种技术因此也被称为多版本并发控制(MVCC)。
下图说明了 PostgreSQL 如何实现基于 MVCC 的快照级别隔离(其他数据库的实现类似)。当事务开始时,首先赋予一个唯一的、单调递增的事务 ID(txid)。每当事务向数据库写入新内容时,所写的数据都会被标记写入者的事务 ID。
表中的每一行都有一个 created_by 字段,其中包含了创建该行的事务 ID。每一行还有一个 deleted_by 字段,初始为空。如果事务要删除某行,该行实际上并未从数据库中删除,而只是将 deleted_by 字段设置为请求删除的事务 ID。事后,当确定没有其他事务引用该标记删除的行时,数据库的垃圾回收进程才去真正删除并释放存储空间。
可见性规则
当事务读数据库时,通过事务 ID 可以决定哪些对象可见,哪些不可见。例如:
- 每笔事务开始时,数据库列出所有当时尚在进行中的其他事务(即尚未提交或中止),然后忽略这些事务完成的部分写入,即不可见。
- 所有中止事务所做的修改全部不可见。
- 较晚事务 ID(即晚于当前事务)所做的任何修改不可见,不管这些事务是否完成了提交。
- 除此之外,其他所有的写入都对应用查询可见。
换句话说,仅当以下两个条件都成立,则该数据对象对事务可见:
- 事务开始的时刻,操作该对象的事务已经完成了提交。
- 对象没有被标记为删除。
长时间运行的事务可能会使用快照很长时间,从其他事务的角度来看,它可能在持续访问正在被覆盖或删除的内容。由于没有就地更新,而是每次修改总创建一个新版本,因此数据库可以以较小的运行代价来维护一致性快照。
防止更新丢失
写事务并发还会带来其他一些值得关注的冲突问题,最著名的就是更新丢失问题。
更新丢失可能发生在这样一个操作场景中:应用程序从数据库读取某些值,根据应用逻辑做出修改,然后写回新值。当有两个事务在同样的数据对象上执行类似操作时,由于隔离性,第二个写操作并不包括第一个事务修改后的值,最终会导致第一个事务的修改值可能会丢失。
并发写事务冲突是一个普遍问题,目前有多种可行的解决方案。
原子写操作
许多数据库提供了原子更新操作,以避免在应用层代码完成“读-修改-写回”操作,如果支持的话,通常这就是最好的解决方案。例如,以下指令在多数关系数据库中都是并发安全的:
UPDATE counters SET value = value + 1 WHERE key = 'foo';
然而,并非所有的应用更新操作都可以以原子操作的方式来表达,例如维基页面的更新涉及各种文本编辑。无论如何,如果原子操作可行,那么它就是推荐的最佳方式。
原子操作通常采用对读取对象加独占锁的方式来实现,这样在更新被提交之前不会其他事务可以读它。另一种实现方式是强制所有的原子操作都在单线程上执行。
显式加锁
如果数据库不支持内置原子操作,另一种防止更新丢失的方法是由应用程序显式锁定待更新的对象。然后,应用程序可以执行“读-修改-写回”这样的操作序列;此时如果有其他事务尝试同时读取对象,则必须等待当前正在执行的序列全部完成。
例如,SELECT ... FOR UPDATE指令指示数据库对返回的所有结果行加写锁,之后就可以对结果行进行安全的并发更新。
自动检测更新丢失
另一种思路则是先让他们并发执行,但如果事务管理器检测到了更新丢失风险,则会中止当前事务,并强制回退到安全的“读-修改-写回”方式。
该方法的一个优点是数据库完全可以借助快照级别隔离来高效地执行检查。PostgreSQL 的可重复读,Oracle 的可串行化以及 SQL Server 的快照级别隔离等,都可以自动检测何时发生了更新丢失,然后会中止违规的那个事务。但是,MySQL/InnoDB 的可重复读却并不支持检测更新丢失。
原子比较和设置
在不提供事务支持的数据库中,有时会发现它们支持原子“比较和设置”操作。使用该操作可以避免更新丢失,即只有在上次读取的数据没有发生变化时才允许更新;如果已经发生了变化,则回退到“读-修改-写回”方式。
例如,为了防止两个用户同时更新同一个 wiki 页面,可以尝试下面的操作,这样只有当页面从上次读取之后没发生变化时,才会执行当前的更新:
UPDATE wiki_pages SET content = 'new content' WHERE id = 1234 AND content = 'old content ';
如果内容已经有了变化且值与“旧内容”不匹配,则更新将失败,需要应用层再次检查并在必要时进行重试。需要注意,如果 WHERE 语句是运行在数据库的某个旧的快照上,即使另一个并发写入正在运行,条件可能仍然为真,最终可能无法防止更新丢失问题。所以在使用之前,应该首先仔细检查“比较-设置”操作的安全运行条件。
冲突解决与复制
对于支持多副本的数据库,防止丢失更新还需要考虑另一个维度:由于多节点上的数据副本,不同的节点可能会并发修改数据,因此必须采取一些额外的措施来防止丢失更新。
多副本数据库通常支持多个并发写,然后保留多个冲突版本,之后由应用层逻辑或依靠特定的数据结构来解决、合并多版本。如果操作可交换(顺序无关,在不同的副本上以不同的顺序执行时仍然得到相同的结果),则原子操作在多副本情况下也可以工作。例如,计数器递增或向集合中添加元素等都是典型的可交换操作。
而“最后写入获胜(LWW)”冲突解决方法则容易丢失更新。不幸的是,目前 LWW 是许多多副本数据库的默认配置。
写倾斜与幻读
设想这样一个例子:你正在开发一个应用程序来帮助医生管理医院的轮班。通常,医院会安排多个医生值班,医生也可以申请调整班次,但前提是确保至少一位医生还在该班次中值班。
现在情况是,Alice 和 Bob 是两位值班医生。两人碰巧都感到身体不适,因而都决定请假。不幸的是,他们几乎同一时刻点击了调班按钮。接下来发生的事情如图所示。
每笔事务总是首先检查是否至少有两名医生目前在值班。如果是的话,则有一名医生可以安全地离开。由于数据库正在使用快照级别隔离,两个检查都返回有两名医生,所以两个事务都安全地进入到下一个阶段。接下来 Alice 更新自己的值班记录为离开,同样,Bob 也更新自己的记录。两个事务都成功提交,最后的结果却是没有任何医生在值班,显然这违背了至少一名医生值班的业务要求。
写倾斜定义
这种异常情况称为写倾斜。它既不是一种脏写,也不是更新丢失,两笔事务更新的是两个不同的对象。这里的写冲突并不那么直接,但很显然这的确是某种竞争状态:试想,如果两笔事务是串行执行,则第二个医生的申请肯定被拒绝,只有同时执行两个事务时才会引发该异常。
可以将写倾斜定义为一种更广义的更新丢失问题。如果两个事务读取相同的一组对象,然后更新其中一部分:不同的事务更新同一个对象,则可能发生脏写或更新丢失;不同的事务更新不同的对象,则可能发生写倾斜。
对于写倾斜,一些常用的防范更新丢失的手段有很多限制:
- 由于涉及多个对象,单对象的原子操作不起作用。
- 基于快照级别隔离来实现更新丢失自动检测也有问题:目前所有的数据库实现,都不支持检测写倾斜问题。
- 某些数据库支持自定义约束条件,然后由数据库代为检查、执行约束(例如,唯一性,外键约束或限制一些特定值)。但是,至少一名医生值班这样的要求涉及对多个对象进行约束,目前大多数数据库不支持这种类型约束。
- 如果不能使用可串行化级别隔离,一个次优的选择是对事务依赖的行来显式的加锁。
为何产生写倾斜
所有写倾斜的场景都遵循以下类似的模式:
- 首先输入一些匹配条件,即采用 SELECT 查询所有满足条件的行(例如,至少有两名医生正在值班,同一时刻房间没有预订,棋盘的某位置没有出现数字,用户名还没有被占用,账户里还有余额等)。
- 根据查询的结果,应用层代码来决定下一步的操作。
- 如果应用程序决定继续执行,它将发起数据库写入并提交事务。而这个写操作会改变步骤 2 做出决定的前提条件。换句话说,如果提交写入之后再重复执行步骤 1 的 SELECT 查询,就会返回完全不同的结果,原因是刚刚的写操作改变了决定的前提条件(现在只有一名医生在值班,现在会议室已被预订,现在棋盘位置已经出现了数字,现在用户名已被占用,现在余额已经不足等)。
上述步骤可能有不同的执行顺序,例如,可以先写入,然后是 SELECT 查询,最后根据查询来决定是否提交或者放弃。
这种在一个事务中的写入改变了另一个事务查询结果的现象,称为幻读。快照级别隔离可以避免只读查询时的幻读,但是对于我们上面所讨论那些读-写事务,它却无法解决棘手的写倾斜问题。
一些可选的解决办法
对于用户名不可被占用这种“唯一性”问题,可以通过在数据列上加唯一索引来避免。
对于医生值班的例子,所修改的行恰好是查询结果的一部分,可以选择在查询的时候对事务依赖的行来显式加锁,避免写倾斜。
如果查询结果中没有对象可以加锁,可以人为引入一些可加锁的对象。例如,对于一个会议室预订系统,构造一个时间-房间表,表的每一行对应于特定时间段的特定房间。我们提前,例如对接下来的 6 个月,创建好所有可能的房间与时间的组合。预订事务可以查询并锁定表中与查询房间和时间段所对应的行,加锁之后,即可检查是否有重叠,然后像之前一样插入新的预订。
这种方法称为实体化冲突,它把幻读问题转变为针对数据库中一组具体行的锁冲突问题。然而,弄清楚如何实现实体化往往具有挑战性,实现过程也容易出错,思路也不够优雅。出于这些原因,除非万不得已,没有其他可选方案,我们不推荐采用实体化冲突。
在大多数情况下,可串行化隔离方案更为可行。
串行化
我们已经分析了很多容易出现竞争条件的例子。采用已提交读和快照隔离可以防止其中一部分,但并非对所有情况都有效,例如写倾斜所导致的棘手问题。
自引入弱隔离级别以后,这种情况就一直存在。长久以来,研究人员给出的答案都很简单:采用可串行化隔离。
可串行化隔离通常被认为是最强的隔离级别。它保证即使事务可能会并行执行,但最终的结果与每次一个即串行执行结果相同。目前大多数提供可串行化的数据库都使用了以下三种技术之一:
- 严格按照串行顺序执行。
- 两阶段锁定,很多年来这几乎是唯一可行的选择。
- 乐观并发控制技术,例如可串行化的快照隔离。
实际串行执行
解决并发问题最直接的方法是避免并发:即在一个线程上按顺序方式每次只执行一个事务。这样我们完全回避了诸如检测、防止事务冲突等问题,其对应的隔离级别一定是严格串行化的。
有两方面的原因促使单线程执行事务的想法变得可行:
- 内存越来越便宜,现在许多应用可以将整个活动数据集都加载到内存中。
- OLTP 事务通常执行很快,只产生少量的读写操作。运行时间较长的分析查询则通常是只读的,可以在一致性快照(使用快照隔离)上运行,而不需要运行在串行主循环里。
采用存储过程封装事务
对于交互式的事务处理,大量时间花费在应用程序与数据库之间的网络通信。如果不允许事务并发,而是一次仅处理一个,那么吞吐量非常低,数据库总是在等待应用提交下一个请求。在这种类型的数据库中,为了获得足够的吞吐性能,需要能够同时处理多个事务。
出于这个原因,采用单线程串行执行的系统往往不支持交互式的多语句事务。应用程序必须提交整个事务代码作为存储过程打包发送到数据库。把事务所需的所有数据全部加载在内存中,使存储过程高效执行,而无需等待网络或磁盘 I/O。
存储过程的缺点
出于以下几种原因,存储过程的使用趋势在下降:
- 每家数据库厂商都有自己的存储过程语言。这些语言并没有跟上通用编程语言的发展,而且缺乏如今大多数编程语言所常用的函数库。
- 在数据库中运行代码难以管理:与应用服务器相比,调试更加困难,版本控制与部署复杂,测试不便,并且不容易和指标监控系统集成。
- 因为数据库实例往往被多个应用服务器所共享,所以数据库通常比应用服务器要求更高的性能。数据库中一个设计不好的存储过程要比同样低效的应用服务器代码带来更大的麻烦。
不过这些问题也是可以克服的。最新的存储过程已经放弃了 PL/SQL,而是使用现有的通用编程语言,例如 Redis 使用 Lua。
存储过程与内存式数据存储使得单线程上执行所有事务变得可行。它们不需要等待 I/O,避免加锁开销等复杂的并发控制机制,可以得到相当不错的吞吐量。
分区
串行执行所有事务使得并发控制更加简单,但是数据库的吞吐量被限制在单机单个 CPU 核。虽然只读事务可以在单独的快照上执行,但是对于那些高写入需求的应用程序,单线程事务处理很容易成为严重的瓶颈。
为了扩展到多个 CPU 核和多节点,可以对数据进行分区。如果你能找到一个方法来对数据集进行分区,使得每个事务只在单个分区内读写数据,这样每个分区都可以有自己的事务处理线程且独立运行。此时为每个 CPU 核分配一个分区,则数据库的总体事务吞吐量可以到达与 CPU 核的数量成线性比例关系。
但是,对于跨分区的事务,数据库必须在涉及的所有分区之间协调事务。存储过程需要跨越所有分区加锁执行,以确保整个系统的可串行化。由于跨分区事务具有额外的协调开销,其性能比单分区内要慢得多。
事务是否能只在单分区上执行很大程度上取决于应用层的数据结构。简单的键-值数据比较容易切分,而带有多个二级索引的数据则需要大量的跨区协调,因此不太合适。
小结
当满足以下约束条件时,串行执行事务可以实现串行化隔离:
- 事务必须简短而高效,否则一个缓慢的事务会影响到所有其他事务的执行性能。
- 仅限于活动数据集完全可以加载到内存的场景。
- 写入吞吐量必须足够低,才能在单个 CPU 核上处理;否则就需要采用分区,最好没有跨分区事务。
- 跨分区事务虽然也可以支持,但是占比必须很小。
两阶段加锁
之前我们看到,可以使用加锁的方法来防止脏写:即如果两个事务同时尝试写入同一个对象时,以加锁的方式来确保第二个写入等待前面事务完成。
两阶段加锁方法类似,但锁的强制性更高。多个事务可以同时读取同一对象,但只要出现任何写操作,则必须加锁以独占访问:
- 如果事务 A 已经读取了某个对象,此时事务 B 想要写入该对象,那么 B 必须等到 A 提交或中止之才能继续。以确保 B 不会在事务 A 执行的过程中间去修改对象。
- 如果事务 A 已经修改了对象,此时事务 B 想要读取该对象,则 B 必须等到A提交或中止之后才能继续。对于 2PL,不会出现读到旧值的情况。
2PL 不仅在并发写操作之间互斥,读取也会和修改产生互斥。快照级别隔离的口号“读写互不干扰”非常准确地点明了它和两阶段加锁的关键区别。
实现
目前,2PL 已经用于 MySQL (InnoDB)和 SQL Server 中的“可串行化隔离”,以及 DB2 中的“可重复读隔离”。
此时数据库的每个对象都有一个读写锁来隔离读写操作。即锁可以处于共享模式或独占模式。基本用法如下:
- 如果事务要读取对象,必须先以共享模式获得锁。可以有多个事务同时获得一个对象的共享锁,但是如果某个事务已经获得了对象的独占锁,则所有其他事务必须等待。
- 如果事务要修改对象,必须以独占模式获取锁。不允许多个事务同时持有该锁,换言之,如果对象上已被加锁,则修改事务必须等待。
- 如果事务首先读取对象,然后尝试写入对象,则需要将共享锁升级为独占锁。升级锁的流程等价于直接获得独占锁。
- 事务获得锁之后,一直持有锁直到事务结束。这也是名字“两阶段”的来由,在第一阶段即事务执行之前要获取锁,第二阶段(即事务结束时)则释放锁。
由于使用了这么多的锁机制,所以很容易出现死锁现象。数据库系统会自动检测事务之间的死锁情况,并强行中止其中的一个以打破僵局,这样另一个可以继续向前执行。而被中止的事务需要由应用层来重试。
性能
两阶段加锁的主要缺点在于性能:其事务吞吐量和查询响应时间相比于其他弱隔离级别下降非常多。部分原因在于锁的获取和释放本身的开销,但更重要的是其降低了事务的并发性。
传统的关系数据库并不限制事务的执行时间,结合 2PL,最终结果是,当一个事务还需要等待另一个事务时,那么最终的等待时间几乎是没有上限的。即使可以保证自己的事务足够简短、高效,但一旦出现多个事务同时访问同一对象,会形成一个等待队列,事务就必须等待队列前面所有其他事务完成之后才能继续。
因此,在 2PL 模式下数据库的访问延迟具有非常大的不确定性,如果工作负载存在严重竞争,以百分比方式观察延迟指标会发现非常缓慢。如果某个事务本身很慢,或者是由于需要访问大量数据而获得了许多锁,则它还会导致系统的其他部分都停顿下来。如果应用需要稳定如一的性能,这种不确定性就是致命的。
在 2PL 下,由于事务的访问模式,死锁可能变得更为频繁。因而导致另一个性能问题,即如果事务由于死锁而被强行中止,应用层就必须从头重试,假如死锁过于频繁,则最后的性能和效率必然大打折扣。
谓词锁
可串行化隔离也必须防止幻读问题。以会议室预订为例,如果事务在查询某个时间段内一个房间的预订情况,则另一个事务不能同时去插入或更新同一时间段内该房间的预订情况,但它可以修改其他房间的预订情况,或者在不影响当前查询的情况下,修改该房间的其他时间段预订。
技术上讲,我们需要引入一种谓词锁。它的作用类似于之前描述的共享/独占锁,而区别在于,它并不属于某个特定的对象,而是作用于满足某些搜索条件的所有查询对象。例如:
SELECT * FROM bookings WHERE room_id = 123 AND
end_time > '2018-01-01 12:00' AND start_time < '2018-01-01 13:00';
谓词锁会限制如下访问:
- 如果事务 A 想要读取某些满足匹配条件的对象,例如采用 SELECT 查询,它必须以共享模式获得查询条件的谓词锁。如果另一个事务 B 正持有任何一个匹配对象的互斥锁,那么 A 必须等到 B 释放锁之后才能继续执行查询。
- 如果事务 A 想要插入、更新或删除任何对象,则必须首先检查所有旧值和新值是否与现有的任何谓词锁冲突。如果事务 B 持有这样的谓词锁,那么 A 必须等到 B 完成后才能继续。
这里的关键点在于,谓词锁甚至可以保护数据库中那些尚不存在但可能马上会被插入的对象(幻读)。将两阶段加锁与谓词锁结合使用,数据库可以防止所有形式的写倾斜以及其他竞争条件,隔离变得真正可串行化。
索引区间锁
不过,谓词锁性能不佳,如果活动事务中存在许多锁,那么检查匹配这些锁就变得非常耗时。因此,大多数使用 2PL 的数据库实际上实现的是索引区间锁(或者 next-key locking),本质上它是对谓词锁的简化或者近似。
简化谓词锁的方式是将其保护的对象扩大化,首先这肯定是安全的。例如,如果一个谓词锁保护的是查询条件是:房间 123,时间段是中午至下午 1 点,则一种方式是通过扩大时间段来简化,即保护 123 房间的所有时间段;或者另一种方式是扩大房间,即保护中午至下午 1 点之间的所有房间。这样,任何与原始谓词锁冲突的操作肯定也和近似之后的区间锁相冲突。
对于房间预订数据库,通常会在 room_id 列上创建索引,或者在 start_time 和 end_time 上有索引:
- 假设索引位于 room_id 上,数据库使用此索引查找 123 号房间的当前预订情况。现在,数据库可以简单地将共享锁附加到此索引条目,表明事务已搜索了 123 号房间的所有时间段预订。
- 如果数据库使用基于时间的索引来查找预订,则可以将共享锁附加到该索引中的一系列值,表示事务已经搜索了该时间段内的所有房间预订。
无论哪种方式,查询条件的近似值都附加到某个索引上。接下来,如果另一个事务想要插入、更新或删除同一个房间重叠时间段的预订,则肯定需要更新这些索引,一定就会与共享锁冲突,因此会自动处于等待状态直到共享锁释放。
这样就有效防止了写倾斜和幻读问题。索引区间锁不像谓词锁那么精确,会锁定更大范围的对象,但由于开销低得多,可以认为是一种很好的折衷方案。
如果没有合适的索引可以施加区间锁,则数据库可以回退到对整个表施加共享锁。这种方式的性能肯定不好,甚至会阻止所有其他事务的写操作,但的确可以保证安全性。
注:MySQL 的 InnoDB 引擎在默认的可重复读隔离级别即实现了索引区间锁,所以在默认情况下就可以防止部分的写倾斜与幻读问题。
可串行化的快照隔离
最近,出现了一种称为可串行化的快照隔离算法,既提供了完整的可串行性保证,而性能相比于快照隔离损失很小。
目前,SSI 可用于单节点数据库或者分布式数据库。相比于其他并发控制机制,SSI 尚需在实践中证明其性能。即使如此,它很有可能成为未来数据库的标配。
悲观与乐观的并发控制
两阶段加锁是一种典型的悲观并发控制机制。它基于这样的设计原则:如果某些操作可能出错,那么直接放弃,采用等待方式直到绝对安全。
某种意义上讲,串行执行是种极端悲观的选择:事务执行期间,等价于事务对整个数据库(或数据库的一个分区)持有互斥锁。而我们只能假定事务执行得足够快、持锁时间足够短,来稍稍弥补这种悲观色彩。
相比之下,可串行化的快照隔离则是一种乐观并发控制。在这种情况下,如果可能发生潜在冲突,事务会继续执行而不是中止,寄希望一切相安无事;而当事务提交时,数据库会检查是否确实发生了冲突,如果是的话,中止事务并接下来重试。
如果冲突很多,则乐观并发控制性能不佳,大量的事务必须中止。如果系统已接近其最大吞吐量,反复重试事务会使系统性能变得更差。但是,如果系统还有足够的性能提升空间,且如果事务之间的竞争不大,乐观并发控制会比悲观方式高效很多。
基于过期的条件做决定
我们在讨论写倾斜时,介绍了这样一种使用场景:事务首先查询某些数据,根据查询的结果来决定采取后续操作,例如修改数据。而在快照隔离情况下,数据可能在查询期间就已经被其他事务修改,导致原事务在提交时决策的依据信息已出现变化。
当应用程序执行查询时,数据库本身无法预知应用层逻辑如何使用这些查询结果。安全起见,数据库假定对查询结果的任何变化都应使写事务失效。换言之,查询与写事务之间可能存在因果依赖关系。为了提供可串行化的隔离,数据库必须检测事务是否会修改其他事务的查询结果,并在此情况下中止写事务。
数据库如何知道查询结果是否发生了改变呢?可以分以下两种情况:
- 读取是否作用于一个(即将)过期的 MVCC 对象(读取之前已经有未提交的写入)。
- 检查写入是否影响即将完成的读取(读取之后,又有新的写入)。
检测是否读取了过期的 MVCC 对象
快照隔离通常采用多版本并发控制技术来实现。当事务从 MVCC 数据库一致性快照读取时,它会忽略那些在创建快照时尚未提交的事务写入。
为防止这种异常,数据库需要跟踪那些由于 MVCC 可见性规则而被忽略的写操作。当事务提交时,数据库会检查是否存在一些当初被忽略的写操作现在已经完成了提交,如果是则必须中止当前事务。
检测写是否影响了之前的读
第二种要考虑的情况是,在读取数据之后,另一个事务修改了数据。可以使用类似索引区间锁的技术来实现。
性能
与两阶段加锁相比,可串行化快照隔离的一大优点是事务不需要等待其他事务所持有的锁。这一点和快照隔离一样,读写通常不会互相阻塞。这样的设计使得查询延迟更加稳定、可预测。特别是,在一致性快照上执行只读查询不需要任何锁,这对于读密集的负载非常有吸引力。
与串行执行相比,可串行化快照隔离可以突破单个 CPU 核的限制。即使数据可能跨多台机器进行分区,事务也可以在多个分区上读、写数据并保证可串行化隔离。
事务中止的比例会显著影响 SSI 的性能表现。例如,一个运行很长时间的事务,读取和写入了大量数据,因而产生冲突并中止的概率就会增大,所以 SSI 要求读-写型事务要简短(而长时间执行的只读事务则没有此限制)。但总体讲,相比于两阶段加锁与串行执行,SSI 更能容忍那些执行缓慢的事务。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。