本文是对《软件架构探索》 一书的读书笔记,并加上一些自己的理解和注释

本文开始记录《软件架构探索》的事务处理部分,对软件架构中的事务处理机制进行学习。


事务处理(1)

事务处理保证了系统中所有的数据都是符合期望、互相关联的数据之间不会产生矛盾,即数据状态的 一致性(Consistency)

达成一致性需要三个方面共同努力来保障:

  • 原子性(Atomic):在同一项业务处理过程中,事务保证了多个对数据的修改,要么同时成功,要么同时被撤销。

  • 隔离性(Isolation):在不同的业务处理过程中,事务保证了各自业务正在读、写的数据互相独立,不会彼此影响。

  • 持久性(Durability):事务应当保证所有成功被提交的数据修改都能够正确地被持久化,不丢失数据。

    以上四种属性就是事务的 “ACID”。A、I、D 是手段,C 是目的。

事务的概念起源于数据库,但如今不再局限于数据库本身,所有需要保证数据一致性的应用场景都有可能用到事务。

1. 本地事务

“本地事务(局部事务)” 与 “全局事务” 相对应。本地事务是指仅操作单一事务资源的、不需要全局事务管理器进行协调的事务。

本地事务是最基础的一种事务解决方案,只适用于单个服务使用单个数据源的场景。本地事务直接依赖于数据源本身提供的事务能力,在代码层面,最多只能对事务接口做一层标准化的包装,并不能深入参与事务的运作过程当中。因此,不得不越过代码层次,去了解数据库本身的事务实现原理。

1.1 实现原子性和持久性

原子性保证了事务的多个操作要么都生效要么都不生效;持久性保证了一旦事务生效,就不会再因为其他原因导致撤销或丢失。
1.1.1 崩溃恢复

当数据存储在内存时,一旦遇到 “崩溃” 等情况就会丢失。数据只有成功被写入磁盘等持久化存储器才能拥有持久性,而实现原子性和持久性面临最大的困难就是 “写入磁盘”。因为,写入磁盘这个操作并不是原子性的,不仅有 “写入” 与 “未写入” 状态,还存在着 “正在写” 的中间状态。

由于写入中间状态与崩溃都是无法避免的,为了保证原子性和持久性,就只能在崩溃后采取恢复的补救措施,这种数据恢复操作被称为 “崩溃恢复”。

1.1.2 Commit Logging

为了实现崩溃恢复,就不能直接对数据进行修改,必须将需要修改数据的这个操作的所有信息以追加的形式写入日志。当所有日志都安全写入,会在日志最后加上 Commit Record。只有数据库在日志中看到表示事务成功提交 的 Commit Record 后,才会根据日志上的操作信息对真正的数据进行修改,修改完成后,再向日志加入 End Record 表示事务完成持久化。这种事务的实现方式称为 “Commit Logging”。

崩溃恢复分析

首先,当日志成功写入 Commit Record,表示整个事务提交成功,即使真正修改数据时发生崩溃了,重启后根据已经写入磁盘的日志信息恢复现场、继续修改数据即可,这保证了持久性。其次,如果日志没有成功写入 Commit Record 就发生了崩溃,表示整个事务是失败的,重启后发现一部分没有 Commit Record 的日志,就会将这一部分日志标记为回滚状态,整个事务就像没有发生过一样,这保证了原子性

Commit Logging 的缺陷

但是 Commit Logging 仍然存在一个巨大缺陷:所有数据的真实修改都必须发生在事务提交之后。即使磁盘 I/O 有足够空闲、即使事务修改的数据量非常庞大,占用了大量的内存缓冲区,无论有何种理由,都决不允许在事务提交之前就修改磁盘上的数据。这一点是 Commit Logging 成立的前提,却对提升数据库的性能十分不利。为了解决这个缺陷,提出了 “Write-Ahead Logging” 的日志改进方案,允许在事务提交之前,提前写入数据。

1.1.3 Write-Ahead Logging

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 “重做日志”。

崩溃分析

  • 分析阶段(Analysis):该阶段从最后一次检查点(Checkpoint,可以理解为前面的数据已经安全写入)开始扫描日志,找出所有没有 End Record 的事务,组成待恢复的事务集合。
  • 重做阶段(Redo):根据分析阶段的事务集合,找到所有 Commit Record 的日志,并将这些事务写入磁盘,写入完成后在日志中增加 End Record 表示该事务已持久化,然后移出待恢复事务集合。
  • 回滚阶段(Undo):分析阶段的事务集合剩下的就是没有 Commit Record 的事务了,它们被称为 Loser。根据 Undo Log 中的信息,将这些事务中已经被写入磁盘的数据重新改写回去,以达成回滚这些 Loser 事务的目的。

