ACID 特性解析

ACID

事务提供一种全做,或不做(All or Nothing)的机制,即将一个活动涉及的所有操作纳入到一个不可分割的执行单元,组成事务的所有操作只有在所有操作均能正常执行的情况下方能提交,只要其中任一操作执行失败,都将导致整个事务的回滚。数据库事务具有 ACID 属性,即原子性(Atomic)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),它由 TheoHärder 和 Andreas Reuter 于 1983 年创建,旨在为数据库中的容错机制建立精确的术语。

ACID 包含了描述事务操作的整体性的原子性,描述事务操作下数据的正确性的一致性,描述事务并发操作下数据的正确性的隔离性,描述事务对数据修改的可靠性的持久性。针对数据库的一系列操作提供了一种从失败状态恢复到正常状态的方法,使数据库在异常状态下也能够保持数据的一致性,且面对并发访问时,数据库能够提供一种隔离方法,避免彼此间的操作互相干扰。

  • 原子性(Atomicity):整个事务中的所有操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。例如:银行转账,从 A 账户转 100 元至 B 账户,分为两个步骤:从 A 账户取 100 元;存入 100 元至 B 账户。这两步要么一起完成,要么一起不完成。

  • 一致性(Consistency):在事务开始之前和事务结束以后,数据库数据的一致性约束没有被破坏;即当事务 A 与 B 同时运行,无论 A,B 两个事务的结束顺序如何,数据库都会达到统一的状态。

  • 隔离性(Isolation):数据库允许多个并发事务同时对数据进行读写和修改的能力,如果一个事务要访问的数据正在被另外一个事务修改,只要另外一个事务未提交,它所访问的数据就不受未提交事务的影响。隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。例如:现有有个交易是从 A 账户转 100 元至 B 账户,在这个交易事务还未完成的情况下,如果此时 B 查询自己的账户,是看不到新增加的 100 元的。

  • 持久性(Durability):当某个事务一旦提交,无论数据库崩溃还是其他未知情况,该事务的结果都能够被持久化保存下来。

原子性(Atomicity)

一般来说,原子是指不能分解成小部分的东西。这个词在计算的不同分支中意味着相似但又微妙不同的东西。例如,在多线程编程中,如果一个线程执行一个原子操作,这意味着另一个线程无法看到该操作的一半结果。系统只能处于操作之前或操作之后的状态,而不是介于两者之间的状态。相比之下,ACID 的原子性并不是关于并发(concurrent)的。它并不是在描述如果几个进程试图同时访问相同的数据会发生什么情况,这种情况包含在隔离性(Isolation)中。

ACID 的原子性描述了,当客户想进行多次写入,但在一些写操作处理完之后出现故障的情况。例如进程崩溃,网络连接中断,磁盘变满或者某种完整性约束被违反。如果这些写操作被分组到一个原子事务中,并且该事务由于错误而不能完成(提交),则该事务将被中止,并且数据库必须丢弃或撤消该事务中迄今为止所做的任何写入。

如果没有原子性,在多处更改进行到一半时发生错误,很难知道哪些更改已经生效,哪些没有生效。该应用程序可以再试一次,但冒着进行两次相同变更的风险,可能会导致数据重复或错误的数据。原子性简化了这个问题:如果事务被中止(abort),应用程序可以确定它没有改变任何东西,所以可以安全地重试。

ACID 原子性的定义特征是:能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力或许可中止性(abortability)是更好的术语。

事务原子性的目的是在多次写操作中途出错的情况下,提供一种简单的语义。事务的结果要么是成功提交,在这种情况下,事务的所有写入都是持久化的;要么是中止,在这种情况下,事务的所有写入都被回滚(即撤消或丢弃)。原子性可以防止失败的事务搅乱数据库,避免数据库陷入半成品结果和半更新状态。这对于多对象事务和维护次级索引的数据库尤其重要。每个辅助索引都是与主数据相分离的数据结构,因此,如果你修改了一些数据,则还需要在辅助索引中进行相应的更改。原子性确保二级索引与主数据保持一致(如果索引与主数据不一致,就没什么用了)。

