ACID 特性解析
ACID
事务提供一种全做,或不做
-
原子性(Atomicity
) :整个事务中的所有操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。例如:银行转账,从A 账户转100 元至B 账户,分为两个步骤:从A 账户取100 元;存入100 元至B 账户。这两步要么一起完成,要么一起不完成。 -
一致性(Consistency
) :在事务开始之前和事务结束以后,数据库数据的一致性约束没有被破坏;即当事务A 与B 同时运行,无论A ,B 两个事务的结束顺序如何,数据库都会达到统一的状态。 -
隔离性(Isolation
) :数据库允许多个并发事务同时对数据进行读写和修改的能力,如果一个事务要访问的数据正在被另外一个事务修改,只要另外一个事务未提交,它所访问的数据就不受未提交事务的影响。隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。例如:现有有个交易是从A 账户转100 元至B 账户,在这个交易事务还未完成的情况下,如果此时B 查询自己的账户,是看不到新增加的100 元的。 -
持久性(Durability
) :当某个事务一旦提交,无论数据库崩溃还是其他未知情况,该事务的结果都能够被持久化保存下来。
原子性(Atomicity)
一般来说,原子是指不能分解成小部分的东西。这个词在计算的不同分支中意味着相似但又微妙不同的东西。例如,在多线程编程中,如果一个线程执行一个原子操作,这意味着另一个线程无法看到该操作的一半结果。系统只能处于操作之前或操作之后的状态,而不是介于两者之间的状态。相比之下,
如果没有原子性,在多处更改进行到一半时发生错误,很难知道哪些更改已经生效,哪些没有生效。该应用程序可以再试一次,但冒着进行两次相同变更的风险,可能会导致数据重复或错误的数据。原子性简化了这个问题:如果事务被中止(abort
事务原子性的目的是在多次写操作中途出错的情况下,提供一种简单的语义。事务的结果要么是成功提交,在这种情况下,事务的所有写入都是持久化的;要么是中止,在这种情况下,事务的所有写入都被回滚(即撤消或丢弃
从单节点到分布式原子提交
对于在单个数据库节点执行的事务,原子性通常由存储引擎实现。当客户端请求数据库节点提交事务时,数据库将使事务的写入持久化,然后将提交记录追加到磁盘中的日志里。如果数据库在这个过程中间崩溃,当节点重启时,事务会从日志中恢复:如果提交记录在崩溃之前成功地写入磁盘,则认为事务被提交;否则来自该事务的任何写入都被回滚。因此,在单个节点上,事务的提交主要取决于数据持久化落盘的顺序:首先是数据,然后是提交记录。事务提交或终止的关键决定时刻是磁盘完成写入提交记录的时刻:在此之前,仍有可能中止(由于崩溃
但是,如果一个事务中涉及多个节点呢?例如,你也许在分区数据库中会有一个多对象事务,或者是一个按关键词分区的二级索引(其中索引条目可能位于与主数据不同的节点上
一致性(Consistency)
一致性这个词在很多地方都被复用,一致性哈希(Consistency Hash)是某些系统用于重新分区的一种分区方法;在
但是,一致性的这种概念取决于应用程序对不变量的观念,应用程序负责正确定义它的事务,并保持一致性。这并不是数据库可以保证的事情:如果你写入违反不变量的脏数据,数据库也无法阻止你。一些特定类型的不变量可以由数据库检查,例如外键约束或唯一约束,但是一般来说,是应用程序来定义什么样的数据是有效的,什么样是无效的,数据库只管存储。大多数
- 某些节点可能会检测到约束冲突或冲突,因此需要中止,而其他节点则可以成功进行提交。
- 某些提交请求可能在网络中丢失,最终由于超时而中止,而其他提交请求则通过。
- 在提交记录完全写入之前,某些节点可能会崩溃,并在恢复时回滚,而其他节点则成功提交。
如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。事务提交必须是不可撤销的,事务提交之后,你不能改变主意,并追溯性地中止事务。这个规则的原因是,一旦数据被提交,其结果就对其他事务可见,因此其他客户端可能会开始依赖这些数据。这个原则构成了读已提交隔离等级的基础,在“读已提交”一节中讨论了这个问题。如果一个事务在提交后被允许中止,所有那些读取了已提交却又被追溯声明不存在数据的事务也必须回滚。当然,提交事务的结果有可能通过事后执行另一个补偿事务来取消,但从数据库的角度来看,这是一个单独的事务,因此任何关于跨事务正确性的保证都是应用自己的问题。
隔离性(Isolation)
大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到并发问题(竞争条件(race conditions

然而实践中很少会使用可序列化隔离,因为它有性能损失。一些流行的数据库如
持久性(Durability)
数据库系统的目的是,提供一个安全的地方存储数据,而不用担心丢失。持久性是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。在单节点数据库中,持久性通常意味着数据已被写入非易失性存储设备,如硬盘或
没有完美的持久性
在历史上,持久性意味着写入归档磁带。后来它被理解为写入硬盘或
-
如果你写入磁盘然后机器宕机,即使数据没有丢失,在修复机器或将磁盘转移到其他机器之前,也是无法访问的。这种情况下,复制系统可以保持可用性。
-
一个相关性故障(停电,或一个特定输入导致所有节点崩溃的
Bug )可能会一次性摧毁所有副本,任何仅存储在内存中的数据都会丢失,故内存数据库仍然要和磁盘写入打交道。 -
在异步复制系统中,当主库不可用时,最近的写入操作可能会丢失。
-
当电源突然断电时,特别是固态硬盘,有证据显示有时会违反应有的保证:甚至
fsync 也不能保证正常工作。硬盘固件可能有错误,就像任何其他类型的软件一样。 -
存储引擎和文件系统之间的微妙交互可能会导致难以追踪的错误,并可能导致磁盘上的文件在崩溃后被损坏。
-
磁盘上的数据可能会在没有检测到的情况下逐渐损坏。如果数据已损坏一段时间,副本和最近的备份也可能损坏。这种情况下,需要尝试从历史备份中恢复数据。
-
一项关于固态硬盘的研究发现,在运行的前四年中,30%到
80 %的硬盘会产生至少一个坏块。相比固态硬盘,磁盘的坏道率较低,但完全失效的概率更高。 -
如果
SSD 断电,可能会在几周内开始丢失数据,具体取决于温度。
在实践中,没有一种技术可以提供绝对保证。只有各种降低风险的技术,包括写入磁盘,复制到远程机器和备份——它们可以且应该一起使用。与往常一样,最好抱着怀疑的态度接受任何理论上的“保证”
单对象和多对象操作
在
-
原子性:如果在一系列写操作的中途发生错误,则应中止事务处理,并丢弃当前事务的所有写入。换句话说,数据库免去了用户对部分失败的担忧——通过提供“宁为玉碎,不为瓦全(all-or-nothing)”的保证。
-
隔离性:同时运行的事务不应该互相干扰。例如,如果一个事务进行多次写入,则另一个事务要么看到全部写入结果,要么什么都看不到,但不应该是一些子集。
这些定义假设你想同时修改多个对象(行,文档,记录
$ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
但如果邮件太多,你可能会觉得这个查询太慢,并决定用单独的字段存储未读邮件的数量(一种反规范化

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

多对象事务需要某种方式来确定哪些读写操作属于同一个事务。在关系型数据库中,通常基于客户端与数据库服务器的
单对象写入
当单个对象发生改变时,原子性和隔离也是适用的。例如,假设您正在向数据库写入一个
- 如果在发送第一个
10 KB 之后网络连接中断,数据库是否存储了不可解析的10KB JSON 片段? - 如果在数据库正在覆盖磁盘上的前一个值的过程中电源发生故障,是否最终将新旧值拼接在一起?
- 如果另一个客户端在写入过程中读取该文档,是否会看到部分更新的值?
存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复,并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象
这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新,但它们不是通常意义上的事务。
多对象事务的需求
许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现,而且在需要高可用性或高性能的情况下,它们可能会碍事。但说到底,在分布式数据库中实现事务,并没有什么根本性的障碍。但是我们是否需要多对象事务?是否有可能只用键值数据模型和单对象操作来实现任何应用程序?
有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象:
-
在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用(类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边
) 。多对象事务使你确信这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的,最新的,不然数据就没有意义。 -
在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化。当需要更新非规范化的信息时,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。
-
在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有
) ,每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。
这些应用仍然可以在没有事务的情况下实现。然而,没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题。
处理错误和中止
事务的一个关键特性是,如果发生错误,它可以中止并安全地重试
错误发生不可避免,但许多软件开发人员倾向于只考虑乐观情况,而不是错误处理的复杂性。例如,像
尽管重试一个中止的事务是一个简单而有效的错误处理机制,但它并不完美:
- 如果事务实际上成功了,但是在服务器试图向客户端确认提交成功时网络发生故障(所以客户端认为提交失败了
) ,那么重试事务会导致事务被执行两次——除非你有一个额外的应用级除重机制。 - 如果错误是由于负载过大造成的,则重试事务将使问题变得更糟,而不是更好。为了避免这种正反馈循环,可以限制重试次数,使用指数退避算法,并单独处理与过载相关的错误(如果允许
) 。 - 仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
- 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果你正在发送电子邮件,那你肯定不希望每次重试事务时都重新发送电子邮件。如果你想确保几个不同的系统一起提交或放弃,二阶段提交(2PC, two-phase commit) 可以提供帮助。
- 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。