1. 从本地事务开始

1.1. 刚性事务ACID

  • 原子性(Atomic):在同一项业务处理过程中,事务保证了多个对数据的修改,要么同时成功,要么一起被撤销。
  • 隔离性(Isolation):在不同的业务处理过程中,事务保证了各自业务正在读、写的数据互相独立,不会彼此影响。
  • 持久性(Durability):事务应当保证所有被成功提交的数据修改都能够正确地被持久化,不丢失数据。
  • 一致性(Consistency):AID最终要实现的目标,保证系统中的数据是正确的,不同数据间不会产生矛盾。

1.2. 实现原子性和持久性

  • 实现原子性和持久性的难点

实现原子性和持久性所面临的困难是,“写入磁盘”这个操作不会是原子的,不仅有“写入”与“未写入”,还客观地存在着“正在写”的中间状态。

  • 对于未提交事务:一旦重启之后,系统必须将该事务已经修改过的数据从磁盘中恢复成没有改过的样子以保证原子性。
  • 对于已提交事务:一旦重启之后,系统必须将当前事务还没来得及写入磁盘的那部分数据重新写入,以保证持久性。

这种数据恢复操作被称为崩溃恢复(Crash Recovery,也有称作 Failure Recovery 或 Transaction Recovery)

  • Commit Logging的方案

为了能够顺利地完成崩溃恢复,在磁盘中写数据就不能像程序修改内存中变量值那样,直接改变某表某行某列的某个值,必须将修改数据这个操作所需的全部信息(比如修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值等等),以日志的形式(日志特指仅进行顺序追加的文件写入方式,这是最高效的写入方式)先记录到磁盘中。只有在日志记录全部都安全落盘,见到代表事务成功提交的“Commit Record”后,数据库才会根据日志上的信息对真正的数据进行修改,修改完成后,在日志中加入一条“End Record”表示事务已完成持久化,这种事务实现方法被称为“Commit Logging”。

Commit Logging的问题在于:所有对数据的真实修改都必须发生在事务提交、日志写入了 Commit Record 之后,即使事务提交前磁盘 I/O 有足够空闲、即使某个事务修改的数据量非常庞大,占用大量的内存缓冲,无论何种理由,都决不允许在事务提交之前就开始修改磁盘上的数据,这一点对提升数据库的性能是很不利的。

  • ARIES理论中的WAL的改良方案

ARIES理论提出了“Write-Ahead Logging”的日志改进方案,其名字里所谓的“提前写入”(Write-Ahead),就是允许在事务提交之前,提前写入变动数据的意思。

Write-Ahead Logging 先将何时写入变动数据,按照事务提交时点为界,分为了 FORCE 和 STEAL 两类:

  1. FORCE:当事务提交后,要求变动数据必须同时完成写入则称为 FORCE,如果不强制变动数据必须同时完成写入则称为 NO-FORCE。现实中绝大多数数据库采用的都是 NO-FORCE 策略,只要有了日志,变动数据随时可以持久化,从优化磁盘 I/O 性能考虑,没有必要强制数据写入立即进行。
  2. STEAL:在事务提交前,允许变动数据提前写入则称为 STEAL,不允许则称为 NO-STEAL。从优化磁盘 I/O 性能考虑,允许数据提前写入,有利于利用空闲 I/O 资源,也有利于节省数据库缓存区的内存。

Commit Logging 是 NO-FORCE,NO-STEAL。

WAL允许 NO-FORCE,也允许 STEAL,它给出的解决办法是增加了另一种称为 Undo Log 的日志当变动数据写入磁盘前,必须先记录 Undo Log,写明修改哪个位置的数据、从什么值改成什么值,以便在事务回滚或者崩溃恢复时,根据 Undo Log 对提前写入的数据变动进行擦除

1.3. 实现隔离性

1.3.1. 隔离性用锁实现

隔离性最常见的方式就是通过锁来实现。