从单节点到分布式原子提交

对于在单个数据库节点执行的事务,原子性通常由存储引擎实现。当客户端请求数据库节点提交事务时,数据库将使事务的写入持久化,然后将提交记录追加到磁盘中的日志里。如果数据库在这个过程中间崩溃,当节点重启时,事务会从日志中恢复:如果提交记录在崩溃之前成功地写入磁盘,则认为事务被提交;否则来自该事务的任何写入都被回滚。因此,在单个节点上,事务的提交主要取决于数据持久化落盘的顺序:首先是数据,然后是提交记录。事务提交或终止的关键决定时刻是磁盘完成写入提交记录的时刻:在此之前,仍有可能中止(由于崩溃),但在此之后,事务已经提交(即使数据库崩溃)。因此,是单一的设备(连接到单个磁盘驱动的控制器,且挂载在单台机器上)使得提交具有原子性。

但是,如果一个事务中涉及多个节点呢?例如,你也许在分区数据库中会有一个多对象事务,或者是一个按关键词分区的二级索引(其中索引条目可能位于与主数据不同的节点上)。

一致性(Consistency)

一致性这个词在很多地方都被复用,一致性哈希(Consistency Hash)是某些系统用于重新分区的一种分区方法;在 CAP 定理中,一致性一词用于表示可线性化;在 ACID 的上下文中,一致性是指数据库在应用程序的特定概念中处于“良好状态”。ACID 一致性的概念是,对数据的一组特定陈述必须始终成立。即不变量(invariants)。例如,在会计系统中,所有账户整体上必须借贷相抵。如果一个事务开始于一个满足这些不变量的有效数据库,且在事务处理期间的任何写入操作都保持这种有效性,那么可以确定,不变量总是满足的。

但是,一致性的这种概念取决于应用程序对不变量的观念,应用程序负责正确定义它的事务,并保持一致性。这并不是数据库可以保证的事情:如果你写入违反不变量的脏数据,数据库也无法阻止你。一些特定类型的不变量可以由数据库检查,例如外键约束或唯一约束,但是一般来说,是应用程序来定义什么样的数据是有效的,什么样是无效的,数据库只管存储。大多数 NoSQL 分布式数据存储不支持这种分布式事务,但是很多关系型数据库集群支持。在这些情况下,仅向所有节点发送提交请求并独立提交每个节点的事务是不够的。这样很容易发生违反原子性的情况:提交在某些节点上成功,而在其他节点上失败:

  • 某些节点可能会检测到约束冲突或冲突,因此需要中止,而其他节点则可以成功进行提交。
  • 某些提交请求可能在网络中丢失,最终由于超时而中止,而其他提交请求则通过。
  • 在提交记录完全写入之前,某些节点可能会崩溃,并在恢复时回滚,而其他节点则成功提交。

如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。事务提交必须是不可撤销的,事务提交之后,你不能改变主意,并追溯性地中止事务。这个规则的原因是,一旦数据被提交,其结果就对其他事务可见,因此其他客户端可能会开始依赖这些数据。这个原则构成了读已提交隔离等级的基础,在“读已提交”一节中讨论了这个问题。如果一个事务在提交后被允许中止,所有那些读取了已提交却又被追溯声明不存在数据的事务也必须回滚。当然,提交事务的结果有可能通过事后执行另一个补偿事务来取消,但从数据库的角度来看,这是一个单独的事务,因此任何关于跨事务正确性的保证都是应用自己的问题。

隔离性(Isolation)

大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到并发问题(竞争条件(race conditions))。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加 1,再回写新值。下图中,因为发生了两次增长,计数器应该从 42 增至 44;但由于竞态条件,实际上只增至 43。

ACID 意义上的隔离性意味着,同时执行的事务是相互隔离的:它们不能相互冒犯。传统的数据库教科书将隔离性形式化为可序列化(Serializability),这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的。

两个客户之间的竞争状态同时递增计数器

