分布式事务 02:Saga模式

分布式事务系列介绍

Posted by Shoukai Huang on November 12, 2018

分布式事务:Saga模式

1 Saga相关概念

1987年普林斯顿大学的Hector Garcia-Molina和Kenneth Salem发表了一篇Paper Sagas,讲述的是如何处理long lived transaction(长活事务)。Saga是一个长活事务可被分解成可以交错运行的子事务集合。其中每个子事务都是一个保持数据库一致性的真实事务。 论文地址:sagas

1.1 Saga的组成

  • 每个Saga由一系列sub-transaction Ti 组成
  • 每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果

可以看到,和TCC相比,Saga没有“预留”动作,它的Ti就是直接提交到库。

Saga的执行顺序有两种:

  • T1, T2, T3, …, Tn
  • T1, T2, …, Tj, Cj,…, C2, C1,其中0 < j < n

Saga定义了两种恢复策略:

  • backward recovery,向后恢复,补偿所有已完成的事务,如果任一子事务失败。即上面提到的第二种执行顺序,其中j是发生错误的sub-transaction,这种做法的效果是撤销掉之前所有成功的sub-transation,使得整个Saga的执行结果撤销。
  • forward recovery,向前恢复,重试失败的事务,假设每个子事务最终都会成功。适用于必须要成功的场景,执行顺序是类似于这样的:T1, T2, …, Tj(失败), Tj(重试),…, Tn,其中j是发生错误的sub-transaction。该情况下不需要Ci

显然,向前恢复没有必要提供补偿事务,如果你的业务中,子事务(最终)总会成功,或补偿事务难以定义或不可能,向前恢复更符合你的需求。

理论上补偿事务永不失败,然而,在分布式世界中,服务器可能会宕机,网络可能会失败,甚至数据中心也可能会停电。在这种情况下我们能做些什么? 最后的手段是提供回退措施,比如人工干预。

1.2 Saga的使用条件

Saga看起来很有希望满足我们的需求。所有长活事务都可以这样做吗?这里有一些限制:

  1. Saga只允许两个层次的嵌套,顶级的Saga和简单子事务
  2. 在外层,全原子性不能得到满足。也就是说,sagas可能会看到其他sagas的部分结果
  3. 每个子事务应该是独立的原子行为
  4. 在我们的业务场景下,各个业务环境(如:航班预订、租车、酒店预订和付款)是自然独立的行为,而且每个事务都可以用对应服务的数据库保证原子操作。

补偿也有需考虑的事项:

  • 补偿事务从语义角度撤消了事务Ti的行为,但未必能将数据库返回到执行Ti时的状态。(例如,如果事务触发导弹发射, 则可能无法撤消此操作)

但这对我们的业务来说不是问题。其实难以撤消的行为也有可能被补偿。例如,发送电邮的事务可以通过发送解释问题的另一封电邮来补偿。

对于ACID的保证:

Saga对于ACID的保证和TCC一样:

  • 原子性(Atomicity):正常情况下保证。
  • 一致性(Consistency),在某个时间点,会出现A库和B库的数据违反一致性要求的情况,但是最终是一致的。
  • 隔离性(Isolation),在某个时间点,A事务能够读到B事务部分提交的结果。
  • 持久性(Durability),和本地事务一样,只要commit则数据被持久。

Saga不提供ACID保证,因为原子性和隔离性不能得到满足。原论文描述如下:

full atomicity is not provided. That is, sagas may view the partial results of other sagas

通过saga log,saga可以保证一致性和持久性。

和TCC对比

Saga相比TCC的缺点是缺少预留动作,导致补偿动作的实现比较麻烦:Ti就是commit,比如一个业务是发送邮件,在TCC模式下,先保存草稿(Try)再发送(Confirm),撤销的话直接删除草稿(Cancel)就行了。而Saga则就直接发送邮件了(Ti),如果要撤销则得再发送一份邮件说明撤销(Ci),实现起来有一些麻烦。

如果把上面的发邮件的例子换成:A服务在完成Ti后立即发送Event到ESB(企业服务总线,可以认为是一个消息中间件),下游服务监听到这个Event做自己的一些工作然后再发送Event到ESB,如果A服务执行补偿动作Ci,那么整个补偿动作的层级就很深。

