一、前言

更多内容见Seata专栏:https://blog.csdn.net/saintmm/category_11953405.html

至此,seata系列的内容已出:

至此,Seata常用的AT模式、TCC模式 和 XA模式已完结,本文就SAGA模式展开详细的介绍。

二、SAGA模式

1987年普林斯顿大学的Hector Garcia-Molina 和 Kenneth Salem发表了一篇论文《sagas》,它讲述了如何处理long lived transaction(长活事务)。

其核心思想在于:允许分布式事务在全部提交前提前释放占用的某些资源

0、saga论文摘要

论文下载地址:https://www.cs.princeton.edu/research/techreps/TR-070-87

saga模式、Seata saga模式详解-LMLPHP

摘要内容翻译:

一个长时间事务会在相对较长的时间内占用数据库资源,明显的阻碍了较短的和公用的其他事务完成。为了缓解这些问题, 我们提出一个 saga的概念。它是由多个有序的事务组成、并且与其他事务可以交错的一个长时间事务(LLT),数据库管理系统保证成功完成 saga 中的所有事务, 或对部分进行事务补偿。saga的概念和它的实施相对简单, 但它们有可能显著提高性能。我们分析了与 sagas 相关的各种实施问题,包括如何在不直接支持它们的现有系统上运行它们。我们进行了数据库和 LLT技术讨论, 使 sagas成为LLT解决方案的可能。

1、什么是长事务?

长事务,就是需要长时间来执行的事务,这类事务往往需要访问大量的数据对象,执行周期也比较长。

二传统的事务执行时需要锁定 / 占用资源,在长事务的场景下,资源将被长期锁定,带来额外的性能消耗。

2、saga的组成

Saga由一系列的本地事务(sub-transaction Ti )组成,其中每个事务在单个服务中更新数据(即:Ti直接提交数据到库);每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果。

在saga中仅有第一个事务由外部请求启动,后续每个步骤(事务)由前一个步骤(事务)完成后触发。

3、saga的两种执行场景

saga的本地事务有两种执行顺序:

  • forward recovery(向前恢复)T1, T2, T3, ..., Tn
  • backward recovery(向后恢复)T1, T2, ..., Tj, Cj,..., C2, C1,其中0 < j < n

1)forward recovery

向前恢复的方式会假设每个子事务(Ti)最终都会成功,会一直重试失败的事务,这种情况下不需要补偿(Ci)。

本地事务的执行顺序类似于:T1, T2, ..., Tj(失败), Tj(重试),..., Tn,其中 j 是发生错误的sub-transaction。

适用于必须要成功的场景。

  • 如果业务中,子事务最终总会成功,亦或是 补偿事务难以定义 或 不可能定义,向前恢复会更符合需求。

2)backward recovery

向后恢复的方式中任意一个子事务失败,会补偿所有已完成的事务,撤销掉之前所有已经成功的 sub-transation,使得整个saga事务的执行结果撤销。

本地事务的执行顺序类似于:T1, T2, ..., Tj, Cj,..., C2, C1,其中0 < j < n,j 是发生错误的sub-transaction

saga模式、Seata saga模式详解-LMLPHP

理论上补偿事务永不失败,然而,在分布式中,服务器可能会宕机,网络可能会失败,在这种情况下要提供回退措施,比如:人工干预。

4、saga log

Saga保证所有的子事务都得以完成或补偿,但saga系统本身可能会崩溃。saga崩溃有以下六种情况:

  1. saga收到事务请求,但尚未开始;子事务对应的微服务状态也未被Saga修改;
  2. saga收到事务请求,并且一些子事务已经完成。重启后,saga接着上次完成的事务继续向后执行
  3. saga收到事务请求,某个子事务已经开始,但尚未完成;由于远程服务可能已完成事务,也可能事务失败,甚至服务请求超时;saga只能重新发起之前未确认完成的子事务。这里子事务必须要幂等
  4. saga收到事务请求,某个子事务失败,其补偿事务尚未开始。saga重启后执行对应的补偿事务
  5. saga收到事务请求,某个子事务失败,补偿事务已开始但尚未完成。补偿事务也必须是幂等的
  6. saga收到事务请求,所有子事务或补偿事务均已完成。

