0.引言

之前我们已经讲解了四种分布式事务模式的前两种:AT模式和TCC模式,如果对于这两种模式有疑惑的,可以翻看专栏之前的文章

今天我们接着来讲讲SAGA模式

1. SAGA模式

saga的定义是“长时间活动的事务”,是普林斯顿大学教授Hector & Kenneth发表的论文《sagas》中提出的概念。它的思想是允许分布式事务在全部提交前提前释放占用的某些资源

其实我看到saga这个名称的第一印象,是想到了圣斗士星矢里的沙迦,沙迦以强悍的实力著称。而SAGA模式也专用于解决长事务资源占用难题

什么是长事务

所谓长事务,就是需要长时间执行的事务,这类事务往往需要访问大量的数据对象,其执行周期甚至能达到几周或几月。但传统的事务执行时需要锁定占用资源,如果在这样的一个场景下,资源被长期锁定,带来的性能消耗可想而知。因此我们引入了SAGA模式来解决长事务。

SAGA的工作原理呢,就比较好理解了:

SAGA模式由一串本地事务组成,每个本地事务都有自己回滚数据的补偿事务。事务之间串型执行,当正向执行的某一个事务出现报错,那么将执行这个事务的补偿事务,并且逆行执行之前事务的补偿事务

SAGA也是有两阶段的,一阶段是正向事务,二阶段是补偿事务
在这里插入图片描述
saga模式依然要求我们自己实现正向服务和补偿服务。但是它于TCC模式的区别之处在于:

  • saga的模式设计使得它天然适合于长流程的业务。TCC要实现同样的长流程的话,需要多写一个confirm操作,并且要考虑如何将业务拆分为两部分

  • saga模式在正向服务中时就已经提交了本地事务了,而补偿事务也比较好实现,将正向服务的结合逆向补偿即可。
    比如正常服务是update product set price=20 where price=30 and id=1;
    那么补偿服务就是update product set price=30 where price=20 and id=1;

  • 比起TCC模式,saga模式更适用于一些老服务、第三方服务或者其他无法改造的服务,要接入到我们的分布式事务中时,就可以将其作为一个正向服务存在,而直接实现他的补偿服务即可。而TCC因为要对业务进行拆分为try-confirm-cancel,所以它不适用于不可改造的服务

同时,saga模式同样不需要全局锁,只需要结合本地事务加本地锁即可,所以性能依旧有保证。

1.1 SAGA模式的三种事务类型

1.1.1 可补偿性事务

所谓可补偿性事务,也就是可以使用、需要使用补偿事务来回滚数据的事务

比如说下订单,就需要删除订单的补偿事务,因此下订单就是一个可补偿性事务

1.1.2 关键性事务

关键性事务是saga执行的关键点,如果关键性事务运行成功,则saga将一直运行到完成。关键性事务不一定是个可补偿性事务或者可重复性事务,但是他可以是最后一个可补偿的事务或第一个可重复的事务
———《微服务架构设计模式》

通过书中的描述,我们知道关键性事务的定义从结构上理解,是处于可补偿性事务和可重复性事务的中间。

具体把哪个事务定义为关键性事务,还要根据具体的业务情况而定,我们可以通过以下标准来判断

  • 从结构上是否处于可补偿事务和可重复事务之间
  • 从业务上该事务是否能表示整个业务执行成功的转折点
    在这里插入图片描述

1.1.3 可重复性事务

关键性事务之后的事务就是可重复性事务,不需要回滚,并且保证能够执行完成。所以我们会通过一些机制来保证这类事务一定能执行成功,比如重试机制。

1.2 SAGA模式服务调用机制

我们上述说了,saga模式是通过正向服务、补偿服务之间的正向串性和逆向串性来实现的。这些服务之间的调用链很长,我们通过什么方式来实现调用呢?

1.2.1 每个服务给后续服务发送消息

我们让每个服务来通知后续的服务进行操作,这是我们串行服务最常想到的做法。但这样有个很明显的问题,那就是具体做起来的困难性。

想象一下,如果我们要对事件进行调用,我们不但要考虑正向的服务调用,还要考虑逆向的补偿调用,特别是要再考虑逆向的补偿调用,一些简单的串型业务设计起来很简单,但是某些交错复杂的业务会非常麻烦。同时服务间的耦合性又增强了。

所以我们做了一个增强版,那就是前一个服务执行完成后发送消息,后一个服务通过订阅消息的模式来实现服务的协调。我们引入了MQ的概念来解决耦合性问题。

1.2.1.1 好处

简单:这种模式的实现相对来说逻辑清晰,当然这里简单只能说针对于部分业务而言,某些比较复杂的场景的话,这样的设计反而很难实现,相互之间的订阅交错复杂。

