3.凤凰架构:构建可靠的大型分布式系统 --- 事务处理

3.凤凰架构:构建可靠的大型分布式系统 --- 事务处理,第1张

3.凤凰架构:构建可靠的大型分布式系统 --- 事务处理
第3章 事务处理
	事务处理的意义在于,确保系统的所有数据都是符合预期的,且互相关联的数据之间不会产生矛盾,即数据状态的一致性(Consistency)。按照数据库的经典理论,
要达成这个目标,需要三方面共同保证:
	1.原子性(Atomic)
	2.隔离性(Isolation)
	3.持久性(Durability)

	即数据库的ACID特性,AID是手段,C是目的,前者是因,后者是果。

	事务的概念虽然起源于数据库,但今天已经有所延伸,不局限于数据库了。所有需要保证数据一致性的应用场景,包括但不限于数据库、事务内存、缓存、消息队列、
分布式存储、等等都可能用到事务。后面会用"数据源"来泛指这些场景中提供与存储数据的逻辑设备,但上述场景所说的事务和一致性含义可能并不完全一致,说明如下:
	1.当一个服务只使用一个数据源的时候,通过AID来获得一致性是最经典的做法,也相对容易。此时,多个并发事务所读写的数据能够被数据源感知是否存在冲突,
	并发事务的读写在时间线上的最终顺序是由数据源来确定的,这种事物间一致性被称为"内部一致性"。
	2.当一个服务使用到多个不同的数据源时,甚至多个不同服务同时涉及多个不同的数据源时,问题就变得困难了很多。此时,并发执行甚至是先后执行的多个事务,
	在时间线上的顺序并不由任何一个数据源来决定,这种涉及多个数据源的事物间一致性被称为"外部一致性"。

	外部一致性很难用AID来解决,因为代价很大;但是外部一致性又是分布式系统中必然会遇到的问题且必须解决,为此我们要转变观念,将一致性从"是或否"的二元
属性转变为可以按照不同强度分开讨论的多元属性,在确保可以承受的前提下获得强度尽可能高的一致性保障。因此,事务从一个具体 *** 作上的"编程问题",上升到一个
需要全局权衡的"架构问题"。

3.1 本地事务
	本地事务(Local Transaction)是指 *** 作单一事务资源的、不需要全局事务管理器进行协调的事务。

	本地事务是一种最基础的事务解决方案,只适用于单个服务使用单个数据源的场景,从应用角度看,它是直接依赖于数据源本身提供的事务能力来工作的,在程序代码
