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模式应用场景
适用于长事务业务场景
适用于需要接入老服务、第三方服务或者其他无法改造的服务的业务场景
需要操作更细分散在多个服务、系统中的数据的业务场景
文章中观点基于个人理解,部分知识点可供学习佐证的资料较少,如果理解有误,欢迎指正!如果本文对你的学习有帮助,不妨点赞支持一下