解耦:加强版中引入了消息订阅,以此降低了耦合性

1.2.1.2 坏处

消息死循环:服务之间通过订阅消息来触发调用,处理不当,容易造成相互订阅的情况,从而出现循环依赖或消息死循环问题。

面对复杂业务的局限性:上述也说了,当业务调用交错复杂时,我们通过订阅消息的形式来获取调用事务的时机,但是也决定了,每个事务都要订阅会影响它的事件消息,复杂场景时会导致我们需要考虑的东西很多,就需要非常强大的逻辑能力来支撑了,存在局限性。

难上手:想象一下,你接手上一个同事设计的稍微复杂一点的协同模式时,你需要花多久理清楚这里面的消息订阅的逻辑线。它的呈现并不直观,较难理解。

1.2.2 事件驱动器来协调

如果接触过过工作流设计的同学可能对这个东西比较熟悉,简单来说就是一个第三方组件,通过它可以进行拖拽化的流程设计,如下图所示。关于这个驱动器就不再多说了,感兴趣的可以去官方了解。
在这里插入图片描述
seata官方也提供了在线的模块设计工具:saga 事件驱动在线设计

1.2.2.1 好处

不会产生死循环:调用是单向的,驱动器会调用事务,但是事务不会调用驱动器,因此其调用关系完全交给驱动器去管理,也就没有了依赖循环的问题

理解简单:虽然设计器的使用学习有成本,但是我们针对其设计上的理解来说,更加容易理解上手。

业务逻辑更加简单清晰:事务协调完全交给了驱动器,业务代码无需关心,可以专注于业务需求上,降低了代码难度。

1.2.2.2 坏处

学习成本:存在一定的设计器的以及相关API的学习成本

1.3 SAGA模式如何保证事务隔离性

聊这个问题之前,我们得先了解,什么是事务的隔离性?

事务独立执行,不受其他并发操作影响就是事务的隔离性。

但如上述,SAGA中各个本地事务执行完就提交,所以是相对独立的,SAGA事务中的某一个事务的执行结果,是可以被其他业务操作或影响的。但一旦中间数据被其他业务修改了,再要回退时就会出现脏写而无法回滚。

因此SAGA模式下的分布式事务就没有隔离性了吗?那还能叫事务吗?出现脏写怎么办?

SAGA模式也提供了一种最终实现隔离性的思路,seata官方文档中的介绍是“宁可长款,不可短款”的原则。

这是什么意思???
在这里插入图片描述
官方解释如下

业务流程设计时遵循“宁可长款, 不可短款”的原则, 长款意思是客户少了钱机构多了钱, 以机构信誉可以给客户退款, 反之则是短款, 少的钱可能追不回来了。所以在业务流程设计上一定是先扣款。

有些业务场景可以允许让业务最终成功, 在回滚不了的情况下可以继续重试完成后面的流程, 所以状态机引擎除了提供“回滚”能力还需要提供“向前”恢复上下文继续执行的能力, 让业务最终执行成功, 达到最终一致性的目的。

通俗来讲,就是不需要按照原路返回,只需要通过一定的措施让数据恢复之前的状态即可,也就是保证最终一致性即可。

那么具体有哪些方式呢?我们列举《微服务架构设计模式》中说明的处理方案,以下处理方案出自书籍中第4章第3节,有兴趣的同学可以翻看下书中原滋原味的解释

1.3.1 语义锁

说的直白点语义锁就是一个标记状态,该标记表示该记录未提交且可能发生改变,正向事务会在其操作的每一条记录中添加这个状态。

比如扣除库存时,给对应商品添加一个状态,当有其他事务访问这个商品时发现状态为锁定中,就不会再访问这个商品,会等待状态更新完成后再操作。其实就是一个手动加锁的过程。

执行成功的话,最后通过一个可重复事务或者定时任务将状态更新为已解锁,执行失败就通过补偿事务将状态更新为已解锁

这里的可重复事务,指的是不管执行成功还是失败都可以执行的事务,其执行不影响原本的数据记录。一般可重复性事务会放到最后执行。

1.3.2 交换式更新

把更新操作设计成可以按任何顺序执行,也就是说操作是可以交换的。

这样说明其实是很抽象的,我们举个例子来说明,比如下订单后,商品要扣减库存,正向事务是库存-10,

update product set inventory=inventory-10 where id=1;

那么将其补偿事务设计为库存+10。

update product set inventory=inventory+10 where id=1;

这样哪怕有其他事务操作污染了库存数据,但是因为更新的内容是纯数字的,不受其他事务的影响,且定位信息为id=1这一点无法篡改。当然我们要求这里没有删除商品的操作。