不过没有预留动作也可以认为是优点:

  • 有些业务很简单,套用TCC需要修改原来的业务逻辑,而Saga只需要添加一个补偿动作就行了。
  • TCC最少通信次数为2n,而Saga为n(n=sub-transaction的数量)。
  • 有些第三方服务没有Try接口,TCC模式实现起来就比较tricky了,而Saga则很简单。
  • 没有预留动作就意味着不必担心资源释放的问题,异常处理起来也更简单(请对比Saga的恢复策略和TCC的异常处理)。

2 Saga相关实现

Saga Log

Saga保证所有的子事务都得以完成或补偿,但Saga系统本身也可能会崩溃。Saga崩溃时可能处于以下几个状态:

  • Saga收到事务请求,但尚未开始。因子事务对应的微服务状态未被Saga修改,我们什么也不需要做。
  • 一些子事务已经完成。重启后,Saga必须接着上次完成的事务恢复。
  • 子事务已开始,但尚未完成。由于远程服务可能已完成事务,也可能事务失败,甚至服务请求超时,saga只能重新发起之前未确认完成的子事务。这意味着子事务必须幂等。
  • 子事务失败,其补偿事务尚未开始。Saga必须在重启后执行对应补偿事务。
  • 补偿事务已开始但尚未完成。解决方案与上一个相同。这意味着补偿事务也必须是幂等的。
  • 所有子事务或补偿事务均已完成,与第一种情况相同。

为了恢复到上述状态,我们必须追踪子事务及补偿事务的每一步。我们决定通过事件的方式达到以上要求,并将以下事件保存在名为saga log的持久存储中:

  • Saga started event 保存整个saga请求,其中包括多个事务/补偿请求
  • Transaction started event 保存对应事务请求
  • Transaction ended event 保存对应事务请求及其回复
  • Transaction aborted event 保存对应事务请求和失败的原因
  • Transaction compensated event 保存对应补偿请求及其回复
  • Saga ended event 标志着saga事务请求的结束,不需要保存任何内容

66ae7b320e502c13f4a21a08baa61ead

通过将这些事件持久化在saga log中,我们可以将saga恢复到上述任何状态。

由于Saga只需要做事件的持久化,而事件内容以JSON的形式存储,Saga log的实现非常灵活,数据库(SQL或NoSQL),持久消息队列,甚至普通文件可以用作事件存储, 当然有些能更快得帮saga恢复状态。

注意事项

对于服务来说,实现Saga有以下这些要求:

  1. Ti和Ci是幂等的。
  2. Ci必须是能够成功的,如果无法成功则需要人工介入。
  3. Ti - Ci和Ci - Ti的执行结果必须是一样的:sub-transaction被撤销了。

第一点要求Ti和Ci是幂等的,举个例子,假设在执行Ti的时候超时了,此时我们是不知道执行结果的,如果采用forward recovery策略就会再次发送Ti,那么就有可能出现Ti被执行了两次,所以要求Ti幂等。如果采用backward recovery策略就会发送Ci,而如果Ci也超时了,就会尝试再次发送Ci,那么就有可能出现Ci被执行两次,所以要求Ci幂等。

第二点要求Ci必须能够成功,这个很好理解,因为,如果Ci不能执行成功就意味着整个Saga无法完全撤销,这个是不允许的。但总会出现一些特殊情况比如Ci的代码有bug、服务长时间崩溃等,这个时候就需要人工介入了。

第三点乍看起来比较奇怪,举例说明,还是考虑Ti执行超时的场景,我们采用了backward recovery,发送一个Ci,那么就会有三种情况:

  1. Ti的请求丢失了,服务之前没有、之后也不会执行Ti
  2. Ti在Ci之前执行
  3. Ci在Ti之前执行

对于第1种情况,容易处理。对于第2、3种情况,则要求Ti和Ci是可交换的(commutative),并且其最终结果都是sub-transaction被撤销。

3 Saga协调