为了处理上述六种情况,必须追踪子事务(Ti)及补偿事务(Ci)的每一步。可以通过事件的方式处理,将事件以JSON的形式存储在名为saga log的持久存储中;事件由以下六种:

  1. Saga started event:保存整个saga请求,其中包括多个事务/补偿请求。
  2. Transaction started event:保存对应事务请求
  3. Transaction ended event:保存对应事务请求及其回复
  4. Transaction aborted event:保存对应事务请求和失败的原因
  5. Transaction compensated event:保存对应补偿请求及其回复
  6. Saga ended event:标志着saga事务请求的结束(不保存任何内容)。

saga模式、Seata saga模式详解-LMLPHP

PS:注意注意

  • TiCi 要是幂等的;
  • Ci 必须要能够成功,如果无法成功需要重试、人工介入;
  • Ti -> CiCi -> Ti 的执行结果必须要一样;即:不能空补偿、悬挂。

5、saga协调(saga实现方式)

saga的实现包含协调saga步骤的逻辑;

  • 当请求打到saga时,协调逻辑必须选择出第一个saga参与者执行本地事务;
  • 一旦前一个事务执行完成,saga协调需要选择并调用下一个saga参与者;直到saga执行了所有步骤
  • 如果任何本地事务失败,saga必须以相反的顺序执行补偿事务。

构建一个saga的协调逻辑有两种方式:

  • 编排(Choreography):当没有中央协调时,每个服务产生并监听其他服务的事件,并决定是否应该采取行动。
  • 控制(Orchestration):协调器服务负责集中saga的决策和排序业务逻辑。

Choreography 策略 通过事件机制实现的,每个服务都监听自己所关心的事件,每个服务执行后会发送相应的事件,监听此事件的服务执行相应的处理逻辑。

Orchestration 策略 通过状态机来实现的整体控制,定义整体的处理流程,不同状态下触发不同的动作。

1)SAGA - Choreography 策略

Choreography 是编舞(把舞者之间的动作配合都编排好)的意思。对应到分布式事务中,可以把各个服务理解为舞者。

SAGA 的 Choreography 策略其实就是要定义好先执行哪个服务,根据执行结果再触发哪些服务的执行。

Choreography 策略通过【事件机制】实现:

  • 各个服务都定义好正常、异常的处理方法;
  • 然后监听目标事件,根据不同的事件 调用不同的处理方法。

1> 优点:

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

2> 缺点:

  • 服务之间循环依赖:saga参与者订阅彼此的事件,导致创建循环依赖关系;
  • 难以理解:整体事件逻辑比较复杂,事件订阅关系很混乱;

2)SAGA - Orchestration 策略

Orchestration 是乐队编排的意思。对应到分布式事务中,各个服务是乐队中的各个演奏者,此外还有一个 总指挥(在 SAGA - Orchestration 策略中需要单独创建一个这样的角色),其是一个控制类,它唯一的职责是告诉saga参与者应该做什么

Orchestration 策略 通过【状态机】实现:

  • 状态机中做整体控制,定义整体的处理流程,不同状态下触发不同的动作;
  • 状态机由一组状态和一组由事件触发的状态 之间的转换组成;
  • 每个transition都可以有一个action,对于saga来说就是一个saga参与者的调用;
  • 状态之间的转换由saga参与者执行本地事务的结果触发不同的状态转换。
    • 流程当前状态和本地事务的执行结果决定了状态如何转换 以及 后续执行的操作;

1> 优点:

  • 简单的依赖关系:服务之间没有关联,不会引入循环依赖关系,整体结构很清晰;
  • 强解耦:每个服务都实现由orchestrator调用的API,服务不需要知道saga参与者发布的事件。

2> 缺点:

  • 复杂度更高:多了一个总指挥的角色,在协调器中集中了过多的业务逻辑。

3)如何选择

建议使用编排(Orchestration)方式。因为使用状态机模型可以更轻松地设计、实施和测试。

Seata的saga模式也是基于编排(Orchestration)的方式,通过状态机实现。

三、Seata saga模式

官方文档地址:https://seata.io/zh-cn/docs/user/saga.html

Seata提供的Saga模式目前只能通过状态机引擎来实现,整体机制为:

  1. 通过状态图来定义服务调用的流程并生成 json 状态语言定义文件;
    • 换言之,需要开发者手工的进行Saga业务流程绘制,并将其转换为JSON配置文件;
  2. 状态图中一个节点可以是调用一个服务,节点可以配置它的补偿节点;
    • 注意: 异常发生时是否进行补偿也可由用户自定义决定,可以选择不配置;
  3. 状态图 json 由状态机引擎驱动执行,当出现异常时状态引擎反向执行已成功节点对应的补偿节点将事务回滚;
    • 在程序启动时,会根据saga状态图加载业务处理流程(包括:服务补偿处理);
  4. 可以实现服务编排需求,支持单项选择、并发、子流程、参数转换、参数映射、服务执行状态判断、异常捕获等功能;

