实现隔离绝不是想象的那么简单,可串行化的隔离会严重影响性能,而许多数据库却不愿意牺牲性能 ,因而更多倾向于采用较弱的隔离级别,它可以防止某些但并非全部的并发问题。这些弱隔离级别理解起来更为困难,甚至可能会带来一些难以捉摸的隐患,但在实践中还是被广泛使用。
读提交是最基本的事务隔离级别,它只提供以下两个保证:
- 读数据库时,==只能看到已成功提交的数据==(防止 ”==脏读==“)。
- 写数据库时,==只会覆盖已成功提交的数据==(防止 “==脏写==")。
当有以下需求时,需要防止脏读:
- ==如果事务需要更新多个对象,脏读意味着另一个事务可能会看到部分更新,而非全部==。
- 如果事务发生中止,则所有写入操作都需要回滚。如果发生了脏读,这意味着它可能会看到一些稍后被回滚的数据,而这些数据并未实际提交到数据库中。之后所引发的后果可能都会变得难以预测。
防止脏写可以避免以下并发问题:
- ==如果事务需要更新多个对象,脏写会带来非预期的错误结果==。
- 但是,读-提交隔离并不能解决图 7-1 中计数器增量的竞争情况。对于后,第二次写入确实在第一个事务提交后才执行,虽然不属于脏写,但结果仍然是错误 的。在接下来的 “防止更新丢失” 中,我们将讨论如何安全递增计数器。
数据库通常采用==行级锁==来==防止脏写==:当事务想修改某个对象,它必须首先获得该对象的锁;然后一直持有锁直到事务提交(或中止)。
==那如何防止脏读呢?一种选择是使用相同的锁,所有试图读取该对象的事务必须先申请锁,事务完成后释放锁==。然而,读锁的方式在实际中并不可行,因为运行时间较长的写事务会导致许多只读的事务等待太长时间,这会严重影响只读事务的响应延迟,且可操作性差:由于读锁,应用程序任何局部的性能问题会扩散进而影响整个应用,产生连锁反应。
因此,大多数数据库采用了图 7-4 所示的方法来==防止脏读==:==对于每个待更新的对象,数据库都会维护其旧值和当前持锁事务将要设置的新值两个版本。在事务提交之前,所有其他读操作都读取旧值;仅当写事务提交之后,才会切换到读取新值==。
在使用读已提交隔离级别时,仍然有很多场景可能导致并发错误。如图 7-6 所示。
这种异常现象被称为==不可重复读取==(Nonrepeatable Read)或==读倾斜==(Read Skew)。如果 Alice 在交易结束时再次读取账户 1 的余额,她将看到不同的值(600美元)。读倾斜在读-提交隔离语义下是可以接受的,Alice 所看到的账户余额的确都是账户当时的最新值。
注:该异常场景在 ANSI SQL ISOLATION 中并不叫 “不可重复读”,而是叫 ”读倾斜(读偏序, Read Skew)“。ANSI SQL ISOLATION 中对于 “不可重复读” 的定义是在一个事务中,读同一行数据,两次读到的值不一样,而不是读多行数据。这里如果改成 Alice 两次都读账户 2 的余额,那么就是不可重复读了。(Berenson, Hal, et al. "A critique of ANSI SQL isolation levels." ACM SIGMOD Record 24.2 (1995): 1-10.)
对于 Alice 这个例子,这并非一个永久性问题,例如几秒钟之后当她重新加载银行页面,可能就能看到一致的账户余额。但是,还有些场景则不能容忍这种暂时的不一致:
- 备份场景:备份任务要复制整个数据库,这可能需要数小时才能完成。在备份过程中,可以继续写入数据库。因此,得到镜像里可能包含部分旧版本数据和部分新版本数据。如果从这样的备份进行恢复,最终就导致了永久性的不一致。
- 分析查询与完整性检查场景
为了实现快照级别隔离,数据库采用了一种类似于图 7-4 中防止脏读但却更为通用的机制。考虑到多个正在进行的事务可能会在不同的时间点查看数据库状态,所以数据库保留了对象多个不同的提交版本,这种技术因此也被称为==多版本并发控制==(Multi-Version Concurrency Control, MVCC)。
如果只是为了提供读-提交级别隔离,而不是完整的快照级别隔离,则只保留对象的两个版本就足够了:一个已提交的旧版本和尚未提交的新版本。所以,支持快照级别隔离的存储引擎往往直接采用 MVCC 来实现读-提交隔离。典型的做法是,==在读-提交级别下,对每一个不同的查询单独创建一个快照==;==而快照级别隔离则是使用一个快照来运行整个事务==。(注:RC 是每条语句开始时创建快照;RR 是整个事务开始时创建一个快照)
图 7-7 说明了 PostgreSQL 如何实现基于 MVCC 的快照级别隔离(其他实现基本类似)。当事务开始时,首先赋予一个唯一的、单调递增的事务 ID (txid)。每当事务向数据库写入新内容时,所写的数据都会被标记写入者的事务 ID。
当事务读数据库时,通过事务 ID 可以决定哪些对象可见,哪些不可见。要想对上层应用维护好快照的一致性,需要精心定义数据的可见性规则。例如:
- 每笔事务开始时,数据库列出所有当时尚在进行中的其他事务(即尚未提交或中止),然后忽略这些事务完成的部分写入(尽管之后可能会被提交),即不可 见。(注:这些事务的 ID 比当前事务的 ID 要小,如果不记录这些 ID,那么当这些事务提交后,就能被当前事务读到了)
- 所有中止事务所做的修改全部不可见。
- 较晚事务 ID(即晚于当前事务)所做的任何修改不可见,不管这些事务是否完成了提交。(注:事务 ID 比当前事务 ID 大)
- 除此之外,其他所有的写人都对应用查询可见。
以上规则可以适用于创建操作和删除操作。在图 7-7 中,当事务 12 从账户 2 读取时,它看到的是 $500 的余额,这是因为删除操作是由稍后事务 13 所产生的(依据规则 3' 事务 12 对事务 13 所做的删除不可见),同理,400 美元余额的创建操作也不可见。
接下来一个问题是,这种多版本数据库该如何支持索引呢?一种方案是索引直接指向对象的所有版本,然后想办法过滤对当前事务不可见的那些版本。当后台的垃圾回收进程决定删除某个旧对象版本时,对应的索引条目也需要随之删除。
在实践中,有许多细节决定了多版本并发控制的实际性能表现。例如,可以把同一对象的不同版本放在一个内存页面上,PostgreSQL 采取这样的优化措施来避免更新索引。
CouchDB 、 Datomic 和 LMDB 则使用另一种方法。它们主体结构是 B-tree,但采用了一种追加/写时复制的技术,当需要更新时,不会修改现有的页面,而总是创建一个新的修改副本,拷贝必要的内容,然后让父结点,或者递归向上直到树的 root 结点都指向新创建的结点。那些不受更新影响的页面都不需要复制,保持不变并被父结点所指向。
写事务并发还会带来其他一些值得关注的冲突问题,面图 7-1 正是这样的一个例子。
更新丢失可能发生在这样一个操作场景中:==应用程序从数据库读取某些值,根据应用逻辑做出修改,然后写回新值(read-modify-write 过程)==。当有两个事务在同样的数据对象上执行类似操作时,由于隔离性,第二个写操作并不包括第一个事务修改后的值,最终会导致第一个事务的修改值可能会丢失。这种冲突还可能在其他不同的场景下发生,例如:
- 递增计数器,或更新账户余额(需要读取当前值,计算新值并写回更新后的值)。
- 对某复杂对象的一部分内容执行修改,例如对 JSON 文档中一个列表添加新元素(需要读取并解析文档,执行更改并写回修改后的文档)。
- 两个用户同时编辑 wiki 页面,且每个用户都尝试将整个页面发送到服务器,覆盖数据库中现有内容以使更改生效。
许多数据库提供了原子更新操作,以避免在应用层代码完成 "read-modify-write" 操作,如果支持的话,通常这就是最好的解决方案。例如,以下指令在多数关系数据库中都是并发安全的:
UPDATE counters SET value=value+1 WHERE key='foo';原子操作通常采用对读取对象加独占锁的方式来实现,这样在更新被提交之前不会其他事务可以读它。这种技术有时被称为游标稳定性,另一种实现方式是强制所 有的原子操作都在单线程上执行。
BEGIN TRANSACTION;
SELECT* FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE; 0
-- Check whether move is valid, then update the position
-- of the piece that was returned by the previous SELECT.
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;
另一种思路则是先让他们并发执行,但如果事务管理器检测到了更新丢失风险,则会中止当前事务,并强制回退到安全的 "read-modify-write" 方式。(PG 支持,MYSQL 不支持)
例如,为了防止两个用户同时更新同一个 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';对于支持多副本的数据库(参见第 5 章),防止丢失更新还需要考虑另一个维度:由于多节点上的数据副本,不同的节点可能会并发修改数据,因此必须采取一些额外的措施来防止丢失更新。
正如第 5 章检测并发写所描述的,多副本数据库通常支持多个并发写,然后保留多个冲突版本(互称为兄弟),之后由应用层逻辑或依靠特定的数据结构来解决、 合并多版本。
当多个事务同时写入同一对象时引发了两种竞争条件,即前面章节所讨论的脏写和更新丢失。为了避免数据不一致,需要借助数据库的一些内置机制,或者采取手动加锁、执行原子操作等。
然而,这还不是并发写所引发的全部间题,本节马上将看到更为微妙的写冲突的例子。
首先,设想这样一个例子:你正在开发一个应用程序来帮助医生管理医院的轮班,通常,医院会安排多个医生值班,医生也可以申请调整班次(例如他们自己生病了),但前提是确保至少一位医生还在该班次中值班。
现在情况是,Alice 和 Bob 是两位值班医生,两人碰巧都感到身体不适,因而都决定请假。不幸的是,他们几乎同一时刻点击了调班按钮。接下来发生的事情如图7-8 所示。
每笔事务总是首先检查是否至少有两名医生目前在值班,如果是的话,则有一名医生可以安全里离开。由于数据库正在使用快照级别隔离,两个检查都返回有两名医生,所以两个事务都安全地进入到下一个阶段。接下来 Alice 更新自己的值班记录为离开,同样,Bob 也更新自己的记录。两个事务都成功提交,最后的结果却是没有任何医生在值班,显然这违背了至少一名医生值班的业务要求。
这种异常情况称为写倾斜。它既不是一种脏写,也不是更新丢失,两笔事务更新的是两个不同的对象(分别是 Alice 和 Bob 的值班记录)。这里的写冲突并不那么直接,但很显然这的确是某种竞争状态:试想,如果两笔事务是串行执行,则第二个医生的申请肯定被拒绝;只有同时执行两个事务时才会引发该异常。
可以将写倾斜视为一种更广义的更新丢失问题。即如果两个事务读取相同的一组对象,然后更新其中一部分:==不同的事务可能更新不同的对象,则可能发生写倾斜==;==而不同的事务如果更新的是同一个对象,则可能发生脏写或更新丢失==(==具体取决于时间窗口==)。
我们已经给出了多种防范更新丢失的手段。然而对于写倾斜,可选的方案则有很多限制:
-
由于涉及多个对象,单对象的原子操作不起作用
-
基于快照级别隔离来实现更新丢失自动检测也有问题:目前所有的数据库实现,包括 PostgreSQL 的可重复读,MySQL/InnoDB 可重复读,Oracle 可串行化以及 SQL Server 的快照级别隔离级别都不支持检测写倾斜问题。自动防止写倾斜要求真正的可串行化隔离(参阅本章后面 ”可串行化“)
-
某些数据库支持自定义约束条件,然后由数据库代为检查、执行约束(例如,唯一性,外键约束或限制一些特定值)。但是,至少一名医生值班这样的要求涉及对多个对象进行约束,目前大多数数据库不支持这种类型约束,所以取决于具体的数据库,开发者可能可以采用触发器或物化视图来自己实现类似约束。
-
==如果不能使用可串行化级别隔离,一个次优的选择是对事务依赖的行来显式的加锁==。对于上述医生值班的例子,可以这样:
BEGIN TRANSACTION; SELECT* FROM doctors WHERE on_call = true AND shift_id = 1234 FOR UPDATE; UPDATE doctors SET on_call = false WHERE name = 'Alice' AND shift_id = 1234; COMMIT;
写倾斜可能看起来很晦涩拗口,可一旦深刻意识到问题的本质,可能发生的场景。下面就是一些例子:
-
会议室预订系统:假设要求同一时间、同一个会议室不能被预订两次 。当有入想要预订时,首先检查是否有冲突的预订(即对同一房间的预订存在时间范围重叠),如果没有,则提交申请,参见示例 7-2:
BEGIN TRANSACTION; - - Check for any existing bookings that overlap with the period of noon-lpm SELECT COUNT(*) FROM bookings WHERE room_id = 123 AND end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00'; -- If the previous query returned zero: INSERT INTO bookings (room_id, start_time, end_time, user_id) VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666); COMMIT;
-
多人游戏:在示例 7-1 中,我们使用加锁来防止更新丢失(即两个玩家不能同时移动同一个数字)。但是,锁并不能阻止玩家将两个不同的数字移动到棋盘上的同一个位置上,或者其他可能违反游戏规则的移动。
-
声明一个用户名:网站通常要求每个用户有唯一的用户名,两个用户可能同时尝试创建相同的用户名。可以采用事务的方式首先来检查名称是否被使用,如果没有,则使用该名称创建账户。但是,和之前的例子类似,在快照级别隔离下这是不安全的。不过,对于该例子,一个简单的方案是采用唯一性约束(第二个事务由于违反约束,会中止创建相同用户名)。
-
防止双重开支
上述所有写倾斜的例子都遵循以下类似的模式:
-
首先输入一些匹配条件,即采用 SELECT 查询所有满足条件的行(例如,至少有两名医生正在值班,同一时刻房间没有预订,棋盘的某位置没有出现数字,用户名还没有被占用,账户里还有余额等)。
-
根据查询的结果,应用层代码来决定下一步的操作(有可能继续,或者报告错误并中止)。
-
如果应用程序决定继续执行,它将发起数据库写入(INSERT、UPDATE 或 DELETE)并提交事务。
==而这个写操作会改变步骤 2 做出决定的前提条件。换句话说,如果提交写入之后再重复执行步骤 1 的 SELECT 查询,就会返回完全不同的结果,原因是刚刚的写操作改变了决定的前提条件==(现在只有一名医生在值班,现在会议室已被预订,现在棋盘位置已经出现了数字,现在用户名已被占用,现在余额已经不等)。
对于医生值班的例子,步骤 3 中所修改的行恰好是步骤 1 查询结果的一部分,因此如果先修改值班记录并加锁(SELECT FOR UPDATE),再查询可以保证事务安全,避免写倾斜。==然而,对于其他例子则并不适用,它们检查的是不满足给定搜索条件的行(预期结果为空),接下来添加符合条件的行。如果步骤 1 的查询根本没有返回任何行,则 SELECT FOR UPDATE 也就无从加锁。==
这种在一个事务中的写入改变了另一个事务查询结果的现象,称为==幻读==(phantom)。==快照级别隔离可以避免只读查询时的幻读,但是对于我们上面所讨论那些读-写事务,它却无法解决棘手的写倾斜问题==。
如果问题的关键是查询结果中没有对象(空)可以加锁,或许可以人为引入一些可加锁的对象?
例如,对于会议室预订的例子,构造一个时间-房间表,表的每一行对应于特定时间段(例如最小 15 分钟间隔)的特定房间。我们提前,例如对接下来的 6 个月,创建好所有可能的房间与时间的组合。
现在,预订事务可以查询并锁定(SELECT FOR UPDATE)表中与查询房间和时间段所对应的行。加锁之后,即可检查是否有重叠,然后像之前一样插入新的订。 注意,这种附加表格并不存储预订相关的信息,它仅仅用于方便加锁,防止同一房间和时间范围内的重复预订。
这种方法称为==实体化冲突==(或物化冲突,materializing conflicts),它把幻读问题转变为针对数据库中一组具体行的锁冲突问题。然而,弄清楚如何实现实体化往往也具有挑战性,实现过程也容易出错,这种把一个并发控制机制降级为数据模型的思路总是不够优雅。出于这些原因,除非万不得已,没有其他可选方案,我们不推荐采用实体化冲突。而在大多数情况下,可串行化隔离方案更为可行。