那么我们就称现在的正向事务和补偿事务是可以交换的。是不是稍微理解一点了,更新的交换性存在着很大的局限性,并不是所有的操作都可以设计为可交换的。

常见的交换性操作也就是数值、枚举值上的更新。因此不同的操作也需要我们结合不同的方案来设计隔离性。

1.3.3 悲观视图

悲观视图实际上并不是一个100%的方案,他的本意是说以最悲观的情况考虑事务被其他事务更改的可能性,然后重新排序saga事务的步骤,以此最大限度的较低脏写风险

所以这也就决定了,悲观视图是一个不那么完善的方案,并不能完全保证隔离性。所以能够应用到的业务也有限。

1.3.4 重读值

重读值的意思是在更新之前重新读取记录,可以通过维护一个计数器,比如版本号,来验证它是否发生改变,如果已经改变了那么事务中止或者重新启动。如果未改变那么继续执行。

其实了解乐观锁的同学应该闻到味儿了,没错,这玩意儿就是乐观锁的一种。

1.3.5 版本文件

《微服务架构设计模式》一书中是这样说明的:

版本文件对策之所以如此命名,是因为它记录了对数据执行的操作,以便可以对它们进行重新排序。这是将不可交换操作转换为可交换操作的一种方法

针对这个方案的理解,我们直接通过一个案例来解释:

我们有一个下订单的业务。我们现在还有一个取消这个订单的业务

当因为网络阻塞或者其他原因导致取消订单的业务先执行了,当下订单的业务后执行时就会创建一个订单,导致用户发起的取消操作变得无效了。从而产生了数据的不一致性

通过版本文件,我们将在取消订单的时候会记录这个取消操作数据,后续收到下订单的操作时会比较版本文件,来跳过订单的创建操作,其效果也就相当于将两个操作顺序调换了一下。以此保证了最后的数据依旧是订单被取消。

1.3.6 业务风险评级

最终的对策是基于价值(业务风险)对策。这是一种基于业务风险选择并发机制的策略。使用此对策的应用程序使用每个请求的属性来决定使用Saga和分布式事务。它使用Saga执行低风险请求,可能会应用前几节中描述的对策。但它使用分布式事务来执行高风险请求(例如涉及大量资金)。此对策使应用程序能够动态地对业务风险、可用性和可伸缩性进行权衡。

讲大白话,就是低风险的不容易出现脏读或者出现脏读也不影响业务的选择saga,高风险的对脏读敏感的选择其他分布式模式。动态地进行选择。

1.4 SAGA模式的补偿措施

与TCC模式类似,SAGA模式也涉及到如下几个问题

1.4.1 幂等性问题

所谓幂等就是操作一次和操作多次的执行效果是一样的。

想象一下,我们的库存扣除操作,如果因为某一步操作报错,导致需要回滚重试,结果每次重试都会重复扣减库存,那这样肯定是不对的。

所以为了保证我们在confirm,cancel中进行的重试机制不会使得我们的资源发生重复消耗,那么需要我们对方法做好幂等性处理:

比如说通过添加状态字段来判断是否执行过。当然这一点在seata等分布式框架中不用我们再手动实现,框架已经帮我们实现了。

1.4.2 悬挂问题

所谓悬挂问题,就是二阶段模式中,二阶段比一阶段先执行

这是怎么导致的呢?

我们拿下订单扣减库存的案例来说,在订单服务中调用商品服务的扣减库存方法reduceInventory时,通常通过RPC(feign)的方式来调用,那么如果调用时刚好网络堵塞,或者商品服务出现问题,导致调用失败,出现报错,TM会通知TC出现错误,TC会通知所有的RM进行本地事务回滚,也就是执行补偿事务。

当补偿事务方法执行完成后,正向事务方法偏偏连通了,又执行了,那么就出现了问题,这个正向服务之前的补偿事务都执行了,但又执行了一个多余的正向服务。

所有我们需要针对悬挂问题进行防悬挂处理,方案呢就是限制如果二阶段执行完成,一阶段就不能再执行。

seata中的解决方案是增加一个事务记录表,在补偿服务执行后往事务记录表中插入一条记录(xid-status)标记补偿服务已经执行过。此时正向服务进入时发现已经执行过回滚操作,则放弃正向服务的执行。

2. SAGA模式应用场景

  • 适用于长事务业务场景
  • 适用于需要接入老服务、第三方服务或者其他无法改造的服务的业务场景
  • 需要操作更细分散在多个服务、系统中的数据的业务场景

文章中观点基于个人理解,部分知识点可供学习佐证的资料较少,如果理解有误,欢迎指正!如果本文对你的学习有帮助,不妨点赞支持一下

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