第七章:事务
事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式。从概念上讲,事务中的所有读写操作被视作单个操作来执行:整个事务要么成功(提交(commit))要么失败(中止(abort),回滚(rollback))。
事务是为了简化应用编程模型而创建的。通过使用事务,应用程序可以自由地忽略某些潜在的错误情况和并发问题,因为数据库会替应用处理好这些。(我们称之为安全保证(safety guarantees))。
事务的棘手概念
人们普遍认为事务是可扩展性的对立面,任何大型系统都必须放弃事务以保持良好的性能和高可用性。另一方面,数据库厂商有时将事务保证作为“重要应用”和“有价值数据”的基本要求。这两种观点都是纯粹的夸张。
事实并非如此简单:与其他技术设计选择一样,事务有其优势和局限性。
ACID的含义
ACID代表原子性(Atomicity),一致性(Consistency),隔离性(Isolation)和持久性(Durability)。
不符合ACID标准的系统有时被称为BASE,它代表基本可用性(Basically Available),软状态(Soft State)和最终一致性(Eventual consistency)。
原子性(Atomicity)
ACID的原子性并不是关于并发(concurrent)的。
原子性:如果事务被中止(abort),应用程序可以确定它没有改变任何东西,所以可以安全地重试。
ACID原子性的定义特征是:能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力。
一致性(Consistency)
ACID一致性的概念是,对数据的一组特定陈述必须始终成立。即不变量(invariants)。
原子性,隔离性和持久性是数据库的属性,而一致性(在ACID意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。
隔离性(Isolation)
ACID意义上的隔离性意味着,同时执行的事务是相互隔离的:它们不能相互冒犯。
每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的。
图 - 两个客户之间的竞争状态同时递增计数器
持久性(Durability)
持久性 是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。
完美的持久性是不存在的 :如果所有硬盘和所有备份同时被销毁,那显然没有任何数据库能救得了你。
单对象和多对象操作
原子性
如果在一系列写操作的中途发生错误,则应中止事务处理,并丢弃当前事务的所有写入。
隔离性
同时运行的事务不应该互相干扰。
通常需要多对象事务(multi-object transaction) 来保持多块数据同步。
图 - 违反隔离性:一个事务读取另一个事务的未被执行的写入(“脏读”)
邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生。隔离性可以避免这个问题:通过确保用户2 要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。
图 - 原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致
如果在事务过程中发生错误,邮箱和未读计数器的内容可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,并且插入的电子邮件将被回滚。
在关系型数据库中,通常基于客户端与数据库服务器的TCP连接:在任何特定连接上,BEGIN TRANSACTION 和 COMMIT 语句之间的所有内容,被认为是同一事务的一部分。
单对象写入
对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复,并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) )。
多对象事务的需求
许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现,而且在需要高可用性或高性能的情况下,它们可能会碍事。
协调写入几个不同的对象场景:
- 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。
- 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化,当需要更新非规范化的信息时,需要一次更新多个文档。
- 在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。
没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题。
处理错误和中止
事务的一个关键特性是,如果发生错误,它可以中止并安全地重试。
尽管重试一个中止的事务是一个简单而有效的错误处理机制,但它并不完美:
- 如果事务实际上成功了,但是在服务器试图向客户端确认提交成功时网络发生故障(所以客户端认为提交失败了),那么重试事务会导致事务被执行两次——除非你有一个额外的应用级除重机制。
- 如果错误是由于负载过大造成的,则重试事务将使问题变得更糟,而不是更好。为了避免这种正反馈循环,可以限制重试次数,使用指数退避算法,并单独处理与过载相关的错误(如果允许)。
- 仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
- 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果你正在发送电子邮件,那你肯定不希望每次重试事务时都重新发送电子邮件。如果你想确保几个不同的系统一起提交或放弃,二阶段提交(2PC, two-phase commit) 可以提供帮助。
- 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。
弱隔离级别
如果两个事务不触及相同的数据,它们可以安全地并行(parallel) 运行,因为两者都不依赖于另一个。当一个事务读取由另一个事务同时修改的数据时,或者当两个事务试图同时修改相同的数据时,并发问题(竞争条件)才会出现。
读已提交
最基本的事务隔离级别是读已提交(Read Committed),它提供了两个保证:
- 从数据库读时,只能看到已提交的数据(没有脏读(dirty reads))。
- 写入数据库时,只会覆盖已经写入的数据(没有脏写(dirty writes))。
没有脏读
脏读(dirty reads):一个事务可以看到另一个事务未提交的数据。
读已提交隔离级别意味着事务的任何写入操作只有在该事务提交时才能被其他人看到。
图 - 没有脏读:用户2只有在用户1的事务已经提交后才能看到x的新值
防止脏读的几个原因:
- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。
- 如果事务中止,则所有写入操作都需要回滚。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。
没有脏写
脏写(dirty write):后面的写入会覆盖一个尚未提交事务的值。
读已提交的隔离级别上延迟第二次写入,直到第一次写入事务提交或中止为止。
防止脏写避免了一些并发问题:
- 如果事务更新多个对象,脏写会导致不好的结果。
- 提交读取并不能防止两个变量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。
图 - 如果存在脏写,来自不同事务的冲突写入可能会混淆在一起
实现读已提交
数据库通过使用行锁(row-level lock) 来防止脏写:当事务想要修改特定对象(行或文档)时,它必须首先获得该对象的锁。然后必须持有该锁直到事务被提交或中止。
一次只有一个事务可持有任何给定对象的锁;如果另一个事务要写入同一个对象,则必须等到第一个事务提交或中止后,才能获取该锁并继续。
读锁的办法在实践中效果并不好。因为一个长时间运行的写入事务会迫使许多只读事务等到这个慢写入事务完成。这会损失只读事务的响应时间,并且不利于可操作性:因为等待锁,应用某个部分的迟缓可能由于连锁效应,导致其他部分出现问题。
大多数数据库使用记录旧值的方式防止脏读:对于写入的每个对象,数据库都会记住旧的已提交值,和由当前持有写入锁的事务设置的新值。 当事务正在进行时,任何其他读取对象的事务都会拿到旧值。 只有当新值提交后,事务才会切换到读取新值。
快照隔离和可重复读
图 - 读取偏差:Alice观察数据库处于不一致的状态
爱丽丝在银行有1000美元的储蓄,分为两个账户,每个500美元。现在一笔事务从她的一个账户中转移了100美元到另一个账户。如果她在事务处理的同时查看其账户余额列表,不幸地在转账事务完成前看到收款账户余额(余额为500美元),而在转账完成后看到另一个转出账户(已经转出100美元,余额400美元)。对爱丽丝来说,现在她的账户似乎只有900美元——看起来100美元已经消失了。
这种异常被称为不可重复读(nonrepeatable read)或读取偏差(read skew):如果Alice在事务结束时再次读取账户1的余额,她将看到与她之前的查询中看到的不同的值(600美元)。在读已提交的隔离条件下,不可重复读被认为是可接受的:Alice看到的帐户余额时确实在阅读时已经提交了。
有些情况下,不能容忍这种暂时的不一致:
备份
备份进程运行时,数据库仍然会接受写入操作。因此备份可能会包含一些旧的部分和一些新的部分。如果从这样的备份中恢复,那么不一致(如消失的钱)就会变成永久的。
分析查询和完整性检查
快照隔离(snapshot isolation):每个事务都从数据库的一致快照(consistent snapshot) 中读取。事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
快照隔离是一个流行的功能:PostgreSQL,使用InnoDB引擎的MySQL,Oracle,SQL Server等都支持。
实现快照隔离
与读取提交的隔离类似,快照隔离的实现通常使用写锁来防止脏写,这意味着进行写入的事务会阻止另一个事务修改同一个对象。但是读取不需要任何锁定。从性能的角度来看,快照隔离的一个关键原则是:读不阻塞写,写不阻塞读。
数据库可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它并排维护着多个版本的对象,所以这种技术被称为多版本并发控制(MVCC, multi-version concurrentcy control)。
如果一个数据库只需要提供读已提交的隔离级别,而不提供快照隔离,那么保留一个对象的两个版本就足够了:提交的版本和被覆盖但尚未提交的版本。
支持快照隔离的存储引擎通常也使用MVCC来实现读已提交隔离级别。
一种典型的方法是读已提交为每个查询使用单独的快照,而快照隔离对整个事务使用相同的快照。
图 - 使用多版本对象实现快照隔离
当一个事务开始时,它被赋予一个唯一的,永远增长的事务ID(txid)。每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写入者的事务ID。
表中的每一行都有一个 created_by 字段,其中包含将该行插入到表中的的事务ID。此外,每行都有一个 deleted_by 字段,最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将 deleted_by 字段设置为请求删除的事务的ID来标记为删除。在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除,并释放其空间。
事务13 从账户2 中扣除100美元,将余额从500美元改为400美元。实际上包含两条账户2 的记录:余额为 $500 的行被标记为被事务13删除,余额为 $400 的行由事务13创建。
观察一致性快照的可见性规则
当一个事务从数据库中读取时,事务ID用于决定它可以看见哪些对象,看不见哪些对象。
工作如下:
- 在每次事务开始时,数据库列出当时所有其他(尚未提交或中止)的事务清单,即使之后提交了,这些事务的写入也都会被忽略。
- 被中止事务所执行的任何写入都将被忽略。
- 由具有较晚事务ID(即,在当前事务开始之后开始的)的事务所做的任何写入都被忽略,而不管这些事务是否已经提交。
- 所有其他写入,对应用都是可见的。
这些规则适用于创建和删除对象。
当事务12 从账户2 读取时,它会看到 $500 的余额,因为 $500 余额的删除是由事务13 完成的(根据规则3,事务12 看不到事务13 执行的删除),且400美元记录的创建也是不可见的(按照相同的规则)。
如果以下两个条件都成立,则可见一个对象:
- 读事务开始时,创建该对象的事务已经提交。
- 对象未被标记为删除,或如果被标记为删除,请求删除的事务在读事务开始时尚未提交。
长时间运行的事务可能会长时间使用快照,并继续读取(从其他事务的角度来看)早已被覆盖或删除的值。
索引和快照隔离
索引如何在多版本数据库中工作?一种选择是使索引简单地指向对象的所有版本,并且需要索引查询来过滤掉当前事务不可见的任何对象版本。当垃圾收集删除任何事务不再可见的旧对象版本时,相应的索引条目也可以被删除。
在CouchDB,Datomic和LMDB中使用另一种方法。虽然它们也使用B树,但它们使用的是一种仅追加/写时拷贝(append-only/copy-on-write) 的变体,它们在更新时不覆盖树的页面,而为每个修改页面创建一份副本。从父页面直到树根都会级联更新,以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制,并且保持不变。
使用仅追加的B树,每个写入事务(或一批事务)都会创建一颗新的B树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务ID过滤掉对象,因为后续写入不能修改现有的B树;它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。
可重复读与命名混淆
快照隔离在Oracle中称为可序列化(Serializable)的,在PostgreSQL和MySQL中称为可重复读(repeatable read)。
防止丢失更新
丢失更新(lost update)
如果应用从数据库中读取一些值,修改它并写回修改的值(读取-修改-写入序列),则可能会发生丢失更新的问题。如果两个事务同时执行,则其中一个的修改可能会丢失,因为第二个写入的内容并没有包括第一个事务的修改。
这种模式发生在各种不同的情况下:
- 增加计数器或更新账户余额(需要读取当前值,计算新值并写回更新后的值)
- 在复杂值中进行本地修改:例如,将元素添加到JSON文档中的一个列表(需要解析文档,进行更改并写回修改的文档)
- 两个用户同时编辑wiki页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容。
原子写
许多数据库提供了原子更新操作,下面的指令在大多数关系数据库中是并发安全的:
UPDATE counters SET value = value + 1 WHERE key = 'foo';
原子操作通常通过在读取对象时,获取其上的排它锁来实现。以便更新完成之前没有其他事务可以读取它。这种技术有时被称为游标稳定性(cursor stability)。
另一个选择是简单地强制所有的原子操作在单一线程上执行。
显式锁定
如果数据库的内置原子操作没有提供必要的功能,防止丢失更新的另一个选择是让应用程序显式地锁定将要更新的对象。然后应用程序可以执行读取-修改-写入序列,如果任何其他事务尝试同时读取同一个对象,则强制等待,直到第一个读取-修改-写入序列完成。
自动检测丢失的更新
原子操作和锁是通过强制读取-修改-写入序列按顺序发生,来防止丢失更新的方法。
允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其读取-修改-写入序列。
MySQL/InnoDB的可重复读并不会检测丢失更新。数据库必须能防止丢失更新才称得上是提供了快照隔离,所以在这个定义下,MySQL下不提供快照隔离。
比较并设置(CAS)
此操作的目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取-修改-写入序列。
冲突解决和复制
在复制数据库中,防止丢失的更新需要考虑另一个维度:由于在多个节点上存在数据副本,并且在不同节点上的数据可能被并发地修改,因此需要采取一些额外的步骤来防止丢失更新。
原子操作可以在复制的上下文中很好地工作,尤其当它们具有可交换性时(即,可以在不同的副本上以不同的顺序应用它们,且仍然可以得到相同的结果)。
最后写入为准(LWW)的冲突解决方法很容易丢失更新,不幸的是,LWW是许多复制数据库中的默认值。
写入偏差与幻读
想象一下这个例子:你正在为医院写一个医生轮班管理程序。医院通常会同时要求几位医生待命,但底线是至少有一位医生在待命。医生可以放弃他们的班次(例如,如果他们自己生病了),只要至少有一个同事在这一班中继续工作。
现在想象一下,Alice和Bob是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。
图 - 写入偏差导致应用程序错误的示例
在两个事务中,应用首先检查是否有两个或以上的医生正在值班;如果是的话,它就假定一名医生可以安全地休班。由于数据库使用快照隔离,两次检查都返回 2 ,所以两个事务都进入下一个阶段。Alice更新自己的记录休班了,而Bob也做了一样的事情。两个事务都成功提交了,现在没有医生值班了。违反了至少有一名医生在值班的要求。
写偏差的特征
它既不是脏写,也不是丢失更新,因为这两个事务正在更新两个不同的对象(Alice和Bob各自的待命记录)。
这显然是一个竞争条件:如果两个事务一个接一个地运行,那么第二个医生就不能歇班了。异常行为只有在事务并发进行时才有可能。
如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入偏差。在多个事务更新同一个对象的特殊情况下,就会发生脏写或丢失更新(取决于时机)。
随着写偏差,我们的选择更受限制:
- 由于涉及多个对象,单对象的原子操作不起作用。
- 在一些快照隔离的实现中,自动检测丢失更新对此并没有帮助。自动防止写入偏差需要真正的可序列化隔离。
- 某些数据库允许配置约束,然后由数据库强制执行(例如,唯一性,外键约束或特定值限制)。
- 如果无法使用可序列化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行。
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;
写偏差的更多例子
会议室预订系统
假设你想强制执行,同一时间不能同时在两个会议室预订【43】。当有人想要预订时,首先检查是否存在相互冲突的预订(即预订时间范围重叠的同一房间),如果没有找到,则创建会议。
BEGIN TRANSACTION;
-- 检查所有现存的与12:00~13:00重叠的预定
SELECT COUNT(*) FROM bookings
WHERE room_id = 123 AND
end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00';
-- 如果之前的查询返回0
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;
多人游戏
我们使用一个锁来防止丢失更新(也就是确保两个玩家不能同时移动同一个棋子)。但是锁定并不妨碍玩家将两个不同的棋子移动到棋盘上的相同位置,或者采取其他违反游戏规则的行为。按照您正在执行的规则类型,也许可以使用唯一约束,否则您很容易发生写入偏差。
抢注用户名
在每个用户拥有唯一用户名的网站上,两个用户可能会尝试同时创建具有相同用户名的帐户。可以在事务检查名称是否被抢占,如果没有则使用该名称创建账户。但是像在前面的例子中那样,在快照隔离下这是不安全的。幸运的是,唯一约束是一个简单的解决办法(第二个事务在提交时会因为违反用户名唯一约束而被中止)。
防止双重开支
允许用户花钱或积分的服务,需要检查用户的支付数额不超过其余额。可以通过在用户的帐户中插入一个试探性的消费项目来实现这一点,列出帐户中的所有项目,并检查总和是否为正值。有了写入偏差,可能会发生两个支出项目同时插入,一起导致余额变为负值,但这两个事务都不会注意到另一个。
导致写入偏差的幻读
所有这些例子都遵循类似的模式:
- 一个SELECT查询找出符合条件的行,并检查是否符合一些要求。
- 按照第一个查询的结果,应用代码决定是否继续。
- 如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。这个写入的效果改变了步骤2 中的先决条件。换句话说,如果在提交写入后,重复执行一次步骤1 的SELECT查询,将会得到不同的结果。因为写入改变符合搜索条件的行集。
一个事务中的写入改变另一个事务的搜索查询的结果,被称为幻读。快照隔离避免了只读查询中幻读,但是在像我们讨论的例子那样的读写事务中,幻影会导致特别棘手的写歪斜情况。
物化冲突
它将幻读变为数据库中一组具体行上的锁冲突。
物化冲突应被视为最后的手段。在大多数情况下。可序列化(Serializable) 的隔离级别是更可取的。
可序列化
可序列化(Serializability)隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。
目前大多数提供可序列化的数据库都使用了三种技术之一:
- 串行顺序执行事务
- 两相锁定(2PL, two-phase locking),唯一可行的选择。
- 乐观并发控制技术,例如可序列化的快照隔离(serializable snapshot isolation)
真的串行执行
避免并发问题的最简单方法就是完全不要并发:在单个线程上按顺序一次只执行一个事务。
直到2007年左右才决定,单线程循环执行事务是可行的,是什么改变致使单线程执行变为可能呢?
- RAM足够便宜了,许多场景现在都可以将完整的活跃数据集保存在内存中。当事务需要访问的所有数据都在内存中时,事务处理的执行速度要比等待数据从磁盘加载时快得多。
- 数据库设计人员意识到OLTP事务通常很短,而且只进行少量的读写操作。相比之下,长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。
设计用于单线程执行的系统有时可以比支持并发的系统更好,因为它可以避免锁的协调开销。
在存储过程中封装事务
几乎所有的OLTP应用程序都避免在事务中等待交互式的用户输入,以此来保持事务的简短。
在Web上,这意味着事务在同一个HTTP请求中被提交——一个事务不会跨越多个请求。一个新的HTTP请求开始一个新的事务。
具有单线程串行事务处理的系统不允许交互式的多语句事务。应用程序必须提前将整个事务代码作为存储过程提交给数据库。
图 - 交互式事务和存储过程之间的区别
存储过程的优点和缺点
存储过程与内存存储,使得在单个线程上执行所有事务变得可行。由于不需要等待I/O,且避免了并发控制机制的开销,它们可以在单个线程上实现相当好的吞吐量。
VoltDB还使用存储过程进行复制:但不是将事务的写入结果从一个节点复制到另一个节点,而是在每个节点上执行相同的存储过程。因此VoltDB要求存储过程是确定性的(在不同的节点上运行时,它们必须产生相同的结果)。
分区
为了扩展到多个CPU核心和多个节点,可以对数据进行分区。
对于需要访问多个分区的任何事务,数据库必须在触及的所有分区之间协调事务。存储过程需要跨越所有分区锁定执行,以确保整个系统的可串行性。
由于跨分区事务具有额外的协调开销,所以它们比单分区事务慢得多。
串行执行小结
- 每个事务都必须小而快,只要有一个缓慢的事务,就会拖慢所有事务处理。
- 仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问,系统就会变得非常慢。
- 写入吞吐量必须低到能在单个CPU核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。
- 跨分区事务是可能的,但是它们的使用程度有很大的限制。
两阶段锁定(2PL)
在数据库中只有一种广泛使用的序列化算法:两阶段锁定(2PL,two-phase locking)。
2PL不是2PC
请注意,虽然两阶段锁定(2PL)听起来非常类似于两阶段提交(2PC),但它们是完全不同的东西。
如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交),然后才能继续。
两阶段锁定:只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要独占访问(exclusive access) 权限:
- 如果事务A读取了一个对象,并且事务B想要写入该对象,那么B必须等到A提交或中止才能继续。 (这确保B不能在A底下意外地改变对象。)
- 如果事务A写入了一个对象,并且事务B想要读取该对象,则B必须等到A提交或中止才能继续。
2PL和快照隔离之间的关键区别:
- 在2PL中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。
- 快照隔离使得读不阻塞写,写也不阻塞读
实现两阶段锁
2PL用于MySQL(InnoDB)和SQL Server中的可序列化隔离级别,以及DB2中的可重复读隔离级别。
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于共享模式(shared mode)或独占模式(exclusive mode):
- 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
- 若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
- 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得排他锁相同。
- 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是“两阶段”这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
事务A等待事务B释放它的锁,反之亦然。这种情况叫做死锁(Deadlock)。数据库会自动检测事务之间的死锁,并中止其中一个,以便另一个继续执行。被中止的事务需要由应用程序重试。
两阶段锁定的性能
两阶段锁定下的事务吞吐量与查询响应时间要比弱隔离级别下要差得多。
这一部分是由于获取和释放所有这些锁的开销,但更重要的是由于并发性的降低。
运行2PL的数据库可能具有相当不稳定的延迟,如果在工作负载中存在争用,那么可能高百分位点处的响应会非常的慢。
当事务由于死锁而被中止并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨大的浪费。
谓词锁
一个事务改变另一个事务的搜索查询的结果。
如果一个事务在某个时间窗口内搜索了一个房间的现有预订(见例7-2),则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订。
我们需要一个谓词锁(predicate lock)。它类似于前面描述的共享/排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象。
谓词锁限制访问:
- 如果事务A想要读取匹配某些条件的对象,它必须获取查询条件上的共享谓词锁(shared-mode predicate lock)。如果另一个事务B持有任何满足这一查询条件对象的排它锁,那么A必须等到B释放它的锁之后才允许进行查询。
- 如果事务A想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务B持有匹配的谓词锁,那么A必须等到B已经提交或中止后才能继续。
谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。
索引范围锁
不幸的是谓词锁性能不佳:如果活跃事务持有很多锁,检查匹配的锁会非常耗时。大多数使用2PL的数据库实现了索引范围锁(也称为间隙锁(next-key locking)),这是一个简化的近似版谓词锁。
通过使谓词匹配到一个更大的集合来简化谓词锁是安全的。
这种方法能够有效防止幻读和写入偏差。索引范围锁并不像谓词锁那样精确(它们可能会锁定更大范围的对象,而不是维持可串行化所必需的范围),但是由于它们的开销较低,所以是一个很好的折衷。
如果没有可以挂载间隙锁的索引,数据库可以退化到使用整个表上的共享锁。这对性能不利,因为它会阻止所有其他事务写入表格,但这是一个安全的回退位置。
序列化快照隔离(SSI)
可序列化快照隔离(SSI, serializable snapshot isolation):它提供了完整的可序列化隔离级别,但与快照隔离相比只有只有很小的性能损失。
悲观与乐观的并发控制
两阶段锁是一种所谓的悲观并发控制机制(pessimistic) :它是基于这样的原则:如果有事情可能出错(如另一个事务所持有的锁所表示的),最好等到情况安全后再做任何事情。
串行执行可以称为悲观到了极致:在事务持续期间,每个事务对整个数据库(或数据库的一个分区)具有排它锁,作为对悲观的补偿,我们让每笔事务执行得非常快,所以只需要短时间持有“锁”。
序列化快照隔离是一种乐观(optimistic) 的并发控制技术:如果存在潜在的危险也不阻止事务,而是继续执行事务,希望一切都会好起来。当一个事务想要提交时,数据库检查是否有什么不好的事情发生(即隔离是否被违反);如果是的话,事务将被中止,并且必须重试。只有可序列化的事务才被允许提交。
如果有足够的备用容量,并且事务之间的争用不是太高,乐观的并发控制技术往往比悲观的要好。
SSI基于快照隔离——也就是说,事务中的所有读取都是来自数据库的一致性快照,在快照隔离的基础上,SSI添加了一种算法来检测写入之间的序列化冲突,并确定要中止哪些事务。
基于过时前提的决策
事务从数据库读取一些数据,检查查询的结果,并根据它看到的结果决定采取一些操作(写入数据库)。
事务中的查询与写入可能存在因果依赖。为了提供可序列化的隔离级别,如果事务在过时的前提下执行操作,数据库必须能检测到这种情况,并中止事务。
数据库如何知道查询结果是否可能已经改变?有两种情况需要考虑:
- 检测对旧MVCC对象版本的读取(读之前存在未提交的写入)
- 检测影响先前读取的写入(读之后发生写入)
检测旧MVCC读取
快照隔离通常是通过多版本并发控制(MVCC)来实现的。当一个事务从MVCC数据库中的一致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。
图 - 检测事务何时从MVCC快照读取过时的值
事务43 认为Alice的 on_call = true ,因为事务42(修改Alice的待命状态)未被提交。然而,在事务43想要提交时,事务42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务43 的前提不再为真。
为了防止这种异常,数据库需要跟踪一个事务由于MVCC可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。
为什么要等到提交?当检测到陈旧的读取时,为什么不立即中止事务43 ?因为如果事务43 是只读事务,则不需要中止,因为没有写入偏差的风险。当事务43 进行读取时,数据库还不知道事务是否要稍后执行写操作。此外,事务42 可能在事务43 被提交的时候中止或者可能仍然未被提交,因此读取可能终究不是陈旧的。通过避免不必要的中止,SSI 保留快照隔离对从一致快照中长时间运行的读取的支持。
检测影响之前读取的写入
另一个事务在读取数据之后修改数据。
图 - 在可序列化快照隔离中,检测一个事务何时修改另一个事务的读取。
事务42 和43 都在班次1234 查找值班医生。如果在shift_id上有索引,则数据库可以使用索引项1234 来记录事务42 和43 读取这个数据的事实。 (如果没有索引,这个信息可以在表级别进行跟踪)。这个信息只需要保留一段时间:在一个事务完成(提交或中止)之后,所有的并发事务完成之后,数据库就可以忘记它读取的数据了。
当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务到其他事务完成,而是像一个引线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。
事务43 通知事务42 其先前读已过时,反之亦然。事务42首先提交并成功,尽管事务43 的写影响了42 ,但因为事务43 尚未提交,所以写入尚未生效。然而当事务43 想要提交时,来自事务42 的冲突写入已经被提交,所以事务43 必须中止。
可序列化的快照隔离的性能
在某些情况下,事务可以读取被另一个事务覆盖的信息:这取决于发生了什么,有时可以证明执行结果无论如何都是可序列化的。 PostgreSQL使用这个理论来减少不必要的中止次数。
与两阶段锁定相比,可序列化快照隔离的最大优点是一个事务不需要阻塞等待另一个事务所持有的锁。
与串行执行相比,可序列化快照隔离并不局限于单个CPU核的吞吐量:FoundationDB将检测到的序列化冲突分布在多台机器上,允许扩展到很高的吞吐量。