Hi guys、happy labor day. Everyone should have a good time to relax during the Labor Day holiday. But don’t forget to improve yourself during the holiday period
参考书籍:
“凤凰架构”
“微服务架构设计模式”
微服务架构下最关心的一个问题是如何实现跨多个服务的事务。 事务是每个企业级应用程序的基本要素。本文将详细地介绍本地事务、全局事务、共享事务、分布式事务等知识点(关系型数据库mysql的角度去解释)
本地事务是指仅操作单一事务资源的、不需要全局事务管理器进行协调的事务。
本地事务是最基础的一种事务解决方案,只适用于单个服务使用单个数据源的场景(其实也就是数据库事务)。往往我们在解释数据库事务的时候会从四种属性即事务的“ACID”特性去解释,但是“凤凰架构”的作者认为这四种特性并不正交,A、I、D 是手段,C 是目的,前者是因,后者是果(个人认为解释的很好),对于这几种属性的解释,原文如下:
事务处理几乎在每一个信息系统中都会涉及,它存在的意义是为了保证系统中所有的数据都是符合期望的,且相互关联的数据之间不会产生矛盾,即数据状态的一致性(Consistency)。
按照数据库的经典理论,要达成这个目标,需要三方面共同努力来保障。
而事务的几种属性的实现原理就需要追究到ARIES 理论,感兴趣的小伙伴可以去看看,大致内容如下图:
如果是比较了解mysql数据库的小伙伴应该能一眼看到几个关键字像Redo log、Undo log、 WAL(先写日志)这篇文章里面都有相应的解释,推荐大家去看看原文
原子性和持久性是关联密切相关的两个属性,特此放到一起说明。原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态;持久性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。
但是数据库是怎么去保证其原子性和持久性的呢?在数据库发展初期是采用的一种叫Commit Logging 机制,如何去理解这个commit logging,举个例子(引用自“凤凰架构”):
购买一本书需要修改三个数据:在用户账户中减去货款、在商家账户中增加货款、在商品仓库中标记一本书为配送状态。由于写入存在中间状态,所以可能发生以下情形:
由于写入中间状态与崩溃都是无法避免的,为了保证原子性和持久性,就只能在崩溃后采取恢复的补救措施,这种数据恢复操作被称为“崩溃恢复”(Crash Recovery,也有资料称作 Failure Recovery 或 Transaction Recovery)
为了能够顺利地完成崩溃恢复,在磁盘中写入数据就不能像程序修改内存中变量值那样,直接改变某表某行某列的某个值,而是必须将修改数据这个操作所需的全部信息,包括修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值,等等,以日志的形式——即仅进行顺序追加的文件写入的形式(这是最高效的写入方式)先记录到磁盘中。只有在日志记录全部都安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条“结束记录”(End Record)表示事务已完成持久化,这种事务实现方法被称为“Commit Logging”(提交日志)
Commit Logging 保障数据持久性、原子性的原理并不难理解:首先,日志一旦成功写入 Commit Record,那整个事务就是成功的,即使真正修改数据时崩溃了,重启后根据已经写入磁盘的日志信息恢复现场、继续修改数据即可,这保证了持久性;其次,如果日志没有成功写入 Commit Record 就发生崩溃,那整个事务就是失败的,系统重启后会看到一部分没有 Commit Record 的日志,那将这部分日志标记为回滚状态即可,整个事务就像完全没好有发生过一样,这保证了原子性。
虽然Commit Loggin机制可以保证在事务过程中保证其原子性和持久性,但是Commit Logging 存在一个巨大的先天缺陷:所有对数据的真实修改都必须发生在事务提交以后,即日志写入了 Commit Record 之后。在此之前,即使磁盘 I/O 有足够空闲、即使某个事务修改的数据量非常庞大,占用了大量的内存缓冲区,无论有何种理由,都决不允许在事务提交之前就修改磁盘上的数据,这一点是 Commit Logging 成立的前提,却对提升数据库的性能十分不利。
那么如何去提升这个性能成为了我们需要重点关注的内容,其实到这里大家也可以猜到是如何去解决的了,引言中我们提到的ARIES就开始闪亮登场,ARIES 提出了“Write-Ahead Logging”的日志改进方案,所谓“提前写入”(Write-Ahead),就是允许在事务提交之前,提前写入变动数据的意思
论文中对于WAL这种机制的描述如上图,翻译成中文,其大概的内容就是:
在计算机科学中,预写日志记录( WAL ) 是在数据库系统中提供原子性和持久性( ACID属性中的两个)的一系列技术。它可以看作是“事件溯源”架构的实现,其中系统的状态是传入事件从初始状态演变而来的结果。预写日志是一种仅附加的辅助磁盘驻留结构,用于崩溃和事务恢复。更改首先记录在日志中,必须写入稳定存储,然后才能将更改写入数据库。
预写日志的主要功能可以概括为:
在使用 WAL 的系统中,所有修改都在应用之前写入日志。通常重做和撤消信息都存储在日志中。
这样做的目的可以用一个例子来说明。想象一下,当运行它的机器断电时,一个程序正在执行一些操作。重新启动时,该程序可能需要知道它正在执行的操作是成功、部分成功还是失败。如果使用预写日志,程序可以检查该日志,并将意外断电时应该做的事情与实际做的事情进行比较。在此比较的基础上,程序可以决定撤消它已经开始的事情,完成它已经开始的事情,或者保持原样。
在一定数量的操作之后,程序应该执行一个检查点(检查点是一种为计算系统提供容错的技术。它基本上包括保存应用程序状态的快照,以便应用程序可以在出现故障时从该点重新启动。这对于在易出故障的计算系统中执行的长时间运行的应用程序尤为重要。),将 WAL 中指定的所有更改写入数据库并清除日志。
翻译成中文之后可能大家还是不能够理解其中的一些细节,因此在此再引用凤凰架构的作者对这篇论文的一个理解:
ARIES 提出了“Write-Ahead Logging”的日志改进方案,所谓“提前写入”(Write-Ahead),就是允许在事务提交之前,提前写入变动数据的意思。
Write-Ahead Logging 先将何时写入变动数据,按照事务提交时点为界,划分为 FORCE 和 STEAL 两类情况。
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 在崩溃恢复时会执行以下三个阶段的操作。
数据库按照是否允许 FORCE 和 STEAL 可以产生共计四种组合,从优化磁盘 I/O 的角度看,NO-FORCE 加 STEAL 组合的性能无疑是最高的;从算法实现与日志的角度看 NO-FORCE 加 STEAL 组合的复杂度无疑也是最高的。这四种组合与 Undo Log、Redo Log 之间的具体关系如下图所示:
其实在论文里面还提到了一种通过Shadow page(在计算机科学中,影子分页是一种在数据库系统中提供原子性和持久性的技术。此上下文中的页面指的是物理存储单元,通常为 1 到 64 KiB 的数量级。有点像java中的COW(CopyOnWriteArrayList)中复制读思路)
Shadow Paging 的大体思路是对数据的变动会写到硬盘的数据中,但并不是直接就地修改原先的数据,而是先将数据复制一份副本,保留原数据,修改副本数据。在事务过程中,被修改的数据会同时存在两份,一份是修改前的数据,一份是修改后的数据,这也是“影子”(Shadow)这个名字的由来。当事务成功提交,所有数据的修改都成功持久化之后,最后一步是去修改数据的引用指针,将引用从原数据改为新复制出来修改后的副本,最后的“修改指针”这个操作将被认为是原子操作,现代磁盘的写操作可以认为在硬件上保证了不会出现“改了半个值”的现象。所以 Shadow Paging 也可以保证原子性和持久性。Shadow Paging 实现事务要比 Commit Logging 更加简单,但涉及隔离性与并发锁时,Shadow Paging 实现的事务并发能力就相对有限,因此在高性能的数据库中应用不多。
到此,我们就已经介绍完了数据库是如何保证原子性和持久性的,如果对这块的知识很感兴趣的小伙伴可以去看看原论文,下面我们再来介绍一下数据库是如何保证隔离性
事务的隔离性是指一个事务的执行 ,即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。
如果无法保证隔离性会怎么样?假设A账户有200元,B账户0元。A账户往B账户转账两次,每次金额为50 元,分别在两个事务中执行。如果无法保证隔离性,会出现下面的情形
UPDATE accounts SET money = money - 50 WHERE NAME = 'AA';
UPDATE accounts SET money = money + 50 WHERE NAME = 'BB';
我们从隔离性的定义上就能嗅出隔离性肯定与并发密切相关,因为如果没有并发,所有事务全都是串行的,那就不需要任何隔离,或者说这样的访问具备了天然的隔离性。但现实情况不可能没有并发,要在并发下实现串行的数据访问该怎样做?数据库提供了两种方案:第一种是通过加锁(很容易就想到)、第二种是通过MVCC
在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的资源。为保证数据的一致性,需要对并发操作进行控制,因此产生了锁 。同时锁机制也为实现MySQL 的各个隔离级别提供了保证。 锁冲突也是影响数据库并发访问性能的一个重要因素。所以锁对数据库而言显得尤其重要,也更加复杂。现代数据库均提供了以下三种锁:
MVCC (Multiversion Concurrency Control),多版本并发控制。顾名思义,MVCC 是通过数据行的多个版本管理来实现数据库的并发控制 。这项技术使得在InnoDB的事务隔离级别下执行一致性读操作有了保证。换言之,就是为了查询一些正在被另一个事务更新的行,并且可以看到它们被更新之前的值,这样在做查询的时候就不用等待另一个事务释放锁。在这句话中,“版本”是个关键词,你不妨将版本理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION 和 DELETE_VERSION,这两个字段记录的值都是事务 ID,事务 ID 是一个全局严格递增的数值,然后根据以下规则写入数据。
此时,如有另外一个事务要读取这些发生了变化的数据,将根据隔离级别来决定到底应该读取哪个版本的数据。
至此,隔离性我们就介绍完了~,下面我们就开始介绍全局事务
全局事务是由资源管理器管理和协调的事务。另一种对全局事务的定义是:单个服务使用多个数据源场景的事务。请注意,理论上真正的全局事务并没有“单个服务”的约束
全局事务是一个DTP模型的事务,所谓DTP模型指的是(X/Open Distributed Transaction Processing Reference Model),是这个组织定义的一套分布式事务的标准,也就是了定义了规范和API接口,由厂商进行具体的实现。
其核心内容是定义了全局的事务管理器(Transaction Manager,用于协调全局事务)和局部的资源管理器(Resource Manager,用于驱动本地事务)之间的通信接口。
X/Open DTP 定义了三个组件和两个协议:
我们着重关注XA协议,在XA协议中将事务提交拆分成为两阶段过程:
以上这两个过程被称为“两段式提交”(2 Phase Commit,2PC)协议,而它能够成功保证一致性还需要一些其他前提条件:
上面所说的协调者、参与者都是可以由数据库自己来扮演的,不需要应用程序介入。协调者一般是在参与者之间选举产生的,而应用程序相对于数据库来说只扮演客户端的角色。两段式提交的交互时序如下图所示。
为了缓解2PC的一部分缺陷,后续又提出了3PC(三段式提交)。三段式提交把原本的两段式提交的准备阶段再细分为两个阶段,分别称为 CanCommit、PreCommit,把提交阶段改称为 DoCommit 阶段。其中,新增的 CanCommit 是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。将准备阶段一分为二的理由是这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,它们所涉及的数据资源即被锁住,如果此时某一个参与者宣告无法完成提交,相当于大家都白做了一轮无用功。所以,增加一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,这也意味着因某个参与者提交时发生崩溃而导致大家全部回滚的风险相对变小。因此,在事务需要回滚的场景中,三段式的性能通常是要比两段式好很多的,但在事务能够正常提交的场景中,两者的性能都依然很差,甚至三段式因为多了一次询问,还要稍微更差一些
同样也是由于事务失败回滚概率变小的原因,在三段式提交中,如果在 PreCommit 阶段之后发生了协调者宕机,即参与者没有能等到 DoCommit 的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待,这就相当于避免了协调者单点问题的风险。三段式提交的操作时序如下图所示。
共享事务(Share Transaction)是指多个服务共用同一个数据源
不推荐使用这种事务,因此不做过多介绍,大家知道有这个事务类型就可
分布式事务(Distributed Transaction)特指多个服务同时访问多个数据源的事务处理机制,请注意它与DTP 模型中“分布式事务”的差异。DTP 模型所指的“分布式”是相对于数据源而言的,并不涉及服务
目前解决分布式事务的几种方案有:可靠事件队列、TCC事务、SAGA事务,篇幅问题,本文只对SAGA事务进行说明。
通过使用异步消息来协调一系列本地事务,从而维护多个服务之间的数据一致性。
Saga 是一种在微服务架构中维护数据一致性的机制,它可以避免分布式事务所带来的问题。 一个Saga 表示需要更新多个服务中数据的一个系统操作。
Saga 的实现包含协调Saga 步骤的逻辑。当通过系统命令启动Saga 时,协调逻辑必领选 择并通知第一个Saga 参与方执行本地事务。一旦该事务完成,Saga 协调选择并调用下 一个 Saga 参与方。这个过程一直持续到Saga 执行完所有步骤。如果任何本地事务失败,则Saga 必须以相反的顺序执行补偿事务。以下几种不同的方法可用来构建Saga 的协调逻辑:
实现Saga 的一种方法是使用协同。使用协同时,没有一个中央协调器会告诉Saga 参与方该做什么。相反Saga 参与方订阅彼此的事件并做出相应的响应,举个例子来详细解释协同式Saga:
案例:用户在美团上的店铺下单(说美团只是为了让大家有一个直观感受,其实这个案例是引用自《微服务架构设计》,由于书中的案例软件和美团类似,因此在此写的美团)
从软件层面来看,当用户在店铺下单到订单创建过程(Create Order Saga),其大致的调用过程如下:
Create Order Saga还必须处理Saga参与方拒绝Order 并发布某种失败事件的场景。例如,消费者信用卡的授权可能会失败。Saga 必领执行补偿性事务来撤销已经完成的事务,其事件执行流程如下:
上面我们说的这个案例数据交互的完成依赖于消息中的发布/订阅模式,那我们就必须考虑一些与服务间通信相关的问题。
第一个问题是确保Saga参与方将更新共本地数据库和发布事件作为数据库事务的一部分。基于协同的Saga的每一步都会更新数据库并发布一个事件。例如,在CreateorderSaga中,Kitchen Service接收consumerverified事件,创建Ticket,并发布Ticketcreated事件。数据库更新和事件发布必须是原子的。所以我们在发布事件消息是必须是选用事务性消息。
第二个问题是确保Saga参与方必须能够将接收到的每个事件映射到自己的数据上。例如,当Orderservice收到CreditCardAuthorized事件时,它必领能够查找相应的order。解决方案是让Saga参与方发布包含相关性1D的事件,该相关性D使其他参与方能够执行数据的操作。例如,Create Order Saga的参与方可以使用orderId作为从一个参与方传递到下一个参与方的相关性ID。Accounting Service发布一个CreditcardAuthorized事件,其中包含Ticketcreated事件中的orderId。当Orderservice接收到Creditcard-Authorized事件时,它使用orderId来检素相应的Order。同样,Kitchen Service使用该事件的orderId来检素相应的Ticket
基于协同式的Saga 有以下几个好处:
基于协同式的Saga 的几个弊端:
编排式是实现Saga 的另外一种方式。当使用编排式Saga时,开发人员定义一个编排器 类,这个类的唯一职责就是告诉Saga的参与方该做什么事情。Saga 编排器使用命令/ 异步 响应方式与Saga 的参与方服务通信。为了完成Saga 中的一个环节,编排器对某个参与方发出一个命令式的消息,告诉这个参与方该做什么操作。当参与方服务完成操作后,会给编排 器发送一个答复消息。编排器处理这个消息,并决定Saga的下一步操作是什么
还是使用协同式Saga中的案例来完成编排式的设计,该Saga由Createorder-saga类编排,该类使用异步请求/响应调用Saga参与方。该类跟踪流程并向Saga参与方发送命令式消息,例如Kitchen Service和ConsumerService。Create Order Saga类从其回复通道读取回复消息,然后确定Saga中的下一步(如果有的话)
Order Service首先创建(实例化)一个Order对象和一个Create Order Saga编排器对象。一切正常情况下的流程如下所示:
需要注意的是,在最后一步中,Saga编排器会向Order Service发送命令式消息,即使它是Order Service的一个组件。原则上,Create Order Saga可以通过直接更新order来批准订单。但为了保持一致性,Saga将order
service视为另一个参与方。
上述的流程描述的是一切正常情况下,但一个Saga可能有很多场景,例如,由于Consumer Service、KitchenService或AccountingService的失败,Saga可能会失败。
那么有没有一种方案可以能很好地描述所有的可能出现的场景,答案就是:将Saga建模为状态机
状态机由一组状态和一组由事件触发的状态之间的转换组成,每个转换都可以有一个动作,对Saga 来说动作就是对某个参与方的调用。 状态之间的转换由Saga 参与方执行的本地事务完成触发。当前状态和本地事务的特定结果决定了状态转换以及执行的动作(如果有的话)。对状态机也有有效的测试策略。因此,使用状态机模型可以更轻松地设计、实现和测试Saga
通过状态机建模之后的Create Order Saga总共包含以下几种状态:
状态机还定义了许多状态转换。例如,状态机从CreatingTicket状态转换为Authorizing Card或Rejected Order状态。当它收到成功回复Create Ticket命令时,它将转换到Authorizing Card状态。或者,如果Kitchen Service无法创建Ticket,则状态机将转换为Order Rejected状态。
状态机的初始操作是将Verify Consumer命令发送到Consumer Service。Consumer Service的响应会触发下一次状态转型。如果消费者被成功验证,则Saga会创建Ticket并转换为Creating Ticket状态。但是,如果消费者验证失败,则Saga会拒绝Order并转换为OrderRejected状态。状态机经历了许多其他状态转换,由Saga参与方的响应驱动,直到达到最终状态为Order Approved或Order Rejected中的一种
基于编排的Saga 有以下好处:
基于编排的Saga 的弊端:
在本地事务中我们说过事务中的隔离性可以有MVCC或者加锁的两种方式去实现。SAGA本身是不支持 ACID 事务的隔离属性的。这是因为一旦该事务提交,每个Saga的本地事务所做的更新都会立即被其他Sagas看到。此行为可能导致两个问题。首先,其他Saga可以在执行时更改该Saga所访问的数据。其他Saga可以在Saga完成更新之前读取其数据,因此可能会暴露不一致的数据。事实上,你可以认为Saga只满足ACD三个属性:
缺乏隔离性可能出现如下几种异常:
例如:
Create order Saga的第一步创建了Order
当该Saga正在执行时,另外一个Cancel Order Saga取消了这个Order
Create Order Saga的最后一步批准Order
在这种情况下,Create order saga 会忽略Cancel order saga 所做的更新并覆盖它
例如:
Consumer Service:增加可用额度
Order Service:将order状态更改为已取消
Delivery Service:取消送货
虽然SAGA不支持事务的隔离性,但是我们开发人员可以通过额外的编码去实现隔离性。1998年一篇名为《Semantic ACID properties in multidata- basesusing remoteprocedurecallsand updatepropagations 》的论文中描述了不使用分布式事务时如何处理多数据库架构中缺乏事务隔离的问题。论文中描述的对策如下:
上面的几种方案的实现,这里就不过多去描述,大家可以去github上找到对应的案例。然后再结合本篇文章,应该可以给大家带来一点收获。