然而实践中很少会使用可序列化隔离,因为它有性能损失。一些流行的数据库如 Oracle 11g,甚至没有实现它。在 Oracle 中有一个名为“可序列化”的隔离级别,但实际上它实现了一种叫做快照隔离(snapshot isolation)的功能,这是一种比可序列化更弱的保证。

持久性(Durability)

数据库系统的目的是,提供一个安全的地方存储数据,而不用担心丢失。持久性是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。在单节点数据库中,持久性通常意味着数据已被写入非易失性存储设备,如硬盘或 SSD。它通常还包括预写日志或类似的文件,以便在磁盘上的数据结构损坏时进行恢复。在带复制的数据库中,持久性可能意味着数据已成功复制到一些节点。为了提供持久性保证,数据库必须等到这些写入或复制完成后,才能报告事务成功提交。

没有完美的持久性

在历史上,持久性意味着写入归档磁带。后来它被理解为写入硬盘或 SSD。最近它已经适应了“复制(replication)”的新内涵。哪种实现更好一些?真相是,没有什么是完美的:

  • 如果你写入磁盘然后机器宕机,即使数据没有丢失,在修复机器或将磁盘转移到其他机器之前,也是无法访问的。这种情况下,复制系统可以保持可用性。

  • 一个相关性故障(停电,或一个特定输入导致所有节点崩溃的 Bug)可能会一次性摧毁所有副本,任何仅存储在内存中的数据都会丢失,故内存数据库仍然要和磁盘写入打交道。

  • 在异步复制系统中,当主库不可用时,最近的写入操作可能会丢失。

  • 当电源突然断电时,特别是固态硬盘,有证据显示有时会违反应有的保证:甚至 fsync 也不能保证正常工作。硬盘固件可能有错误,就像任何其他类型的软件一样。

  • 存储引擎和文件系统之间的微妙交互可能会导致难以追踪的错误,并可能导致磁盘上的文件在崩溃后被损坏。

  • 磁盘上的数据可能会在没有检测到的情况下逐渐损坏。如果数据已损坏一段时间,副本和最近的备份也可能损坏。这种情况下,需要尝试从历史备份中恢复数据。

  • 一项关于固态硬盘的研究发现,在运行的前四年中,30%到 80%的硬盘会产生至少一个坏块。相比固态硬盘,磁盘的坏道率较低,但完全失效的概率更高。

  • 如果 SSD 断电,可能会在几周内开始丢失数据,具体取决于温度。

在实践中,没有一种技术可以提供绝对保证。只有各种降低风险的技术,包括写入磁盘,复制到远程机器和备份——它们可以且应该一起使用。与往常一样,最好抱着怀疑的态度接受任何理论上的“保证”

单对象和多对象操作

在 ACID 中,原子性和隔离性描述了客户端在同一事务中执行多次写入时,数据库应该做的事情:

  • 原子性:如果在一系列写操作的中途发生错误,则应中止事务处理,并丢弃当前事务的所有写入。换句话说,数据库免去了用户对部分失败的担忧——通过提供“宁为玉碎,不为瓦全(all-or-nothing)”的保证。

  • 隔离性:同时运行的事务不应该互相干扰。例如,如果一个事务进行多次写入,则另一个事务要么看到全部写入结果,要么什么都看不到,但不应该是一些子集。

这些定义假设你想同时修改多个对象(行,文档,记录)。通常需要多对象事务(multi-object transaction)来保持多块数据同步。譬如执行以下查询来显示用户未读邮件数量:

$ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true

但如果邮件太多,你可能会觉得这个查询太慢,并决定用单独的字段存储未读邮件的数量(一种反规范化)。现在每当一个新消息写入时,必须也增长未读计数器,每当一个消息被标记为已读时,也必须减少未读计数器。在下图中,用户 2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生。隔离性可以避免这个问题:通过确保用户 2 要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。

违反隔离性:一个事务读取另一个事务的未被执行的写入(“脏读”)

下图说明了对原子性的需求:如果在事务过程中发生错误,邮箱和未读计数器的内容可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,并且插入的电子邮件将被回滚。