数据库按照是否允许 FORCE 和 STEAL 可以产生共计四种组合,从优化磁盘 I/O 的角度看,NO-FORCE 加 STEAL 组合的性能无疑是最高的;从算法实现与日志的角度看 NO-FORCE 加 STEAL 组合的复杂度无疑也是最高的。这四种组合与 Undo Log、Redo Log 之间的具体关系如下图所示:

force-steal

1.2 实现隔离性

隔离性保证了每个事务各自读、写的数据互相独立,不会彼此影响。
1.2.1 可串行化

从定义上就可以看出隔离性与并发相关,如果没有并发,所有事务都是串行的,那么这样的访问就具备了天然的隔离性。而在并发下实现串行数据访问就需要加锁同步。现代数据库提供了三种锁:

  • 写锁(Write Lock,也叫做排他锁 eXclusive Lock,简写为 X-Lock):如果数据被加上写锁,就只有持有写锁的事务能对该数据进行写入,其他事务不能写入,也不能施加读锁。

    注意:其他事务不能施加读锁,不代表不能读取数据。对于普通查询(不带锁的查询)仍然可以读取被施加了写锁的数据。

  • 读锁(Read Lock,也叫做共享锁 Shared Lock,简写为 S-Lock):多个事务可以对同一数据添加多个读锁,数据被加上读锁后就不能再被施加写锁了,所以其他事务不能对该数据进行写入,但可以被读取。如果数据只被一个事务加了读锁,允许将其升级为写锁,然后写入数据。

  • 范围锁(Range Lock):对于某个范围施加排他锁,这个范围内的数据不能被写入,并且在该范围内也不能新增或删除数据。

    注意:范围锁不能理解为一组排他锁的集合。加了范围锁后,不仅无法修改该范围内已有的数据,也不能在该范围内新增或删除任何数据,后者是一组排他锁的集合无法做到的。

串行化访问提供了最强的隔离性,被定义为最高的隔离级别:可串行化。但是考虑性能优化,隔离程度越高,并发访问的吞吐量就越低。为了取得隔离性与吞吐量之间的平衡,现代数据库提供了提供了其他隔离级别,让用户可以调节数据库的加锁方式。

1.2.2 可重复读

可串行化的下一个隔离级别是 “可重复读”,可重复读对事务涉及到的数据加读锁和写锁,且直到事务结束,但不会加范围锁。由于没有使用范围锁来禁止在该范围内新增或删除数据,可重复读会产生 “幻读” 问题。

注意:“可重复读对事务涉及到的数据加读锁和写锁,且直到事务结束” 不能理解为数据同时被施加了写锁和读锁,而是,当作为 “读者” 时会施加读锁直到事务结束,当作为 “写者” 时会施加写锁直到事务结束。

“幻读” 问题示例

事务2 在两次范围查询之间,在该范围内插入了一条新记录,导致事务1 两次查询不一致,产生了幻读问题。

事务1 事务2
SELECT * FROM users WHERE age > 30;
INSERT INTO users(id,name,age) VALUES ( 3, ‘Bob’, 35 );
COMMIT; # 提交事务
SELECT * FROM users WHERE age > 30;
COMMIT; # 提交事务
1.2.3 已提交读

可重复读的下一个隔离级别是 “读已提交”,读已提交对涉及到的数据添加写锁,直到事务结束,但加的读锁在查询完后会马上释放。由于没有贯穿整个事务的读锁,读已提交会产生 “不可重复读” 问题。

注意:当作为 “读者” 访问数据时会在读取时添加读锁,当查询结束后会释放读锁,即使事务还没有结束。

“不可重复读” 问题示例

在事务1 第一次查询释放锁后,事务2 作为 “写者” 对数据施加写锁并修改其数据,导致事务1 两次查询不一致,产生了不可重复读问题。

事务1 事务2
SELECT * FROM users WHERE id = 1;
UPDATE users SET age = 21 WHERE id = 1;
COMMIT; # 提交事务
SELECT * FROM users WHERE id = 1;
COMMIT; # 提交事务
1.2.4 未提交读

读已提交的下一个隔离级别是 “读未提交”,读未提交对事务所涉及的数据只会加写锁,直到事务结束,完全不加读锁。由于在读取事务时没有施加读锁,读未提交会产生 “脏读” 问题。

注意:当作为 “读者” 时,未提交读不会施加任何锁。

“脏读” 问题示例

由于事务1 在读取数据时不施加读锁,所以无法保证所读取的数据已提交的,导致读取了事务2 提交的数据,产生了脏读问题。

事务1 事务2
SELECT age FROM users WHERE id = 1;
UPDATE users SET age = 21 WHERE id = 1;
SELECT age FROM users WHERE id = 1;
ROLLBACK;
1.2.5 多版本并发控制