现代数据库都提供了以下三种锁:

  1. 写锁(Write Lock,也叫做排他锁 eXclusive Lock,简写为 X-Lock):只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁
  2. 读锁(Read Lock,也叫做共享锁 Shared Lock,简写为 S-Lock):多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有读锁的事务,如果该数据只有一个事务加了读锁,那可以直接将其升级为写锁,然后写入数据。
  3. 范围锁(Range Lock):对于某个范围直接加排他锁,在这个范围内的数据不能被读取,也不能被写入。加了范围锁后,不仅无法修改该范围内已有的数据,也不能在该范围内新增或删除任何数据.

1.3.2. 事务隔离级别

注意,这里从上到下是有层次关系的,越后面的级别,问题越多(包含上层所有的问题),隔离性越差,吞吐量越高

  • 可串行化(Serializable)

串行化访问提供了强度最高的隔离性。如果不考虑性能优化的话,对事务所有读、写的数据全都加上读锁、写锁和范围锁即可(这种可串行化的实现方案称为 Two-Phase Lock)。

  • 可重复读(Repeatable Read)

可重复读的意思就是对事务所涉及到的数据加读锁和写锁,并且一直持续到事务结束,但不再加范围锁。

可重复读比可串行化弱化的地方在于幻读问题(Phantom Reads),它是指在事务执行的过程中,两个完全相同的范围查询得到了不同的结果集

注意,这是ARIES 理论,实际上各家数据库厂商的实现有区别,在MySQL中的只读业务是有MVCC机制去解决幻读问题的。

  • 读已提交(Read Committed)

读已提交对事务涉及到的数据加的写锁,会一直持续到事务结束,但加的读锁在查询操作完成后就马上会释放。

读已提交比可重复读弱化的地方在于不可重复读问题(Non-Repeatable Reads),它是指在事务执行过程中,对同一行数据的两次查询得到了不同的结果.

  • 读未提交(Read Uncommitted)

读未提交对事务涉及到的数据只加写锁,这会一直持续到事务结束,但完全不加读锁。

读未提交比读已提交弱化的地方在于脏读问题(Dirty Reads),它是指在事务执行的过程中,一个事务读取到了另一个事务未提交的数据

不同隔离级别以及幻读、脏读等问题都只是表面现象,它们是各种锁在不同加锁时间上组合应用所产生的结果,锁才是根本的原因。

1.3.3. 多版本并发控制(Multi-Version Concurrency Control,MVCC)

MVCC 是一种读取优化策略,它的“无锁”是特指读取时不需要加锁。

MVCC 的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的

MVCC 是只针对“读 + 写”场景的优化,如果是两个事务同时修改数据,即“写 + 写”的情况,那就没有多少优化的空间了,加锁几乎是唯一可行的解决方案

这个各家数据库的具体实现也是有差异,后面笔者应该会安排一篇MySQL相关的文章去探讨MySQL的实现。

2. 迈入分布式事务

2.1. 追求ACID的“强一致性”

2.1.1. XA协议(2PC,3PC)

为了解决分布式事务的一致性问题,1991 年的时候X/Open组织(后来并入了The Open Group)提出了一套叫做X/Open XA(XA 是 eXtended Architecture 的缩写)的事务处理框架。

这个框架的核心内容是,定义了**全局的事务管理器(Transaction Manager,用于协调全局事务)局部的资源管理器(Resource Manager,用于驱动本地事务)**之间的通讯接口。

  • 两阶段提交(2 Phase Commit,2PC)

%E4%BB%8E%E6%9C%AC%E5%9C%B0%E4%BA%8B%E5%8A%A1%E5%88%B0%E5%88%86%E5%B8%83%E5%BC%8F%E4%BA%8B%E5%8A%A1%20bfe3a38c81be426789cd36047f786168/Untitled.png

事务提交拆分成了两阶段过程,也就是准备阶段和提交阶段:

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

    这里的“准备”操作,其实和我们通常理解的“准备”不太一样:对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条 Commit Record。这意味着在做完数据持久化后并不会立即释放隔离性,也就是仍继续持有锁,维持数据对其他非事务内观察者的隔离状态。

  2. 提交阶段,又叫做执行阶段,协调者如果在准备阶段收到所有事务参与者回复的 Prepared 消息,就会首先在本地持久化事务状态为 Commit,然后向所有参与者发送 Commit 指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了 Non-Prepared 消息,或任意一个参与者超时未回复,协调者都会将自己的事务状态持久化为“Abort”之后,向所有参与者发送 Abort 指令,参与者立即执行回滚操作。

  • 三阶段提交(3 Phase Commit,3PC)