层面,最多只能对事务接口做一层标准化的包装(如JDBC接口),并不能深入参与到事务的运作过程中,事务的开启、终止、提交、回滚、嵌套、设置隔离级别,乃至与应用
代码贴近的事务传播方式,全部都要依赖底层数据源的支持才能工作,这一点与后续介绍的XA,TCC,SAGA等主要靠应用程序代码来实现的事务有着十分明显的区别。
	
	ARIES理论,"基于语义的恢复与隔离算法"。

	3.1.1 实现原子性和持久性
		原子性和持久性在事务里是密切相关的两个属性:原子性保证了事务的多个 *** 作要么生效要么不生效,不会存在中间状态;持久性保证了一旦事务生效,就不会再
	因为任何原因而导致其修改的内容被撤销或者丢失。

		众所周知,数据必须要成功写入磁盘、磁带等持久化存储器后才能拥有持久性,只存在内存中的数据,一旦遇到应用程序忽然崩溃的情况数据就会丢失。实现
	原子性和持久性的最大困难是"写入磁盘"这个 *** 作并不是原子的,不仅有"写入"和"未写入"状态,还客观存在着"正在写"的中间状态。由于写入中间状态与崩溃不可能
	消除,所以如果不做额外的保障措施的话,将内存中的数据写入磁盘,并不能保证原子性与持久性。

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

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

		通过日志实现事务的原子性和持久性是当今主流的方案,但并不是唯一的选择。还有一种被称为"Shadow Paging"的事务实现机制,常用的轻量级数据库SQLite
	Version 3 采用的事务机制就是这种。复制一份数据,最后修改引用地址,但事务并发能力相对有限,因此在高性能的数据库中应用不多。

		Commit Logging 保障数据持久性、原子性的原理并不难以理解:首先,日志一旦成功写入Commit Record,那整个事务就是成功的,即使真正修改数据时
	崩溃了,重启后根据已经写入磁盘的日志信息恢复现场、继续修改数据即可,这保证了持久性;其次,如果日志没有成功写入Commit Record就发生崩溃,那整个
	事务就是失败的,系统重启后会看到一部分没有Commit Record的日志,这部分日志标记为回滚状态即可,整个事务就像完全没有发生过一样,这保证了原子性。

		但是,Commit Logging 存在一个巨大的先天缺陷:所有对数据的真实修改必须发生在事务提交以后,即日志写入了Commit Record之后。在此之前,即使
	磁盘IO有足够空闲,即使某个事务修改的数据量非常庞大,占用了大量的内存缓冲区,无论何种理由,都决不允许在事务提交之前就修改磁盘上的数据,这点是Commit
	Logging成立的前提,却对提升数据库的性能十分不利。为此,ARIES提出了"提前写入日志"(Write-Ahead Logging)的日志改进方案,所谓"提前写入"(
	Write-Ahead),就是允许在事务提交之前写入变动数据的意思。

		Write-Ahead Logging 按照事务提交时点,将何时写入变动数据划分为 FORCE和STEAL 两类:
			1.FORCE
				当事务提交后,要求变动的数据必须同时完成写入则称为FORCE,如果不强制变动数据必须同时完成写入则称为 NO FORCE。现实中绝大多数数据库
			采用的是NO-FORCE策略,因为只要有了日志,变动数据随时可以持久化,从优化磁盘IO性能考虑,没有必要强制数据写入时立即进行。

			2.STEAL
				在事务提交前,允许变动的数据提前写入则称为STEAL,不允许则称为NO-STEAL。从优化磁盘性能考虑,允许数据提前写入,有利于利用空闲IO
			资源,也有利于节省数据库缓存区的内存。

		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在崩溃
	恢复时会经历以下3个阶段:	
		1.分析阶段(Analysis)
			该阶段从最后一个检查点(Checkpoint,可以理解为在这个点之前所有应该持久化的变动都已经安全落盘)开始扫描日志,找出所有没有End Record的
		事务,组成待回复的事务集合,这个集合至少会包含事务表(Transaction Table)和脏页表(Ditry Page Table)两个组成部分。

		2.重做阶段(Redo)
			该阶段依据分析阶段产生的 待恢复的事务表集合来重演历史(Repeat History),具体 *** 作是找出所有包含Commit Record的日志,将这些日志修改
		的数据写入磁盘,写入完成后在日志中增加一条End Record,然后移出待恢复事务集合。

		3.回滚阶段(Undo)
			该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务,它们被称为Loser,根据undo log 中的信息,将已经提前写入
		磁盘的信息重新修改回去,以达到回滚这些Loser事务的目的。

	3.1.2 实现隔离性
		隔离性与并发有关系。

		现代数据库提供了3种锁:
			1.写锁(Write Lock, X-Lock)
				如果数据加写锁,就只有持有写锁的事务才能对数据进行写入 *** 作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。

			2.读锁(Read Lock, S-Lock)
				多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有
			读锁的事务,如果该数据只有它自己一个事务加了读锁,则允许直接将其升级为写锁,然后写入数据。

			3.范围锁(Range Lock)
				对于某个范围直接加排排它锁,在这个范围内的数据不能被写入。如:select * from books where price < 100 for upate;

				注意,"范围不能被写入"与"一批数据不能被写入"的差别,即不要把范围锁理解为一组排它锁的集合。加了范围锁之后,不仅不能修改范围内已有的
			数据,也不能在该范围内新增或者删除任何数据,后者是一组排它锁的集合无法实现的。

		可串行化(Serializable):
			提供了最高强度的隔离性。要对事务做 加锁和解锁两阶段去处理读锁、写锁和数据间的关系,称为两阶段锁(2PL)。隔离程度越高,并发度越低。


		可重复读(Repeatable Read):
			对事务所涉及的数据加读锁和写锁,且一直持有至事务结束,但不再加范围锁。可重复读比可串行化弱化的地方在于幻读问题,它是指在事务执行过程中,
		两个完全相同的范围查询得到了不同的结果集。如下的 sql :
			select count(1) from books where price < 100;  # 时间顺序:1,事务:T1
			insert into books(name,price) values("aaa",90); # 时间顺序:2, 事务:T2
			select count(1) from books where price < 100;  #时间顺序:3, 事务:T1

			假如这条sql在同一个事务中重复执行了2次,且这2次执行之间恰好有另外一个事务在数据库插入了一本小于100的书,这是会被允许的,那这2次相同的
		sql查询就会得到不一样的结果,原因是可重复读没有范围锁来禁止在该范围内插入新的数据,这是一个事务受其他事务影响,隔离性被破坏的表现。

			注意,这里是以 ARIES 理论为讨论目标,具体的数据库并不一定要完全遵循理论去实现。一个例子是MySQL/InnoDB 的默认隔离级别为可重复读,但
		它在只读事务中可以完全避免幻读问题,譬如上面的例子中事务T1只有查询语句,是一个只读事务,所以上述问题在mysql中并不会出现。但在读写事务中,
		mysql仍然会出现幻读问题,譬如例子事务T1如果在其他事务插入新书后,不是重新查询一遍数量,而是将所有小于100元的书改名,那就依然会受到插入影响。


		读已提交(Read Committed):
			对事务涉及的数据加的写锁会一直持续到事务结束,但加的读锁在查询操作完成后会马上释放。读已提交比可重复读弱化的地方在于不可重复读的问题,
		它是指在事务执行过程中,对同一行数据的两次查询得到了不同的结果。如下sql:
			select * from books where id = 1; #时间顺序:1, 事务:T1
			update books set price = 110 where id = 1; #时间顺序:2, 事务:T2
			select * from books where id = 1; commit;  #时间顺序:3, 事务:T1

			如果隔离级别是读已提交,这2次重复执行的查询结果就会不一样,原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生
		变化,此时事务T2的更新语句可以马上提交成功,这也是一个事务受到其他事务的影响,隔离性被破坏的表现。假如隔离级别是可重复读,由于数据已经被事务T1
		施加了读锁且读取后不会马上释放,所以事务T2无法获取到写锁,更新就会被阻塞,直至T1被提交或者回滚后才能提交。


		读未提交(Read Uncommitted):
			它只会对事务涉及的数据加写锁,且一直持续到事务结束,但完全不加读锁。读未提交比读已提交弱化的地方在于脏读问题(Drity Read)。它是指事务
		执行过程中,一个事务读取到了另外一个事务未提交的数据。如下sql:
			select * from books where id = 1; #时间顺序:1, 事务:T1

			#注意没有commit
			update books set price = 90 where id = 1; #时间顺序:2, 事务:T2
			select * from books where id = 1; #时间顺序:3, 事务:T1
			rollback; #时间顺序:4, 事务:T2


			不过,在之前修改价格后,事务T1已经按照90元的价格卖出了几本。原因是读未提交在数据上完全不加读锁,这反而令它能读到其他事务加了写锁的数据,
		即上述事务T1中两条查询语句得到的结果并不相同。如果你不能理解这句话中的"反而"二字,再读一遍写锁的定义:写锁禁止其他事务施加读锁,而不是禁止事务
		读取数据,如果事务T1前不需要加读锁的话,就会导致事务T2未提交的数据也马上被事务T1所读取到。这同样是一个事务受到其他事务影响,隔离性被破坏的
		表现。假如隔离级别是读已提交的话,由于事务T2持有数据的写锁,所以事务T1的第二次查询就无法获得读锁,而读已提交级别是要求先加读锁后读取数据的,
		因此T1中的查询就会被阻塞,直至事务T2被提交或者回滚后才能得到结果。


		完全不隔离:
			理论上还有更低的隔离级别,就是"完全不隔离",即读、写锁都不加。读未提交会有脏读问题,但不会有脏写问题(Dirty Write),即一个事务没提交之前
		的修改可以被另外一个事务的修改覆盖。脏写已经不是单纯的隔离性的问题了,它将导致事务的原子性都无法实现,所以一般不会讨论这个。


		其实不同的隔离级别以及幻读、不可重复读、脏读等问题都只是表象,是各种锁在不同加锁时间上组合应用所产生的结果,以锁为手段来实现隔离性才是数据库
	表现出不同隔离级别的根本原因。

		除了都以锁来实现之外,以上4种隔离级别还有另外一个共同特点,就是幻读、不可重复读、脏读等问题都是由一个事务在读数据的过程中,受到另外一个写数据
	的事务影响而破坏了隔离性。针对这种"一个事务读+另外一个事务写"的隔离问题,近年来有一种名为"多版本并发控制"(Multi-Version Concurrency
	 Control,MVCC)的无锁优化方案被主流数据库采用。MVCC是一种读取优化策略,它的"无锁"特指读取时不需要加锁。MVCC的基本思路是对数据库的任何修改都不会
	 直接覆盖之前的数据,而是产生一个新版本和老版本共存,以此达到读取时可以完全不加锁的目的。版本你可以理解为数据库中每一行记录都存在2个看不见的字段:
	 CREATE_VERSION 和 DELETE_VERSION,这2个字段记录的值都是事务ID,事务ID是一个全局严格递增的值,然后根据下面的规则写入:
	 	1.插入数据时:create_version 记录插入数据的事务ID,delete_version为空;
	 	2.删除数据时:delete_version 记录删除数据的事务ID,create_version 为空;
	 	3.修改数据时:将修改的数据视为"删除旧数据,插入新数据"的组合,即先将原有的数据复制一份,原有数据的delete_version记录修改数据的事务ID,
	 	create_version 为空。复制后的新数据的create_version记录修改数据的事务ID,delete_version为空。

	 	此时,如果有另外一个事务要读取这些发生了变化的数据,将根据隔离级别来决定到底应该读取哪个版本的数据:
	 		1.隔离级别是可重复读:总是读取create_version 小于或者等于当前事务ID的记录,在这个前提下,如果数据仍然有多个版本,则读取最新的(ID最大的)。
	 		2.隔离级别是读已提交:总是读取最新的版本即可,即最近被提交的那个版本的数据记录。


	 	另外2个隔离级别根本没有必要用到MVCC,因为读未提交直接修改原数据即可,其他事务查看数据的时候立即可以看到,根本无需版本字段。可串行化本来的语义
	 就是要阻塞其他事务的读取操作,而MVCC是做读取时的无锁优化的,自然不会放到一起用。

	 	MVCC只是针对"读+写"场景优化的,如果是两个事务同时修改数据,即"写+写"的情况,那就没有多少优化的空间了,此时加锁几乎是唯一可行的解决方案了,
	 稍微有点讨论的地方是加锁的策略是选"乐观加锁"还是选"悲观加锁"。前面介绍的加锁都属于加悲观锁策略,即认为如果不先加锁再访问数据,就肯定会出现问题。
	 没有必要迷信乐观锁比悲观锁更快,这纯粹看竞争激烈程度,如果竞争激烈的话,乐观锁反而更慢。