以上四种隔离级别有一个共同特点,就是幻读、不可重复读、脏读等问题都是由于一个事务在读数据的过程中,受另一个写数据的事务影响而破坏了隔离性。针对 “一个事务读 + 一个事务写” 的隔离问题,有一种名为 “多版本并发控制(MVCC)” 的无锁优化方案。

MVCC 的基本思路是对数据库的任何修改都不会覆盖之前的数据,而是产生一个新版副本与老版本共存。可以理解为 MVCC,是通过在每行记录后面保存两个隐藏的列来实现的。一个保存了行创建的时间,一个保存了行的删除时间。当然存储的并不是实际的时间值,而是系统版本号。

  • INSERT:为新插入的每一行保存当前系统版本号作为创建版本号
  • DELETE:为删除的每一行保存当前系统版本号作为行删除版本号
  • UPDATE:将修改数据视为 “删除旧数据,插入新数据”,为新插入的行保存当前系统版本号作为创建版本号,同时保存当前系统版本号作为原来行的删除版本号

在事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。

  • 可重复读:总是读取创建版本号小于或等于当前事务版本号的记录,确保读取到的记录,要么在事务开始前就已经存在,要么是事务自身插入或修改的。
  • 读已提交:总是读取最新的版本。

2. 全局事务

与 “本地事务” 相对应的是 “全局事务”,这里所讨论全局事务为 “单个服务使用多个数据源场景的解决方案”。

注意,理论上真正的全局事务并没有“单个服务”的约束。这里将 “多个服务使用多个数据源” 作为分布式事务在下一章节讲解。

2.1 X/Open XA 处理事务架构

为了解决全局事务,提出了一套名为 X/Open XA 的处理事务架构,其核心内容是定义了全局的事务管理器(用于协调全局事务)和局部的资源管理器(用于驱动本地事务)之间的通讯接口。该接口能在一个事务管理器和多个资源过滤器之间形成通信桥梁,通过协调多个数据源的一致动作,实现全局事务的统一提交或者统一回滚。

2.1.1 两段式提交

XA 将事务拆分成为两阶段过程:

  • 准备阶段:又叫做投票阶段,在这一阶段,协调者会询问事务的所有的参与者是否准备好提交,准备好回复 Prepared,否则回复 Non-Prepared。

    这里的 “准备” 指的是本地事务已经将重做日志写好,只差最后一条 Commit Record。

  • 提交阶段:又叫做执行阶段,如果协调者收到所有事务回复的 Prepared 消息,则自己在本地持久化状态为 Commit,然后向所有发送 Commit 指令,所有参与者立即进行提交;如果任意一个参与者回复 Non-Prepared 消息,或超时为回复,协调者将自己的事务状态持久化为 Abort 后,向所有参与者发送 Abort 指令,所有参与者立即进行回滚。

以上两个过程被称为 “两段式提交” ,而它能够成功保证一致性还要求有其他前提条件:

  • 必须假设网络在提交阶段短时间内可靠的,即提交阶段不会丢失消息,不会传递错误消息。保证通讯正常。
  • 必须假设失联的节点最终能够恢复,不会永久性失联。保证节点恢复后可以向协调者查询事务状态,确定事务应该提交还是回滚。

两段式提交的缺陷

  • 单点问题:在两段式提交中,协调者具有举足轻重的作用,一但协调者宕机,所有参与者都会受到影响。
  • 性能问题:在两段式提交中,所有参与者被绑定为一个整体,期间要经过两次远程调用、三次持久化(准备阶段的重写日志、协调者做状态持久化、提交阶段的 Commit Record),整个过程都将持续到参与者中最慢的那个处理结束为止。
  • 一致性问题:两段式提交有两个前提条件,如果假设不成立就会出现一致性问题。
2.1.2 三段式提交

对于两段式提交的单点问题和准备阶段的性能问题,又发展出了 “三段式提交”。三段式提交将准备阶段细分为 CanCommit、PreCommit 两个阶段,将提交阶段改称为 DoCommit 阶段。

  • CanCommit:询问阶段,协调者让每个参与者对自身进行评估,判断事务是否有可能顺利完成。

在三段式提交中添加了 CanCommit 阶段让参与者进行评估,使参与者发生崩溃的风险相对变小。因此,在事务需要回滚的场景中,三段式提交性能更好。但是在正常的提交场景中,三段式提交因为多了一次询问,性能反而更差。

三段式提交对单点问题和回滚时的性能问题有所改善,但是对于一致性的问题没有任何改进。

3. 附录:参考资料

本地事务 | 软件架构探索:The Fenix Project

全局事务 | 软件架构探索:The Fenix Project

Isolation (database systems) - Wikipedia

SQL-92 - Wikipedia

高性能MySQL(第3版)

對於 MySQL Repeatable Read Isolation 常見的三個誤解

评论