分布式事务: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看起来很有希望满足我们的需求。所有长活事务都可以这样做吗?这里有一些限制:
- Saga只允许两个层次的嵌套,顶级的Saga和简单子事务
- 在外层,全原子性不能得到满足。也就是说,sagas可能会看到其他sagas的部分结果
- 每个子事务应该是独立的原子行为
- 在我们的业务场景下,各个业务环境(如:航班预订、租车、酒店预订和付款)是自然独立的行为,而且每个事务都可以用对应服务的数据库保证原子操作。
补偿也有需考虑的事项:
- 补偿事务从语义角度撤消了事务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事务请求的结束,不需要保存任何内容
通过将这些事件持久化在saga log中,我们可以将saga恢复到上述任何状态。
由于Saga只需要做事件的持久化,而事件内容以JSON的形式存储,Saga log的实现非常灵活,数据库(SQL或NoSQL),持久消息队列,甚至普通文件可以用作事件存储, 当然有些能更快得帮saga恢复状态。
注意事项
对于服务来说,实现Saga有以下这些要求:
- Ti和Ci是幂等的。
- Ci必须是能够成功的,如果无法成功则需要人工介入。
- 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,那么就会有三种情况:
- Ti的请求丢失了,服务之前没有、之后也不会执行Ti
- Ti在Ci之前执行
- 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参与者订阅彼此的事件并做出相应的响应。
通过这个sagas的路径如下:
- Order Service在APPROVAL_PENDING状态下创建一个Order并发布OrderCreated事件。
- Consumer Service消费OrderCreated事件,验证消费者是否可以下订单,并发布ConsumerVerified事件。
- Kitchen Service消费OrderCreated事件,验证订单,在CREATE_PENDING状态下创建故障单,并发布TicketCreated事件。
- Accounting服务消费OrderCreated事件并创建一个处于PENDING状态的Credit CardAuthorization。
- Accounting Service消费TicketCreated和ConsumerVerified事件,收取消费者的信用卡,并发布信用卡授权活动。
- Kitchen Service使用CreditCardAuthorized事件并更改AWAITING_ACCEPTANCE票的状态。
- Order Service收到CreditCardAuthorized事件,更改订单状态到APPROVED,并发布OrderApproved事件。
创建订单saga还必须处理saga参与者拒绝订单并发布某种失败事件的场景。例如,消费者信用卡的授权可能会失败。saga必须执行补偿交易以撤消已经完成的事情。图中显示了AccountingService无法授权消费者信用卡时的事件流。
事件顺序如下:
- Order服务在APPROVAL_PENDING状态下创建一个Order并发布OrderCreated事件。
- Consumer服务消费OrderCreated事件,验证消费者是否可以下订单,并发布ConsumerVerified事件。
- Kitchen服务消费OrderCreated事件,验证订单,在CREATE_PENDING状态下创建故障单,并发布TicketCreated事件。
- Accounting服务消费OrderCreated事件并创建一个处于PENDING状态的Credit CardAuthorization。
- Accounting服务消费TicketCreated和ConsumerVerified事件,向消费者的信用卡收费,并发布信用卡授权失败事件。
- Kitchen服务使用信用卡授权失败事件并将故障单的状态更改为REJECTED。
- 订单服务消费信用卡授权失败事件,并将订单状态更改为已拒绝。
可靠的基于事件的通信
在实施基于编排的saga时,您必须考虑一些与服务间通信相关的问题。第一个问题是确保saga参与者更新其数据库并将事件作为数据库事务的一部分发布。 您需要考虑的第二个问题是确保saga参与者必须能够将收到的每个事件映射到自己的数据。
编组的saga的好处和缺点
基于编舞的saga有几个好处:
- 简单:服务在创建,更新或删除业务时发布事件对象
- 松耦合:参与者订阅事件并且彼此之间没有直接的了解。
并且有一些缺点:
- 更难理解:与业务流程不同,代码中没有一个地方可以定义saga。相反,编排在服务中分配saga的实现。因此,开发人员有时很难理解给定的saga是如何工作的。
- 服务之间的循环依赖关系:saga参与者订阅彼此的事件,这通常会创建循环依赖关系。例如,如果仔细检查图示,您将看到存在循环依赖关系,例如订单服务、会计服务、订单服务。虽然这不一定是个问题,但循环依赖性被认为是设计问题。
- 紧密耦合的风险:每个saga参与者都需要订阅所有影响他们的事件。例如,会计服务必须订阅导致消费者信用卡被收费或退款的所有事件。因此,存在一种风险,即需要与Order Service实施的订单生命周期保持同步更新。
3.2 控制(Orchestration)
控制是实现sagas的另一种方式。使用业务流程时,您可以定义一个控制类,其唯一的职责是告诉saga参与者该做什么。 saga控制使用命令/异步回复样式交互与参与者进行通信。
- Order Service首先创建一个Order和一个创建订单控制器。之后,路径的流程如下:
- saga orchestrator向Consumer Service发送Verify Consumer命令。
- Consumer Service回复Consumer Verified消息。
- saga orchestrator向Kitchen Service发送Create Ticket命令。
- Kitchen Service回复Ticket Created消息。
- saga协调器向Accounting Service发送授权卡消息。
- Accounting服务部门使用卡片授权消息回复。
- saga orchestrator向Kitchen Service发送Approve Ticket命令。
- saga orchestrator向订单服务发送批准订单命令。
使用状态机建模SAGA ORCHESTRATORS
建模saga orchestrator的好方法是作为状态机。状态机由一组状态和一组由事件触发的状态之间的转换组成。每个transition都可以有一个action,对于一个saga来说是一个saga参与者的调用。状态之间的转换由saga参与者执行的本地事务的完成触发。当前状态和本地事务的特定结果决定了状态转换以及执行的操作(如果有的话)。对状态机也有有效的测试策略。因此,使用状态机模型可以更轻松地设计、实施和测试。
图显示了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有几个好处:
- 更简单的依赖关系:编排的一个好处是它不会引入循环依赖关系。 saga orchestrator调用saga参与者,但参与者不会调用orchestrator。因此,协调器依赖于参与者,但反之亦然,因此没有循环依赖性。
- 较少的耦合:每个服务都实现了由orchestrator调用的API,因此它不需要知道saga参与者发布的事件。
- 改善关注点分离并简化业务逻辑:saga协调逻辑本地化在saga协调器中。域对象更简单,并且不了解它们参与的saga。例如,当使用编排时,Order类不知道任何saga,因此它具有更简单的状态机模型。在执行创建订单saga期间,它直接从APPROVAL_PENDING状态转换到APPROVED状态。 Order类没有与saga的步骤相对应的任何中间状态。因此,业务更加简单。
业务流程也有一个缺点:
- 在协调器中集中过多业务逻辑的风险。这导致了一种设计,其中智能协调器告诉哑巴服务要做什么操作。幸运的是,您可以通过设计独立负责排序的协调器来避免此问题,并且不包含任何其他业务逻辑。
除了最简单的saga,我建议使用编排。为您的saga实施协调逻辑只是您需要解决的设计问题之一。