# SAGA 模式

SAGA 事务模式是 DTM 中最常用的模式,主要是因为 SAGA 模式简单易用,工作量少,并且能够解决绝大部分业务的需求。

dtm 的 SAGA 模式与 Seata 的 SAGA 在设计理念上是不一样的,整体使用难度大幅度降低,非常容易上手

SAGA 最初出现在 1987 年 Hector Garcaa-Molrna & Kenneth Salem 发表的论文 SAGAS 里。其核心思想是将长事务拆分为多个短事务,由 Saga 事务协调器协调,如果每个短事务都成功提交完成,那么全局事务就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。

# 拆分为子事务

例如我们要进行一个类似于银行跨行转账的业务,将 A 中的 30 元转给 B,根据 Saga 事务的原理,我们将整个全局事务,切分为以下服务:

  • 转出(TransOut)服务,这里转出将会进行操作 A-30
  • 转出补偿(TransOutCompensate)服务,回滚上面的转出操作,即 A+30
  • 转入(TransIn)服务,转入将会进行 B+30
  • 转入补偿(TransInCompensate)服务,回滚上面的转入操作,即 B-30

整个 SAGA 事务的逻辑是:

执行转出成功 => 执行转入成功 => 全局事务完成

如果在中间发生错误,例如转入 B 发生错误,则会调用已执行分支的补偿操作,即:

执行转出成功 => 执行转入失败 => 执行转入补偿成功 => 执行转出补偿成功 => 全局事务回滚完成

下面我们看一个成功完成的 SAGA 事务典型的时序图:

saga_normal

在这个图中,我们的全局事务发起人,将整个全局事务的编排信息,包括每个步骤的正向操作和反向补偿操作定义好之后,提交给服务器,服务器就会按步骤执行前面 SAGA 的逻辑。

# SAGA 的接入

我们看看 Go 如何接入一个 SAGA 事务

req := &gin.H{"amount": 30} // 微服务的请求 Body
// DtmServer 为 DTM 服务的地址
saga := dtmcli.NewSaga(DtmServer, shortuuid.New()).
  // 添加一个 TransOut 的子事务,正向操作为 url: qsBusi+"/TransOut", 逆向操作为 url: qsBusi+"/TransOutCompensate"
  Add(qsBusi+"/TransOut", qsBusi+"/TransOutCompensate", req).
  // 添加一个 TransIn 的子事务,正向操作为 url: qsBusi+"/TransIn", 逆向操作为 url: qsBusi+"/TransInCompensate"
  Add(qsBusi+"/TransIn", qsBusi+"/TransInCompensate", req)
// 提交 saga 事务,dtm 会完成所有的子事务 / 回滚所有的子事务
err := saga.Submit()

Hyperf 框架 PHP 接入

namespace App\Controller;
use DtmClient\Saga;
use DtmClient\TransContext;
use Hyperf\Di\Annotation\Inject;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\GetMapping;
#[Controller(prefix: '/saga')]
class SagaController
{
    protected string $serviceUri = 'http://127.0.0.1:9501';
    
    #[Inject]
    protected Saga $saga;
    #[GetMapping(path: 'successCase')]
    public function successCase(): string
    {
        $payload = ['amount' => 50];
        // 初始化 Saga 事务
        $this->saga->init();
        // 增加转出子事务
        $this->saga->add(
            $this->serviceUri . '/saga/transOut', 
            $this->serviceUri . '/saga/transOutCompensate', 
            $payload
        );
        // 增加转入子事务
        $this->saga->add(
            $this->serviceUri . '/saga/transIn', 
            $this->serviceUri . '/saga/transInCompensate', 
            $payload
        );
        // 提交 Saga 事务
        $this->saga->submit();
        // 通过 TransContext::getGid () 获得 全局事务 ID 并返回
        return TransContext::getGid();
    }
}

上面的代码首先创建了一个 SAGA 事务,然后添加了两个子事务 TransOut、TransIn,每个事务分支包括 action 和 compensate 两个操作,分别为 Add 函数的第一第二个参数。子事务定好之后提交给 dtm。dtm 收到 saga 提交的全局事务后,会调用所有子事务的正向操作,如果所有正向操作成功完成,那么事务成功结束。

# 高级用法

我们以一个真实用户案例,来讲解 dtm 的 saga 部分高级功能。

问题场景:一个用户出行旅游的应用,收到一个用户出行计划,需要预定去三亚的机票,三亚的酒店,返程的机票。

要求:

  1. 两张机票和酒店要么都预定成功,要么都回滚(酒店和航空公司提供了相关的回滚接口)
  2. 预订机票和酒店是并发的,避免串行的情况下,因为某一个预定最后确认时间晚,导致其他的预定错过时间
  3. 预定结果的确认时间可能从 1 分钟到 1 天不等

