通常,原子是指不可分解为更小粒度的东西。这个术语在计算机的不同领域里有着相似但却微妙的差异。例如,多线程编程中,如果某线程执行一个原子操作,这意味着其他线程是无法看到该操作的中间结果。它只能处于操作之前或操作之后的状态,而不是两者之间的状态。
==而 ACID 中的原子性并不关乎多个操作的并发性,它并没有描述多个线程试图访问相同的数据会发生什么情况,后者其实是由 ACID 的隔离性所定义==。
ACID 中原子性所定义的特征是:在出错时中止事务,并将部分完成的写入全部丢弃。也许可中止性比原子性更为准确,不过我们还是沿用原子性这个惯用术语。
一致性非常重要,但它在不同场景有着不同的具体含义,例如:
- 第 5 章我们讨论了副本一致性以及异步复制模型时,引出了最终一致性问题(参见第5章 “复制滞后问题”)。
- 一致性哈希则是某些系统用于动态分区再平衡的方法(参见第 6 章 ”一致性哈希“)。
- CAP 理论中,一致性一词用来表示线性化(参见第 9 章 ”可线性化“)。
- 而在 ACID 中,一致性主要指数据库处于应用程序所期待的 "预期状态”。
ACID 中的一致性的主要是指对数据有特定的预期状态,任何数据更改必须满足这些状态约束(或者恒等条件)。
这种一致性本质上要求应用层来维护状态一致(或者恒等),应用程序有责任正确地定义事务来保持一致性。这不是数据库可以保证的事情:即如果提供的数据修改违背了恒等条件,数据库很难检测进而阻止该操作(数据库可以完成针对某些特定类型的恒等约束检查,例如使用外键约束或唯一性约束。但通常主要靠应用程序来定义数据的有效/无效状态,数据库主要负责存储)。
原子性、隔离性和持久性是数据库自身的属性,而 ACID 中的一致性更多是应用层的属性。应用程序可能借助数据库提供的原子性和隔离性,以达到一致性,但一致性本身并不源于数据库。
大多数数据库都支持多个客户端同时访问。如果读取和写入的是不同数据,这肯定没有什么问题;但如果访问相同的记录,则可能会遇到并发问题(即带来竞争条件)。
一个简单例子如图 7-1 所示。假设有两个客户端同时增加数据库中的一个计数器。每个客户首先读取当前值,再客户端增加 1 然后写回新值(这里假设数据库尚不支持自增操作)。图 7-1 中,由于有两次相加,计数器应该由 42 增加到 44,但实际上由于竞争条件最终结果却是 43。
ACID 语义中的隔离性意味着并发执行的多个事务相互隔离,它们不能互相交叉。==经典的数据库教材把隔离定义为可串行化==,这意味着可以假装它是数据库上运行的唯一事务。虽然实际上它们可能同时运行,但数据库系统要确保当事务提交时,其结果与串行执行(一个接一个执行)完全相同。
然而实践中,由于性能问题很少使用串行化隔离。一些流行的数据库,如 Oracle 甚至根本就没有实现它。Oracle 虽然也有声称串行化的功能,但它本质上实现的是快照隔离,后者提供了比串行化更弱的保证。我们将在本章后面 ”弱隔离” 级别中讨论快照隔离以及其他形式的隔离。
持久性就是这样的承诺,它保证一且事务提交成功,即使存在硬件故障或数据库崩溃,事务所写入的任何数据也不会消失。
对于单节点数据库,持久性通常意味着数据已被写入非易失性存储设备,如硬盘或 SSD。在写入执行过程中,通常还涉及预写日志等(参阅第 3 章 “可靠的 B-tree")这样万一磁盘数据损坏可以进行恢复。而对于支持远程复制的数据库,持久性则意味若数据已成功复制到多个节点。为了实现持久性的保证,数据库必须等到这些写入或复制完成之后才能报告事务成功提交。
回顾一下,ACID 中的原子性和隔离性主要针对客户端在同一事务中包含多个写操作时,数据库所提供的保证。
这些定义假定在一个事务中会修改多个对象(如行、文档、记录等)。这种多对象事务目的通常是为了在多个数据对象之间保持同步。图 7-2 展示了一个电子邮件应用的例子。要显示用户的未读邮件数量,可以执行查询如下:
SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true如果电子邮件太多,你会发现查询太慢,然后决定将未读的数量直接保存在一个单独的字段中(虽然这违反了范式要求)。这样每当收到一个新邮件,需要增加未读计数器;当邮件标记为己读时,还需减少该计数器。
在图 7-2 中,用户 2 遇到些异常情况:邮箱列表已显示了未读消息,但计数器却还未更新,所显式的数目是 0。==隔离性将保证用户 2 看到要么是更新后的电子邮件和更新后的计数器,要么是二者都未更新,而不会是两者不一致==。
==图 7-3 则说明了对原子性的需求:如果事务执行过程中发生错误,导致邮箱和未读计数器二者不同步。则事务将被中止,且此之前插入的电子邮件将被回滚。==
Tips:
通过上面的例子可以看出隔离性(这里我们特指可串行化隔离级别)和原子性的区别:隔离性保证一个用户不能看到另一个用户未提交的事务,即不能看到事务的中间状态,描述的是并发场景;而原子性描述的是这个事务本身要么提交要么回滚,不能永远处于中间状态。原子性并不影响隔离性,比如不支持原子性使得事务永远处于中间状态,但是隔离性保证了不能看到未提交的事务。
原子性和隔离性也同样适用于单个对象的更新。存储引擎几乎必备的设计就是在单节点、单个对象层面上提供原子性和隔离性(比如 key-value 对)。例如,出现宕机时,基于日志恢复来实现原子性(参阅第 3 章 ”可靠的 B-tree") ,对每个对象采用加锁的方式(每次只允许一个线程访问对象)来实现隔离。
某些数据库还提供了高级的原子操作,例如原子自增操作,这样就不需要像图 7-1 那样执行读取-修改-写回,类似地还有原子比较-设置操作,即只有当前值没有被他入修改时才执行写入(参阅本章后面的 "compare-and-set")。
这些单对象操作可以有效防止多个客户端并发修改同一对象时的更新丢失问题(参阅本章后面的 ”防止更新丢失“) 。但需要注意,它们并不是通常意义上的事务。虽然 compare-and-set 和其他单对象操作有时也被称为 "轻量级事务”,甚至 "ACID" 但这里其实存在一些误导性。通常意义上的事务针对的是多个对象,将多个操作聚合为一个逻辑执行单元。
许多分布式数据存储系统不支持多对象事务 ,主要是因为当出现跨分区时,多对象事务非常难以正确实现,同时在高可用或者极致性能的场景下也会带来很多负面影响。
但是,分布式数据库实现事务并非不可能,并不存什么原理上的限制,我们将在第 9 章讨论分布式事务的实现。
但是否所有应用都需要多对象事务呢?是否可能只用 Key-Value 数据模型和单对象操作就可以满足应用需求?
的确有一些情况,只进行单个对象的插入、更新和删除就足够了。但是,还有许多其他情况要求写入多个不同的对象并进行协调:
- 对于关系数据模型,表中的某行可能是另一个表中的==外键==。类似地,在图数据模型中,顶点具有多个边链接到其他的顶点。多对象事务用以确保这些外键引用的有效性,即当插入多个相互引用的记录时,保证外键总是最新、正确的,否则数据更新就变得毫无意义。
- 对于文档数据模型,如果待更新的字段都在同一个文档中,则可视为单个对象,此时不需要多对象事务。但是,缺少 join 支持的文档数据库往往会滋生反规范化(参阅第 2 章 ”关系数据库与文档数据库现状“),如图 7-2 所示,当更新这种非规范化数据时,就需要一次更新多个文档。此时多对象事务就可以有效防止非规范化数据之间出现不同步。
- 对于带有==二级索引==的数据库(除了纯粹 Key-Value 存储以外几乎所有其他系统都支持二级索引),每次更改值时都需要同步更新索引。从事务角度来看,这些索引是不同的数据库对象:如果没有事务隔离,就会出现部分索引更新。
事务的一个关键特性是,如果发生了意外,所有操作被中止,之后可以安全地重试。ACID 数据库基于这样的一个理念:如果存在违反原子性、隔离性或持久性的风险,则完全放弃整个事务,而不是部分放弃。