协调saga:saga的实现包含协调saga步骤的逻辑。当系统命令启动saga时,协调逻辑必须选择并告知第一个saga参与者执行本地事务。一旦该事务完成,saga的排序协调选择并调用下一个saga参与者。这个过程一直持续到saga执行了所有步骤。如果任何本地事务失败,则saga必须以相反的顺序执行补偿事务。构建一个saga的协调逻辑有几种不同的方法:

  • 编排(Choreography):在saga参与者中分配决策和排序。他们主要通过交换事件进行沟通。
  • 控制(Orchestration):在saga控制类中集中saga的协调逻辑。一个saga控制者向saga参与者发送命令消息,告诉他们要执行哪些操作。

3.1 编排(Choreography)

基于编排的saga:实现sagas的一种方法是使用编排。当使用编排时,没有中央协调员告诉saga参与者该做什么。相反,sagas参与者订阅彼此的事件并做出相应的响应。

Screen Shot 2018-11-27 at 23.24.17

通过这个sagas的路径如下:

  1. Order Service在APPROVAL_PENDING状态下创建一个Order并发布OrderCreated事件。
  2. Consumer Service消费OrderCreated事件,验证消费者是否可以下订单,并发布ConsumerVerified事件。
  3. Kitchen Service消费OrderCreated事件,验证订单,在CREATE_PENDING状态下创建故障单,并发布TicketCreated事件。
  4. Accounting服务消费OrderCreated事件并创建一个处于PENDING状态的Credit CardAuthorization。
  5. Accounting Service消费TicketCreated和ConsumerVerified事件,收取消费者的信用卡,并发布信用卡授权活动。
  6. Kitchen Service使用CreditCardAuthorized事件并更改AWAITING_ACCEPTANCE票的状态。
  7. Order Service收到CreditCardAuthorized事件,更改订单状态到APPROVED,并发布OrderApproved事件。

创建订单saga还必须处理saga参与者拒绝订单并发布某种失败事件的场景。例如,消费者信用卡的授权可能会失败。saga必须执行补偿交易以撤消已经完成的事情。图中显示了AccountingService无法授权消费者信用卡时的事件流。

Screen Shot 2018-11-27 at 23.54.12

事件顺序如下:

  1. Order服务在APPROVAL_PENDING状态下创建一个Order并发布OrderCreated事件。
  2. Consumer服务消费OrderCreated事件,验证消费者是否可以下订单,并发布ConsumerVerified事件。
  3. Kitchen服务消费OrderCreated事件,验证订单,在CREATE_PENDING状态下创建故障单,并发布TicketCreated事件。
  4. Accounting服务消费OrderCreated事件并创建一个处于PENDING状态的Credit CardAuthorization。
  5. Accounting服务消费TicketCreated和ConsumerVerified事件,向消费者的信用卡收费,并发布信用卡授权失败事件。
  6. Kitchen服务使用信用卡授权失败事件并将故障单的状态更改为REJECTED。
  7. 订单服务消费信用卡授权失败事件,并将订单状态更改为已拒绝。

可靠的基于事件的通信

在实施基于编排的saga时,您必须考虑一些与服务间通信相关的问题。第一个问题是确保saga参与者更新其数据库并将事件作为数据库事务的一部分发布。 您需要考虑的第二个问题是确保saga参与者必须能够将收到的每个事件映射到自己的数据。

编组的saga的好处和缺点

基于编舞的saga有几个好处

  • 简单:服务在创建,更新或删除业务时发布事件对象
  • 松耦合:参与者订阅事件并且彼此之间没有直接的了解。

并且有一些缺点

  • 更难理解:与业务流程不同,代码中没有一个地方可以定义saga。相反,编排在服务中分配saga的实现。因此,开发人员有时很难理解给定的saga是如何工作的。
  • 服务之间的循环依赖关系:saga参与者订阅彼此的事件,这通常会创建循环依赖关系。例如,如果仔细检查图示,您将看到存在循环依赖关系,例如订单服务、会计服务、订单服务。虽然这不一定是个问题,但循环依赖性被认为是设计问题。
  • 紧密耦合的风险:每个saga参与者都需要订阅所有影响他们的事件。例如,会计服务必须订阅导致消费者信用卡被收费或退款的所有事件。因此,存在一种风险,即需要与Order Service实施的订单生命周期保持同步更新。

