事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式。
从概念上讲,事务中的所有读写操作被视作单个操作来执行:整个事务要么成功 提交(commit),要么失败 中止(abort)或 回滚(rollback)。
本章将研究许多出错案例,并探索数据库用于防范这些问题的算法。尤其会深入 并发控制 的领域,讨论各种可能发生的竞争条件,以及数据库如何实现 读已提交(read committed),快照隔离(snapshot isolation) 和 可串行化(serializability) 等隔离级别。
不符合 ACID 标准的系统有时被称为 BASE,它代表 基本可用性(Basically Available),软状态(Soft State) 和 最终一致性(Eventual consistency)
定义特征:
能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力。 或许 可中止性(abortability) 是更好的术语。
原子性并 不是关于 并发(concurrent) 的。
原子性简化了这个问题:如果事务被 中止(abort),应用程序可以确定它没有改变任何东西,所以可以安全地重试。
概念: 对数据的一组特定约束必须始终成立。即 不变式(invariants)。
原子性,隔离性和持久性是数据库的属性(数据库只管理存储),而一致性(在 ACID 意义上)是应用程序的属性。
概念:
同时执行的事务是相互隔离的:它们不能相互冒犯。
传统的数据库教科书将隔离性形式化为 可串行化(Serializability),这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。
作用:
数据库确保当多个事务被提交时,结果与它们串行运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的
缺点:
实践中很少会使用可串行的隔离,因为它有性能损失。在 Oracle 中有一个名为 “可串行的” 隔离级别,但实际上它实现了一种叫做 快照隔离(snapshot isolation) 的功能,这是一种比可串行化更弱的保证【8,11】。我们将在 “弱隔离级别” 中研究快照隔离和其他形式的隔离。
在单节点数据库中:
持久性通常意味着数据已被写入非易失性存储设备,如硬盘或 SSD。它通常还包括**预写日志(WAL)**或类似的文件(请参阅 “让 B 树更可靠”),以便在磁盘上的数据结构损坏时进行恢复。
在带复制的数据库中:
持久性可能意味着数据已成功复制到一些节点。为了提供持久性保证,数据库必须等到这些写入或复制完成后,才能报告事务成功提交。
一个事务读取另一个事务的未被执行的写入(“脏读”): 违反隔离性。
多对象事务需要某种方式来确定哪些读写操作属于同一个事务。在关系型数据库中,通常基于客户端与数据库服务器的 TCP 连接:在任何特定连接上,BEGIN TRANSACTION 和 COMMIT 语句之间的所有内容,被认为是同一事务的一部分
这并不完美。如果 TCP 连接中断,则事务必须中止。如果中断发生在客户端请求提交之后,但在服务器确认提交发生之前,客户端并不知道事务是否已提交。为了解决这个问题,事务管理器可以通过一个唯一事务标识符来对操作进行分组,这个标识符并未绑定到特定 TCP 连接。后续再 “数据库的端到端原则” 一节将回到这个主题。
**另一方面,**许多非关系数据库并没有将这些操作组合在一起的方法。即使存在多对象 API(例如,某键值存储可能具有在一个操作中更新几个键的 multi-put 操作),但这并不一定意味着它具有事务语义:该命令可能在一些键上成功,在其他的键上失败,使数据库处于部分更新的状态。
当单个对象发生改变时,原子性和隔离性也是适用的。
一些数据库也提供更复杂的原子操作,例如自增操作。同样流行的是 比较和设置(CAS, compare-and-set) 操作,仅当值没有被其他并发修改过时,才允许执行写操作。
事务通常被理解为,将多个对象上的多个操作合并为一个执行单元的机制。
许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现,而且在需要高可用性或高性能的情况下,它们可能会碍事。但说到底,在分布式数据库中实现事务,并没有什么根本性的障碍。
没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题。
尽管重试一个中止的事务是一个简单而有效的错误处理机制,但它并不完美:
——>如果两个事务不触及相同的数据,它们可以安全地 并行(parallel) 运行,因为两者都不依赖于另一个。当一个事务读取由另一个事务同时修改的数据时,或者当两个事务试图同时修改相同的数据时,并发问题(竞争条件)才会出现。
——>并发 BUG 很难通过测试找到,因为这样的错误只有在特殊时序下才会触发。
——>出于这个原因,数据库一直试图通过提供 事务隔离(transaction isolation) 来隐藏应用程序开发者的并发问题。从理论上讲,隔离可以通过假装没有并发发生,让你的生活更加轻松:可串行的(serializable) 隔离等级意味着数据库保证事务的效果如同串行运行(即一次一个,没有任何并发)。
——>但是可串行的隔离会有性能损失!!!
——>所以,系统通常使用较弱的隔离级别来防止一部分而不是全部的并发问题。
在本节中,我们将看几个在实践中使用的弱(非串行的,即 nonserializable)隔离级别,并详细讨论哪种竞争条件可能发生也可能不发生,以便你可以决定什么级别适合你的应用程序。一旦我们完成了这个工作,我们将详细讨论可串行化(请参阅 “可串行化”)。
最基本的事务隔离级别是 读已提交(Read Committed),它提供了两个保证:
某些数据库支持甚至更弱的隔离级别,称为 读未提交(Read uncommitted)。它可以防止脏写,但不防止脏读。
一个事务已经将一些数据写入数据库,但事务还未提交或中止。另一个事务可以看到未提交的数据。
为什么要防止脏读,有几个原因:
概念:
如果两个事务同时尝试更新数据库中的相同对象。
先前的写入是尚未提交事务的一部分,后面的写入会覆盖一个尚未提交的值。
解决方案:
通常用延迟第二次写入,直到第一次写入事务提交或中止为止。
缺点:
读已提交可以防止脏写,但不能防止两个计数器增量之间的竞争状态。
行锁,当事务想要修改特定对象(行或文档)时,它必须首先获得该对象的锁。然后必须持有该锁直到事务被提交或中止。一次只有一个事务可持有任何给定对象的锁;如果另一个事务要写入同一个对象,则必须等到第一个事务提交或中止后,才能获取该锁并继续。
行锁
一种选择是使用相同的锁,并要求任何想要读取对象的事务来简单地获取该锁,然后在读取之后立即再次释放该锁。
但要求读锁在实践中效果并不好。因为一个长时间运行的写入事务会迫使许多只读事务等到这个慢写入事务完成。这会损失只读事务的响应时间,并且不利于可操作性。
大多数数据库使用以下方式防止脏读:
对于写入的每个对象,数据库都会记住旧的已提交值,和由当前持有写入锁的事务设置的新值。当事务正在进行时,任何其他读取对象的事务都会拿到旧值。 只有当新值提交后,事务才会切换到读取新值。
2.2.1 问题提出
爱丽丝在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元。现在有一笔事务从她的一个账户转移了 100 美元到另一个账户。如果她非常不幸地在事务处理的过程中查看其账户余额列表,她可能会在收到付款之前先看到一个账户的余额(收款账户,余额仍为 500 美元),在发出转账之后再看到另一个账户的余额(付款账户,新余额为 400 美元)。对爱丽丝来说,现在她的账户似乎总共只有 900 美元 —— 看起来有 100 美元已经凭空消失了。
这种异常被称为 不可重复读(nonrepeatable read) 或 读取偏差(read skew)
在读已提交的隔离条件下,不可重复读 被认为是可接受的:Alice 看到的帐户余额时确实在阅读时已经提交了。
2.2.2 适用场景:
虽然这种情况不是持续性,但是有些情况下,不能容忍这种暂时的不一致:
2.2.3 解决方案:快照隔离
每个事务都从数据库的 一致快照中读取 —— 也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
事务可以看到数据库在某个特定时间点冻结时的一致快照。
2.2.4 实现:
与读取提交的隔离类似,快照隔离的实现通常使用写锁来防止脏写,这意味着进行写入的事务会阻止另一个事务修改同一个对象。但是读取不需要任何锁定。
特点: 读不阻塞写,写不阻塞读
2.2.4 总结:
数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它同时维护着单个对象的多个版本,所以这种技术被称为 多版本并发控制(MVCC)
读已提交:
保存两个版本即可,提交的版本和被覆盖但尚未提交的版本。
支持快照隔离的存储引擎通常也使用 MVCC 来实现 读已提交 隔离级别。一种典型的方法是 读已提交 为每个查询使用单独的快照,而 快照隔离 对整个事务使用相同的快照。
2.2.5 观察一致性快照的可见性规则:
当一个事务从数据库中读取时,事务 ID 用于决定它可以看见哪些对象,看不见哪些对象。通过仔细定义可见性规则,数据库可以向应用程序呈现一致的数据库快照。工作如下:
换句话说,如果以下两个条件都成立,则可见一个对象:
2.2.6 索引和快照隔离:
索引如何在多版本数据库中工作?一种选择是使索引简单地指向对象的所有版本,并且需要索引查询来过滤掉当前事务不可见的任何对象版本。当垃圾收集删除任何事务不再可见的旧对象版本时,相应的索引条目也可以被删除。
2.2.7 可重复度与命名混淆:
快照隔离是一个有用的隔离级别,特别对于只读事务而言。
在 Oracle 中称为 可串行化(Serializable) 的,在 PostgreSQL 和 MySQL 中称为 可重复读
读已提交 和 快照隔离 级别,主要保证了 只读事务在并发写入时 可以看到什么。
却忽略了两个事务并发写入的问题(我们只考虑了脏写,没考虑写冲突)。
并发的写入事务之间的典型冲突:
2.3.1:原子写
原子操作通常通过在读取对象时,获取其上的排它锁来实现。 另一个选择是简单地强制所有的原子操作在单一线程上执行。
2.3.2:显示锁定
如果数据库的内置原子操作没有提供必要的功能,防止丢失更新的另一个选择是让应用程序显式地锁定将要更新的对象。然后应用程序可以执行读取 - 修改 - 写入序列,如果任何其他事务尝试同时读取同一个对象,则强制等待,直到第一个 读取 - 修改 - 写入序列 完成。
2.3.3:自动检测丢失的更新
原子操作和锁是通过强制 读取 - 修改 - 写入序列 按顺序发生,来防止丢失更新的方法。
另一种方法是允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其 读取 - 修改 - 写入序列。
优点:
数据库可以结合快照隔离高效地执行此检查。
事实上,PostgreSQL 的可重复读,Oracle 的可串行化和 SQL Server 的快照隔离级别,都会自动检测到丢失更新,并中止惹麻烦的事务。但是,MySQL/InnoDB 的可重复读并不会检测 丢失更新。一些作者认为,数据库必须能防止丢失更新才称得上是提供了 快照隔离,所以在这个定义下,MySQL 下不提供快照隔离。
2.3.4:比较并设置(CAS)
此操作的目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取 - 修改 - 写入序列。
但是,如果数据库允许 WHERE 子句从旧快照中读取,则此语句可能无法防止丢失更新,因为即使发生了另一个并发写入,WHERE 条件也可能为真。
2.3.5:冲突解决和复制
在多个事务更新同一个对象的特殊情况下,就会发生1 脏写或2 丢失更新(取决于时序)。
3 写偏差:
事务并发进行时,两个事务正在更新两个不同的对象(Alice 和 Bob 各自的待命记录)
写入偏差视为丢失更新问题的一般化。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入偏差。
解决方案:
自动防止写入偏差需要真正的可串行化隔离。
FOR UPDATE 告诉数据库锁定返回的所有行以用于更新。
多人同时提交多某个教室的预定,在快照隔离下不安全。
我们使用锁可以防止丢失更新(也即是可以确保两个玩家不能同时移动同一个棋子),但是锁不能保证两个玩家将两个不同的棋子移动到同一个地方,或者其他违反游戏规则类型。
也许可以使用唯一约束(unique constraint),否则你很容易发生写入偏差。
唯一约束是一个简单的解决办法多个用户同时注册同一个用户名(第二个事务在提
交时会因为违反用户名唯一约束而被中止)
幻读:
一个事务中的写入改变另一个事务的搜索查询的结果。
物化冲突:
如果幻读的问题是没有对象可以加锁,也许可以人为地在数据库中引入一个锁对象?
物化冲突将将幻读变为数据库中一组具体行上的锁冲突
读已提交 、快照隔离、写入偏差、幻读
具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。
如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘 I/O。
优点:
存储过程与内存存储,使得在单个线程上执行所有事务变得可行。由于不需要等待 I/O,且避免了并发控制机制的开销,它们可以在单个线程上实现相当好的吞吐量。
只读事务可以使用快照隔离在其它地方执行,但对于写入吞吐量较高的应用,单线程事务处理器可能成为一个严重的瓶颈。
为了伸缩至多个 CPU 核心和多个节点,可以对数据进行分区
事务是否可以是划分至单个分区很大程度上取决于应用数据的结构。
简单的键值数据通常可以非常容易地进行分区,但是具有多个次级索引的数据可能需要大量的跨分区协调
如果事务需要访问不在内存中的数据,最好的解决方案可能是中止事务,异步地将数据提取到内存中,同时继续处理其他事务,然后在数据加载完毕时重新启动事务。
这种方法被称为 反缓存(anti-caching),正如前面在 “在内存中存储一切” 中所述。
两阶段锁定(2PL,two-phase locking)
有时也称为 严格两阶段锁定(SS2PL, strong strict two-phase locking),以便和其他 2PL 变体区分。
两阶段锁的要求更强:
只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要 独占访问权限。(读阻塞写,写阻塞读)
2PL与快照隔离区别:
两阶段锁:
第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
2PL 用于 MySQL(InnoDB)和 SQL Server 中的可串行化隔离级别,以及 DB2 中的可重复读隔离级别。
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于 共享模式 或 独占模式。
规则如下:
因此会引发死锁的情况。
2PL的条件下,事务吞吐量与查询响应时间要比弱隔离级别下要差得多
开销:
谓词锁不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象。
关键:
谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。
谓词锁的性能不佳: 如果活跃事务持有很多锁,检查匹配的锁会非常耗时
因此,大多数使用 2PL 的数据库实际上实现了索引范围锁(index-range locking,也称为 next-key locking),这是一个简化的近似版谓词锁。
通过使谓词匹配到一个更大的集合来简化谓词锁是安全的。这种方法能够有效防止幻读和写入偏差。索引范围锁并不像谓词锁那样精确,但开销较低,所以是一个很好的折衷。
一方面,我们实现了性能不好(2PL)或者伸缩性不好(串行执行)的可串行化隔离级别。另一方面,我们有性能良好的弱隔离级别,但容易出现各种竞争条件(丢失更新,写入偏差,幻读等)。
串行化的隔离级别和高性能是从根本上相互矛盾的吗?
也许不是,可串行化快照隔离(SSI, serializable snapshot isolation) 提供了完整的可串行化隔离级别,但与快照隔离相比只有很小的性能损失。
串行执行可以称为悲观到了极致,,串行化快照隔离 是一种 乐观(optimistic) 的并发控制技术。乐观意味着,如果存在潜在的危险也不阻止事务,而是继续执行事务,希望一切都会好起来。
当一个事务想要提交时,数据库检查是否有什么不好的事情发生(即隔离是否被违反);如果是的话,事务将被中止,并且必须重试。只有可串行化的事务才被允许提交。
如果存在很多 争用(contention,即很多事务试图访问相同的对象),则表现不佳,因为这会导致很大一部分事务需要中止。
但是,如果有足够的备用容量,并且事务之间的争用不太高,乐观的并发控制技术较好。
可串行化的快照隔离(SSI):
事务中的所有读取都是来自数据库的一致性快照,在快照隔离的基础上,SSI 添加了一种算法来检测写入之间的串行化冲突,并确定要中止哪些事务。
先前讨论了快照隔离中的写入偏差,在快照隔离的情况下,原始查询的结果在事务提交时可能不是最新的,因为数据可能在同一时间被修改。
事务中的查询与写入可能存在因果依赖。为了提供可串行化的隔离级别,如果事务在过时的前提下执行操作,数据库必须能检测到这种情况,并中止事务。
数据库如何知道查询结果是否可能已经改变?
当一个事务从 MVCC 数据库中的一致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。
为了防止这种异常,数据库需要跟踪一个事务由于 MVCC 可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。
为什么要等到提交?当检测到陈旧的读取时,为什么不立即中止事务 ?因为如果事务是只读事务,则不需要中止,因为没有写入偏差的风险。通过避免不必要的中止,SSI 保留了快照隔离从一致快照中长时间读取的能力。
当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务指导其他读事务完成,而是像警戒线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。
与两阶段锁定相比,SSI 的最大优点是一个事务不需要阻塞等待另一个事务所持有的锁(写不会阻塞读,读不会阻塞写)
这种设计原则使得: 查询延迟更可预测,变量更少。特别是,只读查询可以运行在一致快照上,而不需要任何锁定,有利于读取繁重的负载。
中止率显著影响 SSI 的整体表现。例如,长时间的读写事务很可能会发生冲突并中止,因此 SSI 要求同时读写的事务尽量短****(只读的长事务可能没问题)。对于慢事务,SSI 可能比两阶段锁定或串行执行更不敏感。
事务是一个抽象层,允许应用程序假装某些并发问题和某些类型的硬件和软件故障不存在。各式各样的错误被简化为一种简单情况:事务中止(transaction abort),而应用需要的仅仅是重试。
如果没有事务处理,各种错误情况(进程崩溃,网络中断,停电,磁盘已满,意外并发等)意味着数据可能以各种方式变得不一致。
一个客户端读取到另一个客户端尚未提交的写入。读已提交 或更强的隔离级别可以防止脏读。
一个客户端覆盖写入了另一个客户端尚未提交的写入。几乎所有的事务实现都可以防止脏写。
在同一个事务中,客户端在不同的时间点会看见数据库的不同状态。快照隔离 经常用于解决这个问题,它允许事务从一个特定时间点的一致性快照中读取数据。快照隔离通常使用 多版本并发控制(MVCC) 来实现。
两个客户端同时执行 读取 - 修改 - 写入序列。其中一个写操作,在没有合并另一个写入变更情况下,直接覆盖了另一个写操作的结果。所以导致数据丢失。快照隔离的一些实现可以自动防止这种异常,而另一些实现则需要手动锁定(SELECT FOR UPDATE)
一个事务读取一些东西,根据它所看到的值作出决定,并将该决定写入数据库。但是,写入时,该决定的前提不再是真实的。只有可串行化的隔离才能防止这种异常。
事务读取符合某些搜索条件的对象。另一个客户端进行写入,影响搜索结果。快照隔离可以防止直接的幻像读取,但是写入偏差上下文中的幻读需要特殊处理,例如索引范围锁定。
弱隔离级别可以防止其中一些异常情况,但要求应用程序开发人员手动处理剩余那些(例如,使用显式锁定)。只有**可串行化的隔离才能防范所有这些问题。**我们讨论了实现可串行化事务的三种不同方法:
如果每个事务的执行速度非常快,并且事务吞吐量足够低,足以在单个 CPU 核上处理,这是一个简单而有效的选择。
数十年来,两阶段锁定一直是实现可串行化的标准方式,但是许多应用出于性能问题的考虑避免使用它。
它使用乐观的方法,允许事务执行而无需阻塞。当一个事务想要提交时,它会进行检查,如果执行不可串行化,事务就会被中止。