1、示例状态图

saga模式、Seata saga模式详解-LMLPHP

2、“状态机”介绍

seata saga的状态语言在一定程度上参考了AWS Step Functions

1)“状态机”属性

  • Name: 表示状态机的名称,必须唯一
  • Comment: 状态机的描述
  • Version: 状态机定义版本
  • StartState: 启动时运行的第一个"状态"
  • States: 状态列表,是一个map结构,key是"状态"的名称,在状态机内必须唯一
  • IsRetryPersistModeUpdate: 向前重试时, 日志是否基于上次失败日志进行更新
  • IsCompensatePersistModeUpdate: 向后补偿重试时, 日志是否基于上次补偿日志进行更新

2)“状态”属性

  1. Type: “状态” 的类型,比如有:
    • ServiceTask: 执行调用服务任务
    • Choice: 单条件选择路由
    • CompensationTrigger: 触发补偿流程
    • Succeed: 状态机正常结束
    • Fail: 状态机异常结束
    • SubStateMachine: 调用子状态机
    • CompensateSubMachine: 用于补偿一个子状态机
  2. ServiceName: 服务名称,通常是服务的beanId(也就是Spring容器中的beanName)
    • 无论是SpringCloud,还是Dubbo、HSF…,最重要的就是配置这个beanId。
  3. ServiceMethod: 服务方法名称(也就是:Spring Bean中的某个方法名)
  4. CompensateState: 该"状态"的补偿"状态"
  5. Loop: 标识该事务节点是否为循环事务, 即由框架本身根据循环属性的配置, 遍历集合元素对该事务节点进行循环执行
  6. Input: 调用服务的输入参数列表, 是一个数组, 对应于服务方法的参数列表, $.表示使用表达式从状态机上下文中取参数,表达使用 SpringEL, 如果是常量直接写值即可
  7. Ouput: 将服务返回的参数赋值到状态机上下文中, 是一个map结构,key为放入到状态机上文时的key(状态机上下文也是一个map),value中$.是表示SpringEL表达式,表示从服务的返回参数中取值,#root表示服务的整个返回参数
  8. Status: 服务执行状态映射,框架定义了三个状态,SU 成功、FA 失败、UN 未知, 我们需要把服务执行的状态映射成这三个状态,帮助框架判断整个事务的一致性,是一个map结构,key是条件表达式,一般是取服务的返回值或抛出的异常进行判断,默认是SpringEL表达式判断服务返回参数,带$Exception{开头表示判断异常类型。value是当这个条件表达式成立时则将服务执行状态映射成这个值
  9. Catch: 捕获到异常后的路由
  10. Next: 服务执行完成后下一个执行的"状态"
  11. Choices: Choice类型的"状态"里, 可选的分支列表, 分支中的Expression为SpringEL表达式, Next为当表达式成立时执行的下一个"状态"
  12. ErrorCode: Fail类型"状态"的错误码
  13. Message: Fail类型"状态"的错误信息

3)更多状态相关内容

更多详细的状态语言使用示例见github:
https://github.com/seata/seata/tree/develop/test/src/test/java/io/seata/saga/engine

3、示例状态图对应的JSON文件解析

1)JSON