%E4%BB%8E%E6%9C%AC%E5%9C%B0%E4%BA%8B%E5%8A%A1%E5%88%B0%E5%88%86%E5%B8%83%E5%BC%8F%E4%BA%8B%E5%8A%A1%20bfe3a38c81be426789cd36047f786168/Untitled%201.png

三段式提交把原本的两段式提交的准备阶段再细分为两个阶段,分别称为 CanCommitPreCommit,把提交阶段改为 DoCommit 阶段。其中,新增的 CanCommit 是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。

将准备阶段一分为二的理由是,这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,这时候涉及的数据资源都会被锁住。如果此时某一个参与者无法完成提交,相当于所有的参与者都做了一轮无用功。

XA协议存在木桶效应(短板效应)的问题,即在准备阶段,必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应(所有涉及到的锁和资源,都需要等到最慢的事务完成后才能统一释放)

2.2. 追求最终一致性(弱一致性的特例)

2.2.1. CAP理论

这个定理里,描述了一个分布式的系统中,当涉及到共享数据问题时,以下三个特性最多只能满足其中两个

  • 一致性(Consistency):代表在任何时刻、任何分布式节点中,我们所看到的数据都是没有矛盾的。这与第 11 讲所提到的 ACID 中的 C 是相同的单词,但它们又有不同的定义(分别指 Replication 的一致性和数据库状态的一致性)。在分布式事务中,ACID 的 C 要以满足 CAP 中的 C 为前提。
  • 可用性(Availability):代表系统不间断地提供服务的能力。
  • 分区容忍性(Partition Tolerance):代表分布式环境中,当部分节点因网络原因而彼此失联(即与其他节点形成“网络分区”)时,系统仍能正确地提供服务的能力。

把前面我们在 CAP、ACID 中讨论的一致性称为“强一致性”(Strong Consistency),有时也称为**“线性一致性”(Linearizability)**

牺牲了 C 的 AP 系统,又要尽可能获得正确的结果的行为,称为追求“弱一致性”

针对追求 ACID 的事务,称为“刚性事务”。为了满足可用性、性能与降级服务的需要,降低一致性(Consistency)与隔离性(Isolation)的要求追求会统称为“柔性事务”

2.2.2. BASE理论

BASE理论由 eBay 的系统架构师丹 · 普利切特(Dan Pritchett)在 2008提出,即:

  • 基本可用(Basically Available,BA)
  • 软状态(Soft State,S)
  • 最终一致性(Eventually Consistent,E)

笔者查了一些资料,简单总结:

  • 基本可用是说系统,出现了不可预知的故障,但还是能用
  • 软状态是允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。注意这里的延时很重要,基本柔性事务的方案都是会存在“延时”。
  • 最终一致性,是说经过一定时限后,从软状态过渡过来的一个最终数据一致性的状态

最终一致性其实是弱一致性中的特例

具体的BASE理论的内容,可以点开文末的参考资料。

2.3. 柔性事务实现最终一致性方案

世间安得双全法,鱼与熊掌不可兼得,很多时候我们需要退而求其次,放弃强一致性,通过柔性事务追求最终一致性。

周志明博士用了以下事例场景进行论述:

Fenix’s Bookstore 是一个在线书店。一份商品成功售出,需要确保以下三件事情被正确地处理:

  • 用户的账号扣减相应的商品款项;
  • 商品仓库中扣减库存,将商品标识为待配送状态;
  • 商家的账号增加相应的商品款项。

2.3.1. 可靠消息队列

%E4%BB%8E%E6%9C%AC%E5%9C%B0%E4%BA%8B%E5%8A%A1%E5%88%B0%E5%88%86%E5%B8%83%E5%BC%8F%E4%BA%8B%E5%8A%A1%20bfe3a38c81be426789cd36047f786168/Untitled%202.png

可以看到,本地事务完成后,会向消息服务中发送消息,要求执行其他关联事务。关联事务消费消息,通过不断重拾来保证可靠性,当然这里要保重幂等性