3.2 控制(Orchestration)

控制是实现sagas的另一种方式。使用业务流程时,您可以定义一个控制类,其唯一的职责是告诉saga参与者该做什么。 saga控制使用命令/异步回复样式交互与参与者进行通信。

Screen Shot 2018-11-28 at 00.08.51

  1. Order Service首先创建一个Order和一个创建订单控制器。之后,路径的流程如下:
  2. saga orchestrator向Consumer Service发送Verify Consumer命令。
  3. Consumer Service回复Consumer Verified消息。
  4. saga orchestrator向Kitchen Service发送Create Ticket命令。
  5. Kitchen Service回复Ticket Created消息。
  6. saga协调器向Accounting Service发送授权卡消息。
  7. Accounting服务部门使用卡片授权消息回复。
  8. saga orchestrator向Kitchen Service发送Approve Ticket命令。
  9. saga orchestrator向订单服务发送批准订单命令。

使用状态机建模SAGA ORCHESTRATORS

建模saga orchestrator的好方法是作为状态机。状态机由一组状态和一组由事件触发的状态之间的转换组成。每个transition都可以有一个action,对于一个saga来说是一个saga参与者的调用。状态之间的转换由saga参与者执行的本地事务的完成触发。当前状态和本地事务的特定结果决定了状态转换以及执行的操作(如果有的话)。对状态机也有有效的测试策略。因此,使用状态机模型可以更轻松地设计、实施和测试。

Screen Shot 2018-11-28 at 00.12.58

图显示了Create Order Saga的状态机模型。此状态机由多个状态组成,包括以下内容:

  • Verifying Consumer:初始状态。当处于此状态时,该saga正在等待消费者服务部门验证消费者是否可以下订单。
  • Creating Ticket:该saga正在等待对创建票证命令的回复。
  • Authorizing Card:等待Accounting服务授权消费者的信用卡。
  • OrderApproved:表示saga成功完成的最终状态。
  • Order Rejected:最终状态表明该订单被其中一方参与者们拒绝。

SAGA ORCHESTRATION和TRANSACTIONAL MESSAGING

基于业务流程的saga的每个步骤都包括更新数据库和发布消息的服务。例如,Order Service持久保存Order和Create Order Saga orchestrator,并向第一个saga参与者发送消息。一个saga参与者,例如Kitchen Service,通过更新其数据库并发送回复消息来处理命令消息。 Order Service通过更新saga协调器的状态并向下一个saga参与者发送命令消息来处理参与者的回复消息。服务必须使用事务性消息传递,以便自动更新数据库并发布消息。

让我们来看看使用saga编排的好处和缺点。

基于ORCHESTRATION的SAGAS的好处和缺点

基于编排的saga有几个好处:

  1. 更简单的依赖关系:编排的一个好处是它不会引入循环依赖关系。 saga orchestrator调用saga参与者,但参与者不会调用orchestrator。因此,协调器依赖于参与者,但反之亦然,因此没有循环依赖性。
  2. 较少的耦合:每个服务都实现了由orchestrator调用的API,因此它不需要知道saga参与者发布的事件。
  3. 改善关注点分离并简化业务逻辑:saga协调逻辑本地化在saga协调器中。域对象更简单,并且不了解它们参与的saga。例如,当使用编排时,Order类不知道任何saga,因此它具有更简单的状态机模型。在执行创建订单saga期间,它直接从APPROVAL_PENDING状态转换到APPROVED状态。 Order类没有与saga的步骤相对应的任何中间状态。因此,业务更加简单。

业务流程也有一个缺点

  • 在协调器中集中过多业务逻辑的风险。这导致了一种设计,其中智能协调器告诉哑巴服务要做什么操作。幸运的是,您可以通过设计独立负责排序的协调器来避免此问题,并且不包含任何其他业务逻辑。

除了最简单的saga,我建议使用编排。为您的saga实施协调逻辑只是您需要解决的设计问题之一。

4 参考示例