前言
分布式事务是指在分布式环境下的事务处理,其目的是保证在多个节点上的操作要么全部执行,要么全部回滚,以确保数据的一致性。
因为分布式环境中存在网络分区、服务器宕机等问题,所以分布式事务处理比单机事务处理更加复杂且难以实现。
分布式理论基础
一、订单超时机制
1. 订单超时机制出现的原因:
- 避免资源浪费: 在分布式环境下,如果订单一直处于未完成状态,可能会浪费系统的资源,如库存、仓储空间等。
- 确保数据一致性: 在分布式环境下,多个服务可能同时对同一订单进行操作,订单超时机制可以避免数据不一致的情况。
2. 分布式订单超时机制的具体步骤如下:
- 订单创建: 在系统中创建一个新的订单,并记录相关信息,例如用户信息、商品信息、订单状态等。
- 设置超时时间: 为该订单设置一个超时时间,即订单在未完成之前的最长存活时间。
- 启动超时服务: 启动一个独立的服务,负责监控所有订单的超时状态。
- 超时判断: 在超时服务中,定期检查所有订单的超时时间,并判断是否已经到期。
- 超时处理: 如果一个订单的超时时间到期,超时服务会触发相应的处理操作,例如取消订单、释放库存等。
- 状态更新: 在超时处理完成后,更新该订单的状态,例如将其状态更新为“已取消”。
这样,分布式订单超时机制就可以在分布式环境下简单有效地维护订单的超时状态。
二、ACID特性
1. 原子性[atomicity]
事务必须作为一个不可分割的工作单元,要么全部执行,要么全部不执行。
2. 一致性[consistency]
一致性是指数据处于一种语义上的有意义且正确的状态。一致性是对数据可见性的约束,保证在一个事务中的多次操作的数据中间状态对其他事务不可见的。因为这些中间状态,是一个过渡状态,与事务的开始状态和事务的结束状态是不一致的。
举个例子,张三给李四转账100元。事务要做的是从张三账户上减掉100元,李四账户上加上100元。一致性的含义是其他事务要么看到张三还没有给李四转账的状态,要么张三已经成功转账给李四的状态,而对于张三少了100元,李四还没加上100元这个中间状态是不可见的。
我们来看一下转账过程中可能存在的状态:
- 张三未扣减、李四未收到
张三已扣减、李四未收到
- 张三已扣减,李四已收到
上述过程中: 1.是初始状态、2是中间状态、3是最终状态,1和3是我们期待的状态,但是2这种状态却不是我们期待出现的状态。-锁
那么反驳的声音来了:
要么转账操作全部成功,要么全部失败,这是原子性
。从例子上看全部成功,那么一致性就是原子性的一部分咯,为什么还要单独说一致性
和原子性
?
你说的不对。在未提交读的隔离级别下是事务内部操作是可见的,明显违背了一致性,怎么解释?
好吧,需要注意的是:
原子性和一致性的的侧重点不同︰ 原子性关注状态
,要么全部成功,要么全部失败,不存在部分成功的状态。而一致性关注数据的可见性
,中间状态的数据对外部不可见,只有最初状态和最终状态的数据对外可见
3. 隔离性[isolation]
事务的隔离性是指多个用户并发访问数据库时,一个用户的事务不能被其它用户的事务所干扰,多个并发事务之间数据要相互隔离。
隔离性是多个事务的时候,相互不能干扰,一致性是要保证操作前和操作后数据或者数据结构的一致性,而我提到的事务的一致性是关注数据的中间状态,也就是一致性需要监视中间状态的数据,如果有变化,即刻回滚
如果不考虑隔离性,事务存在3种并发访问数据问题,也就是事务里面的脏读
、不可重复读
、虚度/幻读
mysql的隔离级别:读未提交、读已提交、可重复读、串行化
4. 持久性[durability]
是事务的保证,事务终结的标志(内存的数据持久到硬盘文件中)
分布式事务顾名思义就是要在分布式系统中实现事务,它其实是由多个本地事务组合而成。
对于分布式事务而言几乎满足不了ACID,其实对于单机事务而言大部分情况下也没有满足ACID,不然怎么会有四种隔离级别呢?所以更别说分布在不同数据库或者不同应用上的分布式事务了。
三、分布式系统中出现哪些故障会导致数据不一致
1.网络问题–硬件故障、网络抖动、网络拥塞
- 没有发送出去
- 发送出去了,返回没有收到,导致以为出错了
2.程序出错
- 代码异常
3.宕机
- 断电
- 系统问题–磁盘满了、电脑坏掉等
-
网络分区: 当分布式系统的部分节点与其他节点网络隔离时,这些隔离的节点将无法与其他节点通信,从而导致数据不一致。
-
节点宕机: 如果节点宕机,分布式系统可能无法正常工作,从而导致数据不一致。
-
数据丢失: 如果在数据传输过程中发生数据丢失,分布式系统中的数据将不一致。
-
数据冲突: 在分布式系统中,如果多个节点同时对同一数据进行更新,数据冲突将可能导致数据不一致。
-
时间不同步: 如果分布式系统中的节点的时钟不同步,将导致分布式系统中的数据不一致。
这些故障是分布式系统中常见的数据不一致的原因,为了确保数据的一致性,开发人员需要采用相应的技术和策略来防范这些故障。
举例:
四、CAP定理
cap理论是分布式系统的理论基石
1. Consistency (一致性):
“all nodes see the same data at the same time",即更新操作成功并返回客户端后,所有节点在同一时间的数据完全一致,这就是分布式的一致性。一致性的问题在并发系统中不可避免,对于客户端来说,一致性指的是并发访问时更新过的数据如何获取的问题。从服务端来看,则是更新如何复制分布到整个系统,以保证数据最终一致。
2. Availability (可用性):
可用性指"Reads and writes always succeed”,即服务一直可用,而且是正常响应时间。好的可用性主要是指系统能够很好的为用户服务,不出现用户操作失败或者访问超时等用户体验不好的情况。
3. Partition Tolerance (分区容错性):
即分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性和可用性的服务。分区容错性要求能够使应用虽然是一个分布式系统,而看上去却好像是在一个可以运转正常的整体。比如现在的分布式系统中有某一个或者几个机器宕掉了,其他剩下的机器还能够正常运转满足系统需求,对于用户而言并没有什么体验上的影响。
如果你你是一个分布式系统,那么你必须要满足一点:分区容错性
4. 取舍策略
CAP三个特性只能满足其中两个,那么取舍的策略就共有三种:
CA without P: 如果不要求P(不允许分区),则C(强一致性)和A(可用性)是可以保证的。但放弃P的同时也就意味着放弃了系统的扩展性,也就是分布式节点受限,没办法部署子节点,这是违背分布式系统设计的初衷的。
CP without A: 如果不要求A(可用),相当于每个请求都需要在服务器之间保持强一致,而P(分区)会导致同步时间无限延长(也就是等待数据同步完才能正常访问服务),一旦发生网络故障或者消息丢失等情况,就要牺牲用户的体验,等待所有数据全部一致了之后再让用户访问系统。设计成CP的系统其实不少,最典型的就是分布式数据库,如Redis、HBase等。对于这些分布式数据库来说,数据的一致性是最基本的要求,因为如果连这个标准都达不到,那么直接采用关系型数据库就好,没必要再浪费资源来部署分布式数据库。
AP wihtout C: 要高可用并允许分区,则需放弃一致性。一旦分区发生,节点之间可能会失去联系,为了高可用,每个节点只能用本地数据提供服务,而这样会导致全局数据的不一致性。典型的应用就如某米的抢购手机场景,可能前几秒你浏览商品的时候页面提示是有库存的,当你选择完商品准备下单的时候,系统提示你下单失败,商品已售完。这其实就是先在A(可用性)方面保证系统可以正常的服务,然后在数据的一致性方面做了些牺牲,虽然多少会影响一些用户体验,但也不至于造成用户购物流程的严重阻塞。
五、BASE定理
分布式系统中的一致性是弱一致性单数据库mysql的一致性强一致性
BASE是Basically Available(基本可用)、Soft state(软状态) 和Eventually consistent(最终一致性) 三个短语的缩写。BASE理论是对CAP中一致性和可用性权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于CAP定理逐步演化而来的。BASE理论的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。接下来看一下BASE中的三要素:
1. 基本可用
基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性―-注意,这绝不等价于系统不可用。比如:
- 响应时间上的损失: 正常情况下,一个在线搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障,查询结果的响应时间增加了1~2秒
- 系统功能上的损失︰ 正常情况下,在一个电子商务网站上进行购物的时候,消费者几乎能够顺利完成每一笔订单,但是在一些节日大促购物高峰的时候,由于消费者的购物行为激增,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面
2. 软状态
软状态指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时
3. 最终一致性
最终一致性强调的是所有的数据副本,在经过一段时间的同步之后,最终都能够达到一个一致的状态。
因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
总的来说,BASE理论面向的是大型高可用可扩展的分布式系统,和传统的事物ACID特性是相反的,它完全不同于ACID的强一致性模型,而是通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态。
但同时,在实际的分布式场景中,不同业务单元和组件对数据一致性的要求是不同的,因此在具体的分布式系统架构设计过程中,ACID特性和BASE理论往往又会结合在一起。
假设有一个分布式银行系统,其中张三和李四的账户都存在于不同的节点上。在这种情况下,如果张三向李四转账100元,那么就需要执行一次分布式事务。
在BASE定理的框架下,分布式事务可以保证“基本可用”,即交易可以在整个系统中进行,但不能保证它是立即一致的。在这种情况下,在事务最终完成之前,可能会有暂时的不一致性。但是,在一段时间后,系统将回到一致状态。
因此,在使用BASE定理的分布式系统中,张三向李四转账100元的事务可以执行,但不能保证立即生效,因为需要一段时间才能完全一致。
一句话:CAP就是告诉你:想要满足C、A、P就是做梦,BASE才是你最终的归宿
分布式事务解决方案
常见分布式事务解决方案:
- 两阶段提交(2PC,Two-phase Commit)
- TCC补偿模式
- 基于本地消息表实现最终一致性
- 最大努力通知
- 基于可靠消息最终一致性方案
一、2pc两阶段提交协议
两阶段提交又称2PC,2PC是一个非常经典的中心化的原子提交协议。
这里所说的中心化是指协议中有两类节点:一个是中心化协调者节点(coordinator)和N个参与者节点(partcipant) 。
两个阶段︰第一阶段:投票阶段和第二阶段:提交/执行阶段。
举例:订单服务A,需要调用支付服务B去支付,支付成功则处理购物订单为待发货状态,否则就需要将购物订单处理为失败状态。
那么看2PC阶段是如何处理的
1. 第一阶段:投票阶段
第一阶段主要分为3步
- 事务询问
协调者向所有的参与者发送事务预处理请求,称之为Prepare,并开始等待各参与者的响应。 - 执行本地事务
各个参与者节点执行本地事务操作,但在执行完成后并不会真正提交数据库本地事务,而是先向协调者报告说:“我这边可以处理了/我这边不能处理”。. - 各参与者向协调者反馈事务询问的响应
如果参与者成功执行了事务操作,那么就反馈给协调者Yes响应,表示事务可以执行,如果没有参与者成功执行事务,那么就反馈给协调者No响应,表示事务不可以执行。
第一阶段执行完后,会有两种可能。1、所有都返回Yes.2、有一个或者多个返回No。
2. 第二阶段:提交/执行阶段(成功流程)
成功条件︰ 所有参与者都返回Yes。
第二阶段主要分为两步
- 所有的参与者反馈给协调者的信息都是Yes ,那么就会执行事务提交协调者向所有参与者节点发出Commit请求.
- 事务提交
参与者收到Commit请求之后,就会正式执行本地事务Commit操作,并在完成提交之后释放整个事务执行期间占用的事务资源。
3. 第二阶段:提交/执行阶段(异常流程)
异常条件︰ 任何一个参与者向协调者反馈了No响应,或者等待超时之后,协调者尚未收到所有参与者的反馈响应。
异常流程第二阶段也分为两步:
- 发送回滚请求
协调者向所有参与者节点发出 RoollBack请求. - 事务回滚
参与者接收到RoollBack请求后,会回滚本地事务。
4. 2PC缺点
通过上面的演示,很容易想到2pc所带来的缺陷
(1) 性能问题
无论是在第一阶段的过程中,还是在第二阶段,所有的参与者资源和协调者资源都是被锁住的,只有当所有节点准备完毕,事务协调者才会通知进行全局提交,
参与者进行本地事务提交后才会释放资源。这样的过程会比较漫长,对性能影响比较大。
(2)单节点故障
由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于
锁定事务资源的状态中,而无法继续完成事务操作。(虽然协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)
2PC出现单点问题的三种情况
1) 协调者正常,参与者宕机
由于协调者无法收集到所有参与者的反馈,会陷入阻塞情况。
解决方案:引入超时机制,如果协调者在超过指定的时间还没有收到参与者的反馈,事务就失败,向所有节点发送终止事务请求。
2)协调者宕机,参与者正常
无论处于哪个阶段,由于协调者宕机,无法发送提交请求,所有处于执行了操作但是未提交状态的参与者都会陷入阻塞情况.
解决方案:引入协调者备份,同时协调者需记录操作日志.当检测到协调者宕机一段时间后,协调者备份取代协调者,并读取操作日志,向所有参与者询问状态。
3)协调者和参与者都宕机
- 发生在第一阶段: 因为第一阶段,所有参与者都没有真正执行commit,所以只需重新在剩余的参与者中重新选出一个协调者,新的协调者在重新执行第一阶段和第二阶段就可以了。
- 发生在第二阶段: 并且挂了的参与者在挂掉之前没有收到协调者的指令。也就是上面的第4步挂了,这是可能协调者还没有发送第4步就挂了。这种情形下,新的协调者重新执行第一阶段和第二阶段操作。
- 发生在第二阶段: 并且有部分参与者已经执行完commit操作。就好比这里订单服务A和支付服务B都收到协调者发送的dommit信息,开始真正执行本地事务commit,但突发情况,Acommit成功,B确挂了。这个时候目前来讲数据是不一致的。虽然这个时候可以再通过手段让他和协调者通信,再想办法把数据搞成一致的,但是,这段时间内他的数据状态已经是不一致的了!2PC无法解决这个问题。
根据以上情况举例:
二、TCC事务
1. TCC分布式事务实现方案
这张图刚开始理解会很难 下面逐步分析后再回来理解
一个订单支付之后,我们需要做下面的步骤:
- 更改订单的状态为“已支付”
- 扣减商品库存
- 给会员增加积分
- 创建销售出库单通知仓库发货
好,业务场景有了,现在我们要更进一步,实现一个TCC分布式事务的效果。
什么意思呢?也就是说:
- 订单服务-修改订单状态
- 库存服务-扣减库存
- 积分服务-增加积分
- 仓储服务-创建销售出库单
上述这几个步骤,要么一起成功,要么一起失败,必须是一个整体性的事务。
举个例子,现在订单的状态都修改为“已支付”了,结果库存服务扣减库存失败。那个商品的库存原来是100件,现在卖掉了2件,本来应该是98件了。
结果呢?由于库存服务操作数据库异常,导致库存数量还是100。这不是在坑人么,当然不能允许这种情况发生了!
但是如果你不用TCC分布式事务方案的话,就用个go开发这么一个微服务系统,很有可能会干出这种事来
我们来看看下面的这个图,直观的表达了上述的过程:
所以说,我们有必要使用TCC分布式事务机制来保证各个服务形成一个整体性的事务。
上面那几个步骤,要么全部成功,如果任何一个服务的操作失败了,就全部一起回滚,撤销已经完成的操作。
比如说库存服务要是扣减库存失败了,那么订单服务就得撤销那个修改订单状态的操作,然后得停止执行增加积分和通知出库两个操作。
说了那么多,老规矩,给大家上一张图,大伙儿顺着图来直观的感受一下:
2. 落地实现TCC分布式事务
那么现在到底要如何来实现一个TCC分布式事务,使得各个服务,要么一起成功?要么一起失败呢?
大家稍安勿躁,我们这就来一步一步的分析一下。咱们就以一个go开发系统作为背景来解释。
首先,订单服务那儿,它的代码大致来说应该是这样子的:
Go语言演示:
type OrderService struct{
creditSrvClient proto.CreditClient //用户积分
wmsSrlvclient proto.wmsClient //记录仓库的变动信息
InventorySrvclient proto.InventoryClient //库存确认扣减
}
func New0rderService( ) *OrderService{
return &OrderService{
CreditSrvclient: proto.Creditclient{},
wmsSrvclient: proto.wmsClient{},
InventorySrvClient: proto.InventoryClient{},
}
}
func (o OrderService) UpdateOrderStatus ( ) error {
return nil
}
func (o OrderService)Notify( ) error {
o.UpdateOrderStatus()//更新订单的状态
o.CreditSrvClient.AddCredit( )//增加积分
o.InventorySrvClient.ReduceStock( ) //1库存确认扣减
o.wmsClient.SaleDelivery()//记录仓库变更记录
return nil
}
其实就是订单服务完成本地数据库操作之后,通过grpc
来调用其他的各个服务罢了。
但是光是凭借这段代码,是不足以实现TCC分布式事务的啊?!兄弟们,别着急,我们对这个订单服务修改点儿代码好不好。
首先,上面那个订单服务先把自己的状态修改为:TRADE_SUCCESS
。
这是啥意思呢?也就是说,在 pay()
那个方法里,你别直接把订单状态修改为已支付啊!你先把订单状态修改为UPDATING
,也就是修改中的意思。
这个状态是个没有任何含义的这么一个状态,代表有人正在修改这个状态罢了。
然后呢,库存服务直接提供的那个reduce_stock()
接口里,也别直接扣减库存啊,你可以是冻结掉库存。
举个例子,本来你的库存数量是100,你别直接100 - 2=98
,扣减这个库存!
你可以把可销售的库存:100 -2= 98
,设置为98
没问题,然后在一个单独的冻结库存的字段里,设置一个2。也就是说,有2个库存是给冻结了。
积分服务的add_credit()
接口也是同理,别直接给用户增加会员积分。你可以先在积分表里的一个预增加积分字段加入积分。
比如:用户积分原本是1190
,现在要增加10
个积分,别直接1190+10= 1200
个积分啊!
你可以保持积分为1190
不变,在一个预增加字段里,比如说prepare_add_credit
字段,设置一个10
,表示有10
个积分准备增加。
仓储服务的sale_delivery()
接口也是同理啊,你可以先创建一个销售出库单,但是这个销售出库单的状态是UNKNOWN
。
也就是说,刚刚创建这个销售出库单,此时还不确定它的状态是什么呢!
上面这套改造接口的过程,其实就是所谓的TCC 分布式事务
中的第一个T字母代表的阶段,也就是Try阶段。
总结上述过程,如果你要实现一个TCC分布式事务
,首先你的业务的主流程以及各个接口提供的业务含义,不是说直接完成那个业务操作,而是完成一个Try的操作。
这个操作,一般都是锁定某个资源,设置一个预备类的状态,冻结部分数据,等等,大概都是这类操作。咱们来一起看看下面这张图,结合上面的文字,再来捋一捋整个过程:
3. TCC 实现阶段二:Confirm
然后就分成两种情况了,第一种情况是比较理想的,那就是各个服务执行自己的那个Try
操作,都执行成功了,Bingo!
这个时候,就需要依靠TCC分布式事务框架来推动后续的执行了。这里简单提一句,如果你要玩儿TCC分布式事务,必须引入一款TCC分布式事务框架,比如java国内开源的seata
、ByteTCC
、Himly
、TCC-transaction
。
否则的话,感知各个阶段的执行情况以及推进执行下一个阶段的这些事情,不太可能自己手写实现,太复杂了。
如果你在各个服务里引入了一个TCC分布式事务的框架,订单服务里内嵌的那个TCC分布式事务框架可以感知到,各个服务的Try操作都成功了。
此时,TCC 分布式事务框架会控制进入TCC下一个阶段,第一个C阶段,也就是Confirm阶段。为了实现这个阶段,你需要在各个服务里再加入一些代码。比如说,订单服务里,你可以加入一个Confirm的逻辑,就是正式把订单的状态设置为“已支付”了,大概是类似下面这样子:
func (o OrderService) Pay ( ) error {
gorm.UpdateStatus ( "TRADE_SUCCESS")
}
库存服务也是类似的,你可以有一个InventoryServiceConfirm
类,里面提供一个reduce_stock()
接口的Confirm
逻辑,这里就是将之前冻结库存字段的2
个库存扣掉变为0
。
这样的话,可销售库存之前就已经变为98了,现在冻结的2个库存也没了,那就正式完成了库存的扣减。
积分服务也是类似的,可以在积分服务里提供一个CreditServiceConfirm
类,里面有一个addCredit()
接口的Confirm
逻辑,就是将预增加字段的10
个积分扣掉,然后加入实际的会员积分字段中,从1190
变为1120
。
仓储服务也是类似,可以在仓储服务中提供一个WmsServiceConfirm
类,提供一个sale_delivery()
接口的Confirm
逻辑,将销售出库单的状态正式修改为“已创建”,可以供仓储管理人员查看和使用,而不是停留在之前的中间状态UNKNOWN
了。
好了,上面各种服务的Confirm
的逻辑都实现好了,一旦订单服务里面的TCC分布式事务框架感知到各个服务的Try
阶段都成功了以后,就会执行各个服务的Confirm
逻辑。
订单服务内的TCC事务框架会负责跟其他各个服务内的TCC事务框架进行通信,依次调用各个服务的Confirm逻辑。然后,正式完成各个服务的所有业务逻辑的执行。
同样,给大家来一张图,顺着图一起来看看整个过程:
4. TCC实现阶段三: Cancel
好,这是比较正常的一种情况,那如果是异常的一种情况呢?
举个例子:在Try
阶段,比如积分服务吧,它执行出错了,此时会怎么样?
那订单服务内的TCC事务框架是可以感知到的,然后它会决定对整个TCC分布式事务进行回滚。
也就是说,会执行各个服务的第二个C阶段,Cancel阶段。同样,为了实现这个Cancel 阶段,各个服务还得加一些代码。
首先订单服务,它得提供一个OrderServiceCancel 的类,在里面有一个pay()接口的Cancel逻辑,就是可以将订单的状态设置为CANCELED
,也就是这个订单的状态是已取消。
库存服务也是同理,可以提供reduce_stock()
的Cancel逻辑,就是将冻结库存扣减掉2,加回到可销售库存里去,98+2= 100
。
积分服务也需要提供addCredit()
接口的Cancel逻辑,将预增加积分字段的10个积分扣减掉。
仓储服务也需要提供一个sale_delivery()
接口的Cancel逻辑,将销售出库单的状态修改为“CANCELED"设置为已取消。
然后这个时候,订单服务的TCC分布式事务框架只要感知到了任何一个服务的Try逻辑失败了,就会跟各个服务内的TCC 分布式事务框架进行通信,然后调用各个服务的Cancel逻辑。
大家看看下面的图,直观的感受一下:
总结
总结一下,你要玩儿TCC分布式事务的话:
- 首先需要选择某种TCC分布式事务框架,各个服务里就会有这个TCC分布式事务框架在运行。
- 然后你原本的一个接口,要改造为3个逻辑,Try-Confirm-Cancel。
- 先是服务调用链路依次执行Try逻辑
- 如果都正常的话,TCC分布式事务框架推进执行Confirm逻辑,完成整个事务
- 如果某个服务的Try逻辑有问题,TCC分布式事务框架感知到之后就会推进执行各个服务的Cancel逻辑,撤销之前执行的各种操作。
- 这就是所谓的TCC分布式事务。
- TCC分布式事务的核心思想,说白了,就是当遇到下面这些情况时,
- 某个服务的数据库宕机了
- 某个服务自己挂了那个服务的redis、elasticsearch、MQ等基础设施故障了
- 某些资源不足了,比如说库存不够这些
- 先来Try一下,不要把业务逻辑完成,先试试看,看各个服务能不能基本正常运转,能不能先冻结我需要的资源。
- 如果Try都ok,也就是说,底层的数据库、redis、elasticsearch、MQ都是可以写入数据的,并且你保留好了需要使用的一些资源(比如冻结了一部分库存)。
- 接着,再执行各个服务的Confirm逻辑,基本上Confirm就可以很大概率保证一个分布式事务的完成了。
- 那如果Try阶段某个服务就失败了,比如说底层的数据库挂了,或者redis挂了,等等。
- 此时就自动执行各个服务的Cancel逻辑,把之前的Try逻辑都回滚,所有服务都不要执行任何设计的业务逻辑。保证大家要么一起成功,要么一起失败。
终极大招
如果有一些意外的情况发生了,比如说订单服务突然挂了,然后再次重启,TCC分布式事务框架是如何保证之前没执行完的分布式事务继续执行的呢?
TCC事务框架都是要记录一些分布式事务的活动日志的,可以在磁盘上的日志文件里记录,也可以在数据库里记录。保存下来分布式事务运行的各个阶段和状态。
万一某个服务的Cancel或者Confirm逻辑执行一直失败怎么办呢?那也很简单,TCC事务框架会通过活动日志记录各个服务的状态。
举个例子,比如发现某个服务的Cancel或者Confirm一直没成功,会不停的重试调用他的Cancel或者Confirm逻辑,务必要他成功!
当然了,如果你的代码没有写什么bug,有充足的测试,而且Try阶段都基本尝试了一下,那么其实一般Confirm、Cancel都是可以成功的!
如果实在解决不了,那么这个一定是很小概率的事件,这个时候发邮件通知人工处理
seata、go-seata
TCC优缺点
优点:
- 解决了跨服务的业务操作原子性问题,例如组合支付,订单减库存等场景非常实用
- TCC的本质原理是把数据库的二阶段提交上升到微服务来实现,从而避免了数据库2阶段中锁冲突的长事务低性能风险。
- TCC异步高性能,它采用了try先检查,然后异步实现confirm,真正提交的是在confirm方法中。
缺点:
- 对微服务的侵入性强,微服务的每个事务都必须实现try,confirm,cancel等3个方法,开发成本高,后维护改造的成本也高。
- 为了达到事务的一致性要求,try,confirm、cancel接口必须实现等幂性操作。(定时器+重试)
- 由于事务管理器要记录事务日志,必定会损耗一定的性能,并使得整个TCC事务时间拉长,建议采用redis的方式来记录事务日志。
- tcc需要通过锁来确保数据的一致性,会加锁导致性能不高
三、基于本地消息的最终一致性实现
本地消息表方案
本地消息表这个方案最初是eBay提出的,此方案的核心是通过本地事务保证数据业务操作和消息的一致性,然后通过定时任务将消息发送至消息中间件,待确认消息发送给消费方成功再将消息删除。
下面以注册送积分为例来说明︰
下例共有两个微服务交互,用户服务和积分服务,用户服务负责添加用户,积分服务负责增加积分。
交互流程如下∶
1. 用户注册
用户服务在本地事务新增用户和增加“积分消息日志”。(用户表和消息表通过本地事务保证一致)下表是伪代码
begin transaction;
//1.新增用户
//2.存储积分消息日志
commit transation;
这种情况下,本地数据库操作与存储积分消息日志处于同一事务中,本地数据库操作与记录消息日志操作具备原子性。
2. 定时任务扫描日志
如何保证将消息发送给消息队列呢?
经过第一步消息已经写到消息日志表中,可以启动独立的线程,定时对消息日志表中的消息进行扫描并发送至消息中间件,在消息中间件反馈发送成功后删除该消息日志,否则等待定时任务下一周期重试。
3. 消费消息
如何保证消费者一定能消费到消息呢?
这里可以使用MQ的ack(即消息确认)机制,消费者监听MQ,如果消费者接收到消息并且业务处理完成后向MQ发送ack(即消息确认),此时说明消费者正常消费消息完成,MQ将不再向消费者推送消息,否则消费者会不断重试向消费者来发送消息。
积分服务接收到“增加积分”消息,开始增加积分,积分增加成功后消息中间件回应ack,否则消息中间件将重复投递此消息。
由于消息会重复投递,积分服务的“增加积分”功能需要实现幂等性。
总结:
消息不可靠
上诉的方式是一种非常经典的实现,基本避免了分布式事务,实现了“最终一致性”。但是,关系型数据库的吞吐量和性能方面存在瓶颈,频繁的读写消息会给数据库造成压力。所以,在真正的高并发场景下,该方案也会有瓶颈和限制的。
四、基于可靠消息的最终一致性实现
RocketMQ是一个来自阿里巴巴的分布式消息中间件,于2012年开源,并在2017年正式成为Apache顶级项目。据了解,包括阿里云上的消息产品以及收购的子公司在内,阿里集团的消息产品全线都运行在RocketMQ之上,并且最近几年的双十一大促中,RocketMQ都有抢眼表现。
Apache RocketMQ4.3之后的版本正式支持事务消息,为分布式事务实现提供来便利性支持。
RocketMQ事务消息设计则主要是为了解决Producer端的消息发送与本地事务执行的原子性问题,RocketMQ的设计中broker与producer端的双向通信能力,使得broker天生可以作为一个事务协调者存在;而RocketMQ本身提供的存储机制为事务消息提供了持久化能力;
RocketMQ的高可用机制以及可靠消息设计则为事务消息在系统发生异常时依然能够保证达成事务的最终一致性。
在RocketMQ4.3后实现了完整的事务消息,实际上其实是对本地消息表的一个封装,将本地消息表移动到了MQ内部,解决Producer端的消息发送与本地事务执行的原子性问题。
执行流程如下︰
为方便理解我们还以注册送积分的例子来描述整个流程。
Producer即MQ发送方,本例中是用户服务,负责新增用户。MQ订阅方即消息消费方,本例中是积分服务,负责新增积分。
1. Producer发送事务消息
Producer (MQ发送方)发送事务消息至MQ Server,MQ Server将消息状态标记为Prepared(预览状态),注意此时这条消息消费者(MQ订阅方)是无法消费到的。
2. MQ Server回应消息发送成功
MQ Server接收到Producer发送给的消息则回应发送成功表示MQ已接收到消息。
3. Producer执行本地事务
Producer端执行业务代码逻辑,通过本地数据库事务控制。本例中,Producer执行添加用户操作。
4. 消息投递
若Producer本地事务执行成功则自动向MQ Server发送commit消息,MQ Server接收到c
ommit消息后将“增加积分消息”状态标记为可消费,此时MQ订阅方(积分服务)即正常消费消息;
若Producer本地事务执行失败则自动向MQ Server发送rollback消息,MQ Server接收到rollback消息后将删除“增加积分消息”。
MQ订阅方(积分服务)消费消息,消费成功则向MQ回应ack,否则将重复接收消息。这里ack默认自动回应,即程序执行正常则自动回应ack。
5. 事务回查
如果执行Producer端本地事务过程中,执行端挂掉,或者超时,MQ Server将会不停的询问同组的其他Producer来获取事务执行状态,这个过程叫事务回查。MQ Server会根据事务回查结果来决定是否投递消息。
以上主干流程已由RocketMQ实现,对用户则来说,用户需要分别实现本地事务执行以及本地事务回查方法,因此只需关注本地事务的执行状态即可。
五、最大努力通知方案
最大努力通知也是一种解决分布式事务的方案,下边是一个是充值的例子:
交互流程:
- 账户系统调用充值系统接口
- 充值系统完成支付处理向账户系统发起充值结果通知,若通知失败,则充值系统按策略进行重复通知
- 账户系统接收到充值结果通知修改充值状态。
- 账户系统未接收到通知会主动调用充值系统的接口查询充值结果。通过上边的例子我们总结最大努力通知方案的目标:
目标:发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。具体包括:
- 对消息重复通知
- 消息校对机制。如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消√引录可由接收方主动向通知方查询消息信息来满足需求。
最大努力通知与可靠消息一致性有什么不同?
- 解决方案思想不同
可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知方来保证。
最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。 - 两者的业务应用场景不同
可靠消息一致性关注的是交易过程的事务一致,以异步的方式完成交易。最大努力通知关注的是交易后的通知事务,即将交易结果可靠的通知出去。 - 技术解决方向不同
可靠消息一致性要解决消息从发出到接收的一致性,即消息发出并且被接收到。
最大努力通知无法保证消息从发出到接收的一致性,只提供消息接收的可靠性机制。可靠机制是,最大努力的将消息通知给接收方,当消息无法被接收方接收时,由接收方主动查询消息(业务处理结果)。
解决方案:
通过对最大努力通知的理解,采用MQ的ack机制就可以实现最大努力通知。
方案1:
本方案是利用MQ的ack机制由MQ向接收通知方发送通知
流程如下:
- 发起通知方将通知发给MQ。使用普通消息机制将通知发给MQ。
注意:如果消息没有发出去可由接收通知方主动请求发起通知方查询业务执行结果。(后边会
讲) - 接收通知方监听MQ。
- 接收通知方接收消息,业务处理完成回应ack。
- 接收通知方若没有回应ack则MQ会重复通知。
MQ会按照间隔1min、5min、10min、30min、1h、2h、5h、10h的方式,逐步拉大通知间隔
(如果MQ采用rocketMq,在broker中可进行配置),直到达到通知要求的时间窗口上限。 - 接收通知方可通过消息校对接口来校对消息的一致性。
方案2:
本方案也是利用MQ的ack机制,与方案1不同的是应用程序向接收通知方发送通知,
如下图:
交互流程如下:
- 使用可靠消息一致方案中的事务消息保证本地事务与消息的原子性,最终将通知先发给MQ。
- 通知程序监听MQ,接收MQ的消息。
方案1中接收通知方直接监听MQ,方案2中由通知程序监听MQ。通知程序若没有回应ack则MQ会重复通知。 - 通知程序通过互联网接口协议(如http、webservice)调用接收通知方案接口,完成通知。
通知程序调用接收通知方案接口成功就表示通知成功,即消费MQ消息成功,MQ将不再向通知
程序投递通知消息。 - 接收通知方可通过消息校对接口来校对消息的一致性。
方案1和方案2的不同点:
- 方案1中接收通知方与MQ接口,即接收通知方案监听MQ,此方案主要应用与内部应用之间的通知。
- 方案2中由通知程序与MQ接口,通知程序监听MQ,收到MQ的消息后由通知程序通过互联网接口协议调用接收通知方。此方案主要应用于外部应用之间的通知,例如支付宝、微信的支付结果通知。