目录
1、事务的发展
2、本地事务
(1)如何保障原子性和持久性?
(2)如何保障隔离性?
2、全局事务
(1)XA事务的两段式提交
(2)XA事务的三段式提交
3、分布式事务
(1)CAP定理
(2)如何保证最终一致性?
(3)TCC事务(冻结资源)
(4)SAGA事务(补偿资源)
1、事务的发展
我们都知道数据库中事务的“ACID”特性,但事务的概念今天已经有所延伸,不再局限于数据库本身了。所有需要保证数据一致性的应用场景,包括但不限于数据库、事务内存、缓存、消息队列、分布式存储,等等,都有可能用到事务。
当一个服务只使用一个数据源时,通过 A、I、D 来获得一致性是最经典的做法,也是相对容易的。此时,多个并发事务所读写的数据能够被数据源感知是否存在冲突,并发事务的读写在时间线上的最终顺序是由数据源来确定的,这种事务间一致性被称为“内部一致性”。// C是最终结果
当一个服务使用到多个不同的数据源,甚至多个不同服务同时涉及多个不同的数据源时,问题就变得困难了许多。此时,并发执行甚至是先后执行的多个事务,在时间线上的顺序并不由任何一个数据源来决定,这种涉及多个数据源的事务间一致性被称为“外部一致性”。//外部一致性少了一个统一的数据源去协调一致性,所以我们需要模拟内部一致性的实现,去创造一个统一的外部的协调器
2、本地事务
本地事务是指仅操作单一事务资源的、不需要全局事务管理器进行协调的事务。
本地事务是一种最基础的事务解决方案,只适用于单个服务使用单个数据源的场景。从应用角度看,它是直接依赖于数据源本身提供的事务能力来工作的,在程序代码层面,最多只能对事务接口做一层标准化的包装(如JDBC接口),并不能深入参与到事务的运作过程中,事务的开启、终止、提交、回滚、嵌套、设置隔离级别,乃至与应用代码贴近的事务传播方式,全部都要依赖底层数据源的支持才能工作,这一点与后续介绍的XA、TCC、SAGA等主要靠应用程序代码来实现的事务有着十分明显的区别。//程序不介入单一事物源的操作
那么传统统数据库管理系统是如何通过ACID来实现事务的?
(1)如何保障原子性和持久性?
实现原子性和持久性的最大困难是“写入磁盘”这个操作并不是原子的,不仅有“写入”与“未写入”状态,还客观存在着“正在写”的中间状态。由于写入中间状态与崩溃都不可能消除,所以如果不做额外保障措施的话,将内存中的数据写入磁盘,并不能保证原子性与持久性。
解决方式是使用日志。
以日志的形式——即以仅进行顺序追加的文件写入的形式(这是最高效的写入方式)先记录到磁盘中。//顺序写
只有在日志记录全部安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再
在日志中加入一条“结束记录”(End Record)表示事务已完成持久化,这种事务实现方法被称为“提交日志”(Commit Logging)。//MySQL中提交日志的两段式提交-redoLog
你以为问题就此解决了吗?NO!单一的提交日志会带来性能问题。
Commit Logging存在一个巨大的先天缺陷:所有对数据的真实修改都必须发生在事务提交以后,即日志写入了 Commit Record 之后。在此之前,即使磁盘 I/O 有足够空闲,即使某个事务修改的数据量非常庞大,占用了大量的内存缓冲区,无论何种理由,都决不允许在事务提交之前就修改磁盘上的数据,这一点是Commit Logging成立的前提,却对提升数据库的性能十分不利。
为此,ARIES 提出了“提前写入日志”(Write-Ahead Logging)的日志改进方案,所谓“提前写入”(Write-Ahead),就是允许在事务提交之前写入变动数据的意思。//预写日志
但是如果事务提交前就有部分变动数据写入磁盘,那一旦事务要回滚,或者发生了崩溃,这些提前写入的变动数据就都成了错误。又如何去避免这个问题呢?
给出的解决办法是增加了另一种被称为 Undo Log 的日志类型,当变动数据写入磁盘前,必须先记录Undo Log,注明修改了哪个位置的数据、从什么值改成什么值等,以便在事务回滚或者崩溃恢复时根据 Undo Log 对提前写入的数据变动进行擦除。//增加回滚日志
(2)如何保障隔离性?
隔离性保证了每个事务各自读、写的数据互相独立,不会彼此影响。只从定义上就能嗅出隔离性肯定与并发密切相关,因为如果没有并发,所有事务全都是串行的,那就不需要任何隔离。
那么,要如何在并发下实现串行的数据访问呢?
答案就是:加锁同步!
现代数据库均提供了以下三种锁:
- 写锁(排他锁):如果数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。
- 读锁(共享锁):多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,则允许直接将其升级为写锁,然后写入数据。
- 范围锁:对于某个范围直接加排他锁,在这个范围内的数据不能被写入。
请注意“范围不能被写入”与“一批数据不能被写入”的差别,即不要把范围锁理解成一组排他锁的集合。加了范围锁后,不仅不能修改该范围内已有的数据,也不能在该范围内新增或删除任何数据,后者是一组排他锁的集合无法做到的。
并发控制(Concurrency Control)理论决定了隔离程度与并发能力是相互抵触的,隔离程度越高,并发访问时的吞吐量就越低。
加锁就是进行串行化,数据库不考虑性能肯定是不行的,所以数据库中如何去权衡隔离性和吞吐量呢?
这里就要说到我们常见的数据库隔离级别了,即“串行化”,“可重复度”(幻读问题),“读已提交”(不可重复读问题),“读未提交”(脏读问题)。//四种隔离级别
“可重复度”会产生幻读问题,原因是可重复读没有范围锁来禁止在该范围内插入新的数据,这是一个事务受到其他事务影响,隔离性被破坏的表现。//缺乏范围锁
“读已提交”对事务涉及的数据加的写锁会一直持续到事务结束,但加的读锁在查询操作完成后会马上释放。因为缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化,所以会出现不可重复读问题。//缺乏贯穿整个事务周期的读锁
“读未提交”会产生脏读问题。原因在于它只会对事务涉及的数据加写锁,且一直持续到事务结束,但完全不加读锁。//完全无读锁,不加锁也就不会去获取锁
总结以上三种问题:幻读、不可重复读、脏读等问题都是由于一个事务在读数据的过程中,受另外一个写数据的事务影响而破坏了隔离性。//都是“一个事务读+另一个事务写”的隔离问题
“多版本并发控制”(Multi-Version Concurrency Control,MVCC)无锁优化方案
MVCC是一种读取优化策略,它的“无锁”特指读取时不需要加锁。MVCC的基本思路是对数据
库的任何修改都不会直接覆盖之前的数据,而是产生一个新版本与老版本共存,以此达到读取时可以完全不加锁的目的。//使用版本快照——不加读锁(可实现读已提交和可重复读两种隔离级别) ,也就是说使用MVCC既保证了可重复读的隔离性,又具备了读未提交的性能。
需要注意的是,MVCC是只针对“读+写”场景的优化,如果是两个事务同时修改数据,即“写+写”的情况,那就没有多少优化的空间了,此时加锁几乎是唯一可行的解决方案。
2、全局事务
在本节里,全局事务被限定为一种适用于单个服务使用多个数据源场景的事务解决方案。
(1)XA事务的两段式提交
1991年,为了解决分布式事务的一致性问题,X/Open组织提出了一套名为X/Open XA(XA是 eXtended Architecture 的缩写)的处理事务架构,其核心内容是定义了全局的事务管理器(Transaction Manager,用于协调全局事务)和局部的资源管理器(Resource Manager,用于驱动本地事务)之间的通信接口。//XA架构
XA 接口是双向的,能在一个事务管理器和多个资源管理器之间形成通信桥梁,通过协调多个数据源的一致动作,实现全局事务的统一提交或者统一回滚。
XA将事务提交拆分成两阶段:
准备阶段:又叫作投票阶段,在这一阶段,协调者询问事务的所有参与者是否准备好提交,参与者如果已经准备好提交则回复 Prepared,否则回复 Non-Prepared。准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条Commit Record而已。//重量操作
提交阶段:又叫作执行阶段,协调者如果在上一阶段收到所有事务参与者回复的 Prepared 消息,则先自己在本地持久化事务状态为 Commit,然后向所有参与者发送 Commit 指令,让所有参与者立即执行提交操作;否则,协调者将在自己完成事务状态为Abort持久化后,向所有参与者发送Abort指令,让参与者立即执行回滚操作。这个阶段的提交操作应是很轻量的,仅仅是持久化一条Commit Record而已,通常能够快速完成,只有收到Abort指令时,才需要根据回滚日志清理已提交的数据,这可能是相对重负载操作。//轻量操作
两段式提交原理简单,并不难实现,但有几个非常显著的缺点:
(1)单点问题:协调者在两段式提交中具有举足轻重的作用。一旦宕机所有参与者都会受到影响。如果协调者一直没有恢复,那所有参与者都必须一直等待。
(2)性能问题:在两段式提交过程中,所有参与者相当于被绑定为一个统一调度的整体,期间要经过两次远程服务调用,三次数据持久化,整个过程将持续到参与者集群中最慢的那一个处理操作结束为止,这决定了两段式提交的性能通常都较差。//一个慢,所有都慢
(3)一致性风险:尽管提交阶段时间很短,但这仍是一段明确存在的危险期,如果协调者在发出准备指令后,根据收到各个参与者发回的信息确定事务状态是可以提交的,协调者会先持久化事务状态,并提交自己的事务,如果这时候网络忽然断开,无法再通过网络向所有参与者发出Commit指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)未提交,且没有办法回滚,产生数据不一致的问题。//提交阶段断网
(2)XA事务的三段式提交
为了缓解两段式提交协议的一部分缺陷,具体地说是协调者的单点问题和准备阶段的性能问题,后续又发展出了“三段式提交”(3 Phase Commit,3PC)协议。//在两阶段提交的基础上增加询问阶段,增加事务成功率
三段式提交把原本的两段式提交的准备阶段再细分为两个阶段,分别称为 CanCommit、PreCommit,把提交阶段改称为 DoCommit 阶段。其中,新增的 CanCommit 是一个询问阶段,即协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。
将准备阶段一分为二的理由是这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,它们所涉及的数据资源即被锁住,如果此时某一个参与者宣告无法完成提交,相当于大家都做了一轮无用功。所以,增加一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,这也意味着因某个参与者提交时发生崩溃而导致大家全部回滚的风险相对变小。因此,在事务需要回滚的场景中,三段式提交的性能通常要比两段式提交好很多,但在事务能够正常提交的场景中,两者的性能都很差,甚至三段式因为多了一次询问,还要稍微更差一些。//减少回滚风险
同样也是由于事务失败回滚概率变小,在三段式提交中,如果在 PreCommit 阶段之后发生了协调者宕机,即参与者没有等到 DoCommit 的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待,这就相当于避免了协调者单点问题的风险。//避免单点问题
三段式提交对单点问题和回滚时的性能问题有所改善,但是对一致性风险问题并未有任何改进,甚至是略有增加的。譬如,进入PreCommit阶段之后,协调者发出的指令不是 Ack 而是Abort,而此时因网络问题,有部分参与者直至超时都未能收到协调者的 Abort 指令的话,这些参与者将会错误地提交事务,这就产生了不同参与者之间数据不一致的问题。//三段式并没有减少一致性风险
//探究,Spring 事务的传播级别是否可以解决多个数据源的事务呢?
3、分布式事务
本节所说的分布式事务(Distributed Transaction)特指多个服务同时访问多个数据源的事务处理机制。
为什么 XA 的事务机制分布式环境中不好用了呢?
这需要从 CAP 与 ACID 的矛盾说起。
(1)CAP定理
CAP定理(Consistency、Availability、Partition Tolerance Theorem)描述了在一个分布式系统中,涉及共享数据问题时,以下三个特性最多只能同时满足其中两个:
- 一致性(Consistency):代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的。
- 可用性(Availability):代表系统不间断地提供服务的能力。
- 分区容忍性(Partition Tolerance):代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成“网络分区”时,系统仍能正确地提供服务的能力。
由于 CAP 定理已有严格的证明,那么接下来直接分析舍弃 C、A、P 时所带来的不同影响。
(1)如果放弃分区容忍性(CA without P),意味着我们将假设节点之间的通信永远是可靠的。永远可靠的通信在分布式系统中必定是不成立的,这不是你想不想的问题,而是只要用到网络来共享数据,分区现象就始终存在。//在分布式系统中不成立
(2)如果放弃可用性(CP without A),意味着我们将假设一旦网络发生分区,节点之间的信息同步时间可以无限制地延长。在现实中,选择放弃可用性的情况一般出现在对数据质量要求很高的场合中,除了 DTP 模型的分布式数据库事务外,著名的HBase也属于 CP 系统。
(3)如果放弃一致性(AP without C),意味着我们将假设一旦发生分区,节点之间所提供的数据可能不一致。选择放弃一致性的 AP 系统是目前设计分布式系统的主流选择,因为 P 是分布式网络的天然属性,你再不想要也无法丢弃;而 A 通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就失去了存在的价值,除非银行、证券这些涉及金钱交易的服务,宁可中断也不能出错,否则多数系统是不能容忍节点越多可用性反而越低的。//主流方案
那么问题来了,“事务”原本的目的就是获得“一致性”,而在分布式环境中,“一致性”却不得不成为通常被牺牲、被放弃的那一项属性。如何解决这个矛盾呢?
无论如何,我们建设信息系统,终究还是要确保操作结果至少在最终交付的时候是正确的,这句话的意思是允许数据在中间过程出错(不一致),但应该在输出时被修正过来。为此,人们又重新给一致性下了定义,将前面我们在 CAP、ACID 中讨论的一致性称为“强一致性”(Strong Consistency),而把牺牲了 C 的 AP 系统又要尽可能获得正确结果的行为称为追求“弱一致性”。// 换一种思路思考问题,保证正确性 -> 非常聪明的想法,技术都是妥协创新出来的
在弱一致性里,人们又总结出了一种稍微强一点的特例,被称为“最终一致性”(Eventual Consistency),它是指如果数据在一段时间之内没有被另外的操作更改,那它最终会达到与强一致性过程相同的结果。
(2)如何保证最终一致性?
最终一致性的概念来源于 BASE 理论。BASE 分别是基本可用性(Basically Available)、柔
性事务(Soft State)和最终一致性(Eventually Consistent)的缩写。
BASE 理论为实现最终一致性提供了一种非常有价值的分布式事务解决思路,那就是“可靠事件队列”,就简单的理解为消息中间件好了,本质上差不多。
“可靠事件队列”是依靠持续重试来保证可靠性的解决方案,它在计算机的其他领域中已被频繁使用,也有了专门的名字——“最大努力交付”(Best-Effort Delivery),譬如 TCP 协议中未收到 ACK 应答自动重新发包的可靠性保障就属于最大努力交付。而可靠事件队列还有一种更普通的形式,被称为“最大努力一次提交”(Best-Effort 1PC),指的是将最有可能出错的业务以本地事务的方式完成后,采用不断重试的方式来促使同一个分布式事务中的其他关联业务全部完成。//不断重试+幂等性
(3)TCC事务(冻结资源)
可靠消息队列虽然能保证最终结果的相对可靠性,但整个过程完全没有任何隔离性可
言,虽然在一些业务中隔离性是无关紧要的,但在有些业务中缺乏隔离性就会带来许多麻烦,比如“超售”的问题。// 消息队列无法保证隔离性
所以如果业务需要隔离,那通常就应该重点考虑 TCC 方案,该方案天生适用于需要强隔离性的分布式事务中。
TCC 是另一种常见的分布式事务机制,它是 “Try-Confirm-Cancel” 三个单词的缩写,它分为以下三个阶段:
- Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需要用到的业务资源(保障隔离性)。
- Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm阶段可能会重复执行,因此本阶段执行的操作需要具备幂等性。
- Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,因此本阶段执行的操作也需要具备幂等性。
//在持续重试的模式下,会发送重复的消息,所以幂等性很重要。
TCC 其实有点类似 2PC 的准备阶段和提交阶段,但 TCC 是在用户代码层面,而不是在基础设施层面,这为它的实现带来了较高的灵活性,可以根据需要设计资源锁定的粒度。//业务侵入式较强的事务方案
TCC 在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,具有很高的性能潜力。但是TCC也带来了更高的开发成本和业务侵入性,即更高的开发成本和更换事务实现方案的替换成本,所以,通常我们并不会完全靠裸编码来实现 TCC,而是基于某些分布式事务中间件(譬如阿里开源的 Seata)去完成,尽量减轻一些编码工作量。
(4)SAGA事务(补偿资源)
TCC 事务具有较强的隔离性,避免了“超售”的问题,而且其性能一般来说是本篇提及的几种柔性事务模式中最高的,但它仍不能满足所有的场景。TCC 的业务侵入性很强,并不是所有的资源都可以让你去 Try,比如尝试冻结银行余额,所以 TCC 中的第一步 Try 阶段往往无法施行。//不是所有的资源都可以去冻结
因此只能考虑采用另外一种柔性事务方案:SAGA 事务。SAGA 在英文中是“长篇故事、长篇记叙、一长串事件”的意思。//长时间事务
所谓“长时间事务”(Long Lived Transaction)就是把一个大事务分解为可以交错运行的一系列子事务集合。原本 SAGA 的目的是避免大事务长时间锁定数据库的资源,后来才发展成将一个分布式环境中的大事务分解为一系列本地事务的设计模式。//分解事务——再一次使用拆分思想
SAGA 由两部分操作组成:
阶段一:
将大事务拆分成若干个小事务,将整个分布式事务 T 分解为 n 个子事务,命名为 T1,T2,…,Ti,…,Tn。每个子事务都应该是或者能被视为原子行为。如果分布式事务能够正常提交,其对数据的影响(即最终一致性)应与连续按顺序成功提交 Ti 等价。
为每一个子事务设计对应的补偿动作,命名为C1,C2,…,Ci,…,Cn。Ti 与 Ci 必须满足以下条件。
- Ti 与 Ci 都具备幂等性。
- Ti 与 Ci 满足交换律(Commutative),即无论先执行 Ti 还是先执行 Ci,其效果都是一样的。
- Ci 必须能成功提交,即不考虑 Ci 本身提交失败被回滚的情形,如出现就必须持续重试直至成功,或者被人工介入为止。
// 拆分事务+设计事务补偿动作
如果 T1 到 Tn 均成功提交,那事务顺利完成,否则,要采取以下两种恢复策略之一。
阶段二:
正向恢复(Forward Recovery):如果 Ti 事务提交失败,则一直对 Ti 进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,譬如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti,(重试)…,Ti+1,…,Tn。//无需补偿
反向恢复(Backward Recovery):如果 Ti 事务提交失败,则一直执行 Ci 对 Ti 进行补偿,直至成功为止(最大努力交付)。这里要求 Ci 必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。//不断执行补偿,直至成功
与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。譬如,账号余额直接在银行维护的场景,从银行划转货款到业务系统中,这步是经由用户支付操作来促使银行提供服务;如果后续业务操作失败,尽管我们无法要求银行撤销之前的用户转账操作,但是由业务系统将货款转回到用户账号上作为补偿措施却是完全可行的。
SAGA 必须保证所有子事务都得以提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log)以保证系统恢复后可以追踪到子事务的执行情况,譬如执行至哪一步或者补偿至哪一步了。
另外,尽管补偿操作通常比冻结/撤销容易实现,但保证正向、反向恢复过程严谨地进行也需要花费不少工夫,譬如通过服务编排、可靠事件队列等方式完成,所以,SAGA 事务通常也不会直接靠裸编码来实现,一般是在事务中间件的基础上完成,前面提到的 Seata 就同样支持 SAGA 事务模式。//实现复杂,依赖框架
声明:上述文字为阅读周志明先生《凤凰架构》一书的读书笔记,大部分内容为书中摘要。