3.2 全局事务
	与本地事务相对的是全局事务(Global Transaction),也称为外部事务(External Transaction)。在这里,全局事务被限定为一种适用于单个服务
使用多个数据源的事务解决方案。请注意,理论上真正的全局事务并没有"单个服务"的约束,它本来就是DTP(分布式事务处理)模型中的概念,
但这里讨论的是一种在分布式环境中仍然追求强一致的事务处理方案。
	
	1991年,为了解决分布式事务的一致性,X/Open 组织(后来并入了 The Open Group)提出了一套名为 X/Open XA(XA 是 eXtended Architecture的
缩写)的处理事务架构,其核心的内容是定义了全局的事务管理器(Transaction Manager,用于协调全局事务)和局部的资源管理器(Resource Manager,用于驱动
本地事务)之间通信的接口。XA接口是双向的,能在一个事务管理器和多个资源管理器之间形成通信桥梁,通过协调多个数据源的一致动作,实现全局事务的统一提交或
统一回滚,现在我们在Java代码中还能偶尔看到XADataSource,XAResource都源于此。
	
	不过,XA并不是Java的技术规范,而是一套与语言无关的通过规范,所以Java中专门定义了JSR 907 Java Transaction API,基于XA模式在Java语言中
实现了全局事务处理的标准,这也是我们现在所熟知的JTA。JTA主要有2个接口:
	1.事务管理器接口
	2.满足XA规范的资源定义接口

	XA 将事务提交拆分成2个阶段:
		1.准备阶段
			又叫做投票阶段,在这一阶段,协调者询问事务的所有参与者是否准备好提交,参与者如果已经准备好提交则回复 Prepared,否则回复Non-Prepared。
		这里说的准备操作跟人类语言中理解的准备不同,对于数据库来说,准备操作是 重做日志 中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的
		区别招股书股暂时不写入最后一条 Commit Record 而已,这意味着在做完数据持久化后并不立即释放隔离性,即仍然持有锁,维持数据对其他非事务内
		观察者的隔离状态。

		2.提交阶段
			又叫做执行阶段,协调者如果在上一阶段收到所有事务参与者回复的Prepared消息,则先自己在本地持久化事务状态为Commit,然后向所有参与者发送
		Commit指令,让所有参与者立即执行提交操作;否则,任一参与者回复了Non-Prepared消息,或者一个参与者超时未回复时,协调者将在自己完成事务状态
		为Abort持久化后,向所有参与者发送Abort指令,让参与者立即执行回滚操作。对于数据库来说,这个阶段的提交操作应是轻量的,仅仅是持久化一条Commit
		Record 而已,通常能够快速完成,只有收到Abort指令时,才需要根据回滚日志清理已提交的数据,这可能是相对重负载的操作。


	上面的过程称为"两阶段提交"(2PC)协议,而它能够成功保证一致性还需要一些其他前提条件:
		1.必须假设网络在提交阶段的短时间内是可靠的,即提交阶段不会丢失消息。同时也假设网络通信在全过程都不会出现差错,即可以丢失消息,但不会传递错误
		的消息,XA的设计目标并不是解决拜占庭将军一类的问题。在两阶段提交中,投票阶段失败了可以补救(回滚),提交阶段失败了则无法补救(不再改变提交或回滚
		的结果,只能等崩溃的节点重新恢复),因而此阶段耗时应尽可能短,这也是为了尽量控制网络风险。

		2.必须假设因为网络分区、机器崩溃或者其他原因而导致失联的节点最终能恢复,不会永久性的处于失联状态。由于在准备阶段依据写入完整的重做日志,所以
		失联机器一旦恢复,就能够从日志中找出依据准备妥当但还未提交的事务数据,进而向协调者查询该事务的状态,确定下一步应该进行提交还是回滚操作。

		请注意,上面所说的协调者、参与者通常都是数据库自己来扮演的,不需要应用程序介入。协调者一般是在参与者之间选举产生,而应用程序对于数据库来说只
	扮演客户端的角色。


	两段式提交原理简单,并不难实现,但有几个非常显著的缺点:
		1.单点问题
			协调者在两阶段提交中具有举足轻重的作用,协调者等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等待协调者指令时无法做出超时处理。
		一旦协调者宕机,所有参与者都会受到影响。如果协调者一直没有恢复,没有正常发送Commit或者Rollback的指令,那所有参与者必须等待。

		2.性能问题
			在两阶段提交过程中,所有参与者相当于被绑定为一个统一调度的整体,期间要经历2次远程服务调用,3次数据持久化(准备阶段写重做日志,协调者做
		状态持久化,提交阶段在日志写入提交记录),整个过程将持续到参与者集群中最慢的那一个处理操作结束为止,这决定了性能比较差。

		3.一致性问题
			两阶段提交的成立是有前提条件的,当网络稳定性和宕机恢复能力的假设不成立时,仍可能出现一致性问题。宕机恢复能力这一点不必多谈,1985年,
		Fischer, Lynch, Paterson 提出了"FLP不可能原理",证明了如果宕机最后不能恢复,那就不存在任何一种分布式协议可以正确的达成一致性结果。
		该原理在分布式中是与CAP不可兼得原理齐名的理论。而网络稳定性带来的一致性风险是指:尽管提交阶段时间很短,但这仍然是一段明确的危险期,如果
		协调者在发出准备指令后,根据收到的各个参与者发回的信息确定事务状态是可以提交的,协调者会先持久化事务状态,并提交自己的事务,如果这时候网络
		突然断开,无法再通过网络向所有参与者发出Commit指令的话,就会导致部分数据(协调者)已提交,但部分数据(参与者)未提交,且没有办法回滚,产生
		数据不一致的问题。


	三段式提交(3PC):
		三段式提交把二段式提交的准备阶段再细分为了2个阶段,分别为 CanCommit,Precommit,把提交阶段改称为 DoCommit阶段。其中,新增的CanCommit
	是一个询问阶段,即协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。将准备阶段一分为二的理由是这个阶段是重负载的操作,一旦协调者
	发出开始准备的消息,每个参与者都将马上重做日志,它所涉及的数据资源即被锁住,如果此时一个参与者宣告无法完成提交,相当于大家都做了无用功。所以,增加
	一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,这也意味着因某个参与者提交时发生崩溃而导致大家全部回滚的风险比较小。因此,
	在事务需要回滚的场景中,三段式提交的性能通常要比两段式提交好很多,但在事务能够正常提交的场景中,两者的性能都很差,甚至三段式提交因为多了一次询问,
	还要稍微更差一点。

		同样也是由于事务失败回滚概率变小,在三段式提交中,如果在PreCommit阶段之后发生了协调者宕机,即参与者没有等到DoCommit的消息的话,默认的操作
	策略将是提交事务而不是回滚事务或者持续等待,这就相当于避免了协调者单点问题的风险。

		从以上过程可以看出,三段式提交对单点问题和回滚时的性能问题有所改善,但是对一致性风险问题并未有任何改进,甚至是略有增加的。譬如,进入PreCommit
	阶段之后,协调者发出的指令不是Ack而是Abort,而此时网络有问题,有部分参与者直至超时都未能收到协调者的Abort指令的话,这些参与者都将错误的提交事务,
	这就产生了不同参与者之间数据不一致的情况。