原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致

多对象事务需要某种方式来确定哪些读写操作属于同一个事务。在关系型数据库中,通常基于客户端与数据库服务器的 TCP 连接:在任何特定连接上,BEGIN TRANSACTION 和 COMMIT 语句之间的所有内容,被认为是同一事务的一部分。另一方面,许多非关系数据库并没有将这些操作组合在一起的方法。即使存在多对象 API(例如,键值存储可能具有在一个操作中更新几个键的多重放置操作),但这并不一定意味着它具有事务语义:该命令可能在一些键上成功,在其他的键上失败,使数据库处于部分更新的状态。

单对象写入

当单个对象发生改变时,原子性和隔离也是适用的。例如,假设您正在向数据库写入一个 20 KB 的 JSON 文档:

  • 如果在发送第一个 10 KB 之后网络连接中断,数据库是否存储了不可解析的 10KB JSON 片段?
  • 如果在数据库正在覆盖磁盘上的前一个值的过程中电源发生故障,是否最终将新旧值拼接在一起?
  • 如果另一个客户端在写入过程中读取该文档,是否会看到部分更新的值?

存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复,并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象)。一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要读取-修改-写入序列了。同样流行的是 比较和设置(CAS, compare-and-set)操作,当值没有并发被其他人修改过时,才允许执行写操作。

这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新,但它们不是通常意义上的事务。CAS 以及其他单一对象操作被称为“轻量级事务”,甚至出于营销目的被称为“ACID”,但是这个术语是误导性的。事务通常被理解为,将多个对象上的多个操作合并为一个执行单元的机制。

多对象事务的需求

许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现,而且在需要高可用性或高性能的情况下,它们可能会碍事。但说到底,在分布式数据库中实现事务,并没有什么根本性的障碍。但是我们是否需要多对象事务?是否有可能只用键值数据模型和单对象操作来实现任何应用程序?

有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象:

  • 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用(类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确信这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的,最新的,不然数据就没有意义。

  • 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化。当需要更新非规范化的信息时,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。

  • 在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。

这些应用仍然可以在没有事务的情况下实现。然而,没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题。

处理错误和中止

事务的一个关键特性是,如果发生错误,它可以中止并安全地重试 ACID 数据库基于这样的哲学:如果数据库有违反其原子性,隔离性或持久性的危险,则宁愿完全放弃事务,而不是留下半成品。然而并不是所有的系统都遵循这个哲学。特别是具有无主复制的数据存储,主要是在“尽力而为”的基础上进行工作。可以概括为“数据库将做尽可能多的事,运行遇到错误时,它不会撤消它已经完成的事情“ ——所以,从错误中恢复是应用程序的责任。

错误发生不可避免,但许多软件开发人员倾向于只考虑乐观情况,而不是错误处理的复杂性。例如,像 Rails 的 ActiveRecord 和 Django 这样的对象关系映射(ORM, object-relation Mapping) 框架不会重试中断的事务—— 这个错误通常会导致一个从堆栈向上传播的异常,所以任何用户输入都会被丢弃,用户拿到一个错误信息。这实在是太耻辱了,因为中止的重点就是允许安全的重试。

尽管重试一个中止的事务是一个简单而有效的错误处理机制,但它并不完美:

  • 如果事务实际上成功了,但是在服务器试图向客户端确认提交成功时网络发生故障(所以客户端认为提交失败了),那么重试事务会导致事务被执行两次——除非你有一个额外的应用级除重机制。
  • 如果错误是由于负载过大造成的,则重试事务将使问题变得更糟,而不是更好。为了避免这种正反馈循环,可以限制重试次数,使用指数退避算法,并单独处理与过载相关的错误(如果允许)。
  • 仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
  • 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果你正在发送电子邮件,那你肯定不希望每次重试事务时都重新发送电子邮件。如果你想确保几个不同的系统一起提交或放弃,二阶段提交(2PC, two-phase commit) 可以提供帮助。
  • 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。