事务这种东西大家都耳熟能详了,通常指由一组操作组成的一个工作单元,这一整个组合要么全部成功,要么全部失败。
在计算机系统中,更多的是通过关系型数据库来控制事务,这是利用数据库本身的事务特性来实现的,因此叫数据库事务,由于应用主要靠关系数据库来控制事务,而数据库和应用在同一服务器,所以基于关系型数据库的事务又被称为本地事务。
数据库事务具有原子性(Atomacity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。
随着互联网的快速发展,软件系统由原来的单体应用转变为分布式应用。分布式系统会把一个应用系统拆分为可独立部署的多个服务,因此需要服务与服务之间远程协作才能完成事务操作,这种分布式系统环境下由不同的服务之间通过网络远程协作完成事务称为分布式事务。
以上面图示举例,当需要创建订单的时候,将会涉及到订单和库存两个操作。那么左图操作两个系统最终需要写入两个数据库,我们需要保证分布式事务的一致性,这一点应该很容易理解,如果我们只创建了订单而没有去减少相应的库存量,就会出现超卖的现象。同样的像中间,虽然最终只涉及到一个数据库,但是同样需要保证一致性,右图同理。
CAP是一致性(Consistency)、可用性(Availability)、分区容忍性(Partition Tolerance)三个词语的缩写,我们先简单解释下这三个词语:
那么在网络分区出现,也就是有多个节点分布在不同的子网中时,如何实现一致性呢?过程大概如下:
所以,如果我们在数据库同步期间锁定从数据库,那么可用性就无法保证;如果不锁定,那么就会有访问向从数据库读取数据,可能会读取到旧的数据,此时又无法保证一致性。
所以用一句话概括CAP原理就是:当网络分区发生时,一致性和可用性难以两全。只能选择CP或者AP。
CAP理论告诉我们一个分布式系统只能同时满足CAP三项中的两项,其中AP在实际应用中较多,AP即舍弃一致性,保证可用性和分区容忍性,但是在实际生产中很多场景也是需要实现一致性的。
比如主数据库向从数据库中同步数据,即使不要一致性,但是最终也要将数据同步成功来保证数据一致,这种一致性和CAP中的一致性不同,CAP中的一致性是要求任何时间查询每个节点数据都必须一致,它所强调的是一致性,但是最终一致性是允许在一段时间内各节点的数据不一致,但是经过一段时间每个节点的数据必须一致,它所强调的是最终一致性。
BASE 是 Basically Available(基本可用)、Soft State(软状态)和Eventually consistent(最终一致性)三个短语的缩写。BASE理论是对CAP中AP的一个扩展,通过牺牲强一致性来获得可用性,当出现故障允许部分不可用但要保核心功能可用,允许数据在一段时间内是不一致的,但是最终要达到一致状态。满足BASE理论的事务,我们称之为“柔性事务”。
2PC即两阶段提交(Two-phase Commit, 2PC),通过引入协调者(Coordinate)来协调参与者的行为,并最终决定这些参与者是否要真正执行事务。
协调者询问参与者事务是否执行成功,参与者发回事务执行结果,如下图所示:
如果事务在每个参与者上都执行成功,事务协调者发送通知让参与者提交事务;否则,协调者发送通知让参与者回滚事务。
注意:在准备阶段,参与者只是执行了事务,但是并没有提交,只有在提交阶段接收到协调者发来的通知后,才进行提交或者回滚。
2PC的传统方案是在数据库层面实现的,如Oracle和MySQL都支持2PC协议,为了统一标准和减少行业内不必要的对接成本,需要制定标准化的处理模型及接口标准,国际开放组织Open Group定义了分布式事务处理模型DTP(Distributed Transaction Processing Reference Model)。
下面以新用户注册时送积分的案例来说明:
执行流程如下:
DTP模型定义如下角色:
DTP模型定义TM和RM之间通讯的接口规范叫XA,简单理解为数据库提供的2PC接口协议,基于数据库的XA协议来实现2PC又称为XA方案。
以上三个交互角色之间的交互方式如下:
XA方案的缺陷:
Seata是由阿里开源的分布式事务框架。传统2PC的问题在Seata中得到了解决,通过对本地关系数据库的分支事务的协调来驱动完成全局事务,是工作在应用层的中间件。主要优点是性能较好,且不长时间占用连接资源,以高效且对事务零侵入的方式解决微服务场景下面临的分布式事务问题,它目前提供AT模式(即2PC)及TCC模式的分布式事务解决方案。
Seata的设计目标包括了对业务的无侵入,因此从业务无侵入的2PC方案着手,在传统的2PC的基础上演进,并解决2PC方案面临的问题。
Seata把一个分布式事务理解成一个包含了若干个分支事务的全局事务。全局事务的职责是协调其下管辖的分支事务达成一致,要么一起提交,要么一起失败回滚。此外,通常分支事务本身就是一个关系数据库的本地事务,下图是全局事务与分支事务的关系图:
与传统的2PC模型相比,Seata定义了三个组件来协调分布式事务的管理过程:
依旧以新用户注册送积分举例Seata的分布式事务过程:
具体的执行流程如下:
架构层次方面,传统2PC方案的RM实际上是在数据库层,RM本质上就是数据库本身,通过XA协议实现,而Seata的RM是以jar包的形式作为中间件层部署在应用程序这一侧的。
两阶段提交方面,传统2PC无论第二阶段的决是commit还是rollback,事务性资源的锁都要保持到Phase2完成才能释放,而Seata的做法是在Phase1就将本地事务提交,这样就可以省去 Phase2 持锁的时间,整体提高效率。
二阶段如果是提交的话就很容易理解,因为”业务SQL“在一阶段已经提交至数据库,所以Seata框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。
二阶段如果是回滚的话,Seata就需要回滚一阶段已经执行的”业务SQL“,还原业务数据。回滚方式便是用”before image“还原业务数据;但在还原前要首先校验脏写,对比”数据库当前业务数据“和”after image“,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。
TCC事务补偿是基于2PC实现的业务层事务控制方案,TCC这一词是由Try、Confirm和Cancel三个单词的首字母,含义如下:
TM首先发起所有的分支事务的Try操作,任何一个分支事务的Try操作执行失败,TM将会发起所有分支事务的Cancel操作,若try操作全部成功,TM将会发起所有的分支事务的Confirm操作,其中Conrirm/Cancel操作若执行失败,TM会进行那个重试。
我们用一个下单同时减少库存的业务来进行说明:
Try
下单业务由订单服务和库存服务协同完成,在try阶段订单和库存服务完成检查和预留资源;
订单服务检查当前是否满足提交订单的条件(比如:当前存在未完成的订单,不允许提交新订单);
库存服务检查是否有充足的资源,并锁定资源;
Confirm
订单服务和库存服务完成Try后开始执行资源操作;
订单服务向订单写一条订单信息;
库存服务减去库存。
Cancel
如果订单服务和库存服务有一方出现失败则全部操作取消;
订单服务需要删除新增的订单信息。
库存服务将减去的库存再还原。
在没有调用TCC资源Try方法的情况下,调用了二阶段的Cancel方法,Cancel方法需要识别出这是一个空回滚,然后直接返回成功。
出现原因是当一个分支事务所在服务宕机或网络异常,分支事务调用记录为失败,这个时候其实是没有执行Try阶段,当故障恢复后,分布式事务进行回滚则会调用二阶段的Cancel方法,从而形成空回滚。
解决思路的关键是要识别出这个空回滚。思路很简单就是需要知道一阶段是否执行了,如果执行了,那就是正常回滚;如果没执行,那就是空回滚。前面已经说过TM在发起全局事务时生成全局事务记录,全局事务ID贯穿整个分布式事务调用链。再额外增加一张分支事务记录表,表中有全局事务ID贯穿和分支事务ID,第一阶段Try方法里会插入一条记录,表示一阶段执行了。Cancel接口里读取该记录,如果该记录存在,则正常回滚;如果该记录不存在,则是空回滚。
TM(即事务管理器)在发起全局事务时生成全局事务记录,全局事务ID贯穿整个分布式事务调用链条,用来记录事务上下s文,追踪和记录状态,由于Confirm和Cancel失败需进行重试,因此需要实现为幂等。幂等性是指同一操作无论请求多少次,其结果都相同。
所以为了保证TCC二阶段提交重试机不会引发数据不一致,要求TCC的二阶段Try、Confirm和Cancel接口保证幂等,这样不会重复使用或者释放资源。如果幂等控制没做好,很有可能导致数据不一致等严重问题。
悬挂就是对于一个分布式事务,其二阶段Cancel接口比Try接口先执行。
出现原因是在RPC调用分支事务Try时,先注册分支事务,再执行RPC调用,如果此时RPC调用的网络发生拥堵,通常RPC调用是有超时时间的,RPC超时以后,TM就会通知RM回滚分布式事务,可能回滚完成后,RPC请求才到达参与者真正执行,而一个Try方法预留的业务资源,只有该分布式事务才能使用,该分布式事务第一阶段预留的业务资源就再也没有人能够处理了,对于这种情况,我们称之为悬挂,即业务资源预留后没法继续处理。
解决思路是如果二阶段执行完成,那一阶段就不再继续执行。在执行一阶段事务时判断在该全局事务下,”分支事务记录“表中是否已有二阶段事务记录,如果有则不执行Try。
可靠消息最终一致性方案是指当事务发起方执行完成本地事务后并发出一条消息,事务参与方(消息消费者)一定能够接收消息并处理事务成功,此方案强调的是只要消息发给事务参与方最终事务要达到一致。
此方案是利用消息中间件完成,如下图:
事务发起方(消息生成方)将消息发给消息中间件,事务参与方从消息中间件接收消息,事务发起方和消息中间件之间,事务参与方(消息消费方)和消息中间件之间都是通过网络通信,由于网络通信的不确定性会导致分布式事务问题。
因此可靠消息最终一致性解决方案要解决以下几个问题:
本地事务与消息发送的原子性问题即:事务发起方在本地事务执行成功后消息必须发送出去,否则就丢弃消息。即实现本地消息和消息发送的原子性,要么都成功,要么都失败。本地事务与消息发送的原子性问题是实现可靠消息最终一致性方案的关键问题。
先来尝试下这种操作,先发送消息,再操作数据库:
begin transaction:
// 1. 发送MQ
// 2. 数据库操作
commit transaction;
这种情况下无法保证数据库操作与发送消息的一致性,因为可能发送消息成功,数据库操作失败。
那么现在来看第二种方案,先进行数据库操作,再发送消息:
begin transaction:
// 1. 数据库操作
// 2. 发送MQ
commit transaction;
这种情况下貌似没有问题,如果发送MQ消息失败,就会抛出异常,导致数据库事务回滚。但如果是超时异常,数据库回滚,但MQ其实已经正常发送了,同样会导致不一致。
事务参与方必须能够从消息队列接收到消息,如果接收消息失败可以重复接收消息。
由于网络2的存在,若某一消费节点超时但是消费成功,此时消息中间件会重复投递此消息,就导致了消息的重复消费。
要解决消息重复消费的问题就要实现事务参与方的方法幂等性。
本地消息方案是通过本地事务保证数据业务操作和消息的一致性,然后通过定时任务将消息发送至消息中间件,待确认消息发送给消费方成功再将消息删除。
以注册送积分为例来说明:
一共有两个微服务交互,用户服务和积分服务,用户服务负责添加用户,积分服务负责添加积分:
交互流程如下:
用户服务在本地事务新增用户增加”积分消息日志“。(用户表和消息表通过本地事务保证一致)
下边是伪代码
begin transaction;
// 1. 新增用户
// 2. 存储积分消息日志
commit transaction;
这种情况下,本地数据库操作与存储积分消息日志处于同一事务中,本地数据库操作与记录消息日志操作具备原子性。
如何保证将消息发送给消息队列呢?
经过第一步消息已经写到消息日志表中,可以启动独立的线程,定时对消息日志表中的消息进行扫描并发送至消息中间件,在消息中间件反馈成功后删除该消息日志,否则等待定时任务下一周期重试。
如何保证消费者一定能消费到消息呢?
这里可以使用MQ的ACK(即消息确认)机制,消费者监听MQ,如果消费者接受到消息并且业务处理完成后MQ发送ACK(即消息确认),此时说明消费者正常消费消息完成,MQ将不再向消费者推送消息,否则消费者会不断重试向消费者来发送消息。
积分服务接收到”增加积分“消息,开始增加积分,积分增加成功后向消息中间件回应ACK,否则消息中间件将重复投递此消息。
由于消息会重复投递,积分服务的”增加积分“功能需要实现幂等性。
RocketMQ是一个来自阿里巴巴的分布式消息中间件。RocketMQ事务消息设计则主要为了解决Producer端的消息发送与本地事务执行的原子性问题,RocketMQ设计中broker与producer端的双向通信能力,使得broker天生可以作为一个事务协调者存在;而RocketMQ本身提供的存储机制为事务消息提供了持久化能力;RocketMQ的高可用机制以及可靠消息设计则为事务消息在系统发生异常时依然能够保证达成事务的最终一致性。
在RocketMQ 4.3 后实现了完整的事务消息,实际上其实是对本地消息表的一个封装,将本地消息表移动到了MQ内部,解决了 Producer 端的消息发送与本地事务执行的原子性问题。
执行流程如下:
我们还以注册送积分的例子来描述整个流程。
Producer即MQ发送方,本例中是用户服务,负责新增用户。MQ订阅方即消息消费方,本例中是积分服务,负责新增积分。
Producer发送事务消息
Producer(MQ发送方)发送事务消息至MQ Server,MQ Server将消息状态标记为 Prepared(预备状态),注意此时这条消息消费者(MQ订阅方)是无法消费到的。
本例中,Producer发送”增加积分消息“到 MQ Server。
MQ Server回应消息发送成功
MQ Server接收到Producer发送给的消息则回应发送表示MQ已接受到消息。
Producer执行本地事务
Producer端执行业务代码逻辑,通过本地数据库事务控制。本例中,Producer执行添加用户操作。
消息投递
若Producer本地事务执行成功功则自动向MQServer发送commit消息,MQ Server接收到commit消息后将”增加积 分消息“ 状态标记为可消费,此时MQ订阅方(积分服务)即正常消费消息;
若Producer 本地事务执行失败则自动向MQServer发送rollback消息,MQ Server接收到rollback消息后 将删 除”增加积分消息“ 。
MQ订阅方(积分服务)消费消息,消费成功则向MQ回应ack,否则将重复接收消息。这里ack默认自动回应,即 程序执行正常则自动回应ack。
事务回查
如果执行Producer端本地事务过程中,执行端挂掉,或者超时,MQ Server将会不停的询问同组的其他 Producer 来获取事务执行状态,这个过程叫事务回查。MQ Server会根据事务回查结果来决定是否投递消息。
以上主干流程已由RocketMQ实现,对用户侧来说,用户需要分别实现本地事务执行以及本地事务回查方法,因此 只需关注本地事务的执行状态即可。
RoacketMQ提供RocketMQQLocalTransactionListener接口:
public interface RocketMQLocalTransactionListener {
/**
‐ 发送prepare消息成功此方法被回调,该方法用于执行本地事务
‐ @param msg 回传的消息,利用transactionId即可获取到该消息的唯一Id
‐ @param arg 调用send方法时传递的参数,当send时候若有额外的参数可以传递到send方法中,这里能获取到
‐ @return 返回事务状态,COMMIT:提交 ROLLBACK:回滚 UNKNOW:回调
*/
RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg);
/**
‐ @param msg 通过获取transactionId来判断这条消息的本地事务执行状态
‐ @return 返回事务状态,COMMIT:提交 ROLLBACK:回滚 UNKNOW:回调
*/
RocketMQLocalTransactionState checkLocalTransaction(Message msg);
}
发送事务消息:以下是RocketMQ提供用于发送事务消息的API:
TransactionMQProducer producer = new TransactionMQProducer("ProducerGroup");
producer.setNamesrvAddr("127.0.0.1:9876");
producer.start();
// 设置TransactionListener实现
producer.setTransactionListener(transactionListener);
// 发送事务消息
SendResult sendResult = producer.sendMessageInTransaction(msg, null);
最大努力通知也是一种解决分布式事务的方案,下面以一个充值的案例举例:
交互流程:
账户系统调用充值系统接口
充值系统完成支付处理向账户系统发起充值结果通知若通知失败,则充值系统按策略进行重复通知
账户系统接收到充值结果通知修改充值状态。
账户系统未接收到通知会主动调用充值系统的接口查询充值结果。
通过上边的例子我们总结最大努力通知方案的目标:
目标:发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。
具体包括:
有一定的消息重复通知机制
因为接收通知方可能没有接收到通知,此时要有一定的机制对消息重复通知;
消息校对机制
如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息来满足需求。
最大努力通知与可靠消息一致性有什么不同?
解决方案思想不同
可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知 方来保证。
最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。
两者的业务应用场景不同
可靠消息一致性关注的是交易过程的事务一致,以异步的方式完成交易。
最大努力通知关注的是交易后的通知事务,即将交易结果可靠的通知出去。
技术解决方向不同
可靠消息一致性要解决消息从发出到接收的一致性,即消息发出并且被接收到。
最大努力通知无法保证消息从发出到接收的一致性,只提供消息接收的可靠性机制。可靠机制是,最大努力的将消 息通知给接收方,当消息无法被接收方接收时,由接收方主动查询消息(业务处理结果)。
通过对最大努力通知的理解,采用MQ的ack机制就可以实现最大努力通知。
方案1:
本方案是利用MQ的ack机制由MQ向接收通知方发送通知,流程如下:
使用普通消息机制将通知发给MQ。
注意:如果消息没有发出去可由接收通知方主动请求发起通知方查询业务执行结果。(后边会讲)
接收通知方监听 MQ。
接收通知方接收消息,业务处理完成回应ack。
接收通知方若没有回应ack则MQ会重复通知。
MQ会按照间隔1 min、5 min、10 min、30 min、1 h、2 h、5 h、10 h的方式,逐步拉大通知间隔(如果 MQ 采用rocketMQ,在broker中可进行配置),直到达到通知要求的时间窗口上限。
5、接收通知方可通过消息校对接口来校对消息的一致性。
方案2:
本方案也是利用MQ的ack机制,与方案1不同的是应用程序向接收通知方发送通知,如下图:
交互流程如下:
发起通知方将通知发给MQ。
使用可靠消息一致方案中的事务消息保证本地事务与消息的原子性,最终将通知先发给MQ。
通知程序监听 MQ,接收MQ的消息。
方案1中接收通知方直接监听MQ,方案2中由通知程序监听MQ。通知程序若没有回应ack则MQ会重复通知。
通知程序通过互联网接口协议(如http、webservice)调用接收通知方案接口,完成通知。
通知程序调用接收通知方案接口成功就表示通知成功,即消费MQ消息成功,MQ将不再向通知程序投递通知消 息。
接收通知方可通过消息校对接口来校对消息的一致性。
方案1和方案2的不同点:
方案1中接收通知方与MQ接口,即接收通知方案监听 MQ,此方案主要应用与内部应用之间的通知。
方案2中由通知程序与MQ接口,通知程序监听MQ,收到MQ的消息后由通知程序通过互联网接口协议调用接收 通知方。此方案主要应用于外部应用之间的通知,例如支付宝、微信的支付结果通知。
资料收集于网络。