V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
dongfuye1
V2EX  ›  推广

深度剖析 Saga 分布式事务

  •  
  •   dongfuye1 · 2021-11-25 09:50:11 +08:00 · 1409 次点击
    这是一个创建于 1097 天前的主题,其中的信息可能已经有所发展或是发生改变。

    saga 是分布式事务领域里一个非常重要的事务模式,特别适合解决出行订票这类的长事务,本文将深度剖析 saga 事务的设计原理,以及在解决订票问题上的最佳实践

    saga 的理论来源

    saga 这种事务模式最早来自这篇论文:sagas

    在这篇论文里,作者提出了将一个长事务,分拆成多个子事务,每个子事务有正向操作 Ti ,反向补偿操作 Ci 。

    假如所有的子事务 Ti 依次成功完成,全局事务完成

    假如子事务 Ti 失败,那么会调用 Ci, Ci-1, Ci-2 ....进行补偿

    论文阐述了上述这部分基本的 saga 逻辑之后,提出了下面几种场景的技术处理

    回滚与重试

    对于一个 SAGA 事务,如果执行过程中遭遇失败,那么接下来有两种选择,一种是进行回滚,另一种是重试继续。

    回滚的机制相对简单一些,只需要在进行下一步之前,把下一步的操作记录到保存点就可以了。一旦出现问题,那么从保存点处开始回滚,反向执行所有的补偿操作即可。

    假如有一个持续了一天的长事务,被服务器重启这类临时失败中断后,此时如果只能进行回滚,那么业务是难以接受的。 此时最好的策略是在保存点处重试并让事务继续,直到事务完成。

    往前重试的支持,需要把全局事务的所有子事务事先编排好并保存,然后在失败时,重新读取未完成的进度,并重试继续执行。

    并发执行

    对于长事务而言,并发执行的特性也是至关重要的,一个串行耗时一天的长事务,在并行的支持下,可能半天就完成了,这对业务的帮助很大。

    某些场景下并发执行子事务,是业务必须的要求,例如订多张及票,而机票确认时间较长时,不应当等前一个票已经确认之后,再去定下一张票,这样会导致订票成功率大幅下降。

    在子事务并发执行的场景下,支持回滚与重试,挑战会更大,涉及了较复杂的保存点。

    saga 的实现分类

    目前看到市面上已经有很多的 saga 实现,他们都具备 saga 的基本功能。

    这些实现,可以大致可以分为两类

    状态机实现

    这一类的典型实现有 seata 的 saga ,他引入了一个 DSL 语言定义的状态机,允许用户做以下操作:

    • 在某一个子事务结束后,根据这个子事务的结果,决定下一步做什么
    • 能够把子事务执行的结果保存到状态机,并在后续的子事务中作为输入
    • 允许没有依赖的子事务之间并发执行

    这种方式的优点是:

    • 功能强大,事务可以灵活自定义

    缺点是:

    • 状态机的使用门槛非常高,需要了解相关 DSL ,可读性差,出问题难调试。官方例子是一个包含两个子事务的全局事务,Json 格式的状态机定义大约有 95 行,较难入门。
    • 接口入侵强,只能使用特定的输入输出接口参数类型,在云原生时代,对强类型的 gRPC 不友好( gRPC 协议,在 TM 拿不到用户自定义的输入输出 pb 文件,因此无法解析结果中的字段)

    非状态机实现

    这一类的实现有 eventuate 的 saga ,dtm 的 saga 。

    在这一类的实现中,没有引入新的 DSL 来实现状态机,而是采用函数接口的方式,定义全局事务下的各个分支事务:

    优点:

    • 简单易上手,易维护

    缺点:

    • 难以做到状态机的事务灵活自定义

    PS:eventuate 的作者将基于事件订阅协作的模式,也称为 saga ,因为他的影响力大,因此许多文章在介绍 saga 模式的时候都会提这个。但事实上这个模式与原先的 saga 论文相关不大,也与各家实现的 saga 模式相关不大,所以这里没有专门去论述这种模式

    还有许多其他的 saga 实现,例如 servicecomb-pack ,Camel ,hmily.由于精力有限,没有一一研究。后续做了更多研究后,会继续更新文章

    dtm 的 saga 设计

    dtm 支持 TCC 和 saga 模式,这两个模式有不同的特点,各自适应不同的业务场景,相互补充。

    image.png

    上述这张表,很好的比较了 TCC 和 SAGA 这两种事务模式。

    TCC 的定位是一致性要求较高的短事务。一致性要求较高的事务一般都是短事务(一个事务长时间未完成,在用户看来一致性是比较差的,一般没有必要采用 TCC 这种高一致性的设计),因此 TCC 的事务分支编排放在了 AP 端(即程序代码里),由用户灵活调用。这样用户可以根据每个分支的结果,做灵活的判断与执行。

    SAGA 的定位是一致性要求较低的长事务 /短事务。对于类似订机票这种这样的场景,持续时间长,可能持续几分钟到一两天,就需要把整个事务的编排保存到服务器,避免发起全局事务的 APP 因为升级、故障等原因,导致事务编排信息丢失。

    状态机提供的灵活性对于在客户端编排的 TCC 是没必要的,但是对于保存在服务器端的 saga 是有意义的。我在最初设计 saga 的时候,进行了较详细的权衡取舍。状态机的这种方式,上手难度非常高,用户容易望而却步。我找了一些用户做需求调研,总结出来的核心需求有:

    • 子事务并发执行,降低延时。例如旅游订票业务的预定往返机票,因为订票可能需要较长时间才能够确认,等去的机票定好之后再订返程票,容易导致订不上。
    • 有些操作无法回滚,需要放在可回滚的子事务之后,保证一旦执行,就能够最终成功。

    在这两项核心需求下,dtm 的 saga 最终没有采用状态机,但是支持了子事务的并发执行以及指定子事务之间的顺序关系。

    下面我们以一个实际问题作为例子,讲解 dtm 中 saga 的用法

    对于订票类业务,子事务的执行结果不是立即返回的,通常是预定机票后,过一段时间第三方才通知结果。对于这种情况 dtm 的 saga 提供了良好的支持,它支持子事务返回进行中的结果,并支持指定重试时间间隔。订票的子事务可以在自己的逻辑中,如果未下订单,则下订单;如果已下订单,那么此时就是重试的请求,可以去第三方查询结果,最后返回成功 /失败 /进行中。

    解决问题实例

    我们以一个真实用户案例,来讲解 dtm 的 saga 最佳实践。

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

    要求:

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

    上述这些要求,正是 saga 事务模式要解决的问题,我们来看看 dtm 怎么解决(以 Go 语言为例)。

    首先我们根据要求 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.SetOptions(&dtmcli.TransOptions{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 要求业务明确返回以下几个值:

    • SUCCESS 表示分支成功,可以进行下一步
    • FAILURE 表示分支失败,全局事务失败,需要回滚
    • ONGOING 表示进行中,后续按照正常的间隔进行重试
    • 其他表示系统问题,后续按照指数退避算法进行重试

    部分第三方操作无法回滚

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

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

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

    超时回滚

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

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

    	saga.SetOptions(&dtmcli.TransOptions{TimeoutToFail: 1800})
    

    在 saga 事务中,设置超时时间一定要注意,这类事务里不能够包含无法回滚的事务分支,否则超时回滚这类的分支会有问题。

    其他分支的结果作为输入

    如果极少数的实际业务不仅需要知道某些事务分支是否执行成功,还想要获得成功的详细结果数据,那么 dtm 如何处理这样的需求呢?例如 B 分支需要 A 分支的执行成功返回的详细数据。

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

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

    小结

    本文总结了 saga 相关的理论知识、设计原则,对比了 saga 的不同实现及其优缺点。最后以一个现实中的问题案例,详细讲解 dtm 的 saga 事务使用

    dtm 是一个一站式的分布式事务解决方案,支持事务消息、SAGA 、TCC 、XA 等多种事务模式,支持 Go 、Java 、Python 、PHP 、C#、Node 等语言 SDK 。

    项目文档还详细讲解了分布式事务相关的基础知识、设计理念和最新理论,是学习分布式事务的绝佳资料。

    欢迎大家访问yedf/dtm,给我们 Issue 、PR 、Star 。

    目前尚无回复
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   5314 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 24ms · UTC 08:31 · PVG 16:31 · LAX 00:31 · JFK 03:31
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.