上述这些要求,正是 saga 事务模式擅长的领域,我们来看看 dtm 怎么解决。

首先我们根据要求 1,创建一个 saga 事务,这个 saga 包含三个分支,分别是,预定去三亚机票,预定酒店,预定返程机票

saga := dtmcli.NewSaga(DtmServer, gid).
			Add(Busi+"/BookTicket", Busi+"/BookTicketRevert", bookTicketInfo1).
			Add(Busi+"/BookHotel", Busi+"/BookHotelRevert", bookHotelInfo2).
			Add(Busi+"/BookTicket", Busi+"/BookTicketRevert", bookTicketBackInfo3)

然后我们根据要求 2,让 saga 并发执行(默认是顺序执行)

saga.EnableConcurrent()

最后我们处理 3 里面的 “预定结果的确认时间” 不是即时响应的问题。由于不是即时响应,所以我们不能够让预定操作等待第三方的结果,而是提交预定请求后,就立即返回状态 - 进行中。我们的分支事务未完成,dtm 会重试我们的事务分支,我们把重试间隔指定为 1 分钟。

saga.RetryInterval = 60
  saga.Submit()
// ........
func bookTicket() string {
	order := loadOrder()
	if order == nil { // 尚未下单,进行第三方下单操作
		order = submitTicketOrder()
		order.save()
	}
	order.Query() // 查询第三方订单状态
	return order.Status // 成功 - SUCCESS 失败 - FAILURE 进行中 - ONGOING
}

固定间隔重试

dtm 默认情况下,重试策略是指数退避算法,可以避免出现故障时,过多的重试导致负载过高。但是这里订票结果不应当采用指数退避算法重试,否则最终用户不能及时收到通知。因此在 bookTicket 中,返回结果 ONGOING,当 dtm 收到这个结果时,会采用固定间隔重试,这样能及时通知到用户。

# 更多高级场景

在实际应用中,还遇见过一些业务场景,需要一些额外的技巧进行处理

# 部分第三方操作无法回滚

例如一个订单中的发货,一旦给出了发货指令,那么涉及线下相关操作,那么很难直接回滚。对于涉及这类情况的 saga 如何处理呢?

我们把一个事务中的操作分为可回滚的操作,以及不可回滚的操作。那么把可回滚的操作放到前面,把不可回滚的操作放在后面执行,那么就可以解决这类问题

saga := dtmcli.NewSaga(DtmServer, shortuuid.New()).
			Add(Busi+"/CanRollback1", Busi+"/CanRollback1Revert", req).
			Add(Busi+"/CanRollback2", Busi+"/CanRollback2Revert", req).
			Add(Busi+"/UnRollback1", "", req).
			Add(Busi+"/UnRollback2", "", req).
			EnableConcurrent().
			AddBranchOrder(2, []int{0, 1}). // 指定 step 2,需要在 0,1 完成后执行
			AddBranchOrder(3, []int{0, 1}) // 指定 step 3,需要在 0,1 完成后执行

示例中的代码,指定 Step 2,3 中的 UnRollback 操作,必须在 Step 0,1 完成后执行。

对于不可回滚的操作,DTM 的设计建议是,不可回滚的操作在业务上也不允许返回失败。可以这么思考,如果发货的操作返回了失败,那么这个失败的含义是不够清晰的,调用方不知道这个失败是修改了部分数据的失败,还是修改数据前的业务校验失败,因为这个操作不可回滚,所以调用方收到这个失败,是不知道如何正确处理这个错误的。

另外当你的一个全局事务中,如果出现了两个既不可回滚的又可能返回失败的操作,那么到了实际运行中,一个执行成功,一个执行失败,此时执行成功的那个事务无法回滚,那么这个事务的一致性就不可能保证了。

对于发货操作,如果可能在校验数据上可能发生失败,那么将发货操作拆分为发货校验、发货两个服务则会清晰很多,发货校验可回滚,发货不可回滚同时也不会失败。

# 超时回滚#

saga 属于长事务,因此持续的时间跨度很大,可能是 100ms 到 1 天,因此 saga 没有默认的超时时间。

dtm 支持 saga 事务单独指定超时时间,到了超时时间,全局事务就会回滚。

saga.TimeoutToFail = 1800

在 saga 事务中,设置超时时间一定要注意,这类事务里不能够包含无法回滚的事务分支,因为超时回滚时,已执行的无法回滚的分支,数据就是错的。

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

PPYYLEE 微信支付

微信支付

PPYYLEE 支付宝

支付宝

PPYYLEE 贝宝

贝宝