可靠消息队列的方式无隔离性可言,各个事务之间有点“各为其主”的感觉

这种靠着持续重试来保证可靠性的操作,叫做“最大努力交付”(Best-Effort Delivery)

2.3.2. TCC(Try-Confirm-Cancel)

%E4%BB%8E%E6%9C%AC%E5%9C%B0%E4%BA%8B%E5%8A%A1%E5%88%B0%E5%88%86%E5%B8%83%E5%BC%8F%E4%BA%8B%E5%8A%A1%20bfe3a38c81be426789cd36047f786168/Untitled%203.png

如果业务需要较强隔离性,通常就应该重点考虑 TCC 方案,它天生适合用于需要强隔离性的分布式事务中。

TCC 在业务执行的时候,只操作预留资源,几乎不会涉及到锁和资源的争用,所以它具有很高的性能潜力

在具体实现上,TCC 的操作其实有点儿麻烦和复杂,它是一种业务侵入性较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认 / 释放消费资源”两个子过程:

  1. Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好事务需要用到的所有业务资源(保障隔离性)
  2. Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。注意,Confirm 阶段可能会重复执行,因此需要满足幂等性
  3. Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。注意,Cancel 阶段也可能会重复执行,因此也需要满足幂等性

TCC方案的问题在于“Try”阶段需要预留业务资源,但是在一些业务场景下,很难做到(比如对接三方API)。

2.3.3. SAGA(基于数据补偿代替回滚)

SAGA是一种基于数据补偿代替回滚的方案,主要有以下步骤:

  1. 把大事务拆分成若干个小事务,将整个分布式事务 T 分解为 n 个子事务,我们命名为 T1,T2,…,Ti,…,Tn。每个子事务都应该、或者能被看作是原子行为。如果分布式事务 T 能够正常提交,那么它对数据的影响(最终一致性)就应该与连续按顺序成功提交子事务 Ti 等价。
  2. 为每一个子事务设计对应的补偿动作,我们命名为 C1,C2,…,Ci,…,Cn。Ti 与 Ci 必须满足以下条件:
    • Ti 与 Ci 都具备幂等性;
    • Ti 与 Ci 满足交换律(Commutative),即不管是先执行 Ti 还是先执行 Ci,效果都是一样的;
    • Ci 必须能成功提交,即不考虑 Ci 本身提交失败被回滚的情况,如果出现就必须持续重试直至成功,或者要人工介入。

如果 T1 到 Tn 均成功提交,那么事务就可以顺利完成。否则,我们就要采取以下两种恢复策略之一:

  1. 正向恢复(Forward Recovery;不断重试,最大努力交付):如果 Ti 事务提交失败,则一直对 Ti 进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,比如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti(重试)…,Ti+1,…,Tn。
  2. 反向恢复(Backward Recovery,回滚,执行补偿动作还原数据):如果 Ti 事务提交失败,则一直执行 Ci 对 Ti 进行补偿,直至成功为止(最大努力交付)。这里要求 Ci 必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。

与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多

2.3.4. AT事务(基于数据补偿改良2PC)

上文也提到过,2PC的流程中有一个木桶效应(短板效应)的问题,即在准备阶段,必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应(所有涉及到的锁和资源,都需要等到最慢的事务完成后才能统一释放),AT 事务基于此进行了改良。

它大致的做法是在业务数据提交时,自动拦截所有 SQL,分别保存 SQL 对数据修改前后结果的快照,生成行锁,通过本地事务一起提交到操作的数据源中,这就相当于自动记录了重做和回滚日志。如果分布式事务成功提交了,那么我们后续只需清理每个数据源中对应的日志数据即可;而如果分布式事务需要回滚,就要根据日志数据自动产生用于补偿的“逆向 SQL”

所以,基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源

AT 事务这种异步提交的模式,相比 2PC 极大地提升了系统的吞吐量水平。而使用的代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚不一定总能成功比如脏写(Dirty Wirte)问题,可能补偿时会覆盖掉其他事务写入的数据。因此常常要配合一些锁来一起使用

3. 参考资料