分布式事务综合解读
分布式事务
CAP理论
c -- 一致性 consistency
a-- 可用性Availability
p-- 分区容错性Partition tolerance
最常用的理论是ap
BASE理论
满足AP的分布式系统,实际也需要实现一致性,用户查询的数据满足最终一致性;
base理论中指:基本可用性(basically availability),软状态(soft state),最终一致性(eventually consistent)
base是对AP的拓展,通过牺牲强一致性来获得可用性,当出现故障允许部分不可用但要保证 核心功能可用,允许数据在一段时间内是不一致的,但最终达到一致状态。满足BASE理论的事务,我们称之为“柔性事务”。
基本可用:分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。如,电商网站交易付款出 现问题了,商品依然可以正常浏览。
软状态:由于不要求强一致性,所以BASE允许系统中存在中间状态(也叫软状态),这个状态不影响系统可用 性,如订单的"支付中"、“数据同步中”等状态,待数据最终一致后状态改为“成功”状态。
最终一致:最终一致是指经过一段时间后,所有节点数据都将会达到一致。如订单的"支付中"状态,最终会变 为“支付成功”或者"支付失败",使订单状态与实际交易结果达成一致,但需要一定时间的延迟、等待。
分布式解决方案
2PC
2PC即两阶段提交协议,是将整个事务流程分为两个阶段,准备阶段(Prepare phase)、提交阶段(commit phase),2是指两个阶段,P是指准备阶段,C是指提交阶段。
整个过程中有事务管理器和事务参与者
在计算机中部分关系数据库如Oracle、MySQL支持两阶段提交协议,如下图:
- 准备阶段(Prepare phase):事务管理器给每个参与者发送Prepare消息,每个数据库参与者在本地执行事务,并写本地的Undo/Redo日志,此时事务没有提交。
(Undo日志是记录修改前的数据,用于数据库回滚,Redo日志是记录修改后的数据,用于提交事务后写入数 据文件)
- 提交阶段(commit phase):如果事务管理器收到了参与者的执行失败或者超时消息时,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据事务管理器的指令执行提交或者回滚操 作,并释放事务处理过程中使用的锁资源。注意:必须在最后阶段释放锁资源。
下图展示了2PC的两个阶段,分成功和失败两个情况说明:
失败情况:
2PC解决方案:
XA方案
2PC的传统方案是在数据库层面实现的,如Oracle、MySQL都支持2PC协议,为了统一标准减少行业内不必要的对 接成本,需要制定标准化的处理模型及接口标准,国际开放标准组织Open Group定义了分布式事务处理模型DTP(Distributed Transaction Processing Reference Model)
为了让大家更明确XA方案的内容程,下面新用户注册送积分为例来说明:
执行流程如下:
TM 事务管理器
1、应用程序(AP)持有用户库和积分库两个数据源。
2、应用程序(AP)通过TM通知用户库RM新增用户,同时通知积分库RM为该用户新增积分,RM此时并未提交事 务,此时用户和积分资源锁定。
3、TM收到执行回复,只要有一方失败则分别向其他RM发起回滚事务,回滚完毕,资源锁释放。
4、TM收到执行回复,全部成功,此时向所有RM发起提交事务,提交完毕,资源锁释放。
DTP模型定义如下角色:
AP(Application Program):即应用程序,可以理解为使用DTP分布式事务的程序。
RM(Resource Manager):即资源管理器,可以理解为事务的参与者,一般情况下是指一个数据库实例,通过资源管理器对该数据库进行控制,资源管理器控制着分支事务。
TM(Transaction Manager):事务管理器,负责协调和管理事务,事务管理器控制着全局事务,管理事务生命周期,并协调各个RM。全局事务是指分布式事务处理环境中,需要操作多个数据库共同完成一个工作,这个 工作即是一个全局事务。
DTP模型定义TM和RM之间通讯的接口规范叫XA,简单理解为数据库提供的2PC接口协议,基于数据库的XA协议来实现2PC又称为XA方案。
以上三个角色之间的交互方式如下:
1) TM向AP提供 应用程序编程接口,AP通过TM提交及回滚事务。
2) TM交易中间件通过XA接口来通知RM数据库事务的开始、结束以及提交、回滚等。
总结:
整个2PC的事务流程涉及到三个角色AP、RM、TM。AP指的是使用2PC分布式事务的应用程序;RM指的是资 源管理器,它控制着分支事务;TM指的是事务管理器,它控制着整个全局事务。
1)在准备阶段RM执行实际的业务操作,但不提交事务,资源锁定;
2)在提交阶段TM会接受RM在准备阶段的执行回复,只要有任一个RM执行失败,TM会通知所有RM执行回滚操 作,否则,TM将会通知所有RM提交该事务。提交阶段结束资源锁释放。
XA方案的问题:
1、需要本地数据库支持XA协议。
2、资源锁需要等到两个阶段结束才释放,性能较差。
seata方案
Seata是由阿里中间件团队发起的开源项目 Fescar,后更名为Seata,它是一个是开源的分布式事务框架。
传统2PC的问题在Seata中得到了解决,它通过对本地关系数据库的分支事务的协调来驱动完成全局事务,是工作在应用层的中间件。主要优点是性能较好,且不长时间占用连接资源,它以高效并且对业务0侵入的方式解决微服 务场景下面临的分布式事务问题,它目前提供AT模式(即2PC)及TCC模式的分布式事务解决方案。
Seata的设计思想如下:
Seata的设计目标其一是对业务无侵入,因此从业务无侵入的2PC方案着手,在传统2PC XA方案的基础上演进,并解决2PC方案面临的问题。
Seata把一个分布式事务理解成一个包含了若干分支事务的全局事务。全局事务的职责是协调其下管辖的分支事务 达成一致,要么一起成功提交,要么一起失败回滚。此外,通常分支事务本身就是一个关系数据库的本地事务,下 图是全局事务与分支事务的关系图:
与 传统2PC 的模型类似,Seata定义了3个组件来协议分布式事务的处理过程:
Transaction Coordinator (TC): 事务协调器,它是独立的中间件,需要独立部署运行,它维护全局事务的运行状态,接收TM指令发起全局事务的提交与回滚,负责与RM通信协调各各分支事务的提交或回滚。
Transaction Manager (TM): 事务管理器,TM需要嵌入应用程序中工作(jar包),它负责开启一个全局事务,并最终向TC发起全局提交或全局回滚的指令。
Resource Manager (RM): 控制分支事务,负责分支注册、状态汇报,并接收事务协调器TC的指令(jar包嵌入),驱动分支(本地)事务的提交和回滚。
还拿新用户注册送积分举例Seata的分布式事务过程:
具体的执行流程如下:
-
用户服务的 TM 向 TC 申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的XID。
-
用户服务的 RM 向 TC 注册 分支事务,该分支事务在用户服务执行新增用户逻辑,并将其纳入 XID 对应全局事务的管辖。
-
用户服务执行分支事务,向用户表插入一条记录。
-
逻辑执行到远程调用积分服务时(XID 在微服务调用链路的上下文中传播)。积分服务的RM 向 TC 注册分支事务,该分支事务执行增加积分的逻辑,并将其纳入 XID 对应全局事务的管辖。
-
积分服务执行分支事务,向积分记录表插入一条记录,执行完毕后,返回用户服务。
-
用户服务分支事务执行完毕。
-
TM 向 TC 发起针对 XID 的全局提交或回滚决议。\8. TC 调度 XID 下管辖的全部分支事务完成提交或回滚请求。
Seata实现2PC与传统2PC的差别:
架构层次方面,传统2PC方案的 RM 实际上是在数据库层,RM 本质上就是数据库自身,通过 XA 协议实现,而
Seata的 RM 是以jar包的形式作为中间件层部署在应用程序这一侧的。
两阶段提交方面,传统2PC无论第二阶段的决议是commit还是rollback,事务性资源的锁都要保持到Phase2完成 才释放。而Seata的做法是在Phase1 就将本地事务提交,这样就可以省去Phase2持锁的时间,整体提高效率。
Seata 执行流程
操作数据是如何插入到undo_log表中的
com.alibaba.druid.proxy.jdbc.DataSourceProxy 操作undo_log表
@Configuration
public class DatabaseConfiguration {
@Bean
@ConfigurationProperties(prefix = "spring.datasource.ds0") public DruidDataSource ds0() {
DruidDataSource druidDataSource = new DruidDataSource();
return druidDataSource;
@Primary
@Bean
public DataSource dataSource(DruidDataSource ds0) { DataSourceProxy pds0 = new DataSourceProxy(ds0); return pds0;
}
- 开启全局事务@GlobalTransactional
- 注册分支事务到TC,分支事务自加注解@Transactional
- 分支事务执行完成查看任务执行是否异常
- 全局事务判断是否需要进行事务回滚操作
TCC.
TCC是Try、Confirm、Cancel三个词语的缩写,TCC要求每个分支事务实现三个操作:预处理Try、确认
Confirm、撤销Cancel。Try操作做业务检查及资源预留,Confirm做业务确认操作,Cancel实现一个与Try相反的 操作即回滚操作。TM首先发起所有的分支事务的try操作,任何一个分支事务的try操作执行失败,TM将会发起所 有分支事务的Cancel操作,若try操作全部成功,TM将会发起所有分支事务的Confirm操作,其中Confirm/Cancel 操作若执行失败,TM会进行重试。
分支执行失败:
TCC分为三个阶段:
-
Try 阶段是做业务检查(一致性)及资源预留(隔离),此阶段仅是一个初步操作,它和后续的Confirm 一起才能真正构成一个完整的业务逻辑。
-
Confirm 阶段是做确认提交,Try阶段所有分支事务执行成功后开始执行 Confirm。通常情况下,采用TCC则认为 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。若Confirm阶段真的出错了,需引入重试机制或人工处理。
-
Cancel 阶段是在业务执行错误需要回滚的状态下执行分支事务的业务取消,预留资源释放。通常情况下,采用TCC则认为Cancel阶段也是一定成功的。若Cancel阶段真的出错了,需引入重试机制或人工处理。
-
TM事务管理器可以实现为独立的服务,也可以让全局事务发起方充当TM的角色,TM独立出来是为了成为公用组件,是为了考虑系统结构和软件复用。
TM在发起全局事务时生成全局事务记录,全局事务ID贯穿整个分布式事务调用链条,用来记录事务上下文, 追踪和记录状态,由于Confirm 和cancel失败需进行重试,因此需要实现为幂等,幂等性是指同一个操作无论请求多少次,其结果都相同。
TCC解决方案 Hmuily
目前市面上的TCC框架众多比如下面这几种:
(以下数据采集日为2019年07月11日)
框架名称 | Gitbub地址 | star数量 |
---|---|---|
tcc-transaction | https://github.com/changmingxie/tcc-transaction | 3850 |
Hmily | https://github.com/yu199195/hmily | 2407 |
ByteTCC | https://github.com/liuyangming/ByteTCC | 1947 |
EasyTransaction | https://github.com/QNJR-GROUP/EasyTransaction | 1690 |
上一节所讲的Seata也支持TCC,但Seata的TCC模式对Spring Cloud并没有提供支持。我们的目标是理解TCC的原理以及事务协调运作的过程,因此更请倾向于轻量级易于理解的框架,因此最终确定了Hmily。
Hmily是一个高性能分布式事务TCC开源框架。基于Java语言来开发(JDK1.8),支持Dubbo,Spring Cloud等
RPC框架进行分布式事务。它目前支持以下特性:
支持嵌套事务(Nested transaction support).
采用disruptor框架进行事务日志的异步读写,与RPC框架的性能毫无差别。支持SpringBoot-starter 项目启动,使用简单。
RPC框架支持 : dubbo,motan,springcloud。
本地事务存储支持 : redis,mongodb,zookeeper,file,mysql。事务日志序列化支持 :java,hessian,kryo,protostuff。
采用Aspect AOP 切面思想与Spring无缝集成,天然支持集群。RPC事务恢复,超时异常恢复等。
Hmily利用AOP对参与分布式事务的本地方法与远程方法进行拦截处理,通过多方拦截,事务参与者能透明的 调用到另一方的Try、Confirm、Cancel方法;传递事务上下文;并记录事务日志,酌情进行补偿,重试等。
Hmily不需要事务协调服务,但需要提供一个数据库(mysql/mongodb/zookeeper/redis/file)来进行日志存储。
Hmily实现的TCC服务与普通的服务一样,只需要暴露一个接口,也就是它的Try业务。Confirm/Cancel业务 逻辑,只是因为全局事务提交/回滚的需要才提供的,因此Confirm/Cancel业务只需要被Hmily TCC事务框架发现即可,不需要被调用它的其他业务服务所感知。
官网介绍:
https://dromara.org/website/zh-cn/docs/hmily/index.html
TCC需要注意三种异常处理分别是空回滚、幂等、悬挂:
空回滚:(针对cancel方法)
在没有调用 TCC 资源 Try 方法的情况下,调用了二阶段的 Cancel 方法,Cancel 方法需要识别出这是一个空回滚,然后直接返回成功。
出现原因是当一个分支事务所在服务宕机或网络异常,分支事务调用记录为失败,这个时候其实是没有执行Try阶 段,当故障恢复后,分布式事务进行回滚则会调用二阶段的Cancel方法,从而形成空回滚。
解决思路是关键就是要识别出这个空回滚。思路很简单就是需要知道一阶段是否执行,如果执行了,那就是正常回 滚;如果没执行,那就是空回滚。前面已经说过TM在发起全局事务时生成全局事务记录,全局事务ID贯穿整个分 布式事务调用链条。再额外增加一张分支事务记录表,其中有全局事务 ID 和分支事务 ID,第一阶段 Try 方法里会插入一条记录,表示一阶段执行了。Cancel 接口里读取该记录,如果该记录存在,则正常回滚;如果该记录不存在,则是空回滚。
幂等:(try,confirm和cancel方法重试,最终结果一致)
通过前面介绍已经了解到,为了保证TCC二阶段提交重试机制不会引发数据不一致,要求 TCC 的二阶段 Try、
Confirm 和 Cancel 接口保证幂等,这样不会重复使用或者释放资源。如果幂等控制没有做好,很有可能导致数据不一致等严重问题。
解决思路在上述“分支事务记录”中增加执行状态,每次执行前都查询该状态。悬挂:(针对try方法)
悬挂就是对于一个分布式事务,其二阶段 Cancel 接口比 Try 接口先执行。
出现原因是在 RPC 调用分支事务try时,先注册分支事务,再执行RPC调用,如果此时 RPC 调用的网络发生拥堵, 通常 RPC 调用是有超时时间的,RPC 超时以后,TM就会通知RM回滚该分布式事务,可能回滚完成后,RPC 请求才到达参与者真正执行,而一个 Try 方法预留的业务资源,只有该分布式事务才能使用,该分布式事务第一阶段预留的业务资源就再也没有人能够处理了,对于这种情况,我们就称为悬挂,即业务资源预留后没法继续处理。
解决思路是如果二阶段执行完成,那一阶段就不能再继续执行。在执行一阶段事务时判断在该全局事务下,“分支 事务记录”表中是否已经有二阶段事务记录,如果有则不执行Try。
事务发起方事务处理逻辑:
try:
try幂等校验;
try悬挂处理
检查余额是够扣减金额扣减金额
confirm:
空
cancel:
cancel幂等校验
cancel空回滚处理增加可用余额;
事务接受方:
try:
空
confirm:
confirm幂等校验正式增加金额
cancel:
空
小结
如果拿TCC事务的处理流程与2PC两阶段提交做比较,2PC通常都是在跨库的DB层面,而TCC则在应用层面的处理,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据操作的粒度,使得降低锁冲突、提高吞吐量成为可能。
而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。此 外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。
可靠消息最终一致性
概念:
可靠消息最终一致性方案是指当事务发起方执行完成本地事务后并发出一条消息,事务参与方(消息消费者)一定能 够接收消息并处理事务成功,此方案强调的是只要消息发给事务参与方最终事务要达到一致。
此方案是利用消息中间件完成,如下图:
事务发起方(消息生产方)将消息发给消息中间件,事务参与方从消息中间件接收消息,事务发起方和消息中间件 之间,事务参与方(消息消费方)和消息中间件之间都是通过网络通信,由于网络通信的不确定性会导致分布式事 务问题。
因此可靠消息最终一致性方案要解决以下几个问题:
1.本地事务与消息发送的原子性问题
本地事务与消息发送的原子性问题即:事务发起方在本地事务执行成功后消息必须发出去,否则就丢弃消息。即实 现本地事务和消息发送的原子性,要么都成功,要么都失败。本地事务与消息发送的原子性问题是实现可靠消息最 终一致性方案的关键问题。
先来尝试下这种操作,先发送消息,再操作数据库:
begin transaction;
commit transation;
这种情况下无法保证数据库操作与发送消息的一致性,因为可能发送消息成功,数据库操作失败。 你立马想到第二种方案,先进行数据库操作,再发送消息:
begin transaction;
commit transation;
这种情况下貌似没有问题,如果发送MQ消息失败,就会抛出异常,导致数据库事务回滚。但如果是超时异常,数 据库回滚,但MQ其实已经正常发送了,同样会导致不一致。
2、事务参与方接收消息的可靠性
事务参与方必须能够从消息队列接收到消息,如果接收消息失败可以重复接收消息。
3、消息重复消费的问题
由于网络2的存在,若某一个消费节点超时但是消费成功,此时消息中间件会重复投递此消息,就导致了消息的重 复消费。
要解决消息重复消费的问题就要实现事务参与方的方法幂等性。
解决方案
1.本地消息方案(非rocketmq的解决方案)
本地消息表这个方案最初是eBay提出的,此方案的核心是通过本地事务保证数据业务操作和消息的一致性,然后 通过定时任务将消息发送至消息中间件,待确认消息发送给消费方成功再将消息删除。
这里需要本地多添加一张本地消息日志表,定时任务来扫描日志表,并确认消息确认机制;
2.RocketMQ方案
执行流程如下:
为方便理解我们还以注册送积分的例子来描述 整个流程。
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。
注:
ACK机制是消费者从RabbitMQ收到消息并处理完成后,反馈给RabbitMQ,RabbitMQ收到反馈后才将此消息从队列中删除。
如果一个消费者在处理消息出现了网络不稳定、服务器异常等现象,那么就不会有ACK反馈,RabbitMQ会认为这个消息没有正常消费,会将消息重新放入队列中。
如果在集群的情况下,RabbitMQ会立即将这个消息推送给这个在线的其他消费者。这种机制保证了在消费者服务端故障的时候,不丢失任何消息和任务。
消息永远不会从RabbitMQ中删除,只有当消费者正确发送ACK反馈,RabbitMQ确认收到后,消息才会从RabbitMQ服务器的数据中删除。
消息的ACK确认机制默认是打开的。
- 事务回查
如果执行Producer端本地事务过程中,执行端挂掉,或者超时,MQ Server将会不停的询问同组的其他 Producer
来获取事务执行状态,这个过程叫事务回查。MQ Server会根据事务回查结果来决定是否投递消息。
以上主干流程已由RocketMQ实现,对用户侧来说,用户需要分别实现本地事务执行以及本地事务回查方法,因此 只需关注本地事务的执行状态即可。
方案实例:
producer端
// 发送事务消息
//param1 生产组
//param2 topic主题
//param3 消息
//param4 参数
TransactionSendResult sendResult =
rocketMQTemplate.sendMessageInTransaction("producer_group_txmsg_bank1", "topic_txmsg", message, null);
// 执行本地事务
// 幂等性判断
accountInfoDao.checkTx(accountChangeEvent.getTxNo());
accountInfoDao.updateAccountBalance(accountChangeEvent.getAccountNo(),accountChangeEvent.getAmount() * ‐1);
// 调价事务日志
accountInfoDao.addTx(accountChangeEvent.getTxNo());
RocketMQLocalTransactionListener
编写RocketMQLocalTransactionListener接口实现类,实现执行本地事务和事务回查两个方法。
// 发送消息时的生产组 producerGroup
@RocketMQTransactionListener(txProducerGroup = "producer_group_txmsg_bank1")
public class ProducerTxmsgListener implements RocketMQLocalTransactionListener {
@Autowired
AccountInfoService accountInfoService;
@Autowired
AccountInfoDao accountInfoDao;
//消息发送成功回调此方法,此方法执行本地事务
@Override
@Transactional
public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object arg) {
//解析消息内容
try {
String jsonString = new String((byte[]) message.getPayload()); JSONObject jsonObject = JSONObject.parseObject(jsonString); AccountChangeEvent accountChangeEvent =
JSONObject.parseObject(jsonObject.getString("accountChange"), AccountChangeEvent.class);
//扣除金额
accountInfoService.doUpdateAccountBalance(accountChangeEvent);
return RocketMQLocalTransactionState.COMMIT;// 自动向mq发送commit消息,将消息标记为可消费
} catch (Exception e) {
log.error("executeLocalTransaction 事务执行失败",e);
e.printStackTrace();
return RocketMQLocalTransactionState.ROLLBACK;
}
}
//此方法检查事务执行状态
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message message) { RocketMQLocalTransactionState state;
final JSONObject jsonObject = JSON.parseObject(new String((byte[]) message.getPayload()));
AccountChangeEvent accountChangeEvent = JSONObject.parseObject(jsonObject.getString("accountChange"),AccountChangeEvent.class);
//事务id
String txNo = accountChangeEvent.getTxNo();
int isexistTx = accountInfoDao.isExistTx(txNo);
log.info("回查事务,事务号: {} 结果: {}", accountChangeEvent.getTxNo(),isexistTx);
if(isexistTx>0){
state= RocketMQLocalTransactionState.COMMIT;
}else{
state= RocketMQLocalTransactionState.UNKNOWN;
}
return state;
}
}
consumer端
@Transactional
public void addAccountInfoBalance(AccountChangeEvent accountChangeEvent) {
log.info("bank2更新本地账号,账号:{},金额:
{}",accountChangeEvent.getAccountNo(),accountChangeEvent.getAmount());
//幂等校验
int existTx = accountInfoDao.isExistTx(accountChangeEvent.getTxNo());
if(existTx<=0){
//执行更新
accountInfoDao.updateAccountBalance(accountChangeEvent.getAccountNo(),accountChangeEvent.getAmoun t());
//添加事务记录
accountInfoDao.addTx(accountChangeEvent.getTxNo());
log.info("更新本地事务执行成功,本次事务号: {}", accountChangeEvent.getTxNo());
}else{
log.info("更新本地事务执行失败,本次事务号: {}", accountChangeEvent.getTxNo());
}
}
mq监听
@Component
@RocketMQMessageListener(topic = "topic_txmsg",consumerGroup = "consumer_txmsg_group_bank2")
@Slf4j
public class TxmsgConsumer implements RocketMQListener<String> {
@Autowired
AccountInfoService accountInfoService;
@Override
public void onMessage(String s) {
log.info("开始消费消息:{}",s);
//解析消息为对象
final JSONObject jsonObject = JSON.parseObject(s); AccountChangeEvent accountChangeEvent =
JSONObject.parseObject(jsonObject.getString("accountChange"),AccountChangeEvent.class);
// 调 用 service 增 加 账 号 金 额
accountChangeEvent.setAccountNo("2");
// 不能直接调用dao ,service需要实现幂等性控制
accountInfoService.addAccountInfoBalance(accountChangeEvent);
}
}
最大努力通知
分布式的一种解决方案
目标:发起方通过一定机制通知目标接受方;
交互流程如下:
1、用户请求充值系统进行充值。
2、充值系统完成充值将充值结果发给MQ。
3、账户系统监听MQ,接收充值结果通知,如果接收不到消息,MQ会重复发送通知。接收到充值结果通知账户系 统增加充值金额。
4、账户系统也可以主动查询充值系统的充值结果查询接口,增加金额。
通过上边的例子我们总结最大努力通知方案的目标:
目标:发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。具体包括:
1、有一定的消息重复通知机制。
因为接收通知方可能没有接收到通知,此时要有一定的机制对消息重复通知。
2、消息校对机制。
如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息 信息来满足需求。
最大努力通知与可靠消息一致性有什么不同?
1、解决方案思想不同
可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知 方来保证。
最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接 收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。
2、两者的业务应用场景不同
可靠消息一致性关注的是交易过程的事务一致,以异步的方式完成交易。 最大努力通知关注的是交易后的通知事务,即将交易结果可靠的通知出去。
3、技术解决方向不同
可靠消息一致性要解决消息从发出到接收的一致性,即消息发出并且被接收到。
最大努力通知无法保证消息从发出到接收的一致性,只提供消息接收的可靠性机制。可靠机制是,最大努力的将消 息通知给接收方,当消息无法被接收方接收时,由接收方主动查询消息(业务处理结果)。
解决方案
方案1:
最大努力通知主要ack机制执行实现;(消息确认机制)
本方案是利用MQ的ack机制由MQ向接收通知方发送通知,流程如下:
1、发起通知方将通知发给MQ。
使用普通消息机制将通知发给MQ。
注意:如果消息没有发出去可由接收通知方主动请求发起通知方查询业务执行结果。(后边会讲)
2、接收通知方监听 MQ。
3、接收通知方接收消息,业务处理完成回应ack。
4、接收通知方若没有回应ack则MQ会重复通知。
MQ会按照间隔1min、5min、10min、30min、1h、2h、5h、10h的方式,逐步拉大通知间隔 (如果MQ采用
rocketMq,在broker中可进行配置),直到达到通知要求的时间窗口上限。
5、接收通知方可通过消息校对接口来校对消息的一致性。
方案二:(调用外部系统,比如对接微信支付)
接受方不直接监听MQ,通过通知程序监听MQ;
交互流程如下:
1、发起通知方将通知发给MQ。
使用可靠消息一致方案中的事务消息保证本地事务与消息的原子性,最终将通知先发给MQ。
2、通知程序监听 MQ,接收MQ的消息。
方案1中接收通知方直接监听MQ,方案2中由通知程序监听MQ。
通知程序若没有回应ack则MQ会重复通知。
3、通知程序通过互联网接口协议(如http、webservice)调用接收通知方案接口,完成通知。
通知程序调用接收通知方案接口成功就表示通知成功,即消费MQ消息成功,MQ将不再向通知程序投递通知消 息。
4、接收通知方可通过消息校对接口来校对消息的一致性。
方案1和方案2的不同点:
1、方案1中接收通知方与MQ接口,即接收通知方案监听 MQ,此方案主要应用与内部应用之间的通知。
2、方案2中由通知程序与MQ接口,通知程序监听MQ,收到MQ的消息后由通知程序通过互联网接口协议调用接收 通知方。此方案主要应用于外部应用之间的通知,例如支付宝、微信的支付结果通知
MQ实现最大努力通知实践
1、用户请求充值系统进行充值。
2、充值系统完成充值将充值结果发给MQ。
3、账户系统监听MQ,接收充值结果通知,如果接收不到消息,MQ会重复发送通知。接收到充值结果通知账户系 统增加充值金额。
4、账户系统也可以主动查询充值系统的充值结果查询接口,增加金额。
rocketmq.producer.group = producer_bank2// 程序启动时mq初始化,和程序中的group不同
rocketmq.name‐server = 127.0.0.1:9876
pay充值微服务
@Transactional
@Override
public AccountPay insertAccountPay(AccountPay accountPay) {
// 先进行事务执行
int result = accountPayDao.insertAccountPay(accountPay.getId(), accountPay.getAccountNo(), accountPay.getPayAmount(), "success");
if(result>0){
//发送通知
rocketMQTemplate.convertAndSend("topic_notifymsg",accountPay);
return accountPay;
}
return null;
}
// 接受方会主动查询充值记录
public AccountPay getAccountPay(String txNo) {
AccountPay accountPay = accountPayDao.findByIdTxNo(txNo);
}
account端(接收端)
@Transactional
@Override
public void updateAccountBalance(AccountChangeEvent accountChange) {
//幂等校验
int existTx = accountInfoDao.isExistTx(accountChange.getTxNo());
if(existTx >0){
log.info("已处理消息:{}", JSONObject.toJSONString(accountChange));
return ;
}
//添加事务记
accountInfoDao.addTx(accountChange.getTxNo());
//更新账户金额
accountInfoDao.updateAccountBalance(accountChange.getAccountNo(),accountChange.getAmount());
}
// 调用方主动查询结果
public AccountPay queryPayResult(String tx_no) {
//主动请求充值系统查询充值结果
AccountPay accountPay = payClient.queryPayResult(tx_no);
//充值结果
String result = accountPay.getResult();
log.info("主动查询充值结果:{}", JSON.toJSONString(accountPay)); if("success".equals(result)){
AccountChangeEvent accountChangeEvent = new AccountChangeEvent();
accountChangeEvent.setAccountNo(accountPay.getAccountNo());
accountChangeEvent.setAmount(accountPay.getPayAmount()); accountChangeEvent.setTxNo(accountPay.getId()); updateAccountBalance(accountChangeEvent);
}
return accountPay;
}
mq监听
@Component
@Slf4j
@RocketMQMessageListener(topic="topic_notifymsg",consumerGroup="consumer_group_notifymsg_bank1") public class NotifyMsgListener implements RocketMQListener<AccountPay> {
@Autowired
AccountInfoService accountInfoService;
@Override
public void onMessage(AccountPay accountPay) {
log.info(" 接 收 到 消 息 :{}", JSON.toJSONString(accountPay)); AccountChangeEvent accountChangeEvent = new AccountChangeEvent(); accountChangeEvent.setAmount(accountPay.getPayAmount()); accountChangeEvent.setAccountNo(accountPay.getAccountNo()); accountChangeEvent.setTxNo(accountPay.getId()); accountInfoService.updateAccountBalance(accountChangeEvent);
log.info("处理消息完成:{}", JSON.toJSONString(accountChangeEvent));
}
}
分布式事务综合案例分析
项目p2p
项目架构:
模块说明
统一账号服务
用户的登录账号、密码、角色、权限、资源等系统级信息的管理,不包含用户业务信息。
用户中心
提供用户业务信息的管理,如会员信息、实名认证信息、绑定银行卡信息等,“用户中心”的每个用户与“统一账号服 务”中的账号关联。
交易中心
提供发标、投标等业务。
还款服务
提供还款计划的生成、执行、记录与归档。
账号服务
分析
针对注册业务,如果用户与账号信息不一致,则会导致严重问题,因此该业务对一致性要求较为严格,即当用户服 务和账号服务任意一方出现问题都需要回滚事务
方案说明
1、采用可靠消息一致性方案
可靠消息一致性要求只要消息发出,事务参与者接到消息就要将事务执行成功,不存在回滚的要求,所以不适用
2、采用最大努力通知方案
最大努力通知表示发起通知方执行完本地事务后将结果通知给事务参与者,即使事务参与者执行业务处理失败发起 通知方也不会回滚事务,所以不适用
3、采用Seata实现2PC
在用户中心发起全局事务,统一账户服务为事务参与者,用户中心和统一账户服务只要有一方出现问题则全局事务 回滚,符合要求。
实现方法如下:
1、用户中心添加用户信息,开启全局事务
2、统一账号服务添加账号信息,作为事务参与者
3、其中一方执行失败Seata对SQL进行逆操作删除用户信息和账号信息,实现回滚。
4、采用Hmily实现TCC
TCC也可以实现用户中心和统一账户服务只要有一方出现问题则全局事务回滚,符合要求。 实现方法如下: 1 、 用 户 中 心
try:添加用户,状态为不可用
confirm:更新用户状态为可用
cancel:删除用户
2 、 统 一 账 号 服 务
try:添加账号,状态为不可用
confirm:更新账号状态为可用
cancel:删除账号
存管开户
分析
根据政策要求,P2P业务必须让银行存管资金,用户的资金在银行存管系统的账户中,而不在P2P平台中,因此用 户要在银行存管系统开户。
用户向用户中心提交开户资料,用户中心生成开户请求号并重定向至银行存管系统开户页面。用户设置存管密码并 确认开户后,银行存管立即返回“请求已受理”。在某一时刻,银行存管系统处理完该开户请求后,将调用回调地址 通知处理结果,若通知失败,则按一定策略重试通知。同时,银行存管系统应提供开户结果查询的接口,供用户中 心校对结果;
解决方案分析:
P2P平台的用户中心与银行存管系统之间属于跨系统交互,银行存管系统属于外部系统,用户中心无法干预银行存 管系统,所以用户中心只能在收到银行存管系统的业务处理结果通知后积极处理,开户后的使用情况完全由用户中 心来控制。
根据上述需求进行解决方案分析:
1、采用Seata实现2PC
需要侵入银行存管系统的数据库,由于它的外部系统,所以不适用。
2、采用Hmily实现TCC TCC侵入性更强,所以不适用。
3、基于MQ的可靠消息一致性
如果让银行存管系统监听 MQ则不合适 ,因为它的外部系统。
如果银行存管系统将消息发给MQ用户中心监听MQ是可以的,但是由于相对银行存管系统来说用户中心属于外部 系统,银行存管系统是不会让外部系统直接监听自己的MQ的,基于MQ的通信协议也不方便外部系统间的交互, 所以本方案不合适。
4、最大努力通知方案
银行存管系统内部使用MQ,银行存管系统处理完业务后将处理结果发给MQ,由银行存管的通知程序专门发送通 知,并且采用互联网协议通知给第三方系统(用户中心)。
满标审核
分析
在借款人标的募集够所有的资金后,P2P运营管理员审批该标的,触发放款,并开启还款流程。
1、采用Seata实现2PC
Seata在事务执行过程会进行数据库资源锁定,由于事务执行时长较长会将资源锁定较长时间,所以不适用。 2、采用Hmily实现TCC
本需求对业务一致性要求较低,因为生成还款计划的时长较长,所以不要求交易中心修改标的状态为“还款中”就立 即生成还款计划 ,所以本方案不适用。
3、基于MQ的可靠消息一致性
满标审批通过后由交易中心修改标的状态为“还款中”并且向还款服务发送消息,还款服务接收到消息开始生成还款 计划,基本于MQ的可靠消息一致性方案适用此场景 。
4、最大努力通知方案
满标审批通过后由交易中心向还款服务发送通知要求生成还款计划,还款服务并且对外提供还款计划生成结果校对 接口供其它服务查询,最大努力 通知方案也适用本场景
总结
事务的基本概念以及本地事务特性。 CAP、BASE理论的概念。 2PC、TCC、可靠消息最终一致性、最大努力通知各类型原理及特性。不同分布式事务类型的应用场景讨论。
RocketMQ事务消息机制。Seata与传统XA原理上的差异。分布式事务对比分析****:
在学习各种分布式事务的解决方案后,我们了解到各种方案的优缺点:
2PC 最大的诟病是一个阻塞协议。RM在执行分支事务后需要等待TM的决定,此时服务会阻塞并锁定资源。由于其阻塞机制和最差时间复杂度高, 因此,这种设计不能适应随着事务涉及的服务数量增加而扩展的需要,很难用于并发较高以及子事务生命周期较长 (long-running transactions) 的分布式服务中。
如果拿TCC事务的处理流程与2PC两阶段提交做比较,2PC通常都是在跨库的DB层面,而TCC则在应用层面的处 理,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据操作的粒度,使 得降低锁冲突、提高吞吐量成为可能。而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现
try、confirm、cancel三个操作。此外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实 现不同的回滚策略。典型的使用场景:满,登录送优惠券等。
可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景。引入消息机制后,同步的事务操作变为基于消 息执行的异步操作, 避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。典型的使用场景:注册送积分,登录送优惠券等。
最大努力通知是分布式事务中要求最低的一种,适用于一些最终一致性时间敏感度低的业务;允许发起通知方处理业 务失败,在接收通知方收到通知后积极进行失败处理,无论发起通知方如何处理结果都会不影响到接收通知方的后 续处理;发起通知方需提供查询执行情况接口,用于接收通知方校对结果。典型的使用场景:银行通知、支付结果 通知等。
2PC | TCC | 可靠消息 | 最大努力通知 | |
---|---|---|---|---|
一致性 | 强一致性 | 最终一致 | 最终一致 | 最终一致 |
吞吐量 | 低 | 中 | 高 | 高 |
实现复杂度 | 易 | 难 | 中 | 易 |
在条件允许的情况下,我们尽可能选择本地事务单数据源,因为它减少了网络交互带来的性能损耗,且避免了数据 弱一致性带来的种种问题。若某系统频繁且不合理的使用分布式事务,应首先从整体设计角度观察服务的拆分是否 合理,是否高内聚低耦合?是否粒度太小?分布式事务一直是业界难题,因为网络的不确定性,而且我们习惯于拿 分布式事务与单机事务ACID做对比。
无论是数据库层的XA、还是应用层TCC、可靠消息、最大努力通知等方案,都没有完美解决分布式事务问题,它们 不过是各自在性能、一致性、可用性等方面做取舍,寻求某些场景偏好下的权衡。