本文由 简悦 SimpRead 转码, 原文地址 blog.csdn.net
分布式事务专题
资料及代码地址:https://gitee.com/gjhpub/seata-demo
在视频评论区也有 https://www.bilibili.com/video/BV1FJ411A7mV
学习要点:
- 四种事务:2PC、TCC、可靠消息、尽最大努力通知
- 事务 undo_log、全局事务 ID、幂等性、MQ 半消息
一、基础概念
1.1. 什么是事务
什么是事务?举个生活中的例子:你去小卖铺买东西,“一手交钱,一手交货” 就是一个事务的例子,交钱和交货必须全部成功,事务才算成功,任一个活动失败,事务将撤销所有已成功的活动。
明白上述例子,再来看事务的定义:
事务可以看做是一次大的活动,它由不同的小活动组成,这些活动要么全部成功,要么全部失败。
1.2. 本地事务
在计算机系统中,更多的是通过关系型数据库来控制事务,这是利用数据库本身的事务特性来实现的,因此叫数据库事务,由于应用主要靠关系数据库来控制事务,而数据库通常和应用在同一个服务器,所以基于关系型数据库的事务又被称为本地事务。
回顾一下数据库事务的四大特性 ACID:
- A(Atomic):原子性,构成事务的所有操作,要么都执行完成,要么全部不执行,不可能出现部分成功部分失败的情况。
- C(Consistency):一致性,在事务执行前后,数据库的一致性约束没有被破坏。比如:张三向李四转 100 元,转账前和转账后的数据是正确状态这叫一致性,如果出现张三转出 100 元,李四账户没有增加 100 元这就出现了数据错误,就没有达到一致性。
- I(Isolation):隔离性,数据库中的事务一般都是并发的,隔离性是指并发的两个事务的执行互不干扰,一个事务不能看到其他事务运行过程的中间状态。通过配置事务隔离级别可以避脏读、重复读等问题。
- D(Durability):持久性,事务完成之后,该事务对数据的更改会被持久化到数据库,且不会被回滚。
数据库事务在实现时会将一次事务涉及的所有操作全部纳入到一个不可分割的执行单元,该执行单元中的所有操作要么都成功,要么都失败,只要其中任一操作执行失败,都将导致整个事务的回滚
1.3. 分布式事务
随着互联网的快速发展,软件系统由原来的单体应用转变为分布式应用
分布式系统会把一个应用系统拆分为可独立部署的多个服务,因此需要服务与服务之间远程协作才能完成事务操作,这种分布式系统环境下由不同的服务之间通过网络远程协作完成事务称之为分布式事务,例如用户注册送积分事务、创建订单减库存事务,银行转账事务等都是分布式事务。
我们知道本地事务依赖数据库本身提供的事务特性来实现,因此以下逻辑可以控制本地事务:
begin transation;
// 1.本地数据库操作:张三减少金额
// 2.本地数据库操作:李四增加金额
commit transation;
但是在分布式环境下,会变成下边这样:
begin transation;
// 1.本地数据库操作:张三减少金额
// 2.远程调用:李四增加金额
commit transation;
可以设想,当远程调用让李四增加金额成功了,由于网络问题远程调用并没有返回,此时本地事务提交失败就回滚了张三减少金额的操作,此时张三和李四的数据就不一致了。
因此在分布式架构的基础上,传统数据库事务就无法使用了,张三和李四的账户不在一个数据库中甚至不在一个应用系统里,实现转账事务需要通过远程调用,由于网络问题就会导致分布式事务问题。
1.4 分布式事务产生的场景
1、典型的场景就是微服务架构 微服务之间通过远程调用完成事务操作。 比如:订单微服务和库存微服务,下单的同时订单微服务请求库存微服务减库存。 简言之:跨 JVM 进程产生分布式事务。
2、单体系统访问多个数据库实例 当单体系统需要访问多个数据库(实例)时就会产生分布式事务。 比如:用户信息和订单信息分别在两个 MySQL 实例存储,用户管理系统删除用户信息,需要分别删除用户信息及用户的订单信息,由于数据分布在不同的数据实例,需要通过不同的数据库链接去操作数据,此时产生分布式事务。 简言之:跨数据库实例产生分布式事务。
3、多服务访问同一个数据库实例 比如:订单微服务和库存微服务即使访问同一个数据库也会产生分布式事务,原因就是跨 JVM 进程,两个微服务持有了不同的数据库链接进行数据库操作,此时产生分布式事务。
2. 分布式事务基础理论
通过前面的学习,我们了解到了分布式事务的基础概念。与本地事务不同的是,分布式系统之所以叫分布式,是因为提供服务的各个节点分布在不同机器上,相互之间通过网络交互。不能因为有一点网络问题就导致整个系统无法提供服务,网络因素成为了分布式事务的考量标准之一。因此,分布式事务需要更进一步的理论支持,接下来,我们先来学习一下分布式事务的 CAP 理论。
在讲解分布式事务控制解决方案之前需要先学习一些基础理论,通过理论知识指导我们确定分布式事务控制的目标,从而帮助我们理解每个解决方案。
2.1. CAP 理论
2.1.1. 理解 CAP
CAP 是 Consistency、Availability、Partition tolerance 三个词语的缩写,分别表示一致性、可用性、分区容忍性。
- 一致性(consistency)
在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本) - 可用性(Availability)
在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性) - 分区容惜性(Partitiontolerance)
- 大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区 (partition)。
分区容错的意思是,区间通信可能失败。比如,一台服务器放在中国,另一台服务器放在美国,这就是两个区,它们之间可能无法通信。
- 大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区 (partition)。
CAP 原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾
- CP 要求一致性(有一个没同步好就不可用)
- AP 要求高可用
下边我们分别来解释:
为了方便对 CAP 理论的理解,我们结合电商系统中的一些业务场景来理解 CAP。如下图,是商品信息管理的执行流程:
整体执行流程如下:
1、商品服务请求主数据库写入商品信息(添加商品、修改商品、删除商品)
2、主数据库向商品服务响应写入成功。
3、商品服务请求从数据库读取商品信息。
C - Consistency:
一致性是指写操作后的读操作可以读取到最新的数据状态,当数据分布在多个节点上,从任意结点读取到的数据都是最新的状态。
上图中,商品信息的读写要满足一致性就是要实现如下目标:
1、商品服务写入主数据库成功,则向从数据库查询新数据也成功。
2、商品服务写入主数据库失败,则向从数据库查询新数据也失败。如何实现一致性?
1、写入主数据库后要将数据同步到从数据库。
2、写入主数据库后,在向从数据库同步期间要将从数据库锁定,待同步完成后再释放锁,以免在新数据写入成功后,向从数据库查询到旧的数据。
分布式系统一致性的特点:
1、由于存在数据同步的过程,写操作的响应会有一定的延迟。
2、为了保证数据一致性会对资源暂时锁定,待数据同步完成释放锁定资源。
3、如果请求数据同步失败的结点则会返回错误信息,一定不会返回旧数据。
A - Availability :
可用性是指任何事务操作都可以得到响应结果,且不会出现响应超时或响应错误。上图中,商品信息读取满足可用性就是要实现如下目标:
1、从数据库接收到数据查询的请求则立即能够响应数据查询结果。
2、从数据库不允许出现响应超时或响应错误。如何实现可用性?
1、写入主数据库后要将数据同步到从数据库。
2、由于要保证从数据库的可用性,不可将从数据库中的资源进行锁定。
3、即时数据还没有同步过来,从数据库也要返回要查询的数据,哪怕是旧数据,如果连旧数据也没有则可以按照约定返回一个默认信息,但不能返回错误或响应超时。
分布式系统可用性的特点:
1、 所有请求都有响应,且不会出现响应超时或响应错误。
P - Partition tolerance :
通常分布式系统的各各结点部署在不同的子网,这就是网络分区,不可避免的会出现由于网络问题而导致结点之间通信失败,此时仍可对外提供服务,这叫分区容忍性。
上图中,商品信息读写满足分区容忍性就是要实现如下目标:
1、主数据库向从数据库同步数据失败不影响读写操作。
2、其一个结点挂掉不影响另一个结点对外提供服务。如何实现分区容忍性?
1、尽量使用异步取代同步操作,例如使用异步方式将数据从主数据库同步到从数据,这样结点之间能有效的实现松耦合。
2、添加从数据库结点,其中一个从结点挂掉其它从结点提供服务。分布式分区容忍性的特点:
1、分区容忍性分是布式系统具备的基本能力。
2.1.2 CAP 组合方式
1、上边商品管理的例子是否同时具备 CAP 呢?
在所有分布式事务场景中不会同时具备 CAP 三个特性,因为在具备了 P 的前提下 C 和 A 是不能共存的。比如:
下图满足了 P 即表示实现分区容忍:
本图分区容忍的含义是:
1)主数据库通过网络向从数据同步数据,可以认为主从数据库部署在不同的分区,通过网络进行交互。
2)当主数据库和从数据库之间的网络出现问题不影响主数据库和从数据库对外提供服务。
3)其一个结点挂掉不影响另一个结点对外提供服务。
如果要实现 C 则必须保证数据一致性,在数据同步的时候为防止向从数据库查询不一致的数据则需要将从数据库数据锁定,待同步完成后解锁,如果同步失败从数据库要返回错误信息或超时信息。
如果要实现 A 则必须保证数据可用性,不管任何时候都可以向从数据查询数据,则不会响应超时或返回错误信息。通过分析发现在满足 P 的前提下 C 和 A 存在矛盾性。
2、CAP 有哪些组合方式呢?
所以在生产中对分布式事务处理时要根据需求来确定满足 CAP 的哪两个方面。
1) AP:
放弃一致性,追求分区容忍性和可用性。这是很多分布式系统设计时的选择。例如:
上边的商品管理,完全可以实现 AP,前提是只要用户可以接受所查询的到数据在一定时间内不是最新的即可。
通常实现 AP 都会保证最终一致性,后面讲的 BASE 理论就是根据 AP 来扩展的,一些业务场景 比如:订单退款,今日退款成功,明日账户到账,只要用户可以接受在一定时间内到账即可。
2) CP:
放弃可用性,追求一致性和分区容错性,我们的 zookeeper 其实就是追求的强一致,又比如跨行转账,一次转账请求要等待双方银行系统都完成整个事务才算完成。
3) CA:
放弃分区容忍性,即不进行分区,不考虑由于网络不通或结点挂掉的问题,则可以实现一致性和可用性。那么系统将不是一个标准的分布式系统,我们最常用的关系型数据就满足了 CA。
主数据库和从数据库中间不再进行数据同步,数据库可以响应每次的查询请求,通过事务隔离级别实现每个查询请求都可以返回最新的数据。
2.1.3 总结
通过上面我们已经学习了 CAP 理论的相关知识,CAP 是一个已经被证实的理论:一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)这三项中的两项。它可以作为我们进行架构设计、技术选型的考量标准。对于多数大型互联网应用的场景,结点众多、部署分散,而且现在的集群规模越来越大,所以节点故障、网络故障是常态,而且要保证服务可用性达到 N 个 9(99.99…%),并要达到良好的响应性能来提高用户体验,因此一般都会做出如下选择:保证 P 和 A,舍弃 C 强一致,保证最终一致性。
2.2. BASE 理论
1、理解强一致性和最终一致性
CAP 理论告诉我们一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)这三项中的两项,其中 AP 在实际应用中较多,AP 即舍弃一致性,保证可用性和分区容忍性,但是在实际生产中很多场景都要实现一致性,比如前边我们举的例子主数据库向从数据库同步数据,即使不要一致性,但是最终也要将数据同步成功来保证数据一致,这种一致性和 CAP 中的一致性不同,CAP 中的一致性要求在任何时间查询每个结点数据都必须一致,它强调的是强一致性,但是最终一致性是允许可以在一段时间内每个结点的数据不一致,但是经过一段时间每个结点的数据必须一致,它强调的是最终数据的一致性。
2、Base 理论介绍
BASE 理论是对 CAP 中 AP 的一个扩展,通过牺牲强一致性来获得可用性,当出现故障允许部分不可用但要保证核心功能可用,允许数据在一段时间内是不一致的,但最终一致性
。满足 BASE 理论的事务,我们称之为 “柔性事务”。
BASE 是指:
- 基本可用(
B
asicallyA
vailable)
基本可用是指分布式系统在出现故障的时候,允许损失部分可用性(例如响应时间、功能上的可用性),允许损失部分可用性。需要注意的是,基本可用绝不等价于系统不可用。- 响应时间上的损失:正常情况下搜索引擎需要在 0.5 秒之内返回给用户相应的查询结果,但由于出现故障(比如系统部分机房发生断电或断网故障),查询结果的响应时间增加到了 1 ~ 2 秒。
- 功能上的损失:购物网站在购物高峰(如双十一)时,为了保护系统的稳定性,部分消费者可能会被引导到一个降级页面。
- 软状态(
S
oft state)- 软状态是指允许系统存在中间状态,而该中间状态不会影响系统整体可用性。分布式存储中一般一份数据会有多个副本,允许不同副本同步的延时就是软状态的体现。mysql replication 的异步复制也是一种体现。
- 最终一致性(
E
ventual Consistency)- 最终一致性是指系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。弱一致性和强一致性相反,最终一致性是弱一致性的一种特殊情况。(这也是分布式事务的想法)
从客户端角度,多进程并发访同时,更新过的数据在不同程如何获的不同策珞,决定了不同的一致性。
- 对于关系型要求更新过据能后续的访同都能看到,这是强一致性。
- 如果能容忍后经部分过者全部访问不到,则是弱一致性
- 如果经过一段时间后要求能访问到更新后的数据,则是最终一致性
三、解决方案之 2PC
(两阶段提交)
前面已经学习了分布式事务的基础理论,以理论为基础,针对不同的分布式场景业界常见的解决方案有 2PC、TCC、可靠消息最终一致性、最大努力通知这几种。
3.1. 什么是 2PC
2PC 即两阶段提交协议,是将整个事务流程分为两个阶段,准备阶段(Prepare phase)、提交阶段(commit phase),2 是指两个阶段,P 是指准备阶段,C 是指提交阶段。
注意这只是协议或者说是理论指导,只阐述了大方向,具体落地还是有会有差异的。
数据库支持的
2pc
【2 二阶段提交】,又叫做XA Transactions
2PC(Two-phase commit protocol),中文叫二阶段提交。 二阶段提交是一种强一致性设计,2PC 引入一个事务协调者的角色来协调管理各参与者(也可称之为各本地资源)的提交和回滚,二阶段分别指的是准备(投票)和提交两个阶段。
在计算机中部分关系数据库如 Oracle、MySQL 支持两阶段提交协议,如下图:
-
\1. 准备阶段(Prepare phase):事务管理器给每个参与者发送 Prepare 消息,每个数据库参与者在本地执行事务,并写本地的 Undo/Redo 日志,此时事务没有提交。
(Undo 日志是记录修改前的数据,用于数据库回滚,Redo 日志是记录修改后的数据,用于提交事务后写入数据文件)
-
\2. 提交阶段(commit phase):如果事务管理器收到了参与者的执行失败或者超时消息时,直接给每个参与者发送回滚 (Rollback) 消息;否则,发送提交 (Commit) 消息;参与者根据事务管理器的指令执行提交或者回滚操作,并释放事务处理过程中使用的锁资源。注意: 必须在最后阶段释放锁资源。
如果任一资源管理器在第一阶段返回准备失败,那么事务管理器会要求所有资源管理器在第二阶段执行回滚操作。通过事务管理器的两阶段协调,最终所有资源管理器要么全部提交,要么全部回滚,最终状态都是一致的
处理步骤:
- (1)投票阶段(voting phase):协调者将通知事务参与者准备提交或取消事务,然后进入表决过程。
- 参与者将告知协调者自己的决策:同意(事务参与者本地事务执行成功,但未提交)或取消(本地事务执行故障);
- (2)提交阶段(commit phase):收到参与者的通知后,协调者再向参与者发出通知,根据反馈情况决定各参与者是否要提交还是回滚;
两阶段提交(2PC
),对业务侵⼊很小,它最⼤的优势就是对使用方透明,用户可以像使⽤本地事务一样使用基于 XA 协议的分布式事务,能够严格保障事务 ACID 特性。
2PC 缺点:它是一个强一致性的同步阻塞协议,事务执行过程中需要将所需资源全部锁定,也就是俗称的 刚性事务
。所以它比较适⽤于执⾏时间确定的短事务,整体性能比较差。
像第一阶段协调者会等待所有参与者响应才会进行下一步操作,当然第一阶段的协调者有超时机制,假设因为网络原因没有收到某参与者的响应或某参与者挂了,那么超时后就会判断事务失败,向所有参与者发送回滚命令。
在第二阶段协调者的没法超时,因为按照我们上面分析只能不断重试!
一旦事务协调者宕机或者发生网络抖动,会让参与者一直处于锁定资源的状态或者只有一部分参与者提交成功,导致数据的不一致。因此,在高并发性能至上的场景中,基于 XA 协议的分布式事务并不是最佳选择。
举例:张三和李四聚餐,饭店要求先买单,才能出票。这时张三和李四 AA 制。只有张三和李四都付款,老板才能出票安排就餐。但由于张三和李四都是铁公鸡,形成了尴尬的一幕:
准备阶段:老板要求张三付款,张三付款。老板要求李四付款,李四付款。
提交阶段:老板出票,两人拿票纷纷落座就餐。
例子中形成了一个事务,若张三或李四其中一人拒绝付款,或钱不够,店老板都不会给出票,并且会把已收款退回。
整个事务过程由事务管理器和参与者组成,店老板就是事务管理器,张三、李四就是事务参与者,事务管理器负责决策整个分布式事务的提交和回滚,事务参与者负责自己本地事务的提交和回滚。
2PC 缺点?
- 同步阻塞问题。执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。(1pc 准备阶段,只执行 sql,而不提交,并且占用数据库连接资源)
- 单点故障。由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)
- 数据不一致。在二阶段提交的阶段二中,当协调者向参与者发送 commit 请求之后,发生了局部网络异常或者在发送 commit 请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了 commit 请求。而在这部分参与者接到 commit 请求之后就会执行 commit 操作。但是其他部分未接到 commit 请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据部一致性的现象。
- 二阶段无法解决的问题:协调者在发出 commit 消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。
3.2 . 解决方案
3.2.1 XA 方案
2PC 的传统方案是在数据库层面实现的,如 Oracle、MySQL 都支持 2PC 协议,为了统一标准减少行业内不必要的对接成本,需要制定标准化的处理模型及接口标准,国际开放标准组织 Open Group 定义了分布式事务处理模型 DTP(Distributed Transaction Processing Reference Model)。
为了让大家更明确 XA 方案的内容程,下面新用户注册送积分为例来说明:
执行流程如下:
- 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、资源锁需要等到两个阶段结束才释放,性能较差。
3.2.2 Seata-AT 方案
Seata 是由阿里中间件团队发起的开源项目 Fescar,后更名为 Seata,它是一个是开源的分布式事务框架。
传统 2PC 的问题在 Seata 中得到了解决,它通过对本地关系数据库的分支事务的协调来驱动完成全局事务,是工作在应用层的中间件。主要优点是性能较好,且不长时间占用连接资源,它以高效并且对业务 0 侵入的方式解决微服务场景下面临的分布式事务问题,它目前提供 AT 模式 (即 2PC) 及 TCC 模式的分布式事务解决方案。
Seata 的设计思想如下:
Seata 的设计目标其一是对业务无侵入,因此从业务无侵入的 2PC 方案着手,在传统 2PC 的基础上演进,并解决 2PC 方案面临的问题。
Seata 把一个分布式事务理解成一个包含了若干分支事务的全局事务。全局事务的职责是协调其下管辖的分支事务达成一致,要么一起成功提交,要么一起失败回滚。此外,通常分支事务本身就是一个关系数据库的本地事务
与 传统 2PC 的模型类似,Seata 定义了 3 个组件来协议分布式事务的处理过程:
- Transaction Coordinator (TC): 事务协调器,它是独立的中间件,需要独立部署运行,它维护全局事务的运行状态,接收 TM 指令发起全局事务的提交与回滚,负责与 RM 通信协调各各分支事务的提交或回滚。
- Transaction Manager ™: 事务管理器,TM 需要嵌入应用程序中工作,它负责开启一个全局事务,并最终向 TC 发起全局提交或全局回滚的指令。(jar 包)
- Resource Manager (RM): 控制分支事务,负责分支注册、状态汇报,并接收事务协调器 TC 的指令,驱动分支(本地)事务的提交和回滚。
事务协调者有几个相关 DB 表
- global_table:全局事务。每当有一个全局事务发起后,就会在该表中记录全局事务 ID
- branch_table:分支事务。记录每一个分支事务的 ID,分支事务操作的数据库等信息
- lock_table:全局锁
还拿新用户注册送积分举例 Seata 的分布式事务过程:
先注册再送积分,所以开启全局事务的一方是用户服务,所以 TM 在用户服务。谁发起,谁是 TM
一旦开始执行后,就生成一个全局事务和 2 个 branch 数据
具体的执行流程如下:
- \1. 用户服务的 TM 向 TC 申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的 XID。
- \2. 用户服务的 RM 向 TC 注册 分支事务,该分支事务在用户服务执行新增用户逻辑,并将其纳入 XID 对应全局事务的管辖。
- \3. 用户服务执行分支事务,向用户表插入一条记录。
- \4. 逻辑执行到远程调用积分服务时 (XID 在微服务调用链路的上下文中传播)。积分服务的 RM 向 TC 注册分支事务,该分支事务执行增加积分的逻辑,并将其纳入 XID 对应全局事务的管辖。
- \5. 积分服务执行分支事务,向积分记录表插入一条记录,执行完毕后,返回用户服务。
- \6. 用户服务分支事务执行完毕。
- \7. TM 向 TC 发起针对 XID 的全局提交或回滚决议。
- \8. TC 调度 XID 下管辖的全部分支事务完成提交或回滚请求。
Seata 实现 2PC 与传统 2PC 的差别:
架构层次方面,传统 2PC 方案的 RM 实际上是在数据库层,RM 本质上就是数据库自身,通过 XA 协议实现,而 Seata 的 RM 是以 jar 包的形式作为中间件层部署在应用程序这一侧的。
两阶段提交方面,传统 2PC 无论第二阶段的决议是 commit 还是 rollback,事务性资源的锁都要保持到阶段 2 完成才释放。而 Seata 的做法是在阶段 1 就将本地事务提交,这样就可以省去 Phase2 持锁的时间,整体提高效率。
写隔离
- 一阶段本地事务提交前,需要确保先拿到 全局锁 。
- 拿不到 全局锁 ,不能提交本地事务。
- 拿 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
以一个示例来说明:
两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。 tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁 。
tx1 二阶段全局提交,释放 全局锁 。tx2 拿到 全局锁 提交本地事务。
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。
此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。
因为整个过程 全局锁 在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。
读隔离
在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted) 。
如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。
SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。
出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。
3.3. seata 实现 2PC 事务
3.3.1. 业务说明
本示例通过 Seata 中间件实现分布式事务,模拟三个账户的转账交易过程。
两个账户在三个不同的银行 (张三在 bank1、李四在 bank2),bank1 和 bank2 是两个个微服务。交易过程是,张三给李四转账指定金额。
张三 ---转账100-->李四
上述交易步骤,要么一起成功,要么一起失败,必须是一个整体性的事务。
3.3.2. 程序组成部分
本示例程序组成部分如下:
包括bank1和bank2两个数据库。
微服务框架:spring-boot-2.1.3、spring-cloud-Greenwich.RELEASE
seata客户端(RM、TM):spring-cloud-alibaba-seata-2.1.0.RELEASE
seata服务端(TC):seata-server-0.7.1
微服务及数据库的关系 :
dtx/dtx-seata-demo/seata-demo-bank1 银行1,操作张三账户, 连接数据库bank1
dtx/dtx-seata-demo/seata-demo-bank2 银行2,操作李四账户,连接数据库bank2
服务注册中心:dtx/discover-server
本示例程序技术架构如下:
ban1操作后openFeign调用bank2
交互流程如下:
1、请求bank1进行转账,传入转账金额。
2、bank1减少转账金额,调用bank2,传入转账金额。
3.3.3. 创建数据库 undo_log
因为要用 seata,所以还需要创建
undo_log
表
导入数据库脚本:资料\sql\bank1.sql、资料\sql\bank2.sql
sql在本文末
包括如下数据库:
bank1库,包含张三账户
bank2库,包含李四账户
分别在 bank1、bank2 库中创建undo_log
表,此表为 seata 框架使用:
-- 注意此处0.3.0+ 增加唯一索引 ux_undo_log
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
3.3.4. 启动 TC(事务协调器)
(1)下载 seata 服务器
下载地址:https://github.com/seata/seata/releases/download/v0.7.1/seata-server-0.7.1.zip
(2)解压并启动
[seata服务端解压路径]/bin/seata-server.bat -p 8888 -m file
注:其中 8888 为服务端口号;file 为启动模式,这里指 seata 服务将采用文件的方式存储信息。
出现 “Server started…” 的字样则表示启动成功。
3.3.5 注册中心 discover-server
discover-server 是服务注册中心,测试工程将自己注册至 discover-server。
导入:资料 \ 基础代码 \ dtx 父工程,此工程自带了 discover-server(基于 Eureka 实现)。
3.3.6 导入案例工程 dtx-seata-demo
dtx-seata-demo 是 seata 的测试工程,根据业务需求需要创建两个 dtx-seata-demo 工程。
(1)导入 dtx-seata-demo
导入:资料 \ 基础代码 \ dtx-seata-demo 到父工程 dtx 下。两个测试工程如下:
dtx/dtx-seata-demo/dtx-seata-demo-bank1 ,操作张三账户,连接数据库 bank1
dtx/dtx-seata-demo/dtx-seata-demo-bank2 ,操作李四账户,连接数据库 bank2
(2)父工程 maven 依赖说明
在 dtx 父工程中指定了 SpringBoot 和 SpringCloud 版本
在 dtx-seata-demo 父工程中指定了 spring-cloud-alibaba-dependencies 的版本。
(3)配置 seata
在 src/main/resource 中,新增 registry.conf、file.conf 文件,内容可拷贝 seata-server-0.7.1 中的配置文件子。在 registry.conf 中 registry.type 使用 file:
在 file.conf 中更改 service.vgroup_mapping.[springcloud 服务名]-fescar-service-group = “default”,并修改 service.default.grouplist =[seata 服务端地址]
关于 vgroup_mapping 的配置:
vgroup_mapping.事务分组服务名=Seata Server集群名称(默认名称为default)
default.grouplist = Seata Server集群地址
在 org.springframework.cloud:spring-cloud-starter-alibaba-seata 的org.springframework.cloud.alibaba.seata.GlobalTransactionAutoConfiguration 类中,默认会使用${spring.application.name}-fescar-service-group 作为事务分组服务名注册到 Seata Server上,如果和file.conf 中的配置不一致,会提示 no available server to connect 错误
也可以通过配置 spring.cloud.alibaba.seata.tx-service-group 修改后缀,但是必须和 file.conf 中的配置保持
一致
(4)创建代理数据源
新增 DatabaseConfiguration.java,Seata 的 RM 通过 DataSourceProxy 才能在业务代码的事务提交时,通过这个切入点,与 TC 进行通信交互、记录 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) {
// 代理,管理undo_log
DataSourceProxy pds0 = new DataSourceProxy(ds0);
return pds0;
}
}
3.3.7 Seata 执行流程
1、正常提交流程
- 第一个程序开启全局事务,然后得到了
事务ID
,执行完逻辑后把内容写到undo_log
表,然后提交事务给 TC。 - 然后远程调用 bank,此时 RPC 时携带上事务 ID,这样 bank2 页知道了事务 ID,bank2 执行完逻辑后也存入
undo_log
表,然后汇报给 TC - 然后 bank1 提交全局事务,提交后删除
undo_log
2、回滚流程
回滚流程省略前的 RM 注册过程。
要点说明:
- 1、每个 RM 使用
DataSourceProxy
连接数据库,其目的是使用 ConnectionProxy,使用数据源和数据连接代理的目的就是在第一阶段将undo_log
和业务数据放在一个本地事务提交,这样就保存了只要有业务操作就一定有 undo_log。 - 2、在第一阶段 undo_log 中存放了数据修改前和修改后的值,为事务回滚作好准备,所以第一阶段完成就已经将分支事务提交,也就释放了锁资源。
- 3、TM 开启全局事务开始,将
XID
全局事务 id 放在事务上下文中,通过 feign 调用也将 XID 传入下游分支事务,每个分支事务将自己的Branch ID分支事务ID
与XID
关联。 - 4、第二阶段全局事务提交,TC 会通知各各分支参与者提交分支事务,在第一阶段就已经提交了分支事务,这里各各参与者只需要删除 undo_log 即可,并且可以异步执行,第二阶段很快可以完成。
- 5、第二阶段全局事务回滚,TC 会通知各各分支参与者回滚分支事务,通过 XID 和 Branch ID 找到相应的回滚日志,通过回滚日志生成反向的 SQL 并执行,以完成分支事务回滚到之前的状态,如果回滚失败则会重试回滚操作。
3.3.8 dtx-seata-demo-bank1
dtx-seata-demo-bank1 实现如下功能:
1、张三账户减少金额,开启全局事务。
2、远程调用 bank2 向李四转账。
(1) DAO
@Mapper
@Component
public interface AccountInfoDao {
//更新账户金额
@Update("update account_info set account_balance = account_balance + #{amount} where account_no = #{accountNo}")
int updateAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
}
(2) FeignClient
远程调用 bank2 的客户端
@FeignClient(value="seata-demo-bank2",fallback=Bank2ClientFallback.class)
public interface Bank2Client {
//远程调用李四的微服务
@GetMapping("/bank2/transfer")
public String transfer(@RequestParam("amount") Double amount);
}
@Component
public class Bank2ClientFallback implements Bank2Client {
@Override
public String transfer(Double amount) {
return "fallback";
}
}
(3) Service
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
AccountInfoDao accountInfoDao;
@Autowired
Bank2Client bank2Client;
@Transactional
@GlobalTransactional//开启全局事务
@Override
public void updateAccountBalance(String accountNo, Double amount) {
log.info("bank1 service begin,XID:{}", RootContext.getXID());
//扣减张三的金额
accountInfoDao.updateAccountBalance(accountNo,amount *-1);
//调用李四微服务,转账
String transfer = bank2Client.transfer(amount);
if("fallback".equals(transfer)){
//调用李四微服务异常
throw new RuntimeException("调用李四微服务异常");
}
if(amount == 2){
//人为制造异常
throw new RuntimeException("bank1 make exception..");
}
}
}
事务 ID 的自动传递
将@GlobalTransactional
注解标注在全局事务发起的 Service 实现方法上,开启全局事务:
GlobalTransactionalInterceptor 会拦截 @GlobalTransactional 注解的方法,生成全局事务ID(XID)
,XID 会在整个分布式事务中传递。
在远程调用时,spring-cloud-alibaba-seata 会拦截 Feign 调用将 XID 传递到下游服务。
(6)Controller
@RestController
public class Bank1Controller {
@Autowired
AccountInfoService accountInfoService;
//张三转账
@GetMapping("/transfer")
public String transfer(Double amount){
accountInfoService.updateAccountBalance("1",amount);
return "bank1"+amount;
}
}
@SpringBootApplication
@EnableDiscoveryClient
@EnableHystrix
@EnableFeignClients(basePackages = {"cn.itcast.dtx.seatademo.bank1.spring"})
public class Bank1Server {
public static void main(String[] args) {
SpringApplication.run(Bank1Server.class, args);
}
}
3.3.9 dtx-seata-demo-bank2
dtx-seata-demo-bank2 实现如下功能:
1、李四账户增加金额。
dtx-seata-demo-bank2 在本账号事务中作为分支事务不使用 @GlobalTransactional。
Service
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
AccountInfoDao accountInfoDao;
@Transactional
@Override
public void updateAccountBalance(String accountNo, Double amount) {
// RootContext.getXID()
log.info("bank2 service begin,XID:{}",RootContext.getXID());
//李四增加金额
accountInfoDao.updateAccountBalance(accountNo,amount);
if(amount==3){
//人为制造异常
throw new RuntimeException("bank2 make exception..");
}
}
}
(3) Controller
@RestController
public class Bank2Controller {
@Autowired
AccountInfoService accountInfoService;
//接收张三的转账
@GetMapping("/transfer")
public String transfer(Double amount){
//李四增加金额
accountInfoService.updateAccountBalance("2",amount);
return "bank2"+amount;
}
}
3.3.10 测试场景
- 张三向李四转账成功。
- 李四事务失败,张三事务回滚成功。
- 张三事务失败,李四事务回滚成功。
- 分支事务超时测试。
注意执行一半的时候张三是转账成功的
3.4. 小结
本节讲解了传统 2PC(基于数据库 XA 协议)和 Seata 实现 2PC 的两种 2PC 方案,由于 Seata 的 0 侵入性并且解决了传统 2PC 长期锁资源的问题,所以推荐采用 Seata 实现 2PC。
Seata 实现 2PC 要点:
- 1、全局事务开始使用
@GlobalTransactional
标识 。 - 2、每个本地事务方案仍然使用
@Transactional
标识。 - 3、每个数据都需要创建
undo_log
表,此表是 seata 保证本地事务一致性的关键。
2PC 失效案例
1)协调者故障分析
协调者是一个单点,存在单点故障问题。
- 假设协调者在发送准备命令之前挂了,还行等于事务还没开始。
- 假设协调者在发送准备命令之后挂了,这就不太行了,有些参与者等于都执行了处于事务资源锁定的状态。不仅事务执行不下去,还会因为锁定了一些公共资源而阻塞系统其它操作。
- 假设协调者在发送回滚事务命令之前挂了,那么事务也是执行不下去,且在第一阶段那些准备成功参与者都阻塞着。
- 假设协调者在发送回滚事务命令之后挂了,这个还行,至少命令发出去了,很大的概率都会回滚成功,资源都会释放。但是如果出现网络分区问题,某些参与者将因为收不到命令而阻塞着。
- 假设协调者在发送提交事务命令之前挂了,这个不行,傻了!这下是所有资源都阻塞着。
- 假设协调者在发送提交事务命令之后挂了,这个还行,也是至少命令发出去了,很大概率都会提交成功,然后释放资源,但是如果出现网络分区问题某些参与者将因为收不到命令而阻塞着。
协调组故障可以通过选举算法来得到新的协调点
2)协调者故障,通过选举得到新协调者
因为协调者单点问题,因此我们可以通过选举等操作选出一个新协调者来顶替。
如果处于第一阶段,其实影响不大都回滚好了,在第一阶段事务肯定还没提交。
如果处于第二阶段,假设参与者都没挂,此时新协调者可以向所有参与者确认它们自身情况来推断下一步的操作。
假设有个别参与者挂了!这就有点僵硬了,比如协调者发送了回滚命令,此时第一个参与者收到了并执行,然后协调者和第一个参与者都挂了。
此时其他参与者都没收到请求,然后新协调者来了,它询问其他参与者都说 OK,但它不知道挂了的那个参与者到底 O 不 OK,所以它傻了。
问题其实就出在每个参与者自身的状态只有自己和原协调者知道,因此新协调者无法通过在场的参与者的状态推断出挂了的参与者是什么情况。
虽然协议上没说,不过在实现的时候我们可以灵活的让协调者将自己发过的请求在哪个地方记一下,也就是日志记录,这样新协调者来的时候不就知道此时该不该发了?
但是就算协调者知道自己该发提交请求,那么在参与者也一起挂了的情况下没用,因为你不知道参与者在挂之前有没有提交事务。
如果参与者在挂之前事务提交成功,新协调者确定存活着的参与者都没问题,那肯定得向其他参与者发送提交事务命令才能保证数据一致。
如果参与者在挂之前事务还未提交成功,参与者恢复了之后数据是回滚的,此时协调者必须是向其他参与者发送回滚事务命令才能保持事务的一致。
所以说极端情况下还是无法避免数据不一致问题。
总结 2PC:
- 支持情况:mysql 从 5.5 版本开始支持,SQLserver2005 开始支持,Oracle7 开始支持。
- XA 协议比较简单,而且一旦商业数据库实现了 XA 协议,使用分布式事务的成本也比较低。
- 性能不理想,特别是在交易下单链路,往往并发量很高,XA 无法满足高并发场景
- XA 目前在商业数据库支持的比较理想,在 mysql 数据库中支持的不太理想,mysql 的 XA 实现,没有记录阶段日志,主备切换回导致主库与备库数据不一致。
- 许多 nosql 没有支持 XA,这让 XA 的应用场景变得非常狭隘。
- 也有
3PC
,引入了超时机制(无论协调者还是参与者,在向对方发送请求后,若长时间未收到回应则做出相应处理)
还有一点不知道你们看出来没,2PC 适用于数据库层面的分布式事务场景,而我们业务需求有时候不仅仅关乎数据库,也有可能是上传一张图片或者发送一条短信。
而且像 Java 中的 JTA 只能解决一个应用下多数据库的分布式事务问题,跨服务了就不能用了。
简单说下 Java 中 JTA,它是基于 XA 规范实现的事务接口,这里的 XA 你可以简单理解为基于数据库的 XA 规范来实现的 2PC。
接下来我们再来看看 3PC。
3PC
- 首先准备阶段的变更成不会直接执行事务,而是会先去询问此时的参与者是否有条件接这个事务,因此不会一来就干活直接锁资源,使得在某些资源不可用的情况下所有参与者都阻塞着。
- 而预提交阶段的引入起到了一个统一状态的作用,它像一道栅栏,表明在预提交阶段前所有参与者其实还未都回应,在预处理阶段表明所有参与者都已经回应了。
假如你是一位参与者,你知道自己进入了预提交状态那你就可以推断出来其他参与者也都进入了预提交状态。
但是多引入一个阶段也多一个交互,因此性能会差一些,而且绝大部分的情况下资源应该都是可用的,这样等于每次明知可用执行还得询问一次
我们再来看下参与者超时能带来什么样的影响。
我们知道 2PC 是同步阻塞的,上面我们已经分析了协调者挂在了提交请求还未发出去的时候是最伤的,所有参与者都已经锁定资源并且阻塞等待着。
那么引入了超时机制,参与者就不会傻等了,如果是等待提交命令超时,那么参与者就会提交事务了,因为都到了这一阶段了大概率是提交的,如果是等待预提交命令超时,那该干啥就干啥了,反正本来啥也没干。
然而超时机制也会带来数据不一致的问题,比如在等待提交命令时候超时了,参与者默认执行的是提交事务操作,但是有可能执行的是回滚操作,这样一来数据就不一致了。
当然 3PC 协调者超时还是在的,具体不分析了和 2PC 是一样的。
从维基百科上看,3PC 的引入是为了解决提交阶段 2PC 协调者和某参与者都挂了之后新选举的协调者不知道当前应该提交还是回滚的问题。
新协调者来的时候发现有一个参与者处于预提交或者提交阶段,那么表明已经经过了所有参与者的确认了,所以此时执行的就是提交命令。
所以说 3PC 就是通过引入预提交阶段来使得参与者之间的状态得到统一,也就是留了一个阶段让大家同步一下。
但是这也只能让协调者知道该如果做,但不能保证这样做一定对,这其实和上面 2PC 分析一致,因为挂了的参与者到底有没有执行事务无法断定。
所以说 3PC 通过预提交阶段可以减少故障恢复时候的复杂性,但是不能保证数据一致,除非挂了的那个参与者恢复。
让我们总结一下, 3PC 相对于 2PC 做了一定的改进:引入了参与者超时机制,并且增加了预提交阶段使得故障恢复之后协调者的决策复杂度降低,但整体的交互过程更长了,性能有所下降,并且还是会存在数据不一致问题。
所以 2PC 和 3PC 都不能保证数据 100% 一致,因此一般都需要有定时扫描补偿机制。
我再说下 3PC 我没有找到具体的实现,所以我认为 3PC 只是纯的理论上的东西,而且可以看到相比于 2PC 它是做了一些努力但是效果甚微,所以只做了解即可。
3PC
3PC 的出现是为了解决 2PC 的一些问题,相比于 2PC 它在参与者中也引入了超时机制,并且新增了一个阶段使得参与者可以利用这一个阶段统一各自的状态。
3PC 包含了三个阶段
-
准备阶段 CanCommit
-
1. 事务询问 协调者向参与者发送 CanCommit 请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
2. 响应反馈 参与者接到 CanCommit 请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回 Yes 响应,并进入预备状态。否则反馈 No
-
-
预提交阶段 PreCommit
-
如果都收到了准备:
-
1. 发送预提交请求 协调者向参与者发送 PreCommit 请求,并进入 Prepared 阶段。
-
2. 事务预提交 参与者接收到 PreCommit 请求后,会执行事务操作,并将 undo 和 redo 信息记录到事务日志中。
-
3. 响应反馈 如果参与者成功的执行了事务操作,则返回 ACK 响应,同时开始等待最终指令。
-
如果有任一没有准备好 / 超时:
-
1. 发送中断请求 协调者向所有参与者发送 abort 请求。
2. 中断事务 参与者收到来自协调者的 abort 请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。
-
-
提交阶段 DoCommit:该阶段进行真正的事务提交,也可以分为以下两种情况。
-
执行提交
1. 发送提交请求 协调接收到参与者发送的 ACK 响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送 doCommit 请求。
2. 事务提交 参与者接收到 doCommit 请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
3. 响应反馈 事务提交完之后,向协调者发送 Ack 响应。
4. 完成事务 协调者接收到所有参与者的 ack 响应之后,完成事务。
-
中断事务 协调者接收到参与者发送的 N-ACK 响应 / 超时,那么就会执行中断事务。
1. 发送中断请求 协调者向所有参与者发送 abort 请求
2. 事务回滚 参与者接收到 abort 请求之后,利用其在阶段二记录的 undo 信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
3. 反馈结果 参与者完成事务回滚之后,向协调者发送 ACK 消息
4. 中断事务 协调者接收到参与者反馈的 ACK 消息之后,执行事务的中断。
-
2PC 和 3PC 的区别是什么?
1.3pc 比 2pc 多了一个 can commit 阶段,减少了不必要的资源浪费。因为 2pc 在第一阶段会占用资源,而 3pc 在第一阶段不占用资源,只是校验一下 sql,如果不能执行,就直接返回,减少了资源占用。
- 引入超时机制。同时在协调者和参与者中都引入超时机制。
2pc:只有协调者有超时机制,超时后,发送回滚指令。
3pc:协调者和参与者都有超时机制。
协调者超时: can commit,pre commit 中,如果收不到参与者的反馈,则协调者向参与者发送中断指令。
参与者超时: pre commit 阶段,参与者进行中断; do commit 阶段,参与者进行提交。
四、解决方案之 TCC
2PC 和 3PC 都是数据库层面的,而 TCC 是业务层面的分布式事务,就像我前面说的分布式事务不仅仅包括数据库的操作,还包括发送短信等,这时候 TCC 就派上用场了!
4.1. 什么是 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 阶段真的出错了,需要引入重试机制或进行人工处理;
TCC 不存在资源阻塞的问题,因为每个方法都直接进行事务的提交,一旦出现异常通过则
Cancel
来进行回滚补偿,这也就是常说的补偿性事务。原本一个方法,现在却需要三个方法来支持,可以看到 TCC 对业务的侵入性很强,而且这种模式并不能很好地被复用,会导致开发量激增。还要考虑到网络波动等原因,为保证请求一定送达都会有重试机制,所以考虑到接口的幂等性。
TCC 对业务的侵入较大和业务紧耦合,需要根据特定的场景和业务逻辑来设计相应的操作。
还有一点要注意,撤销和确认操作的执行可能需要重试,因此还需要保证操作的幂等。
相对于 2PC、3PC ,TCC 适用的范围更大,但是开发量也更大,毕竟都在业务上实现,而且有时候你会发现这三个方法还真不好写。不过也因为是在业务上实现的,所以 TCC 可以跨数据库、跨不同的业务系统来实现事务。
4.2. TCC 解决方案
目前市面上的 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 需要注意三种异常处理分别是空回滚、幂等、悬挂:
1)空回滚:
解释:在没有调用 TCC 资源 Try 方法的情况下,调用了二阶段的 Cancel 方法,Cancel 方法需要识别出这是一个空回滚,然后直接返回成功。
出现原因:当一个分支事务所在服务宕机或网络异常,分支事务调用记录为失败,这个时候其实是没有执行 Try 阶段,当故障恢复后,分布式事务进行回滚则会调用二阶段的 Cancel 方法,从而形成空回滚。
解决思路是关键就是要识别出这个空回滚。思路很简单就是需要知道一阶段是否执行,如果执行了,那就是正常回滚;如果没执行,那就是空回滚。前面已经说过 TM 在发起全局事务时生成全局事务记录,全局事务 ID 贯穿整个分布式事务调用链条。再额外增加一张分支事务记录表,其中有全局事务 ID 和分支事务 ID,第一阶段 Try 方法里会插入一条记录,表示一阶段执行了。Cancel 接口里读取该记录,如果该记录存在,则正常回滚;如果该记录不存在,则是空回滚。
2)幂等:
通过前面介绍已经了解到,为了保证 TCC 二阶段提交重试机制不会引发数据不一致,要求 TCC 的二阶段 Try、
Confirm 和 Cancel 接口保证幂等,这样不会重复使用或者释放资源。如果幂等控制没有做好,很有可能导致数据不一致等严重问题。
解决思路在上述 “分支事务记录” 中增加执行状态,每次执行前都查询该状态。
3)悬挂:
说明:悬挂就是对于一个分布式事务,其二阶段 Cancel 接口比 Try 接口先执行。
出现原因:在 RPC 调用分支事务 try 时,先注册分支事务,再执行 RPC 调用,如果此时 RPC 调用的网络发生拥堵,通常 RPC 调用是有超时时间的,RPC 超时以后,TM 就会通知 RM 回滚该分布式事务,可能回滚完成后,RPC 请求才到达参与者真正执行,而一个 Try 方法预留的业务资源,只有该分布式事务才能使用,该分布式事务第一阶段预留的业务资源就再也没有人能够处理了,对于这种情况,我们就称为悬挂,即业务资源预留后没法继续处理。
解决思路:如果二阶段执行完成,那一阶段就不能再继续执行。在执行一阶段事务时判断在该全局事务下,“分支事务记录” 表中是否已经有二阶段事务记录,如果有则不执行 Try。
举例,场景为 A 转账 30 元给 B,A 和 B 账户在不同的服务。
方案 1:
账户 A
try:
检查余额是否够30元
扣减30元
confirm:
空
cancel:
增加30元
账户 B
try:
增加30元
confirm:
空
cancel:
减少30元
方案 1 说明:
1)账户 A,这里的余额就是所谓的业务资源,按照前面提到的原则,在第一阶段需要检查并预留业务资源,因此,我们在扣钱 TCC 资源的 Try 接口里先检查 A 账户余额是否足够,如果足够则扣除 30 元。 Confirm 接口表示正式提交,由于业务资源已经在 Try 接口里扣除掉了,那么在第二阶段的 Confirm 接口里可以什么都不用做。Cancel 接口的执行表示整个事务回滚,账户 A 回滚则需要把 Try 接口里扣除掉的 30 元还给账户。
2)账号 B,在第一阶段 Try 接口里实现给账户 B 加钱,Cancel 接口的执行表示整个事务回滚,账户 B 回滚则需要把 Try 接口里加的 30 元再减去。
方案 1 的问题分析:
1)如果账户 A 的 try 没有执行,直接 cancel 则就多加了 30 元。
2)由于 try,cancel、confirm 都是由单独的线程去调用,且会出现重复调用,所以都需要实现幂等。
3)账号 B 在 try 中增加 30 元,当 try 执行完成后可能会其它线程给消费了。
4)如果账户 B 的 try 没有执行在 cancel 则就多减了 30 元。
问题解决:
1)账户 A 的 cancel 方法需要判断 try 方法是否执行,正常执行 try 后方可执行 cancel。
2)try,cancel、confirm 方法实现幂等。
3)账号 B 在 try 方法中不允许更新账户金额,在 confirm 中更新账户金额。
4)账户 B 的 cancel 方法需要判断 try 方法是否执行,正常执行 try 后方可执行 cancel。
优化方案:
加钱的操作要在第二阶段进行,减钱的操作无所谓
要防止还没执行第一阶段就执行第二阶段的情况,所以在操作钱前面加个幂等
账户 A
try:
try幂等校验
try悬挂处理
检查余额是否够30元
扣减30元
confirm:
空
cancel:
cancel幂等校验
cancel空回滚处理
增加可用余额30元
账户 B
try:
空
confirm:
confirm幂等校验
正式增加30元
cancel:
空
4.3. Hmily
实现 TCC 事务
4.3.1 . 业务说明
本实例通过 Hmily 实现 TCC 分布式事务,模拟两个账户的转账交易过程。
两个账户分别在不同的银行 (张三在 bank1、李四在 bank2),bank1、bank2 是两个微服务。
交易过程是,张三给李四转账指定金额。
上述交易步骤,要么一起成功,要么一起失败,必须是一个整体性的事务。
4.3.2 . 程序组成部分
微服务:spring-boot-2.1.3、spring-cloud-Greenwich.RELEASE
Hmily:hmily-springcloud.2.0.4-RELEASE
微服务及数据库的关系 :
dtx/dtx-tcc-demo/dtx-tcc-demo-bank1 银行1,操作张三账户, 连接数据库bank1
dtx/dtx-tcc-demo/dtx-tcc-demo-bank2 银行2,操作李四账户,连接数据库bank2
服务注册中心:dtx/discover-server
4.3.3 . 创建数据库
导入数据库脚本:资料 \ sql\bank1.sql、资料 \ sql\bank2.sql、已经导过不用重复导入。
创建 hmily 数据库,用于存储 hmily 框架记录的数据。
CREATE DATABASE `hmily` CHARACTER SET 'utf8' COLLATE 'utf8_general_ci';
每个数据库都创建 try、confirm、cancel 三张日志表:
# 前面说了顺利可能会很乱,所以需要为各个阶段建立表
CREATE TABLE `local_try_log` (
`tx_no` varchar(64) NOT NULL COMMENT '事务id',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`tx_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
CREATE TABLE `local_confirm_log` (
`tx_no` varchar(64) NOT NULL COMMENT '事务id',
`create_time` datetime DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8
CREATE TABLE `local_cancel_log` (
`tx_no` varchar(64) NOT NULL COMMENT '事务id',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`tx_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
4.3.5 discover-server
discover-server 是服务注册中心,测试工程将自己注册至 discover-server。
导入:资料 \ 基础代码 \ dtx 父工程,此工程自带了 discover-server,discover-server 基于 Eureka 实现。已经导过不用重复导入。
4.3.6 导入案例工程 dtx-tcc-demo
dtx-tcc-demo 是 tcc 的测试工程,根据业务需求需要创建两个 dtx-tcc-demo 工程。
(1)导入 dtx-tcc-demo
导入:资料\基础代码\dtx-tcc-demo到父工程dtx下。两个测试工程如下:
dtx/dtx-tcc-demo/dtx-tcc-demo-bank1 银行1,操作张三账户,连接数据库bank1
dtx/dtx-tcc-demo/dtx-tcc-demo-bank2 银行2,操作李四账户,连接数据库bank2
(2)引入 maven 依赖
(3)配置 hmily application.yml:
org:
dromara:
hmily :
serializer : kryo
recoverDelayTime : 30
retryMax : 30
scheduledDelay : 30
scheduledThreadMax : 10
repositorySupport : db
started: true # 在bank2下为false
hmilyDbConfig :
driverClassName : com.mysql.jdbc.Driver
url : jdbc:mysql://localhost:3306/hmily?useUnicode=true
username : root
password : mysql
新增配置类接收 application.yml 中的 Hmily 配置信息,并创建 HmilyTransactionBootstrap Bean:
启动类增加 @EnableAspectJAutoProxy 并增加 org.dromara.hmily 的扫描项:
4.3.7 dtx-tcc-demo-bank1
dtx-tcc-demo-bank1 实现 try 和 cancel 方法,如下
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass=true)
public class DatabaseConfiguration {
@Autowired
private Environment env;
@Bean
@ConfigurationProperties(prefix = "spring.datasource.ds0")
public DruidDataSource ds0() {
// 是绑定这个bean的属性
DruidDataSource druidDataSource = new DruidDataSource();
return druidDataSource;
}
@Bean
public HmilyTransactionBootstrap hmilyTransactionBootstrap(HmilyInitService hmilyInitService){
HmilyTransactionBootstrap hmilyTransactionBootstrap = new HmilyTransactionBootstrap(hmilyInitService);
hmilyTransactionBootstrap.setSerializer(env.getProperty("org.dromara.hmily.serializer"));
hmilyTransactionBootstrap.setRecoverDelayTime(Integer.parseInt(env.getProperty("org.dromara.hmily.recoverDelayTime")));
hmilyTransactionBootstrap.setRetryMax(Integer.parseInt(env.getProperty("org.dromara.hmily.retryMax")));
hmilyTransactionBootstrap.setScheduledDelay(Integer.parseInt(env.getProperty("org.dromara.hmily.scheduledDelay")));
hmilyTransactionBootstrap.setScheduledThreadMax(Integer.parseInt(env.getProperty("org.dromara.hmily.scheduledThreadMax")));
hmilyTransactionBootstrap.setRepositorySupport(env.getProperty("org.dromara.hmily.repositorySupport"));
hmilyTransactionBootstrap.setStarted(Boolean.parseBoolean(env.getProperty("org.dromara.hmily.started")));
HmilyDbConfig hmilyDbConfig = new HmilyDbConfig();
hmilyDbConfig.setDriverClassName(env.getProperty("org.dromara.hmily.hmilyDbConfig.driverClassName"));
hmilyDbConfig.setUrl(env.getProperty("org.dromara.hmily.hmilyDbConfig.url"));
hmilyDbConfig.setUsername(env.getProperty("org.dromara.hmily.hmilyDbConfig.username"));
hmilyDbConfig.setPassword(env.getProperty("org.dromara.hmily.hmilyDbConfig.password"));
hmilyTransactionBootstrap.setHmilyDbConfig(hmilyDbConfig);
return hmilyTransactionBootstrap;
}
/*@Bean
@ConfigurationProperties(prefix = "org.dromara.hmily")
public HmilyConfig hmilyConfig(){
return new HmilyConfig();
}
@Bean
public HmilyTransactionBootstrap hmilyTransactionBootstrap(HmilyInitService hmilyInitService, HmilyConfig hmilyConfig){
HmilyTransactionBootstrap hmilyTransactionBootstrap = new HmilyTransactionBootstrap(hmilyInitService);
return hmilyTransactionBootstrap;
}*/
}
1) Dao
@Mapper
@Component
public interface AccountInfoDao {
@Update("update account_info set account_balance=account_balance - #{amount} where account_balance>=#{amount} and account_no=#{accountNo} ")
int subtractAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
@Update("update account_info set account_balance=account_balance + #{amount} where account_no=#{accountNo} ")
int addAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
/**
* 增加某分支事务try执行记录
* @param localTradeNo 本地事务编号
*/
@Insert("insert into local_try_log values(#{txNo},now());")
int addTry(String localTradeNo);
@Insert("insert into local_confirm_log values(#{txNo},now());")
int addConfirm(String localTradeNo);
@Insert("insert into local_cancel_log values(#{txNo},now());")
int addCancel(String localTradeNo);
/**
* 查询分支事务try是否已执行
* @param localTradeNo 本地事务编号
*/
@Select("select count(1) from local_try_log where tx_no = #{txNo} ")
int isExistTry(String localTradeNo);
/**
* 查询分支事务confirm是否已执行
* @param localTradeNo 本地事务编号
*/
@Select("select count(1) from local_confirm_log where tx_no = #{txNo} ")
int isExistConfirm(String localTradeNo);
/**
* 查询分支事务cancel是否已执行
* @param localTradeNo 本地事务编号
*/
@Select("select count(1) from local_cancel_log where tx_no = #{txNo} ")
int isExistCancel(String localTradeNo);
}
2) try 和 cancel 方法
前面我们说过了 Seata 会携带事务 ID,同样 Hmily 也会携带事务 ID
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
AccountInfoDao accountInfoDao;
@Autowired
Bank2Client bank2Client;
// 账户扣款,就是tcc的try方法
/**
* try幂等校验
* try悬挂处理
* 检查余额是够扣减金额
* 扣减金额、
*/
@Override
@Transactional
//只要标记@Hmily就是try方法,在注解中指定confirm、cancel两个方法的名字
@Hmily(confirmMethod="commit",cancelMethod="rollback")
public void updateAccountBalance(String accountNo, Double amount) {
//获取全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank1 try begin 开始执行...xid:{}",transId);
//幂等判断 判断local_try_log表中是否有try日志记录,如果有则不再执行
if(accountInfoDao.isExistTry(transId)>0){
log.info("bank1 try 已经执行,无需重复执行,xid:{}",transId);
return ;
}
//try悬挂处理,如果cancel、confirm有一个已经执行了,try不再执行
if(accountInfoDao.isExistConfirm(transId)>0 || accountInfoDao.isExistCancel(transId)>0){
log.info("bank1 try悬挂处理 cancel或confirm已经执行,不允许执行try,xid:{}",transId);
return ;
}
//扣减金额
if(accountInfoDao.subtractAccountBalance(accountNo, amount)<=0){
//扣减失败
throw new RuntimeException("bank1 try 扣减金额失败,xid:{}"+transId);
}
//插入try执行记录,用于幂等判断
accountInfoDao.addTry(transId);
//远程调用李四,转账
if(!bank2Client.transfer(amount)){
throw new RuntimeException("bank1 远程调用李四微服务失败,xid:{}"+transId);
}
if(amount == 2){
throw new RuntimeException("人为制造异常,xid:{}"+transId);
}
log.info("bank1 try end 结束执行...xid:{}",transId);
}
//confirm方法
@Transactional
public void commit(String accountNo, Double amount){
//获取全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank1 confirm begin 开始执行...xid:{},accountNo:{},amount:{}",transId,accountNo,amount);
}
/** cancel方法
* cancel幂等校验
* cancel空回滚处理
* 增加可用余额
*/
@Transactional
public void rollback(String accountNo, Double amount){
//获取全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank1 cancel begin 开始执行...xid:{}",transId);
// cancel幂等校验
if(accountInfoDao.isExistCancel(transId)>0){
log.info("bank1 cancel 已经执行,无需重复执行,xid:{}",transId);
return ;
}
//cancel空回滚处理,如果try没有执行,cancel不允许执行
if(accountInfoDao.isExistTry(transId)<=0){
log.info("bank1 空回滚处理,try没有执行,不允许cancel执行,xid:{}",transId);
return ;
}
// 增加可用余额
accountInfoDao.addAccountBalance(accountNo,amount);
//插入一条cancel的执行记录
accountInfoDao.addCancel(transId);
log.info("bank1 cancel end 结束执行...xid:{}",transId);
}
}
3) feignClient
@FeignClient(value="tcc-demo-bank2",fallback=Bank2ClientFallback.class)
public interface Bank2Client {
//远程调用李四的微服务
@GetMapping("/bank2/transfer")
@Hmily // 是为了携带事务ID
public Boolean transfer(@RequestParam("amount") Double amount);
}
@Component
public class Bank2ClientFallback implements Bank2Client {
@Override
public Boolean transfer(Double amount) {
return false;
}
}
4) Controller
4.3.8 dtx-tcc-demo-bank2
dtx-tcc-demo-bank2 实现如下功能:
1)Dao
@Component
@Mapper
public interface AccountInfoDao {
@Update("update account_info set account_balance=account_balance + #{amount} where account_no=#{accountNo} ")
int addAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
/**
* 增加某分支事务try执行记录
* @param localTradeNo 本地事务编号
*/
@Insert("insert into local_try_log values(#{txNo},now());")
int addTry(String localTradeNo);
@Insert("insert into local_confirm_log values(#{txNo},now());")
int addConfirm(String localTradeNo);
@Insert("insert into local_cancel_log values(#{txNo},now());")
int addCancel(String localTradeNo);
/**
* 查询分支事务try是否已执行
* @param localTradeNo 本地事务编号
*/
@Select("select count(1) from local_try_log where tx_no = #{txNo} ")
int isExistTry(String localTradeNo);
/**
* 查询分支事务confirm是否已执行
* @param localTradeNo 本地事务编号
*/
@Select("select count(1) from local_confirm_log where tx_no = #{txNo} ")
int isExistConfirm(String localTradeNo);
/**
* 查询分支事务cancel是否已执行
* @param localTradeNo 本地事务编号
*/
@Select("select count(1) from local_cancel_log where tx_no = #{txNo} ")
int isExistCancel(String localTradeNo);
}
2)实现 confirm 方法
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
AccountInfoDao accountInfoDao;
@Override
@Hmily(confirmMethod="confirmMethod", cancelMethod="cancelMethod")
public void updateAccountBalance(String accountNo, Double amount) {
//获取全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank2 try begin 开始执行...xid:{}",transId);
}
/**
* confirm方法
* confirm幂等校验
* 正式增加金额
* @param accountNo
* @param amount
*/
@Transactional
public void confirmMethod(String accountNo, Double amount){
//获取全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank2 confirm begin 开始执行...xid:{}",transId);
if(accountInfoDao.isExistConfirm(transId)>0){
log.info("bank2 confirm 已经执行,无需重复执行...xid:{}",transId);
return ;
}
//增加金额
accountInfoDao.addAccountBalance(accountNo,amount);
//增加一条confirm日志,用于幂等
accountInfoDao.addConfirm(transId);
log.info("bank2 confirm end 结束执行...xid:{}",transId);
}
/**
* @param accountNo
* @param amount
*/
public void cancelMethod(String accountNo, Double amount){
//获取全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank2 cancel begin 开始执行...xid:{}",transId);
}
}
3)Controller
@RestController
public class Bank2Controller {
@Autowired
AccountInfoService accountInfoService;
@RequestMapping("/transfer")
public Boolean transfer(@RequestParam("amount") Double amount) {
this.accountInfoService.updateAccountBalance("2", amount);
return true;
}
}
3.3.9 测试场景
- 张三向李四转账成功。
- 李四事务失败,张三事务回滚成功。
- 张三事务失败,李四分支事务回滚成功。
- 分支事务超时测试。
测试时注意清空库
4.4. 小结
如果拿 TCC 事务的处理流程与 2PC 两阶段提交做比较,2PC 通常都是在跨库的 DB 层面,而 TCC 则在应用层面的处理,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据操作的粒度,使得降低锁冲突、提高吞吐量成为可能。
而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现 try、confirm、cancel 三个操作。此外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。
五、解决方案之可靠消息最终一致性
5.1 . 什么是可靠消息最终一致性事务
可靠消息最终一致性方案是指当事务发起方执行完成本地事务后并发出一条消息,事务参与方 (消息消费者) 一定能够接收消息并处理事务成功,此方案强调的是只要消息发给事务参与方最终事务要达到一致。
假设消息中间件是可靠的
此方案是利用消息中间件完成,如下图:
网络1 网络2
事务发起方---------消息中间件-----------事务参与方
事务发起方(消息生产方)将消息发给消息中间件,事务参与方从消息中间件接收消息,事务发起方和消息中间件之间,事务参与方(消息消费方)和消息中间件之间都是通过网络通信,由于网络通信的不确定性会导致分布式事务问题。
因此可靠消息最终一致性方案要解决以下几个问题:
1. 本地事务与消息发送的原子性问题
本地事务与消息发送的原子性问题即:事务发起方在本地事务执行成功后消息必须发出去,否则就丢弃消息。即实现本地事务和消息发送的原子性,要么都成功,要么都失败。本地事务与消息发送的原子性问题是实现可靠消息最终一致性方案的关键问题。
先来尝试下这种操作,先发送消息,再操作数据库:
begin transaction;
//1.发送MQ
//2.数据库操作
commit transation;
这种情况下无法保证数据库操作与发送消息的一致性,因为可能发送消息成功,数据库操作失败。
你立马想到第二种方案,先进行数据库操作,再发送消息:
begin transaction;
//1.数据库操作
//2.发送MQ
commit transation;
这种情况下貌似没有问题,如果发送 MQ 消息失败,就会抛出异常,导致数据库事务回滚。
但如果是超时异常,数据库回滚,但 MQ 其实已经正常发送了,同样会导致不一致。
所以在后面不直接发 MQ 了,而是打日志数据库,然后定时任务扫描日志数据库去发消息
2、事务参与方接收消息的可靠性
事务参与方必须能够从消息队列接收到消息,如果接收消息失败可以重复接收消息。
3、消息重复消费的问题
由于网络 2 的存在,若某一个消费节点超时但是消费成功,此时消息中间件会重复投递此消息,就导致了消息的重复消费。
要解决消息重复消费的问题就要实现事务参与方的方法幂等性。
通过确认机制
5.2 . 解决方案
上节讨论了可靠消息最终一致性事务方案需要解决的问题,本节讨论具体的解决方案。
5.2.1. 本地消息表方案
本地消息表这个方案最初是 eBay 提出的,此方案的核心是通过本地事务保证数据业务操作和消息的一致性,然后通过定时任务将消息发送至消息中间件,待确认消息发送给消费方成功再将消息删除。
下面以注册送积分为例来说明:
下例共有两个微服务交互,用户服务和积分服务,用户服务负责添加用户,积分服务负责增加积分。
日志也是数据库
然后是定时任务扫描刚才的日志表
本地消息表其实就是利用了 各系统本地的事务来实现分布式事务。
本地消息表顾名思义就是会有一张存放本地消息的表,一般都是放在数据库中,然后在执行业务的时候 将业务的执行和将消息放入消息表中的操作放在同一个事务中,这样就能保证消息放入本地表中业务肯定是执行成功的。
然后再去调用下一个操作,如果下一个操作调用成功了好说,消息表的消息状态可以直接改成已成功。
如果调用失败也没事,会有 后台任务定时去读取本地消息表,筛选出还未成功的消息再调用对应的服务,服务更新成功了再变更消息的状态。
这时候有可能消息对应的操作不成功,因此也需要重试,重试就得保证对应服务的方法是幂等的,而且一般重试会有最大次数,超过最大次数可以记录下报警让人工处理。
可以看到本地消息表其实实现的是最终一致性,容忍了数据暂时不一致的情况。
交互流程如下:
1、用户注册
用户服务在本地事务新增用户和增加 ” 积分消息日志 “。(用户表和消息表通过本地事务保证一致)下边是伪代码
begin transaction;
//1.新增用户
//2.存储积分消息日志
commit transation;
这种情况下,本地数据库操作与存储积分消息日志处于同一个事务中,本地数据库操作与记录消息日志操作具备原子性。
2、定时任务扫描日志
如何保证将消息发送给消息队列呢?
经过第一步消息已经写到消息日志表中,可以启动独立的线程,定时对消息日志表中的消息进行扫描并发送至消息中间件,在消息中间件反馈发送成功后删除该消息日志,否则等待定时任务下一周期重试。
3、消费消息
如何保证消费者一定能消费到消息呢?
这里可以使用 MQ 的 ack(即消息确认)机制,消费者监听 MQ,如果消费者接收到消息并且业务处理完成后向 MQ 发送 ack(即消息确认),此时说明消费者正常消费消息完成,MQ 将不再向消费者推送消息,否则消费者会不断重试向消费者来发送消息。
积分服务接收到” 增加积分 “消息,开始增加积分,积分增加成功后向消息中间件回应 ack,否则消息中间件将重复投递此消息。
由于消息会重复投递,积分服务的” 增加积分 “功能需要实现幂等性。
5.2.2.RocketMQ 事务消息方案
RocketMQ 是一个来自阿里巴巴的分布式消息中间件,于 2012 年开源,并在 2017 年正式成为 Apache 顶级项目。据了解,包括阿里云上的消息产品以及收购的子公司在内,阿里集团的消息产品全线都运行在 RocketMQ 之上,并且最近几年的双十一大促中,RocketMQ 都有抢眼表现。Apache RocketMQ 4.3 之后的版本正式支持事务消息,为分布式事务实现提供了便利性支持。
RocketMQ 事务消息设计则主要是为了解决 Producer 端的消息发送与本地事务执行的原子性问题,RocketMQ 的设计中 broker 与 producer 端的双向通信能力,使得 broker 天生可以作为一个事务协调者存在;而 RocketMQ 本身提供的存储机制为事务消息提供了持久化能力;RocketMQ 的高可用机制以及可靠消息设计则为事务消息在系统发生异常时依然能够保证达成事务的最终一致性。
在 RocketMQ4.3 后实现了完整的事务消息,实际上其实是对本地消息表的一个封装,将本地消息表移动到了 MQ 内部,解决 Producer 端的消息发送与本地事务执行的原子性问题。
1 加钱,发送放收到 2ACK 后,(此时还不让消费者消费),
然后开始执行本地事务,执行完后生产者发送 commit 消息(或者 rollback),这时就可被消费了(rollback 就不能被消费)
为了防止 commit 消息丢失,消息中间件没收到 commit 时会定时回查发送者发过没有
执行流程
总的来说就是我们调用的是发消息的接口,此时发 MQ 还只是发了半消息,
发送完后自动回调事务代码,
事务代码提交后发送 commit 消息
收到 commit-ACK 后提交本地事务
说明:我们只保证了生产者,至于消费者,那是 MQ 中间件需要保证的
为方便理解我们还以注册送积分的例子来描述 整个流程。
Producer 即 MQ 发送方,本例中是用户服务,负责新增用户。MQ 订阅方即消息消费方,本例中是积分服务,负责新增积分。
1、Producer 发送事务消息
Producer (MQ 发送方)发送事务消息至 MQ Server,MQ Server 将消息状态标记为 Prepared(预备状态),注意此时这条消息消费者(MQ 订阅方)是无法消费到的。
本例中,Producer 发送 ” 增加积分消息 “ 到 MQ Server。
2、MQ Server 回应消息发送成功
MQ Server 接收到 Producer 发送给的消息则回应发送成功表示 MQ 已接收到消息。
3、Producer 执行本地事务
Producer 端执行业务代码逻辑,通过本地数据库事务控制。本例中,Producer 执行添加用户操作。
4、消息投递
若 Producer 本地事务执行成功则自动向 MQServer 发送 commit 消息,MQ Server 接收到 commit 消息后将” 增加积分消息 “ 状态标记为可消费,此时 MQ 订阅方(积分服务)即正常消费消息;
若 Producer 本地事务执行失败则自动向 MQServer 发送 rollback 消息,MQ Server 接收到 rollback 消息后 将删除” 增加积分消息 “ 。
MQ 订阅方(积分服务)消费消息,消费成功则向 MQ 回应 ack,否则将重复接收消息。这里 ack 默认自动回应,即程序执行正常则自动回应 ack。
5、事务回查
如果执行 Producer 端本地事务过程中,执行端挂掉,或者超时,MQ Server 将会不停的询问同组的其他 Producer
来获取事务执行状态,这个过程叫事务回查。MQ Server 会根据事务回查结果来决定是否投递消息。
以上主干流程已由 RocketMQ 实现,对用户侧来说,用户需要分别实现本地事务执行以及本地事务回查方法,因此只需关注本地事务的执行状态即可。
RocketMQ 提供 RocketMQLocalTransactionListener 接口:
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);
实现:业务处理服务在业务事务提交之前,向实时消息服务请求发送捎息,实时捎息服务只记录消息数据,而不是真正的发送。业务处理服务在业务事务提交之后,向实时消息服务确认发送。只有在得到确认发送指令后,实时消息服务才会真正发送。
消息事务
RocketMQ 就很好的支持了消息事务,让我们来看一下如何通过消息实现事务。
第一步先给 Broker 发送事务消息即半消息,半消息不是说一半消息,而是这个消息对消费者来说不可见,然后发送成功后发送方再执行本地事务。
再根据本地事务的结果向 Broker 发送 Commit 或者 RollBack 命令。
并且 RocketMQ 的发送方会提供一个反查事务状态接口,如果一段时间内半消息没有收到任何操作请求,那么 Broker 会通过反查接口得知发送方事务是否执行成功,然后执行 Commit 或者 RollBack 命令。
如果是 Commit 那么订阅方就能收到这条消息,然后再做对应的操作,做完了之后再消费这条消息即可。
如果是 RollBack 那么订阅方收不到这条消息,等于事务就没执行过。
5.3. RocketMQ 实现可靠消息最终一致性事务
5.3.1 . 业务说明
本实例通过 RocketMQ 中间件实现可靠消息最终一致性分布式事务,模拟两个账户的转账交易过程。
两个账户在分别在不同的银行 (张三在 bank1、李四在 bank2),bank1、bank2 是两个微服务。
交易过程是,张三给李四转账指定金额。
上述交易步骤,张三扣减金额与给 bank2 发转账消息,两个操作必须是一个整体性的事务
5.3.2 . 程序组成部分
本示例程序组成部分如下:
包括 bank1 和 bank2 两个数据库。
rocketmq 服务端:RocketMQ-4.5.0
rocketmq 客户端:RocketMQ-Spring-Boot-starter.2.0.2-RELEASE
微服务框架:spring-boot-2.1.3、spring-cloud-Greenwich.RELEASE
微服务及数据库的关系 :
dtx/dtx-txmsg-demo/dtx-txmsg-demo-bank1 银行 1,操作张三账户, 连接数据库 bank1
dtx/dtx-txmsg-demo/dtx-txmsg-demo-bank2 银行 2,操作李四账户,连接数据库 bank2
本示例程序技术架构如下:
交互流程如下:
1、Bank1 向 MQ Server 发送转账消息
2、Bank1 执行本地事务,扣减金额
3、Bank2 接收消息,执行本地事务,添加金额
5.3.3 . 创建数据库
导入数据库脚本:资料 \ sql\bank1.sql、资料 \ sql\bank2.sql,已经导过不用重复导入。
创建 bank1 库创建 bank2 库,并导入以下表结构和数据 (包含李四账户)
在 bank1、bank2 数据库中新增 de_duplication,交易记录表 (去重表),用于交易幂等控制。
DROP TABLE IF EXISTS `de_duplication`;
CREATE TABLE `de_duplication` (
`tx_no` varchar(64) COLLATE utf8_bin NOT NULL,
`create_time` datetime(0) NULL DEFAULT NULL,
PRIMARY KEY (`tx_no`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic;
5.3.4 . 启动 RocketMQ
(1)下载 RocketMQ 服务器
下载地址:http://mirrors.tuna.tsinghua.edu.cn/apache/rocketmq/4.5.0/rocketmq-all-4.5.0-bin-release.zip)
(2)解压并启动
# 启动nameserver:
start [rocketmq服务端解压路径]/bin/mqnamesrv.cmd
# 启动broker:
start [rocketmq服务端解压路径]/bin/mqbroker.cmd ‐n 127.0.0.1:9876 autoCreateTopicEnable=true
3.3.5 导入 dtx-txmsg-demo
dtx-txmsg-demo 是本方案的测试工程,根据业务需求需要创建两个 dtx-txmsg-demo 工程。
(1)导入dtx-txmsg-demo
导入:资料\基础代码\dtx-txmsg-demo到父工程dtx下。两个测试工程如下:
dtx/dtx-txmsg-demo/dtx-txmsg-demo-bank1 ,操作张三账户,连接数据库bank1
dtx/dtx-txmsg-demo/dtx-txmsg-demo-bank2 ,操作李四账户,连接数据库bank2
(2)父工程maven依赖说明
在dtx父工程中指定了SpringBoot和SpringCloud版本
在dtx-txmsg-demo父工程中指定了rocketmq-spring-boot-starter的版本。
(3)配置rocketMQ
在application-local.propertis中配置rocketMQ nameServer地址及生产组:
其它详细配置见导入的基础工程。
3.3.6 dtx-txmsg-demo-bank1
dtx-txmsg-demo-bank1 实现如下功能:
1、张三扣减金额,提交本地事务。
2、向 MQ 发送转账消息。
2)Dao
@Mapper
@Component
public interface AccountInfoDao {
@Update("update account_info set account_balance=account_balance+#{amount} where account_no=#{accountNo}")
int updateAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
@Select("select * from account_info where where account_no=#{accountNo}")
AccountInfo findByIdAccountNo(@Param("accountNo") String accountNo);
@Select("select count(1) from de_duplication where tx_no = #{txNo}")
int isExistTx(String txNo);
@Insert("insert into de_duplication values(#{txNo},now());")
int addTx(String txNo);
}
controller
@RestController
@Slf4j
public class AccountInfoController {
@Autowired
private AccountInfoService accountInfoService;
@GetMapping(value = "/transfer")
public String transfer(@RequestParam("accountNo")String accountNo, @RequestParam("amount") Double amount){
// 创建一个事务id,作为消息内容发到mq
String tx_no = UUID.randomUUID().toString();
AccountChangeEvent accountChangeEvent = new AccountChangeEvent(accountNo,amount,tx_no);
// 发送消息
accountInfoService.sendUpdateAccountBalance(accountChangeEvent);
return "转账成功";
}
}
3) AccountInfoService 生产消息
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
AccountInfoDao accountInfoDao;
@Autowired
RocketMQTemplate rocketMQTemplate;
//向mq发送转账消息
@Override
public void sendUpdateAccountBalance(AccountChangeEvent accountChangeEvent) {
// 将accountChangeEvent转成json
JSONObject jsonObject =new JSONObject();
jsonObject.put("accountChange",accountChangeEvent);
String jsonString = jsonObject.toJSONString();
// 生成message类型
Message<String> message = MessageBuilder.withPayload(jsonString).build();
//发送一条事务消息
/**
* String txProducerGroup 生产组
* String destination topic,
* Message<?> message, 消息内容
* Object arg 参数
*/
rocketMQTemplate.sendMessageInTransaction("producer_group_txmsg_bank1",
"topic_txmsg",
message,
null);
}
//更新账户,扣减金额
@Override
@Transactional
public void doUpdateAccountBalance(AccountChangeEvent accountChangeEvent) {
// 幂等判断
if(accountInfoDao.isExistTx(accountChangeEvent.getTxNo())>0){
return ;
}// 有线程安全问题,所以上面步骤其实得用分布式锁?
// 扣减金额
accountInfoDao.updateAccountBalance(accountChangeEvent.getAccountNo(),accountChangeEvent.getAmount() * -1);
// 添加事务日志
accountInfoDao.addTx(accountChangeEvent.getTxNo());
if(accountChangeEvent.getAmount() == 3){
throw new RuntimeException("人为制造异常");
}
}
}
上面只是发消息,那么半消息什么的怎么实现的?
其实发 MQ 消息后就有回调方法执行本地事务,本地事务执行完后就执行 commit
看下面的回调方法代码
回调方法
@Component
@Slf4j
@RocketMQTransactionListener(txProducerGroup = "producer_group_txmsg_bank1")
public class ProducerTxmsgListener implements RocketMQLocalTransactionListener {
@Autowired
AccountInfoService accountInfoService;
@Autowired
AccountInfoDao accountInfoDao;
// 事务消息发送后的回调方法,当消息发送给mq成功,此方法被回调
@Override
@Transactional
public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
try {
// 解析message,转成AccountChangeEvent
String messageString = new String((byte[]) message.getPayload());
JSONObject jsonObject = JSONObject.parseObject(messageString);
String accountChangeString = jsonObject.getString("accountChange");
// 将accountChange(json)转成AccountChangeEvent
AccountChangeEvent accountChangeEvent = JSONObject.parseObject(accountChangeString, AccountChangeEvent.class);
// 执行本地事务,扣减金额
accountInfoService.doUpdateAccountBalance(accountChangeEvent);
// 当返回RocketMQLocalTransactionState.COMMIT,自动向mq发送commit消息,mq将消息的状态改为可消费
return RocketMQLocalTransactionState.COMMIT;
} catch (Exception e) {
e.printStackTrace();
// 发送ROLLBACK
return RocketMQLocalTransactionState.ROLLBACK;
}
}
// 事务状态回查,查询是否张三扣减金额
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
// 解析message,转成AccountChangeEvent
String messageString = new String((byte[]) message.getPayload());
JSONObject jsonObject = JSONObject.parseObject(messageString);
String accountChangeString = jsonObject.getString("accountChange");
// 将accountChange(json)转成AccountChangeEvent
AccountChangeEvent accountChangeEvent = JSONObject.parseObject(accountChangeString, AccountChangeEvent.class);
// 事务id
String txNo = accountChangeEvent.getTxNo();
int existTx = accountInfoDao.isExistTx(txNo );
if(existTx>0){
return RocketMQLocalTransactionState.COMMIT;
}else{
// 没有commit
return RocketMQLocalTransactionState.UNKNOWN;
}
}
}
3.3.7 dtx-txmsg-demo-bank2
项目 1 已经保证了 bank1 执行成功,下面就该保证 bank2 执行成功了
dtx-txmsg-demo-bank2 需要实现如下功能:
1、监听 MQ,接收消息。
2、接收到消息增加账户金额。
1) Service
注意为避免消息重复发送,这里需要实现幂等。
@Mapper
@Component
public interface AccountInfoDao {
@Update("update account_info set account_balance=account_balance+#{amount} where account_no=#{accountNo}")
int updateAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
@Select("select count(1) from de_duplication where tx_no = #{txNo}")
int isExistTx(String txNo);
@Insert("insert into de_duplication values(#{txNo},now());")
int addTx(String txNo);
}
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
AccountInfoDao accountInfoDao;
//更新账户,增加金额
@Override
@Transactional
public void addAccountInfoBalance(AccountChangeEvent accountChangeEvent) {
log.info("bank2更新本地账号,账号:{},金额:{}",accountChangeEvent.getAccountNo(),accountChangeEvent.getAmount());
if(accountInfoDao.isExistTx(accountChangeEvent.getTxNo())>0){
return ;
}
//增加金额
accountInfoDao.updateAccountBalance(accountChangeEvent.getAccountNo(),accountChangeEvent.getAmount());
//添加事务记录,用于幂等
accountInfoDao.addTx(accountChangeEvent.getTxNo());
if(accountChangeEvent.getAmount() == 4){
throw new RuntimeException("人为制造异常");
}
}
}
2) MQ 监听类
@Component
@Slf4j
@RocketMQMessageListener(consumerGroup = "consumer_group_txmsg_bank2",topic = "topic_txmsg")
public class TxmsgConsumer implements RocketMQListener<String> {
@Autowired
AccountInfoService accountInfoService;
//接收消息
@Override
public void onMessage(String message) {
log.info("开始消费消息:{}",message);
// 解析消息
JSONObject jsonObject = JSONObject.parseObject(message);
String accountChangeString = jsonObject.getString("accountChange");
// 转成AccountChangeEvent
AccountChangeEvent accountChangeEvent = JSONObject.parseObject(accountChangeString, AccountChangeEvent.class);
// 设置账号为李四的
accountChangeEvent.setAccountNo("2");
// 更新本地账户,增加金额 // service
accountInfoService.addAccountInfoBalance(accountChangeEvent);
}
}
思考:bank2 应该启用 ACK 机制
A 发送完 B 挂了怎么办?那只能重启 B 了。所以这种场景下 AB 不可逆,必须先扣 A 的钱,然后加 B 的钱
5.3.8 测试场景
bank1 本地事务失败,则 bank1 不发送转账消息。
bank2 接收转账消息失败,会进行重试发送消息。
bank2 多次消费同一个消息,实现幂等。
5.4. 小结
可靠消息最终一致性就是保证消息从生产方经过消息中间件传递到消费方的一致性,本案例使用了 RocketMQ 作为消息中间件,RocketMQ 主要解决了两个功能:
1、本地事务与消息发送的原子性问题。
2、事务参与方接收消息的可靠性。
可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景。引入消息机制后,同步的事务操作变为基于消息执行的异步操作, 避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。
六、解决方案之最大努力通知
不能用于转账,只能用于转账后的通知
6.1 . 什么是最大努力通知
最大努力通知也是一种解决分布式事务的方案,
下边是一个是充值的例子:
交互流程:
1、账户系统调用充值系统接口
2、充值系统完成支付处理向账户系统发起充值结果通知(这个是重点)
若通知失败,则充值系统按策略进行重复通知
3、账户系统接收到充值结果通知修改充值状态。
4、账户系统未接收到通知会主动调用充值系统的接口查询充值结果。
如果实在通知不到,就提供查询接口,让用户主动去查询一遍
通过上边的例子我们总结最大努力通知方案的目标:
目标:发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。具体包括:
1、有一定的消息重复通知机制。
因为接收通知方可能没有接收到通知,此时要有一定的机制对消息重复通知。
2、消息校对机制。
如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息信息来满足需求。
最大努力通知与可靠消息一致性有什么不同?
1、解决方案思想不同
- 可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知方来保证。
- 最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。
2、两者的业务应用场景不同
可靠消息一致性关注的是交易过程的事务一致,以异步的方式完成交易。最大努力通知关注的是交易后的通知事务,即将交易结果可靠的通知出去。
3、技术解决方向不同
- 可靠消息一致性要解决消息从发出到接收的一致性,即消息发出并且被接收到。
- 最大努力通知无法保证消息从发出到接收的一致性,只提供消息接收的可靠性机制。可靠机制是,最大努力的将消息通知给接收方,当消息无法被接收方接收时,由接收方主动查询消息(业务处理结果)。
6.2 . 解决方案
通过对最大努力通知的理解,采用 MQ 的 ack 机制就可以实现最大努力通知。
方案 1
重点不在于谁发起调用,重点是将通知给谁
但是问题是接收方居然是个 MQ,浏览器怎么会有 MQ 呢?
还有支付宝的 MQ 怎么会让你监控呢?
所以后面会有优化
本方案是利用 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、接收通知方可通过消息校对接口来校对消息的一致性。
方案 2:
本方案也是利用 MQ 的 ack 机制,与方案 1 不同的是应用程序向接收通知方发送通知,如下图:
交互流程如下:
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 的消息后由通知程序通过互联网接口协议调用接收通知方。此方案主要应用于外部应用之间的通知,例如支付宝、微信的支付结果通知。
6.3. RocketMQ 实现最大努力通知型事务
6.3.1 . 业务说明
本实例通过 RocketMQ 中间件实现最大努力通知型分布式事务,模拟充值过程。
本案例有账户系统和充值系统两个微服务,其中
- 账户系统的数据库是 bank1 数据库,其中有张三账户。
- 充值系统的数据库是 bank1_pay 数据库,记录了账户的充值记录。
业务流程如下图:
交互流程如下:
1、用户请求充值系统进行充值。
2、充值系统完成充值将充值结果发给 MQ。
3、账户系统监听 MQ,接收充值结果通知,如果接收不到消息,MQ 会重复发送通知。接收到充值结果通知账户系统增加充值金额。
4、账户系统也可以主动查询充值系统的充值结果查询接口,增加金额。
6.3.2 . 程序组成部分
本示例程序组成部分如下:
包括 bank1 和 bank1_pay 两个数据库。
rocketmq 服务端:RocketMQ-4.5.0
rocketmq 客户端:RocketMQ-Spring-Boot-starter.2.0.2-RELEASE
微服务框架:spring-boot-2.1.3、spring-cloud-Greenwich.RELEASE
微服务及数据库的关系 :
dtx/dtx-notifymsg-demo/dtx-notifymsg-demo-bank1 银行 1,操作张三账户, 连接数据库 bank1
dtx/dtx-notifymsg-demo/dtx-notifymsg-demo-pay 银行 2,操作充值记录,连接数据库 bank1_pay
交互流程如下:
1、用户请求充值系统进行充值。
2、充值系统完成充值将充值结果发给 MQ。
3、账户系统监听 MQ,接收充值结果通知,如果接收不到消息,MQ 会重复发送通知。接收到充值结果通知账户系统增加充值金额。
4、账户系统也可以主动查询充值系统的充值结果查询接口,增加金额。
6.3.3 . 创建数据库
导入数据库脚本:资料 \ sql\bank1.sql、资料 \ sql\bank1_pay.sql,已经导过不用重复导入。
创建 bank1 库,并导入以下表结构和数据 (包含张三账户)
创建 bank1_pay 库,并导入以下表结构:
CREATE DATABASE `bank1_pay` CHARACTER SET 'utf8' COLLATE 'utf8_general_ci';
CREATE TABLE `account_pay` (
`id` varchar(64) COLLATE utf8_bin NOT NULL,
`account_no` varchar(100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '账号',
`pay_amount` double NULL DEFAULT NULL COMMENT '充值余额',
`result` varchar(20) COLLATE utf8_bin DEFAULT NULL COMMENT '充值结果:success,fail',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic;
6.3.4 . 启动 RocketMQ
rocketmq 启动方式与 RocketMQ 实现可靠消息最终一致性事务中完全一致
6.3.5 discover-server
discover-server 是服务注册中心,测试工程将自己注册至 discover-server。
导入:资料 \ 基础代码 \ dtx 父工程,此工程自带了 discover-server,discover-server 基于 Eureka 实现。已经导过不用重复导入。
6.3.6 导入 dtx-notifymsg-demo
dtx-notifymsg-demo 是本方案的测试工程,根据业务需求需要创建两个 dtx-notifymsg-demo 工程。
(1)导入 dtx-notifymsg-demo
导入:资料 \ 基础代码 \ dtx-notifymsg-demo 到父工程 dtx 下。两个测试工程如下:
dtx/dtx-notifymsg-demo/dtx-notifymsg-demo-bank1 ,操作张三账户,连接数据库 bank1
dtx/dtx-notifymsg-demo/dtx-notifymsg-demo-pay,操作李四账户,连接数据库 bank1_pay
(2)父工程 maven 依赖说明
在 dtx 父工程中指定了 SpringBoot 和 SpringCloud 版本
在 dtx-notifymsg-demo 父工程中指定了 rocketmq-spring-boot-starter 的版本。
(3) 配置 rocketMQ
在 application-local.propertis 中配置 rocketMQ
nameServer 地址及生产组:
其它详细配置见导入的基础工程。
6.3.7 pay(第三方充值系统)
dtx-notifydemo-pay 实现如下功能:
1、充值接口
2、充值完成要通知
3、充值结果查询接口
@Mapper
@Component
public interface AccountPayDao {
// 插入充值记录
@Insert("insert into account_pay(id,account_no,pay_amount,result) values(#{id},#{accountNo},#{payAmount},#{result})")
int insertAccountPay(@Param("id") String id,@Param("accountNo") String accountNo,
@Param("payAmount") Double pay_amount,@Param("result") String result);
// 查询充值结果
@Select("select id,account_no accountNo,pay_amount payAmount,result from account_pay where id=#{txNo}")
AccountPay findByIdTxNo(@Param("txNo") String txNo);
}
3) Service
@Service
@Slf4j
public class AccountPayServiceImpl implements AccountPayService {
@Autowired
AccountPayDao accountPayDao;
@Autowired
RocketMQTemplate rocketMQTemplate;
//插入充值记录
@Override
public AccountPay insertAccountPay(AccountPay accountPay) {
int success = accountPayDao.insertAccountPay(accountPay.getId(), accountPay.getAccountNo(),
accountPay.getPayAmount(), "success");
if(success>0){
// 发送通知,使用普通消息发送通知
accountPay.setResult("success");
rocketMQTemplate.convertAndSend("topic_notifymsg",accountPay);
return accountPay;
}
return null;
}
// 查询充值记录,接收通知方调用此方法来查询充值结果
@Override
public AccountPay getAccountPay(String txNo) {
AccountPay accountPay = accountPayDao.findByIdTxNo(txNo);
return accountPay;
}
}
4) Controller
@RestController
public class AccountPayController {
@Autowired
AccountPayService accountPayService;
//充值
@GetMapping(value = "/paydo")
public AccountPay pay(AccountPay accountPay){
//生成事务编号
String txNo = UUID.randomUUID().toString();
accountPay.setId(txNo);
return accountPayService.insertAccountPay(accountPay);
}
//查询充值结果 // 事务号就是刚才的主键id'
@GetMapping(value = "/payresult/{txNo}")
public AccountPay payresult(@PathVariable("txNo") String txNo){
return accountPayService.getAccountPay(txNo);
}
}
6.3.8 bank1(本地账户系统)
dtx-notifydemo-bank1 实现如下功能:
1、监听 MQ,接收充值结果,根据充值结果完成账户金额修改。
2、主动查询充值系统,根据充值结果完成账户金额修改。
1)DAO
@Mapper
@Component
public interface AccountInfoDao {
// 修改账户金额
@Update("update account_info set account_balance=account_balance+#{amount} where account_no=#{accountNo}")
int updateAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
// 查询幂等记录,用于幂等控制
@Select("select count(1) from de_duplication where tx_no = #{txNo}")
int isExistTx(String txNo);
// 添加事务记录,用于幂等控制
@Insert("insert into de_duplication values(#{txNo},now());")
int addTx(String txNo);
}
2)Service
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
AccountInfoDao accountInfoDao;
@Autowired
PayClient payClient;
// 更新账户金额
@Override
@Transactional
public void updateAccountBalance(AccountChangeEvent accountChange) {
// 幂等校验
if(accountInfoDao.isExistTx(accountChange.getTxNo())>0){
return ;
}
int i = accountInfoDao.updateAccountBalance(accountChange.getAccountNo(), accountChange.getAmount());
//插入事务记录,用于幂等控制
accountInfoDao.addTx(accountChange.getTxNo());
}
//远程调用查询充值结果
@Override
public AccountPay queryPayResult(String tx_no) {
//远程调用
AccountPay payresult = payClient.payresult(tx_no);
if("success".equals(payresult.getResult())){
//更新账户金额
AccountChangeEvent accountChangeEvent = new AccountChangeEvent();
accountChangeEvent.setAccountNo(payresult.getAccountNo());//账号
accountChangeEvent.setAmount(payresult.getPayAmount());//金额
accountChangeEvent.setTxNo(payresult.getId());//充值事务号
updateAccountBalance(accountChangeEvent);
}
return payresult;
}
}
3)监听 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));
if("success".equals(accountPay.getResult())){
//更新账户金额
AccountChangeEvent accountChangeEvent = new AccountChangeEvent();
accountChangeEvent.setAccountNo(accountPay.getAccountNo());
accountChangeEvent.setAmount(accountPay.getPayAmount());
accountChangeEvent.setTxNo(accountPay.getId());
accountInfoService.updateAccountBalance(accountChangeEvent);
}
log.info("处理消息完成:{}", JSON.toJSONString(accountPay));
}
}
4)Controller
@RestController
@Slf4j
public class AccountInfoController {
@Autowired
private AccountInfoService accountInfoService;
// 主动查询充值结果
@GetMapping(value = "/payresult/{txNo}")
public AccountPay result(@PathVariable("txNo") String txNo){
AccountPay accountPay = accountInfoService.queryPayResult(txNo);
return accountPay;
}
}
@FeignClient(value = "dtx-notifymsg-demo-pay",fallback = PayFallback.class)
public interface PayClient {
//远程调用充值系统的接口查询充值结果
@GetMapping(value = "/pay/payresult/{txNo}")
public AccountPay payresult(@PathVariable("txNo") String txNo);
}
@Component // 降级方法
public class PayFallback implements PayClient {
@Override
public AccountPay payresult(String txNo) {
AccountPay accountPay = new AccountPay();
accountPay.setResult("fail");
return accountPay;
}
}
6.3.9 测试场景
充值系统充值成功,账户系统主动查询充值结果,修改账户金额。充值系统充值成功,发送消息,账户系统接收消息,修改账户金额。账户系统修改账户金额幂等测试。
6.4. 小结
最大努力通知方案是分布式事务中对一致性要求最低的一种, 适用于一些最终一致性时间敏感度低的业务;最大努力通知方案需要实现如下功能:
1、消息重复通知机制。
2、消息校对机制。
七、综合案例分析
前边我们已经学习了四种分布式事务解决方案,2PC、TCC、可靠消息最终一致性、最大努力通知,每种解决方案我们通过案例开发进行学习,本章节我们结合互联网金融项目中的业务场景,来进行分布式事务解决方案可行性分析。
7.1 . 系统介绍
7.1.1. P2P 介绍
P2P 金融又叫 P2P 信贷。其中 P2P 是 peer-to-peer 或 person-to-person 的简写,意思是:个人对个人。P2P 金融指个人与个人间的小额借贷交易,一般需要借助电子商务专业网络平台帮助借贷双方确立借贷关系并完成相关交易手续。借款者可自行发布借款信息,包括金额、利息、还款方式和时间,实现自助式借款; 投资者根据借款人发布的信息,自行决定出借金额,实现自助式借贷。
目前,国家对 P2P 行业的监控与规范性控制越来越严格,出台了很多政策来对其专项整治。并主张采用 “银行存管模式” 来规避 P2P 平台挪用借投人资金的风险,通过银行开发的 “银行存管系统” 管理投资者的资金,**每位 **P**2****P 平台用户在银行的存管系统内都会有一个独立账号**,平台来管理交易,做到资金和交易分开,让 P2P 平台不能接触到资金,就可以一定程度避免资金被挪用的风险。
什么是银行存管模式?
银行存管模式涉及到 2 套账户体系,P2P 平台和银行各一套账户体系。投资人在 P2P 平台注册后,会同时跳转到银行再开一个电子账户,2 个账户间有一一对应的关系。当投资人投资时,资金进入的是平台在银行为投资人开设的二级账户中,每一笔交易,是由银行在投资人与借款人间的交易划转,P2P 平台仅能看到信息的流动。
7.1.2. 总体业务流程
7.1.2. 业务术语
术语 | 描述 |
---|---|
银行存管模式 | 此种模式下,涉及到 2 套账户体系,P2P 平台和银行各一套账户体系。投资人在 P2P 平台注册后,会同时跳转到银行再开一个电子账户,2 个账户间有一一对应的关系。当投资人投资时,资金进入的是平台在银行为投资人开设的二级账户中,每一笔交易,是由银行在投资人与借款人间的交易划转,P2P 平台仅能看到信息的流动。 |
标的 | P2P 业内,习惯把借款人发布的投资项目称为 “标的”。 |
发标 | 借款人在 P2P 平台中创建并发布 “标的” 过程。 |
投标 | 投资人在认可相关借款人之后进行的一种借贷行为,对自己中意的借款标的进行投资操作,一个借款标可由单个投资人或多个投资人承接。 |
满标 | 单笔借款标筹集齐所有借款资金即为满标,计息时间是以标满当日开始计息,投资人较多的平台多数会当天满标。 |
7.1.2. 模块说明
统一账号服务
用户的登录账号、密码、角色、权限、资源等系统级信息的管理,不包含用户业务信息。用户中心
提供用户业务信息的管理,如会员信息、实名认证信息、绑定银行卡信息等,“用户中心” 的每个用户与 “统一账号服务” 中的账号关联。
交易中心
提供发标、投标等业务。还款服务
提供还款计划的生成、执行、记录与归档。银行存管系统 (模拟)
模拟银行存管系统,进行资金的存管,划转。
7.2 . 注册账号案例分析
7.2.1 . 业务流程
采用用户、账号分离设计 (这样设计的好处是,当用户的业务信息发生变化时,不会影响的认证、授权等系统机制),因此需要保证用户信息与账号信息的一致性。
用户向用户中心发起注册请求,用户中心保存用户业务信息,然后通知统一账号服务新建该用户所对应登录账号。
7.2.2 . 解决方案分析
针对注册业务,如果用户与账号信息不一致,则会导致严重问题,因此该业务对一致性要求较为严格,即当用户服务和账号服务任意一方出现问题都需要回滚事务。
根据上述需求进行解决方案分析:
1、采用可靠消息一致性方案
可靠消息一致性要求只要消息发出,事务参与者接到消息就要将事务执行成功,不存在回滚的要求,所以不适用。
2、采用最大努力通知方案
最大努力通知表示发起通知方执行完本地事务后将结果通知给事务参与者,即使事务参与者执行业务处理失败发起通知方也不会回滚事务,所以不适用。
3、采用 Seata 实现 2PC
在用户中心发起全局事务,统一账户服务为事务参与者,用户中心和统一账户服务只要有一方出现问题则全局事务回滚,符合要求。
实现方法如下:
1、用户中心添加用户信息,开启全局事务
2、统一账号服务添加账号信息,作为事务参与者
3、其中一方执行失败 Seata 对 SQL 进行逆操作删除用户信息和账号信息,实现回滚。
4、采用 Hmily 实现 TCC
TCC 也可以实现用户中心和统一账户服务只要有一方出现问题则全局事务回滚,符合要求。实现方法如下:
1、用户中心
try:添加用户,状态为不可用
confirm:更新用户状态为可用
cancel:删除用户
2、统一账号服务
try:添加账号,状态为不可用
confirm:更新账号状态为可用
cancel:删除账号
7.3 . 存管开户
7.3.1 . 业务流程
根据政策要求,P2P 业务必须让银行存管资金,用户的资金在银行存管系统的账户中,而不在 P2P 平台中,因此用户要在银行存管系统开户。
用户向用户中心提交开户资料,用户中心生成开户请求号并重定向至银行存管系统开户页面。用户设置存管密码并确认开户后,银行存管立即返回 “请求已受理”。在某一时刻,银行存管系统处理完该开户请求后,将调用回调地址通知处理结果,若通知失败,则按一定策略重试通知。同时,银行存管系统应提供开户结果查询的接口,供用户中心校对结果。
7.3.2 . 解决方案分析
P2P 平台的用户中心与银行存管系统之间属于跨系统交互,银行存管系统属于外部系统,用户中心无法干预银行存管系统,所以用户中心只能在收到银行存管系统的业务处理结果通知后积极处理,开户后的使用情况完全由用户中心来控制。
根据上述需求进行解决方案分析:
1、采用 Seata 实现 2PC
需要侵入银行存管系统的数据库,由于它的外部系统,所以不适用。
2、采用 Hmily 实现 TCC
TCC 侵入性更强,所以不适用。
3、基于 MQ 的可靠消息一致性
如果让银行存管系统监听 MQ 则不合适 ,因为它的外部系统。
如果银行存管系统将消息发给 MQ 用户中心监听 MQ 是可以的,但是由于相对银行存管系统来说用户中心属于外部系统,银行存管系统是不会让外部系统直接监听自己的 MQ 的,基于 MQ 的通信协议也不方便外部系统间的交互,所以本方案不合适。
4、最大努力通知方案
银行存管系统内部使用 MQ,银行存管系统处理完业务后将处理结果发给 MQ,由银行存管的通知程序专门发送通知,并且采用互联网协议通知给第三方系统(用户中心)。
下图中发起通知即银行存管系统:
7.4 . 满标审核
7.4.1 . 业务流程
在借款人标的募集够所有的资金后,P2P 运营管理员审批该标的,触发放款,并开启还款流程。
管理员对某标的满标审批通过,交易中心修改标的状态为 “还款中”,同时要通知还款服务生成还款计划。
7.4.2 . 解决方案分析
生成还款计划是一个执行时长较长的业务,不建议阻塞主业务流程,此业务对一致性要求较低。根据上述需求进行解决方案分析:
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、可靠消息、最大努力通知等方案,都没有完美解决分布式事务问题,它们不过是各自在性能、一致性、可用性等方面做取舍,寻求某些场景偏好下的权衡。
sql1
/* MySQL - 5.7.21-log : Database - bank1
*********************************
*/
/*!40101 SET NAMES utf8 */;
/*!40101 SET SQL_MODE=''*/;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
CREATE DATABASE /*!32312 IF NOT EXISTS*/`bank1` /*!40100 DEFAULT CHARACTER SET utf8 */;
USE `bank1`;
/*Table structure for table `account_info` */
DROP TABLE IF EXISTS `account_info`;
CREATE TABLE `account_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`account_name` varchar(100) COLLATE utf8_bin DEFAULT NULL COMMENT '户主姓名',
`account_no` varchar(100) COLLATE utf8_bin DEFAULT NULL COMMENT '银行卡号',
`account_password` varchar(100) COLLATE utf8_bin DEFAULT NULL COMMENT '帐户密码',
`account_balance` double DEFAULT NULL COMMENT '帐户余额',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COLLATE=utf8_bin ROW_FORMAT=DYNAMIC;
/*Data for the table `account_info` */
insert into `account_info`(`id`,`account_name`,`account_no`,`account_password`,`account_balance`) values (2,'张三','1',NULL,1000);
/*Table structure for table `de_duplication` */
DROP TABLE IF EXISTS `de_duplication`;
CREATE TABLE `de_duplication` (
`tx_no` varchar(64) COLLATE utf8_bin NOT NULL,
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`tx_no`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin ROW_FORMAT=DYNAMIC;
/*Data for the table `de_duplication` */
/*Table structure for table `local_cancel_log` */
DROP TABLE IF EXISTS `local_cancel_log`;
CREATE TABLE `local_cancel_log` (
`tx_no` varchar(64) NOT NULL COMMENT '事务id',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`tx_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*Data for the table `local_cancel_log` */
/*Table structure for table `local_confirm_log` */
DROP TABLE IF EXISTS `local_confirm_log`;
CREATE TABLE `local_confirm_log` (
`tx_no` varchar(64) NOT NULL COMMENT '事务id',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`tx_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*Data for the table `local_confirm_log` */
/*Table structure for table `local_trade_log` */
DROP TABLE IF EXISTS `local_trade_log`;
CREATE TABLE `local_trade_log` (
`tx_no` bigint(20) NOT NULL,
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`tx_no`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin ROW_FORMAT=DYNAMIC;
/*Data for the table `local_trade_log` */
insert into `local_trade_log`(`tx_no`,`create_time`) values (182529322697780,'2019-08-12 10:06:48'),(182556963958191,'2019-08-12 10:07:15'),(182557059333569,'2019-08-12 10:07:15'),(182557156706509,'2019-08-12 10:07:16'),(182557256097796,'2019-08-12 10:07:16'),(182557291483231,'2019-08-12 10:07:16'),(182557355483613,'2019-08-12 10:07:16'),(182557456937006,'2019-08-12 10:07:17'),(182557520747040,'2019-08-12 10:07:17'),(182557555714584,'2019-08-12 10:07:17'),(188816462428295,'2019-08-12 11:51:46'),(188816757026017,'2019-08-12 11:51:47'),(188816903251343,'2019-08-12 11:51:47'),(188817227863181,'2019-08-12 11:51:48');
/*Table structure for table `local_try_log` */
DROP TABLE IF EXISTS `local_try_log`;
CREATE TABLE `local_try_log` (
`tx_no` varchar(64) NOT NULL COMMENT '事务id',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`tx_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*Data for the table `local_try_log` */
/*Table structure for table `undo_log` */
DROP TABLE IF EXISTS `undo_log`;
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=167 DEFAULT CHARSET=utf8;
/*Data for the table `undo_log` */
insert into `undo_log`(`id`,`branch_id`,`xid`,`context`,`rollback_info`,`log_status`,`log_created`,`log_modified`,`ext`) values (166,2019228885,'192.168.1.101:8888:2019228047','serializer=jackson','{}',1,'2019-08-11 15:16:43','2019-08-11 15:16:43',NULL);
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
sql2
/* MySQL - 5.7.21-log : Database - bank2
*********************************************************************
*/
/*!40101 SET NAMES utf8 */;
/*!40101 SET SQL_MODE=''*/;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
CREATE DATABASE /*!32312 IF NOT EXISTS*/`bank2` /*!40100 DEFAULT CHARACTER SET utf8 */;
USE `bank2`;
/*Table structure for table `account_info` */
DROP TABLE IF EXISTS `account_info`;
CREATE TABLE `account_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`account_name` varchar(100) COLLATE utf8_bin DEFAULT NULL COMMENT '户主姓名',
`account_no` varchar(100) COLLATE utf8_bin DEFAULT NULL COMMENT '银行卡号',
`account_password` varchar(100) COLLATE utf8_bin DEFAULT NULL COMMENT '帐户密码',
`account_balance` double DEFAULT NULL COMMENT '帐户余额',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COLLATE=utf8_bin ROW_FORMAT=DYNAMIC;
/*Data for the table `account_info` */
insert into `account_info`(`id`,`account_name`,`account_no`,`account_password`,`account_balance`) values (3,'李四的账户','2',NULL,0);
/*Table structure for table `de_duplication` */
DROP TABLE IF EXISTS `de_duplication`;
CREATE TABLE `de_duplication` (
`tx_no` varchar(64) COLLATE utf8_bin NOT NULL,
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`tx_no`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin ROW_FORMAT=DYNAMIC;
/*Data for the table `de_duplication` */
/*Table structure for table `local_cancel_log` */
DROP TABLE IF EXISTS `local_cancel_log`;
CREATE TABLE `local_cancel_log` (
`tx_no` varchar(64) NOT NULL COMMENT '事务id',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`tx_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*Data for the table `local_cancel_log` */
/*Table structure for table `local_confirm_log` */
DROP TABLE IF EXISTS `local_confirm_log`;
CREATE TABLE `local_confirm_log` (
`tx_no` varchar(64) NOT NULL COMMENT '事务id',
`create_time` datetime DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*Data for the table `local_confirm_log` */
/*Table structure for table `local_trade_log` */
DROP TABLE IF EXISTS `local_trade_log`;
CREATE TABLE `local_trade_log` (
`tx_no` bigint(20) NOT NULL,
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`tx_no`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin ROW_FORMAT=DYNAMIC;
/*Data for the table `local_trade_log` */
insert into `local_trade_log`(`tx_no`,`create_time`) values (182530043150692,'2019-08-12 10:06:48'),(182557045478629,'2019-08-12 10:07:15'),(182557298838620,'2019-08-12 10:07:16'),(182557478780006,'2019-08-12 10:07:16'),(182557803225927,'2019-08-12 10:07:16'),(182558121161357,'2019-08-12 10:07:16'),(182558382192161,'2019-08-12 10:07:17'),(188820404490612,'2019-08-12 11:51:39'),(188820685393110,'2019-08-12 11:51:39'),(188820917679152,'2019-08-12 11:51:40'),(188821878389027,'2019-08-12 11:51:41'),(188822534455755,'2019-08-12 11:51:41'),(188822767387230,'2019-08-12 11:51:41'),(188822952712333,'2019-08-12 11:51:42'),(188823959674263,'2019-08-12 11:51:43'),(188825409206652,'2019-08-12 11:51:44'),(188825574954701,'2019-08-12 11:51:44'),(188825774771791,'2019-08-12 11:51:44'),(188825910550941,'2019-08-12 11:51:44'),(188826172994771,'2019-08-12 11:51:45'),(188826464823692,'2019-08-12 11:51:46'),(188827317384440,'2019-08-12 11:51:46'),(188827592973234,'2019-08-12 11:51:46'),(188827921195124,'2019-08-12 11:51:46'),(188828063594889,'2019-08-12 11:51:47'),(188828333394837,'2019-08-12 11:51:47'),(188829166438333,'2019-08-12 11:51:48');
/*Table structure for table `local_try_log` */
DROP TABLE IF EXISTS `local_try_log`;
CREATE TABLE `local_try_log` (
`tx_no` varchar(64) NOT NULL COMMENT '事务id',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`tx_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*Data for the table `local_try_log` */
/*Table structure for table `undo_log` */
DROP TABLE IF EXISTS `undo_log`;
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*Data for the table `undo_log` */
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
sql3
/* Database - bank1_pay
******************************
*/
/*!40101 SET NAMES utf8 */;
/*!40101 SET SQL_MODE=''*/;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
CREATE DATABASE /*!32312 IF NOT EXISTS*/`bank1_pay` /*!40100 DEFAULT CHARACTER SET utf8 */;
USE `bank1_pay`;
/*Table structure for table `account_pay` */
DROP TABLE IF EXISTS `account_pay`;
CREATE TABLE `account_pay` (
`id` varchar(64) COLLATE utf8_bin NOT NULL,
`account_no` varchar(100) COLLATE utf8_bin DEFAULT NULL COMMENT '账号',
`pay_amount` double DEFAULT NULL COMMENT '充值余额',
`result` varchar(20) COLLATE utf8_bin DEFAULT NULL COMMENT '充值结果:success,fail',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin ROW_FORMAT=DYNAMIC;
/*Data for the table `account_pay` */
insert into `account_pay`(`id`,`account_no`,`pay_amount`,`result`) values ('5678ef0a-1ff0-4cfd-97ac-640d749d596f','1',2,'success'),('7d7d469c-f100-4066-b927-014c0c3aa010','1',2,'success'),('947fafad-c19c-46bc-b0f0-43703a124fd4','1',2,'success');
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;