3.3 共享事务
	共享事务(Share Transaction),是指多个服务共同用一个数据源。数据源与数据库的区别:数据源是指提供数据的逻辑设备,不必与物理设备一一对应。

	通过数据库代理或者消息队列,来共享数据库。


3.4 分布式事务
	本节说的分布式事务,特指多个服务同时访问多个数据源的事务处理机制,与DTP模型中"分布式事务"的差异,DTP相对于数据源而言,并不涉及服务。这里说的分布式
相对于服务而言,严谨的说是,"在分布式服务环境下的事务处理机制"。

	3.4.1 CAP与ACID
		一个分布式系统中,涉及共享数据问题时,以下三个特性最多只能满足其中2个:
			1.一致性(Consistency)
				代表数据在任何时刻、任何分布式节点所看到的都是符合预期的。

			2.可用性(Availability)
				代表系统不间断提供服务的能力。理解可用性要先理解与其密切相关的2个指标:
					1.可靠性(Reliability)
					2.可维护性(Serviceability)

				可靠性使用 平均无故障时间(Mean Time Between Failure, MTFB)来度量;
				可维护性使用 平均可修复时间(Mean Time to Repair, MTTR)来度量;

				可用性衡量系统可以正常使用的时间与总时间之比,其表征Wie: A = MTBF/(MTBF+MTTR),即可用性是由可靠性和可维护性计算得出的比例值,
			譬如 99.9999% 可用,即代表平均年故障修复时间为32秒。

			3.分区容忍性(Partition Tolerance)
				代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成"网络分区"时,系统仍然能够正确的提供服务的能力。

		舍弃C、A、P 时所带来的影响:
			1.如果放弃分区容忍性(CA without P)
				意味着我们将假设节点之间的通信永远是可靠的。永远可靠的通信在分布式系统中必定是不成立的,这不是你想不想的问题,而是只要用到网络来共享
			数据,分区现象就始终存在。如传统数据库集群,通过共享磁盘的方式来避免出现网络分区。

			2.如果放弃可用性(CP without A)
				意味着我们将假设有点网络出现分区,节点之间的信息同步时间可以无限制的延长。在现实中,选择放弃可用性的情况一般出现在对数据质量要求非常
			高的场景,除了DTP模型的分布式数据库事务外,著名的Hbase也属于CP系统。以Hbase集群为例,假如某个RegionServer宕机了,这个RegionServer
			持有的所有键值范围都将离线,直到数据恢复过程完成为止,这个过程要消耗的时间是无法预计的。

			3.如果放弃一致性(AP without C)
				意味着我们将假设一旦发生分区,节点之间提供的数据可能不一致。选择放弃一致性的AP系统是目前分布式系统的主流选择,因为P是天然存在的,你
			再不想也无法丢弃;而A通常是建设分布式的目的,如果可用性随着节点数量的增加反而降低的话,很多分布式系统可能就失去了存在的价值,除了银行
			这种,宁可中断服务也不能出错,否则多数系统是不能容忍节点越多可用性反而越低的。目前大多数NoSQL库和支持分布式的缓存框架都是AP系统。


		我们在建设信息系统的时候,终究还是要确保操作结果至少在最终交付的时候是正确的,这句话的意思是,允许数据在中间过程出错(不一致),但应该在输出时
	被修正过来。为此人们重新给一致性下了定义,将前面的CAP、ACID的一致性称为"强一致性",有时也称为"线性一致性"(通常是在共识算法中)。而把牺牲了C的AP
	系统又要尽可能获得正确的行为称为追求"最终一致性"。是指如果数据在一段时间之内没有被另外的操作更改,那它最终会达到与强一致性过程相同的结果,有时候
	面向最终一致性的算法也被称为"乐观复制算法"。

	3.4.2 可靠事件队列
		base:
			1.基本可用性(Basically Available)
			2.柔性事务(Soft State)
			3.最终一致性(Eventually Consistent)

		可靠消息队列,尽最大努力交付。

	3.4.3 TCC事务
		TCC 是常见的分布式事务机制,Try-/confirm/i-Cancel。

		前面介绍的 可靠消息队列 虽然能保证最终结果的相对可靠性,过程也足够简单(对于TCC来说),但整个过程完全没有任何隔离性而言,虽然在一些业务中
	隔离性是无关紧要的,但在有些业务中缺乏隔离性就会带来许多麻烦。譬如,缺乏隔离性就会带来"超售"问题:如果2个客户在短时间内都成功购买了同一件商品,
	而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。如果这件事属于刚性事务,且隔离级别足够时是可以完全避免的,譬如,上述
	场景就需要"可重复读"的隔离级别,以保证后面提交的事务会因为无法获得锁而导致失败,但用可靠消息队列就无法保证这一点。如果业务需要隔离,那架构师就需要
	考虑TCC方案,该方案天生适用于需要强隔离性的分布式事务中。

		在具体实现上,TCC较为繁琐,它是一种业务入侵式较强的事务方案,要求业务处理过程必须拆分为"预留业务资源"和"确认/释放消息资源"2个子过程。如同
	TCC的名字,它分为下面3个阶段:
		1.Try
			尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需要用到的业务资源(保障隔离性)。

		2.Confirm
			确认执行阶段,不进行任何业务检查,直接使用Try阶段准备的资源来完成业务处理。/confirm/i阶段可能会重复执行,因此本阶段的操作需要具备幂等性。

		3.Cancel
			取消执行阶段,释放Try阶段预留的业务资源。Cancel 阶段可能会重复执行,因此本阶段执行的操作也需要具备幂等性。


		TCC 其实有点类似于2PC的指标阶段和提交阶段,但TCC是在用户代码层面,而不是在基础设施层面,这位它的实现带来了较高的灵活性,可以根据需要设计锁定
	资源锁定的粒度。TCC在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,具有很高的性能潜力。但TCC也带来了更高的开发成本和业务入侵性,即更高的
	开发成本和更换事务实现方案的替换成本,所以,通常我们不会完全依靠裸代码来实现TCC,而是基于某些分布式事务中间件(如阿里的Seata)去完成,尽量减少编码
	工作。

	3.4.4 SAGA事务
		TCC事务具有较强的隔离性,避免了"超售"问题,而且性能一般来说是几种柔性事务中最高的,但它仍然不能满足所有场景。TCC的最主要限制是它的业务入侵性
	很强,只它所要求的的技术可控性上的约束。如现在网络支付流行,在购物的时候允许U盾或者扫描支付,在银行账户中划转货款,这给系统的事务设计增加了额外的
	限制:如果用户、商户的账户余额由银行管理的话,其操作权限和数据结构就不可能再随心所欲的自行定义,通常就无法完成冻结款项、解冻、减扣这样的操作,因为
	银行一般不会配合你。所以TCC的第一步Try阶段往往无法实行。我们只能采用另外一种柔性事务:SAGA事务。SAGA在英文中是"长篇故事,长篇叙事,一长串事件"
	的意思。

		论文"SAGAS"提出了一种提升"长时间事务"运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务集合。原本SAGA的目的是避免大事务
	长时间锁定数据库的资源,后来才发展为将一个分布式环境中的大事务分解为一系列本地事务的设计模式。SAGA由2部分组成:
		1.将大事务拆成若干个小事务,将整个分布式事务T分解为n个子事务,命名为T1,T2, ..., Ti, ... , Tn。每个子事务都应该是或者被视为原子行为。如果
		分布式事务能够正常提交,其对数据的影响(即最终一致性)应与连续按顺序成功提交Ti等价。
		
		2.为每个子事务设计对应的补偿动作,命名为C1,C2, ..., Ci, ... , Cn。Ti与Ci 必须满足下面条件:
			a) Ti与Ci 都具备幂等性;
			b) Ti与Ci 满足交换律,即无论先执行Ti还是先执行Ci,其效果都是一样的;
			c) Ci必须能够成功提交,即不考虑Ci本身提交失败被回滚的情形,如出现就必须持续重试直至成功,或者被人工介入。


		如果Ti到Tn均成功提交,那事务顺利完成,否则,要采取下面2种恢复策略之一:
			a) 正向恢复
				如果事务Ti提交失败,则一直对Ti进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,譬如在别人
			的银行账户扣了款,就一定要别人发货。正向恢复的执行模式为:T1,T2, ..., Ti(失败),Ti(重试)..., Tn。

			b) 反向恢复
				如果Ti事务提交失败,则一直执行Ci对Ti进行补偿,直至成功提交(最大努力交付)。这里要求Ci必须(在持续重试后)执行成功。反向恢复的模式
			为:T1,T2,...,Ti(失败),Ci(补偿), ..., C2,C1。


		与TCC相比,SAGA 不需要为资源设计冻结状态和撤销冻结状态,补偿往往要比冻结操作更容易。譬如,用户扣款,补偿就是从系统将货款转给用户。

		SAGA必须保证所有子事务都得以提交或者补偿,但SAGA系统本身也有可能崩溃。所以它必须设计成与数据库类似的日志机制(被称为SAGA Log)以保证系统
	恢复后可以追踪到子事务的执行情况,譬如执行到哪一步或者补偿到哪一步了。另外,尽管补偿操作通常比冻结/撤销容易实现,到保证正向、反向恢复过程严谨进行
	也需要花费不少功夫,譬如通过服务编排,可靠消息队列完成,所以,SAGA事务通常也不会直接靠裸编码来实现,一般是在事务中间件的基础上完成,Seata就同样
	支持SAGA。	

		基于数据补偿来代替回滚的思路,还可以应用于其他事务方案上,如阿里的GTS(Seata由GTS开源而来)所提出的"AT事务模式"就是这样的应用。

		从整体看,AT参照了XA两段式提交协议实现的,但对于XA 2PC的缺陷,即在准备阶段必须等待所有数据源都返回成功后,协调者才能统一发出Commit命令而
	导致木桶效应(所涉及的锁和资源都需要等待最慢的事务完成后才能统一释放),AT事务设计了针对性的方案。大致的做法是,在业务数据提交时自动拦截所有的sql,
	将sql对数据修改前,修改后的结果分别保存快照,生成行锁,通过本地事务一起提交到操作的数据源中,相当于自动记录了重做和回滚日志。如果分布式事务提交
	成功,那后续清理每个数据源中对应的日志数据即可;如果分布式事务需要回滚,就根据日志数据自动产生用于补偿的"逆向sql"。基于这种补偿方式,分布式事务
	中涉及的每一个数据源都可以单独提交,然后立即释放锁和资源。这种异步提交的方式,相比2PC极大的提升了系统的吞吐量,而代价就是牺牲了隔离性,甚至影响了
	原子性。因为缺乏隔离性的前提下,该数据被补偿之前又被其他操作修改过,即出现了脏写,这时候一旦分布式事务需要回滚,就不可能再通过自动逆向sql来实现
	补偿了,只能有人工接入。

 

 

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/zaji/5694488.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-17
下一篇 2022-12-17

发表评论

登录后才能评论

评论列表(0条)

保存