先了解下本地事务,数据库是如何保证本地事务的?
数据库具有ACID的特性,即原子性A、一致性C、隔离性I、持久性D。
- 原子性A:通过undolog回滚日志保证,即要么执行成功,如果执行失败通过undolog回滚日志进行回滚
- 一致性C:通过undolog回滚日志保证,多个数据同时执行,如果有一个执行失败就回滚
- 隔离性I:通过悲观锁和乐观锁(mvcc)保证
- 持久性D:通过redolog的两阶段提交
分布式事务场景:
分布式系统中是无法通过本地事务保证全局事务的,其问题点在于:每一个系统中虽然能通过本地事务保证自己所在的库的事务,但是整体的事务无法保证。
如果在一个调用链上涉及到2个及以上的服务系统,并且涉及到多个数据库的数据修改,改那就涉及到分布式事务的问题了。和本地事务的本质区别就是本地事务涉及到一个数据库,分布式事务涉及到多个数据库。
解决方案之本地事件表
通过本地事务+ 本地事件表 + 定时任务 + 消息队列 实现
通过本地事务保证全局事务
第一步、第二步、第三步、第四步这几块每一块都是自己的本地事务,都可以通过自己的本地事务保证全局的事务
幂等性的保证
在消费者端通过消息事件id、数据库的主键约束来保证消息消费的幂等性
注意执行顺序
-
2.1 DB select --> 2.2 DB update --> 2.3 发到消息队列。
执行顺序要严格按照上图的2.1 ->2.2 -> 2.3来执行,如果先2.1 ->2.3 -> 2.2 会发生消息2.2的DB操作失败后消息已经发到队列里了
-
3.1监听队列 -->3.2 DB insert --> 3.3 ACK
执行顺序要严格按照上图的3.1 ->3.2 -> 3.3来执行,ACK一定要放在最后,如果放在前面的话DB操作失败造成数据回滚,ACK后消息从队列中丢弃,那么这个消息就丢了。(仔细想)
改进方案:
-
冷热数据分离,防止堆积。
事件表的冷热数据可以分离,随着时间的推进,大量的数据会在事件表中堆积,会导致事件的查询变的非常慢。
可以将距离时间较远的事件转移到其他表中进行存储
-
防止消息丢失:
消息队列中的消息要作为持久化,防止MQ中已收到消息但是没等到消息发出去宕机了出现消息丢失
-
异步发送消息的优化
创建完消息事件后可以异步的将消息发送到MQ中,可以省下方法的执行时间,但是增大了排错成本
需要注意的本地事件表方案不适用于数据量特别大的情况
解决方案之最大努力通知
应用场景:此方案一般用在第三方系统调用中。对我方系统来说它属于第三方系统,对第三方系统来说它属于开放平台
为什么叫最大努力通知
如上,支付宝会尽最努力通知
和本地事件表方案相比较:优势在于,将多个服务于自己的本地事件表,抽出一个单独的可靠消息服务,同一事务中的每一个模块都基于这个可靠消息服务
分布式事务提交协议之2PC/3PC
刚性事务和柔性事务
- 刚性事务:像是ACID就是刚性事务,要求数据的实时一致性(强一致性)
- 柔性事务:要求的是数据的最终一致性 (Base)
分布式事务的场景都是柔性事务。分布式事务无法百分之百的解决数据的不一致性。
Base理论介绍
Basically Availbale (基本可用)、Soft state(软状态)、Eventually consistent(最终一致性)。
BASE理论是对CAP中AP的一个扩展,通过牺牲强一致性来获得可用性,当出现故障允许部分不可用但要保证核心功能可用,允许数据在一段时间内是不一致的,但最终要达到一致状态。满足BASE理论的事务,我们称之为“柔性事务”。
- 基本可用:分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。如电商网址交易付款出现问题,商品依然可以正常浏览。
- 软状态:由于不要求强一致性,所以BASE允许系统中存在中间状态(也叫软状态),这个状态不影响系统可用性,如订单中的“支付中”、“数据同步中”等状态,待数据最终一致后状态改为“成功”状态。
- 最终一致性:最终一致是指的经过一段时间后,所有节点数据都将会达到一致。如订单的“支付中”状态,最终会变为“支付成功”或者“支付失败”,使订单状态与实际交易结果达成一致,但需要一定时间的延迟、等待。
两阶段提交协议-2PC
RM - 资源管理者(Resource Manager),也就是每个参与事务的微服务
TM - 事务发起者(管理者 Transaction Manager),TM也是RM的一种
-
第一阶段:预提交阶段(precommit 阶段)
-
协调者开启事务通知节点执行sql指令但不提交事务
-
节点通知协调者是否执行成功
预提交阶段如果有执行失败就回滚
-
-
第二阶段:真正提交执行阶段 (commit/rollback阶段)
如果第一阶段所有节点的通知都是yes,必须所有都是yes,协调者通知所有节点统一提交事务
阶段一:提交事务请求
- 事务询问
协调者节点向所有参与者节点询问是否可以执行提交操作(vote),并开始等待各参与者节点的响应。-执行事务
参与者节点执行询问发起的所有事务操作,并将Undo信息和Redo信息写入日志。(注意:若成功这里其实每个参与者已经执行了事务操作) - 各参与者向协调者反馈事务询问的响应
各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个”同意”消息;如果参与者节点的事务操作实际执行失败,则它返回一个”中止”消息。
阶段二:执行事务提交
当协调者节点从所有参与者节点获得的相应消息都为”同意”时:协调者节点向所有参与者节点发出”正式提交(commit)”的请求。
-
事务提交
参与者节点正式完成操作,并释放在整个事务期间内占用的资源。反馈事务提交结果。参与者节点向协调者节点发送”完成”消息。 -
完成事务
协调者节点受到所有参与者节点反馈的”完成”消息后,完成事务。 -
中断事务:
如果任一参与者节点在第一阶段返回的响应消息为“中止”,或者 协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:中断事务,发起回滚请求。协调者节点向所有参与者节点发出”回滚操作(rollback)”的请求。
-
事务回滚
参与者节点利用之前写入的Undo信息执行回滚,并释放在整个事务期间内占用的资源。
反馈事务回滚结果
参与者节点向协调者节点发送”回滚完成”消息。 -
结束事务
协调者节点受到所有参与者节点反馈的”回滚完成”消息后,取消事务。
不管最后结果如何,第二阶段都会结束当前事务。
两阶段提交协议存在的问题
二阶段提交看起来确实能够提供原子性的操作,但是不幸的是,二阶段提交还是有几个缺点的:
1、同步阻塞问题。
两阶段提交方案下全局事务的ACID特性,是依赖于RM的。一个全局事务内部包含了多个独立的事务分支,这一组事务分支要么都成功,要么都失败。各个事务分支的ACID特性共同构成了全局事务的ACID特性。也就是将单个事务分支的支持的ACID特性提升一个层次到分布式事务的范畴。 **即使在本地事务中,如果对操作读很敏感,我们也需要将事务隔离级别设置为SERIALIZABLE。而对于分布式事务来说,更是如此,可重复读隔离级别RR不足以保证分布式事务一致性。**如果我们使用mysql来支持XA分布式事务的话,那么最好将事务隔离级别设置为SERIALIZABLE,然而SERIALIZABLE(串行化)是四个事务隔离级别中最高的一个级别,也是执行效率最低的一个级别。
2、单点故障。
由于协调者的重要性,一旦协调者TM发生故障,参与者RM会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)
简言之:单点故障会导致RM永久阻塞
3、数据不一致。
在二阶段提交中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这会导致只有一部分参与者接受到了commit请求,而在这部分参与者接到commit请求之后就会执行commit操作,但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致性的现象。未执行commit的服务只能进行人工补偿
由于二阶段提交存在着诸如同步阻塞、单点问题等缺陷,所以,研究者们在二阶段提交的基础上做了改进,提出了三阶段提交。
简言之:二阶段提交会导致其中的一个RM已经commit而另一个RM未commit,造成数据不一致
三阶段提交协议-3PC
三阶段提交多了个询问操作canCommit,也就是第一步网络询问,判断各个服务是否调用的通,增大了成功的概率,降低了意外发生的可能性。但是也不能完全避免。要想完全避免还是要通过可靠消息服务来实现。
2PC和3PC的区别
资源的锁定
无论是两阶段提交还是三阶段提交,整个执行过程都会涉及到资源的锁定,直到整个事务执行完成释放资源
区别在于:两阶段提交是上来就锁定资源,如果后面出现问题会出现资源无法释放。三阶段提交开始的时候没锁定资源,在第一个阶段进行网络询问,如果询问不通就不往后走,如果询问的通就往后继续执行,三阶段提交的方式减少了询问后资源锁定的时长和因网络抖动导致资源锁定的概率。
因此,二阶段的缺点就是由于网络抖动可能会导致资源阻塞时间过长,性能稍差,三阶段由于进行网络询问会减少阻塞资源的时间
超时机制
超时机制在两阶段提交和三阶段提交的区别
两阶段提交中:只有在协调者TM中有超时机制,也就是在第一个阶段TM在超过规定时间内没有收到RM的yes或no应答,提交请求就会回滚事务。
三阶段提交中:同时在协调者TM和参与者RM中都引入超时机制。在第一阶段网络确认请求中超时了,就不会往下进行;在第二阶段RM超过一定时间收不到TM的命令就会中断事务,TM收不到RM的yes或no应答就回滚;在第三阶段中,超过一定时间没有收到docommit请求会自己主动进行提交
CAP理论
CAP是一个已经被证实的理论:一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)
这三项中的两项。它可以作为我们架构设计、技术选型的考量标准。对于多数大型互联网应用的场景,节点众多、部署分散,而且现在的集群规模越来越大,所以节点故障、网络故障是常态,而且要保证服务可用性达到N个9(99.99.%),并要达到良好的响应性能来提高用户体验,也就是说分区容忍性P必须要有!
因此一般都会做出如下选择:保证P和A,舍弃C强一致性,保证最终一致性。
CAP理论告诉我们一个分布式系统最多只能同时满足CAP这三项中的两项,其中AP在实际应用中较多,AP既舍弃一致性,保证可用性和分区容忍性,但是在实际生产中很多场景都要实现一致性,比如前边我们举的例子,AP即舍弃一致性,保证可用性和分区容忍性,但是在实际产生中很多场景都要实现一致性,比如前边我们觉得例子主数据库向从数据库同步数据,即使不要一致性,但是最终也要将数据同步成功来保证数据一致,这种一致性和CAP中的一致性不同,CAP中的一致性要求在任何时间查询每个节点据都必须一致,它强调的是强一致性,但是最终一致性是允许可以在一段时间内每个节点的数据不一致,但是经过一段时间每个节点的数据必须一致,它强调的是最终
数据的一致性。
分布式事务框架LCN
概念
LCN即为: Lock 锁定事务单元 、confirm确认事务、notify通知事务
LCN是柔性事务,使用的是两阶段提交协议,其实很多分布式事务框架都是使用两阶段提交协议,如LCN、TCC、Seata等
像是XA协议是通过中间件操作数据库,属于刚性事务,追求强一致性
原理
LCN框架结构图
在上图中,微服务 A,微服务 B,TxManager 事务协调器,都需要去 Eureka 中注册服务。使用Eureka保证了 TxManager 与其他服务之间的相互服务发现
LCN框架和两阶段提交不同的是TM有着自己的数据库和redis缓存,用来记录事务组信息和补偿信息
LCN时序图
LCN框架的执行流程
-
创建事务组
每个节点的事务合起来组成一个事务组,在事务发起方开始执行业务代码之前先调用 TxManager 创建事务组对象
-
执行业务
创建事务组之后执行业务发起方系统中的业务流程,然后进行服务与服务之间的调用
-
添加事务组
事务组的添加是整个调用链路执行完才会添加,然后一个个的添加前面节点的事务组。这个有点类似于栈,只有所有方法都压栈以后才知道这个栈有多深,因此最后一个微服务调用完才知道这个业务在整个系统中有多少个本地事务
-
关闭事务组
关闭事务组,通知TxManager进行提交或回滚
-
提交或回滚
当执行完关闭事务组的方法以后,TxManager 将根据事务组信息来通知相应的参与模块提交或回滚事务。
LCN原理图
协调机制
LCN框架无非也就是这两个阶段,预执行 -> 真正提交或回滚
如何保证第一阶段数据库执行后,第二阶段通知数据库提交或回滚它就听你的话?
底层原理就是使用数据库连接池,得保持住第一阶段数据库的连接,第二阶段才能进行基于它预执行的提交或回滚
要知道在数据库连接池中sql执行完后是要在连接池中释放connection连接的,但是在底层是将connection储存到map中,相当于将连接池拦截下来,代理了DataSource connection数据源连接 ,虽然在里面确实是执行了close方法,但实际上这个连接没有真正释放(假释放),实际资源并没有释放,资源的真正释放权掌握在在TxManager中,通过这种方式TxManager也就真正的控制了连接池的事务提交和回滚
补偿机制
**概念:**在服务宕机或网络抖动情况下 TxManager 无法通知某些事务单元提交或回滚时,TxManager 会做一个标识,把通知不到RM记录下来,标识本次事务是否需要补偿
触发条件:
当执行关闭事务组时,发起方接受到失败的状态后将会把该次事务识别为待补偿事务,然后发起方将该次事务数据异步通知给 TxManager。TxManager 接受到补偿事务以后先通知补偿回调地址,然后再根据是否开启自动补偿事务状态来补偿或保存该次事务数据。
分布式事务框架TCC
TCC : try - confirm - cancel
TCC框架使用的是两阶段协议,第一阶段:try ;第二阶段 confirm或cancel
try:每一个服务都执行try操作,将数据预提交,锁定资源
confirm:如果每一个服务的try都执行成功,就执行confirm操作,进行提交。
cancel:如果其中一个服务try失败了,执行cancel操作,
TCC在debug中会出现超时异常,需要设置超时时间
TCC的异常场景及应对机制
幂等
问题:confirm/cancel被多次执行
由于网络抖动等原因,Comfirm/Cancel可能会重复调用。所以Confirm/Cancel需要能够保证幂等性。否则会导致业务故障。
空回滚
问题:try未执行,cancel执行
某一个阶段的try无法执行,或所有的try都无法执行,导致执行cancel发生回滚。
可能发生的场景:网络异常,导致所有try或某一个try永远都无法执行,try超时,cancel发生回滚
Cancel方法是必须要识别出Try有没有执行的。如果Try还没执行,表示这个Cancel操作是无效的,即本次Cancel属于空回滚;如果Try已经执行,那么执行的cancel属于正常的回滚逻辑。
悬挂
悬挂是try发生在cancel之前,先执行了cancel,然后才执行了try
可能发生的场景:如果网络抖动原因导致try始终没有响应,提前执行了cancel方法,之后后try才开始执行。
下面的时序展示了这种可能性:
- 发起方通过RPC调用参与者一阶段Try,但是发生网络阻塞导致RPC超时
- RPC超时后,TC会回滚分布式事务(可能是发起方主动通知TC回滚或者是TC发现事务超时后回滚),调用已注册的各个参与方的二阶段Cancel
- 参与方空回滚后,发起方对参与者的一阶段Try才开始执行,进行资源预留从而形成悬挂
空回滚和悬挂的区别
空回滚和悬挂在概念上看起来很像,都是没有try就cancel,但完全不是一回事。空回滚是某一个try或所有try永远都无法执行;而悬挂是try最后还能执行 且发生在cancel后
解决方案
加事务控制表
-
tx_id(全局事务id)
-
branch_id(分支id)
-
状态(1:事务初始化try 2:已提交 3:已回滚)
cancel的时候新增一条cancel记录,如果原来没有记录执行空方法,再插入已回滚的记录,try的时候发现有记录进行空try
LCN和TCC的选型:
TCC的缺点就是开发成本大,需要关注cancelXxx方法,可能会多写一倍的业务逻辑。
带本地事务中间件比如mysql等:使用LCN
其他不带事务的中间件,比如Redis:使用TCC
源码:自己照着流程走
Seata
Seata框架中一个分布式事务包含3种角色:
- Transaction Coordinator (TC): 事务协调者,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚,相当于LCN和TCC中的TM
- Transaction Manager ™: 分布式事务的发起者和终结者,并决定着全局提交或全局回滚。
- Resource Manager (RM):事务的参与者
本地锁和全局锁
- 本地锁: 锁的是每一个服务下的本地事务(数据库的锁ACID)
- 全局锁: 锁的是某一条整个的分布式事务(分布式锁) 全局锁的作用是写隔离,避免了脏写
无状态
seata无论在AT模式和TCC模式都属于参与者RM无状态,因为不需要拦截代理第一阶段的DataSource,不需要知道上一阶段的信息。好处:节省资源
就像JWT,服务端不需要记录session,下次来,不知道上次来,每次都是新的请求。
seata的AT模式
Seata的AT模式(Atomic Transaction).也是两阶段提交协议, 不同的是 原来的二阶段占用本地资源,seata的第二阶段不占用本地资源例如LCN的第二阶段回滚,是需要拿到上一阶段的连接的(通过代理DataSource数据源),而Seata的第二阶段的回滚是利用undolog回滚日志。好处就是不需要知道上次的连接。
执行流程
一个分布式事务在seata中的执行流程(AT模式中)
提交的时候,删除回滚数据
分布式事务在seata中RM的具体执行流程,抢锁过程
右边的分布式事务想要提交RM1的业务,但是必须要获取到全局锁才能提交,但是这个全局锁被左边的服务占有,因此获取不到全局锁,会不断重试,超过一定时间获取不到全局锁会回滚,并释放本地锁
左边的事务如果执行到了RM2,释放了RM1中的本地锁,但是RM2服务出错了,通过本地事务即可回滚,但是RM1也要回滚的。
此时要获取RM1的本地锁,但是RM1的本地锁被右边的分布式事务持有,左边的服务没有办法回滚,因此只能等待右边获取全局锁失败后回滚,释放RM1的本地锁后,左边的分布式事务中的RM1拿到本地锁,回滚。
回滚操作
本地事务的回滚是需要有本地锁的,回滚之后释放本地锁,有全局锁的话释放全局锁
使用条件:TM、RM 必须要有sql事务支持才适用
Seata的TCC模式
执行流程
事务消息方案
RocketMQ事务消息
RocketMQ 4.3+提供分布事务功能,通过 RocketMQ事务消息能达到分布式事务的最终一致
**Half Message:**预处理消息,当broker收到此类消息后,会存储到RMQ_SYS_TRANS_HALF_TOPIC的消息消费队列中
**检查事务状态:**Broker会开启一个定时任务,消费RMQ_SYS_TRANS_HALF_TOPIC队列中的消息,每次执行任务会向消息发送者确认事务执行状态(提交、回滚、未知),如果是未知,等待下一次回调。
**超时:**如果超过回查次数,默认回滚消息
TransactionListener的两个方法
-
executeLocalTransaction:半消息发送成功触发此方法来执行本地事务
-
checkLocalTransaction:broker将发送检查消息来检查事务状态,并将调用此方法来获取本地事务状态
本地事务执行状态
- LocalTransactionState.COMMIT_MESSAGE: 执行事务成功,确认提交
- LocalTransactionState.ROLLBACK_MESSAGE: 回滚消息,broker端会删除半消息
- LocalTransactionState.UNKNOW: 暂时为未知状态,等待broker回查
2pc | tcc | 消息队列 | ||
---|---|---|---|---|
一致性 | 强 | 最终 | 最终 | |
吞吐量 | 低 | 中等 | 高 | |
复杂度 | 简单 | 复杂 | 中等 |