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()

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

详细例子代码参考dtm-examples

我们前面的的例子,是基于HTTP协议SDK进行DTM接入,gRPC协议的接入基本一样,详细例子代码可以在dtm-examples

失败回滚

如果有正向操作失败,例如账户余额不足或者账户被冻结,那么dtm会调用各分支的补偿操作,进行回滚,最后事务成功回滚。

我们将上述的第二个分支调用,传递参数,让他失败

  Add(qsBusi+"/TransIn", qsBusi+"/TransInCompensate", &TransReq{Amount: 30, TransInResult: "FAILURE"})

失败的时序图如下:

saga_rollback

补偿执行顺序

dtm的SAGA事务在1.10.0及之前,补偿操作是并发执行的,1.10.1之后,是根据用户指定的分支顺序,进行回滚的。

如果是普通SAGA,没有打开并发选项,那么SAGA事务的补偿分支是完全按照正向分支的反向顺序进行补偿的。

如果是并发SAGA,补偿分支也会并发执行,补偿分支的执行顺序与指定的正向分支顺序相反。假如并发SAGA指定A分支之后才能执行B,那么进行并发补偿时,DTM保证A的补偿操作在B的补偿操作之后执行

如何做补偿

当SAGA对分支A进行失败补偿时,A的正向操作可能1. 已执行;2. 未执行;3. 甚至有可能处于执行中,最终执行成功或者失败是未知的。那么对A进行补偿时,要妥善处理好这三种情况,难度很大。

dtm提供了子事务屏障技术,自动处理上述三种情况,开发人员只需要编写好针对1的补偿操作情况即可,相关工作大幅简化,详细原理,参见下面的异常章节。

失败的分支是否需要补偿

dtm 常被问到的一个问题是,TransIn返回失败,那么这个时候是否还需要调用TransIn的补偿操作?DTM 的做法是,统一进行一次调用,这种的设计考虑点如下:

  • XA, TCC 等事务模式是必须要的,SAGA 为了保持简单和统一,设计为总是调用补偿
  • DTM 支持单服务多数据源,可能出现数据源1成功,数据源2失败,这种情况下,需要确保补偿被调用,数据源1的补偿被执行
  • DTM 提供的子事务屏障,自动处理了补偿操作中的各种情况,用户只需要执行与正向操作完全相反的补偿即可

异常

在事务领域,异常是需要重点考虑的问题,例如宕机失败,进程crash都有可能导致不一致。当我们面对分布式事务,那么分布式中的异常出现更加频繁,对于异常的设计和处理,更是重中之重。

我们将异常分为以下几类:

  • 偶发失败: 在微服务领域,由于网络抖动、机器宕机、进程Crash会导致微小比例的请求失败。这类问题的解决方案是重试,第二次进行重试,就能够成功,因此微服务框架或者网关类的产品,都会支持重试,例如配置重试3次,每次间隔2s。DTM的设计对重试非常友好,应当支持幂等的各个接口都已支持幂等,不会发生因为重试导致事务bug的情况
  • 故障宕机: 大量公司内部都有复杂的多项业务,这些业务中偶尔有一两个非核心业务故障也是常态。DTM也考虑了这样的情况,在重试方面做了指数退避算法,如果遇见了故障宕机情况,那么指数退避可以避免大量请求不断发往故障应用,避免雪崩。
  • 网络乱序: 分布式系统中,网络延时是难以避免的,所以会发生一些乱序的情况,例如转账的例子中,可能发生服务器先收到撤销转账的请求,再收到转账请求。这类的问题是分布式事务中的一个重点难点问题,详情参考:异常与子事务屏障

业务上的失败与异常是需要做严格区分的,例如前面的余额不足,是业务上的失败,必须回滚,重试毫无意义。分布式事务中,有很多模式的某些阶段,要求最终成功。例如dtm的补偿操作,是要求最终成功的,只要还没成功,就会不断进行重试,直到成功。关于这部分的更详细的论述,参见最终成功

介绍到这里,您已经具备足够的知识,开发完成一个普通的SAGA任务。下面我们将介绍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事务中,设置超时时间一定要注意,这类事务里不能够包含无法回滚的事务分支,因为超时回滚时,已执行的无法回滚的分支,数据就是错的。

其他分支的结果作为输入

前面的设计环节讲了为什么dtm没有支持这样的需求,那么如果极少数的实际业务有这样的需求怎么处理?例如B分支需要A分支的执行结果

dtm的建议做法是,在ServiceA再提供一个接口,让B可以获取到相关的数据。这种方案虽然效率稍低,但是易理解已维护,开发工作量也不会太大。

PS:有个小细节请注意,尽量在你的事务外部进行网络请求,避免事务时间跨度变长,导致并发问题。

如果您需要其他分支的结果作为输入,也可以考虑一下dtm里面的 TCC 模式,该模式有不同的适用场景,但是提供了非常便捷的获取其他分支结果的接口

SAGA 设计原则

Seata的SAGA采用了状态机实现,而DTM的SAGA没有采用状态机,因此常常有用户会问,为什么DTM没有采用状态机,状态机可以提供更加灵活的事务自定义。

我在DTM设计SAGA高级用法时,充分调研了状态机实现,经过仔细权衡之后,决定不采用状态机实现,主要原因如下:

易用性对比

可能在阿里内部,需要SAGA提供类似状态机的灵活性,但是在阿里外部,看到使用Seata的Saga事务的用户特别少。我调研了Seata中SAGA的开发资料,想要上手写一个简单的SAGA事务,需要

  1. 了解状态机的原理
  2. 上手状态机的图形界面工具,生成状态机定义Json(一个简单的分布式事务任务,需要大约90多行的Json定义)
  3. 将上述Json配置到Java项目中
  4. 如果遇见问题,需要跟踪调试状态机定义的调用关系,非常复杂

而对比之下,DTM的SAGA事务,则非常简单易用,开发者没有理解成本,通常五六行代码就完成了一个全局事务的编写,因此也成为DTM中,应用最为广泛的事务模式。而对于高级场景,DTM也经过实践的检验,以极简单的选项,例如EnableConcurrent、RetryInterval,解决了复杂的应用场景。目前收集到的用户需求中,暂未看到状态机能解决,而DTM的SAGA不能解决的案例。

gRPC友好度

gRPC 是云原生时代中应用非常广泛的协议。而Seata的状态机,对HTTP的支持度较好,而对gRPC的支持度不友好。一个gRPC服务中返回的结果,如果没有相关的pb定义文件,就无法解析出其中的字段,因此就无法采用状态机做灵活的判断,那么想用状态机的话,就必须固定结果类型,这样对应用的侵入性就比较强,适用范围就比较窄。

DTM则对gRPC的支持更加友好,对结果类型无任何要求,适用范围更加广泛。

小结

这里详细介绍了SAGA的简单用法到高级用法,如果您熟练掌握了DTM中的SAGA事务,那么就可以解决分布式事务中的绝大部分问题了

Last Updated: