目录
概述
场景事例
本地事务
实现原子性和持久性
实现隔离性
概述
事务处理几乎在每一个信息系统中都会涉及,它存在的意义是为 了保证系统中所有的数据都是符合期望的,且相互关联的数据之间不 会产生矛盾,即数据状态的一致性(Consistency)。按照数据库的经 典理论,要达成这个目标,需要三方面共同努力来保障。
- 原子性(Atomic):在同一项业务处理过程中,事务保证了对 多个数据的修改,要么同时成功,要么同时被撤销。
- 隔离性(Isolation):在不同的业务处理过程中,事务保证了各 业务正在读、写的数据相互独立,不会彼此影响。
- 持久性(Durability):事务应当保证所有成功被提交的数据修 改都能够正确地被持久化,不丢失数据。
以上四种属性即事务的“ACID”特性,但笔者对这种说法其实不 太认同,因为这四种特性并不正交,A、I、D是手段,C是目的,前者 是因,后者是果,弄到一块去完全是为了拼凑个单词缩写。
事务的概念虽然最初起源于数据库系统,但今天已经有所延伸, 不再局限于数据库本身了。所有需要保证数据一致性的应用场景,包 括但不限于数据库、事务内存、缓存、消息队列、分布式存储,等 等,都有可能用到事务。后文里笔者会使用“数据源”来泛指所有这 些场景中提供与存储数据的逻辑设备,但是上述场景所说的事务和一 致性含义可能并不完全一致,说明如下。
- 当一个服务只使用一个数据源时,通过A、I、D来获得一致性 是最经典的做法,也是相对容易的。此时,多个并发事务所读写的数 据能够被数据源感知是否存在冲突,并发事务的读写在时间线上的最 终顺序是由数据源来确定的,这种事务间一致性被称为“内部一致 性”。
- 当一个服务使用到多个不同的数据源,甚至多个不同服务同时 涉及多个不同的数据源时,问题就变得困难了许多。此时,并发执行 甚至是先后执行的多个事务,在时间线上的顺序并不由任何一个数据 源来决定,这种涉及多个数据源的事务间一致性被称为“外部一致 性”。
外部一致性问题通常很难使用A、I、D来解决,因为这样需要付出 很大甚至不切实际的代价;但是外部一致性又是分布式系统中必然会 遇到且必须要解决的问题,为此我们要转变观念,将一致性从“是或 否”的二元属性转变为可以按不同强度分开讨论的多元属性,在确保 代价可承受的前提下获得强度尽可能高的一致性保障,也正因如此, 事务处理才从一个具体操作上的“编程问题”上升成一个需要全局权 衡的“架构问题”。 人们在探索这些解决方案的过程中,产生了许多新的思路和概 念,有一些概念看上去并不那么直观,在本章,笔者会通过同一个场 景事例讲解如何在不同的事务方案中贯穿、理顺这些概念。
场景事例
Fenix’s Bookstore是一个在线书店。当一本书被成功售出时,需 要确保以下三件事情被正确地处理:
- 用户的账号扣减相应的商品款项;
- 商品仓库中扣减库存,将商品标识为待配送状态;
- 商家的账号增加相应的商品款项。
接下来,笔者将逐一介绍在“单个服务使用单个数据源”“单个 服务使用多个数据源”“多个服务使用单个数据源”以及“多个服务 使用多个数据源”下,可以采用哪些手段来保证数据在以上场景中被 正确地读写。
本地事务
本地事务(Local Transaction)其实应该翻译成“局部事务”才 好与稍后的“全局事务”相对应,不过现在“本地事务”的译法似乎 已经成为主流,这里也就不去纠结名称了。本地事务是指仅操作单一 事务资源的、不需要全局事务管理器进行协调的事务。在没有介绍什 么是“全局事务管理器”前,很难从概念入手去讲解“本地事务”, 所以这里先暂且将概念放下,等后面再来对比理解。
本地事务是一种最基础的事务解决方案,只适用于单个服务使用 单个数据源的场景。从应用角度看,它是直接依赖于数据源本身提供 的事务能力来工作的,在程序代码层面,最多只能对事务接口做一层 标准化的包装(如JDBC接口),并不能深入参与到事务的运作过程 中,事务的开启、终止、提交、回滚、嵌套、设置隔离级别,乃至与 应用代码贴近的事务传播方式,全部都要依赖底层数据源的支持才能 工作,这一点与后续介绍的XA、TCC、SAGA等主要靠应用程序代码来实 现的事务有着十分明显的区别。举个例子,假设你的代码调用了JDBC 中的Transaction::rollback()方法,方法的成功执行也并不一定代表 事务就已经被成功回滚,如果数据表采用的引擎是MyISAM,那 rollback()方法便是一项没有意义的空操作。因此,我们要想深入讨 论本地事务,便不得不越过应用代码的层次,去了解一些数据库本身 的事务实现原理,弄明白传统数据库管理系统是如何通过ACID来实现 事务的。
实现原子性和持久性
原子性和持久性在事务里是密切相关的两个属性:原子性保证了 事务的多个操作要么都生效要么都不生效,不会存在中间状态;持久 性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容 被撤销或丢失。
众所周知,数据必须要成功写入磁盘、磁带等持久化存储器后才 能拥有持久性,只存储在内存中的数据,一旦遇到应用程序忽然崩 溃,或者数据库、操作系统一侧崩溃,甚至是机器突然断电宕机等情 况就会丢失,后文我们将这些意外情况都统称为“崩溃”(Crash)。 实现原子性和持久性的最大困难是“写入磁盘”这个操作并不是原子 的,不仅有“写入”与“未写入”状态,还客观存在着“正在写”的 中间状态。由于写入中间状态与崩溃都不可能消除,所以如果不做额 外保障措施的话,将内存中的数据写入磁盘,并不能保证原子性与持 久性。下面通过具体事例来说明。
按照前面预设的场景事例,从Fenix’s Bookstore购买一本书需 要修改三个数据:在用户账户中减去货款、在商家账户中增加货款、 在商品仓库中标记一本书为配送状态。由于写入存在中间状态,所以 可能出现以下情形。
- 未提交事务,写入后崩溃:程序还没修改完三个数据,但数据 库已经将其中一个或两个数据的变动写入磁盘,若此时出现崩溃,一 旦重启之后,数据库必须要有办法得知崩溃前发生过一次不完整的购 物操作,将已经修改过的数据从磁盘中恢复成没有改过的样子,以保 证原子性。
- 已提交事务,写入前崩溃:程序已经修改完三个数据,但数据 库还未将全部三个数据的变动都写入磁盘,若此时出现崩溃,一旦重 启之后,数据库必须要有办法得知崩溃前发生过一次完整的购物操 作,将还没来得及写入磁盘的那部分数据重新写入,以保证持久性。
由于写入中间状态与崩溃都是无法避免的,为了保证原子性和持 久性,就只能在崩溃后采取恢复的补救措施,这种数据恢复操作被称 为“崩溃恢复”(Crash Recovery,也有资料称作Failure Recovery 或Transaction Recovery)。
为了能够顺利地完成崩溃恢复,在磁盘中写入数据就不能像程序 修改内存中的变量值那样,直接改变某表某行某列的某个值,而是必 须将修改数据这个操作所需的全部信息,包括修改什么数据、数据物 理上位于哪个内存页和磁盘块中、从什么值改成什么值,等等,以日 志的形式——即以仅进行顺序追加的文件写入的形式(这是最高效的 写入方式)先记录到磁盘中。只有在日志记录全部安全落盘,数据库 在日志中看到代表事务成功提交的“提交记录”(Commit Record) 后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再 在日志中加入一条“结束记录”(End Record)表示事务已完成持久 化,这种事务实现方法被称为“提交日志”(Commit Logging)。
Commit Logging保障数据持久性、原子性的原理并不难理解:首 先,日志一旦成功写入Commit Record,那整个事务就是成功的,即使 真正修改数据时崩溃了,重启后根据已经写入磁盘的日志信息恢复现 场、继续修改数据即可,这保证了持久性;其次,如果日志没有成功 写入Commit Record就发生崩溃,那整个事务就是失败的,系统重启后 会看到一部分没有Commit Record的日志,将这部分日志标记为回滚状 态即可,整个事务就像完全没有发生过一样,这保证了原子性。
Commit Logging的原理很清晰,也确实有一些数据库就是直接采 用Commit Logging机制来实现事务的,譬如较具代表性的是阿里的 OceanBase。但是,Commit Logging存在一个巨大的先天缺陷:所有对 数据的真实修改都必须发生在事务提交以后,即日志写入了Commit Record之后。在此之前,即使磁盘I/O有足够空闲,即使某个事务修改 的数据量非常庞大,占用了大量的内存缓冲区,无论何种理由,都决 不允许在事务提交之前就修改磁盘上的数据,这一点是Commit Logging成立的前提,却对提升数据库的性能十分不利。为此,ARIES 提出了“提前写入日志”(Write-Ahead Logging)的日志改进方案, 所谓“提前写入”(Write-Ahead),就是允许在事务提交之前写入变 动数据的意思。
Write-Ahead Logging按照事务提交时点,将何时写入变动数据划 分为FORCE和STEAL两类情况。
- FORCE:当事务提交后,要求变动数据必须同时完成写入则称为 FORCE,如果不强制变动数据必须同时完成写入则称为NO-FORCE。 现实中绝大多数数据库采用的都是NO-FORCE策略,因为只要有了日 志,变动数据随时可以持久化,从优化磁盘I/O性能考虑,没有必要强 制数据写入时立即进行。
- STEAL:在事务提交前,允许变动数据提前写入则称为STEAL, 不允许则称为NO-STEAL。从优化磁盘I/O性能考虑,允许数据提前写 入,有利于利用空闲I/O资源,也有利于节省数据库缓存区的内存。
Commit Logging允许NO-FORCE,但不允许STEAL。因为假如事务提 交前就有部分变动数据写入磁盘,那一旦事务要回滚,或者发生了崩 溃,这些提前写入的变动数据就都成了错误。 Write-Ahead Logging允许NO-FORCE,也允许STEAL,它给出的解 决办法是增加了另一种被称为Undo Log的日志类型,当变动数据写入 磁盘前,必须先记录Undo Log,注明修改了哪个位置的数据、从什么 值改成什么值等,以便在事务回滚或者崩溃恢复时根据Undo Log对提 前写入的数据变动进行擦除。Undo Log现在一般被翻译为“回滚日 志”,此前记录的用于崩溃恢复时重演数据变动的日志就相应被命名 为Redo Log,一般翻译为“重做日志”。由于Undo Log的加入, Write-Ahead Logging在崩溃恢复时会经历以下三个阶段。
- 分析阶段(Analysis):该阶段从最后一次检查点(Checkpoint, 可理解为在这个点之前所有应该持久化的变动都已安全落盘)开始扫 描日志,找出所有没有End Record的事务,组成待恢复的事务集合,这 个集合至少会包括事务表(Transaction Table)和脏页表(Dirty Page Table)两个组成部分。
- 重做阶段(Redo):该阶段依据分析阶段中产生的待恢复的事 务集合来重演历史(Repeat History),具体操作是找出所有包含 Commit Record的日志,将这些日志修改的数据写入磁盘,写入完成后 在日志中增加一条End Record,然后移出待恢复事务集合。
- 回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的 恢复事务集合,此时剩下的都是需要回滚的事务,它们被称为Loser, 根据Undo Log中的信息,将已经提前写入磁盘的信息重新改写回去, 以达到回滚这些Loser事务的目的。
重做阶段和回滚阶段的操作都应该设计为幂等的。为了追求高I/O 性能,以上三个阶段无可避免地会涉及非常烦琐的概念和细节(如 Redo Log、Undo Log的具体数据结构等)。数据库按照是否允许FORCE和STEAL可以产生四种组合,从优化磁 盘I/O的角度看,NO-FORCE加STEAL的组合的性能无疑是最高的;从算 法实现与日志的角度看,NO-FORCE加STEAL的组合的复杂度无疑也是最 高的。
实现隔离性
隔离性保证了每个 事务各自读、写的数据互相独立,不会彼此影响。只从定义上就能嗅 出隔离性肯定与并发密切相关,因为如果没有并发,所有事务全都是 串行的,那就不需要任何隔离,或者说这样的访问具备了天然的隔离 性。但现实情况是不可能没有并发,那么,要如何在并发下实现串行 的数据访问呢?几乎所有程序员都会回答:加锁同步呀!正确,现代 数据库均提供了以下三种锁。
- 写锁(Write Lock,也叫作排他锁,eXclusive Lock,简写为XLock):如果数据有加写锁,就只有持有写锁的事务才能对数据进行 写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加 读锁。
- 读锁(Read Lock,也叫作共享锁,Shared Lock,简写为SLock):多个事务可以对同一个数据添加多个读锁,数据被加上读锁 后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍 然可以读取。对于持有读锁的事务,如果该数据只有它自己一个事务 加了读锁,则允许直接将其升级为写锁,然后写入数据。
- 范围锁(Range Lock):对于某个范围直接加排他锁,在这个 范围内的数据不能被写入。如下语句是典型的加范围锁的例子:
SELECT * FROM books WHERE price < 100 FOR UPDATE;
请注意“范围不能被写入”与“一批数据不能被写入”的差别, 即不要把范围锁理解成一组排他锁的集合。加了范围锁后,不仅不能 修改该范围内已有的数据,也不能在该范围内新增或删除任何数据, 后者是一组排他锁的集合无法做到的。
明天继续...