{
    "Name": "reduceInventoryAndBalance",
    "Comment": "reduce inventory then reduce balance in a transaction",
    "StartState": "ReduceInventory",
    "Version": "0.0.1",
    "States": {
        "ReduceInventory": {
            "Type": "ServiceTask",
            "ServiceName": "inventoryAction",
            "ServiceMethod": "reduce",
            "CompensateState": "CompensateReduceInventory",
            "Next": "ChoiceState",
            "Input": [
                "$.[businessKey]",
                "$.[count]"
            ],
            "Output": {
                "reduceInventoryResult": "$.#root"
            },
            "Status": {
                "#root == true": "SU",
                "#root == false": "FA",
                "$Exception{java.lang.Throwable}": "UN"
            }
        },
        "ChoiceState":{
            "Type": "Choice",
            "Choices":[
                {
                    "Expression":"[reduceInventoryResult] == true",
                    "Next":"ReduceBalance"
                }
            ],
            "Default":"Fail"
        },
        "ReduceBalance": {
            "Type": "ServiceTask",
            "ServiceName": "balanceAction",
            "ServiceMethod": "reduce",
            "CompensateState": "CompensateReduceBalance",
            "Input": [
                "$.[businessKey]",
                "$.[amount]",
                {
                    "throwException" : "$.[mockReduceBalanceFail]"
                }
            ],
            "Output": {
                "compensateReduceBalanceResult": "$.#root"
            },
            "Status": {
                "#root == true": "SU",
                "#root == false": "FA",
                "$Exception{java.lang.Throwable}": "UN"
            },
            "Catch": [
                {
                    "Exceptions": [
                        "java.lang.Throwable"
                    ],
                    "Next": "CompensationTrigger"
                }
            ],
            "Next": "Succeed"
        },
        "CompensateReduceInventory": {
            "Type": "ServiceTask",
            "ServiceName": "inventoryAction",
            "ServiceMethod": "compensateReduce",
            "Input": [
                "$.[businessKey]"
            ]
        },
        "CompensateReduceBalance": {
            "Type": "ServiceTask",
            "ServiceName": "balanceAction",
            "ServiceMethod": "compensateReduce",
            "Input": [
                "$.[businessKey]"
            ]
        },
        "CompensationTrigger": {
            "Type": "CompensationTrigger",
            "Next": "Fail"
        },
        "Succeed": {
            "Type":"Succeed"
        },
        "Fail": {
            "Type":"Fail",
            "ErrorCode": "PURCHASE_FAILED",
            "Message": "purchase failed"
        }
    }
}

2)状态图解析

4、状态机设计器

Seata Saga 提供了一个可视化的状态机设计器方便用户使用,代码和运行指南请参考:
https://github.com/seata/seata/tree/develop/saga/seata-saga-statemachine-designer

  • 想要使用状态机设计器,必须要安装nodeJS环境
    saga模式、Seata saga模式详解-LMLPHP

状态机设计器截图如下:

saga模式、Seata saga模式详解-LMLPHP

状态机设计器演示(在线画图工具)地址:http://seata.io/saga_designer/index.html

状态机设计器视频教程:http://seata.io/saga_designer/vedio.html

四、saga模式的缺陷

1、空补偿、悬挂、幂等

因为需要自己实现正向服务和逆向补偿服务,所以saga模式会遇到和TCC模式一样的问题:空补偿、悬挂、幂等

TCC模式针对这块的处理见博文: 分布式事务Seata TCC空回滚/幂等/悬挂问题、解决方案(seata1.5.1如何解决?)

1)空补偿

saga模式可能会出现空补偿问题(原服务未执行,补偿服务执行了);

出现原因:

  • 原服务 超时(丢包)
  • Saga 事务触发 回滚
  • 未收到 原服务请求,先收到 补偿请求

解决措施:

  • 需要允许空补偿,在没有找到要补偿的业务主键时返回补偿成功并将原业务主键记录下来。

2)悬挂

saga模式可能会出现悬挂问题(补偿服务 比 原服务 先执行);

出现原因:

  • 原服务 超时(拥堵)
  • Saga 事务回滚,触发 回滚
  • 拥堵的 原服务 到达

解决措施:

  • 检查当前业务主键是否已经在空补偿记录下来的业务主键中存在,如果存在则要拒绝服务的执行。

3)幂等

原服务与补偿服务都需要保证幂等性, 由于网络可能超时, 可以设置重试策略,重试发生时要通过幂等控制避免业务数据重复更新

2、脏读问题

由于 Saga 事务不保证隔离性, 在极端情况下可能由于脏写无法完成回滚操作;

以一个比较极端的例子为例:

  • 分布式事务中先给用户A充值,然后给用户B扣减余额;
  • 如果在给A用户充值成功, 在事务提交以前, 其他事务把A用户的余额消费掉了;
  • 此时,如果当前事务发生回滚, 这时则没有办法进行补偿了。

这就是缺乏隔离性造成的典型的问题, 实践中一般的应对方法是:

  • 业务流程设计时遵循“宁可长款, 不可短款”的原则, 长款意思是客户少了钱机构多了钱, 以机构信誉可以给客户退款, 反之则是短款, 少的钱可能追不回来了。所以在业务流程设计上一定是先扣款。
  • 有些业务场景可以允许让业务最终成功, 在回滚不了的情况下可以继续重试完成后面的流程, 所以状态机引擎除了提供“回滚”能力还需要提供“向前”恢复上下文继续执行的能力, 让业务最终执行成功, 达到最终一致性的目的。
03-31 12:52