# 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 的逻辑。
# 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 分钟到 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 事务中,设置超时时间一定要注意,这类事务里不能够包含无法回滚的事务分支,因为超时回滚时,已执行的无法回滚的分支,数据就是错的。