分布式事务
什么是分布式事务?
对于分布式系统而言,需要保证分布式系统中的数据一致性,保证数据在子系统中始终保持一致,避免业务出现问题。
简单的说,在分布式系统上,一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务节点上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。
举个例子:在电商网站中,用户对商品进行下单,需要在订单表中创建一条订单数据,同时需要在库存表中修改当前商品的剩余库存数量,两步操作一个添加,一个修改,我们一定要保证这两步操作一定同时操作成功或失败,否则业务就会出现问题。
任何事务机制在实现时,都应该考虑事务的ACID特性,包括:本地事务、分布式事务。对于分布式事务而言,即使不能都很好的满足,也要考虑支持到什么程度。
典型的分布式事务场景:
1. 跨库事务
跨库事务指的是,一个应用某个功能需要操作多个库,不同的库中存储不同的业务数据。
2. 分库分表
通常一个库数据量比较大或者预期未来的数据量比较大,都会进行水平拆分,也就是分库分表。
对于分库分表的情况,一般开发人员都会使用一些数据库中间件来降低sql操作的复杂性。进行了分库分表后,开发人员希望将1号记录插入分库1,2号记录插入分库2。所以数据库中间件要将其改写为2条sql,分别插入两个不同的分库,此时要保证两个库要不都成功,要不都失败,因此基本上所有的数据库中间件都面临着分布式事务的问题。3. 微服务化
Service A完成某个功能需要直接操作数据库,同时需要调用Service B和Service C,而Service B又同时操作了2个数据库,Service C也操作了一个库。需要保证这些跨服务的对多个数据库的操作要不都成功,要不都失败,实际上这可能是最典型的分布式事务场景。
分布式事务实现方案必须要考虑性能的问题,如果为了严格保证ACID特性,导致性能严重下降,那么对于一些要求快速响应的业务,是无法接受的。
理论基础:
CAP:指的是在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)这三个要素最多只能同时实现两点,不可能三者兼顾。在分布式场景中,由于网络硬件等客观因素,网络之间的通信可能会存在中断、丢包等情况,所以分区容错性(Partition tolerance)是我们分布式场景中必须要满足的,三要素中就只能有有这2种组合:CP和AP。
AP:AP模型强调的是系统的可用性,在做系统设计时,需要优先考虑可用性;
CP:CP模型强调的是系统的一致性,在做系统设计时,需要优先考虑一致性;
基于CAP定理的AP模型和CP模型,又演化出了BASE理论。
BASE是Basically Available(基本可用)、Soft state(软状态)和Eventually consistent(最终一致性)的简写。核心是既然没办法做到强一致性,但每一个应用都可根据自身的业务特点采用适当的方式来达到最终一致性。
Basically Available(基本可用):指系统出现不可预知的故障时,允许损失部分可用性,但这绝不等价于不可用。
Soft state(软状态):系统中的数据存在中间状态,并认为该中间状态不影响系统的整体可用性,即表示数据副本之间的同步有延迟。
Eventually consistent(最终一致性):系统中的所有数据副本,在经过一段时间后,所有数据的状态都能达到一个最终的一致的状态。
刚性分布式事务
刚性分布式事务的特点是:数据的状态强调的是强一致性,系统能支持的并发低,事务执行的时间都比较短,属于短事务,所有数据在事务内同步执行。刚性分布式事务遵循XA协议,通过实现XA的接口来实现分布式事务。XA规范由AP(Application Program)、RM(Resource Manager)、TM(Transaction Manager)组成。
AP:定义事务的开始和结束,并访问事务内的资源;
RM:通常指的就是数据库资源;
TM:负责管理事务,分配事务的唯一标识、监控事务的执行情况、并负责事务的提交、回滚等操作;
柔性分布式事务
柔性分布式事务是相对刚性分布式事务、是对强一致性的妥协,从而降低对数据库资源的锁定时间,提升系统的性能。柔性分布式事务适合于长事务、高并发,强调最终一致性的场合。常用的实现柔性分布式事务的方式有:TCC模型、Saga模型、基于消息队列的异步模型。
分布式事务有哪些解决方案?
分布式的XA协议:
XA协议是一个基于数据库的分布式事务协议,其分为两部分:事务管理器和本地资源管理器。事务管理器作为一个全局的调度者,负责对各个本地资源管理器统一号令提交或者回滚。二阶提交协议(2PC)
和三阶提交协议(3PC)
就是根据此协议衍生出来而来。主流的诸如Oracle、MySQL等数据库均已实现了XA接口。
XA接口是双向的系统接口,在事务管理器(Transaction Manager)以及一个或多个资源管理器(Resource Manager)之间形成通信桥梁。也就是说,在基于XA的一个事务中,我们可以针对多个资源进行事务管理,例如一个系统访问多个数据库,或即访问数据库、又访问像消息中间件这样的资源。这样我们就能够实现在多个数据库和消息中间件直接实现全部提交、或全部取消的事务。XA规范不是java的规范,而是一种通用的规范。
2PC:
两段提交顾名思义就是要进行两个阶段的提交:
- 第一阶段,准备阶段(投票阶段);
- 第二阶段,提交阶段(执行阶段)。
一个下单请求过来通过协调者,给每一个参与者发送Prepare消息,执行本地数据脚本但不提交事务。如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中被占用的资源,显然2PC做到了所有操作要么全部成功、要么全部失败.
两段提交(2PC)的缺点:二阶段提交看似能够提供原子性的操作,但它存在着严重的缺陷:
- 网络抖动导致的数据不一致:第二阶段中协调者向参与者发送commit命令之后,一旦此时发生网络抖动,导致一部分参与者接收到了commit请求并执行,可其他未接到commit请求的参与者无法执行事务提交。进而导致整个分布式系统出现了数据不一致。
- 超时导致的同步阻塞问题:2PC中的所有的参与者节点都为事务阻塞型,当某一个参与者节点出现通信超时,其余参与者都会被动阻塞占用资源不能释放。
- 单点故障的风险:由于严重的依赖协调者,一旦协调者发生故障,而此时参与者还都处于锁定资源的状态,无法完成事务commit操作。虽然协调者出现故障后,会重新选举一个协调者,可无法解决因前一个协调者宕机导致的参与者处于阻塞状态的问题。
3PC:
三段提交(3PC
)是二阶段提交(2PC
)的一种改进版本 。
2PC
中只有协调者有超时机制,3PC
在协调者和参与者中都引入了超时机制,协调者出现故障后,参与者就不会一直阻塞。而且在第一阶段和第二阶段中又插入了一个准备阶段,保证了在最后提交阶段之前各参与节点的状态是一致的。
虽然 3PC
用超时机制,解决了协调者故障后参与者的阻塞问题,但与此同时却多了一次网络通信,性能上反而变得更差,也不太推荐。解决了2PC的单点故障问题,但3PC还是没能从根本上解决数据一致性的问题。
3PC的三个阶段分别是CanCommit、PreCommit、DoCommit:
- CanCommit:协调者向所有参与者发送CanCommit命令,询问是否可以执行事务提交操作。如果全部响应YES则进入下一个阶段。
- PreCommit:协调者向所有参与者发送PreCommit命令,询问是否可以进行事务的预提交操作,参与者接收到PreCommit请求后,如参与者成功的执行了事务操作,则返回Yes响应,进入最终commit阶段。一旦参与者中有向协调者发送了No响应,或因网络造成超时,协调者没有接到参与者的响应,协调者向所有参与者发送abort请求,参与者接受abort命令执行事务的中断。
- DoCommit:在前两个阶段中所有参与者的响应反馈均是YES后,协调者向参与者发送DoCommit命令正式提交事务,如协调者没有接收到参与者发送的ACK响应,会向所有参与者发送abort请求命令,执行事务的中断。
TCC:
TCC
为在业务层编写代码实现的两阶段提交(2PC是DB层面的)。 TCC
分别指 Try
、Confirm
、Cancel
,一个业务操作要对应的写这三个方法。 Try
阶段去占库存,Confirm
阶段则实际扣库存,如果库存扣减失败 Cancel
阶段进行回滚,释放库存。 Cancel
来进行回滚补偿,这也就是常说的补偿性事务。
TCC的缺点:
- 应用侵入性强:TCC由于基于在业务层面,至使每个操作都需要有try、confirm、cancel三个接口。
- 开发难度大:代码开发量很大,要保证数据一致性confirm和cancel接口还必须实现幂等性。
原本一个方法,现在却需要三个方法来支持,可以看到 TCC 对业务的侵入性很强,而且这种模式并不能很好地被复用,会导致开发量激增。还要考虑到网络波动等原因,为保证请求一定送达都会有重试机制,所以考虑到接口的幂等性。
消息事务(最终一致性):
消息事务其实就是基于消息中间件的两阶段提交,将本地事务和发消息放在同一个事务里,保证本地操作和发送消息同时成功。 下单扣库存原理图:
- 订单系统向
MQ
发送一条预备扣减库存消息,MQ
保存预备消息并返回成功ACK.
- 接收到预备消息执行成功
ACK
,订单系统执行本地下单操作,为防止消息发送成功而本地事务失败,订单系统会实现MQ
的回调接口,其内不断的检查本地事务是否执行成功,如果失败则rollback
回滚预备消息;成功则对消息进行最终commit
提交。 - 库存系统消费扣减库存消息,执行本地事务,如果扣减失败,消息会重新投,一旦超出重试次数,则本地表持久化失败消息,并启动定时任务做补偿。
基于消息中间件的两阶段提交方案,通常用在高并发场景下使用,牺牲数据的强一致性换取性能的大幅提升,不过实现这种方式的成本和复杂度是比较高的,还要看实际业务情况。
RocketMQ 如何实现分布式事务?
最终一致性: RocketMQ是一种最终一致性的分布式事务,就是说它保证的是消息最终一致性,而不是像2PC、3PC、TCC那样强一致分布式事务。 Half Message(半消息): 是指暂不能被Consumer消费的消息,在事务提交之前,对于消费者来说,这个消息是不可见的。Producer已经把消息成功发送到了Broker端,但此消息被标记为暂不能投递状态,处于该种状态下的消息称为半消息。需要 Producer对消息的二次确认后,Consumer才能去消费它。 消息回查: 由于网络闪段,生产者应用重启等原因。导致 Producer 端一直没有对 Half Message(半消息) 进行 二次确认。
这时Broker服务器会定时扫描长期处于半消息的消息,会主动询问Producer端 该消息的最终状态(Commit或者Rollback),该消息即为 消息回查。
使用 事务消息加上事务反查机制 来解决分布式事务问题。
1、A服务先发送个Half Message给broker,消息中携带 B服务 即将要+100元的信息. 2、当A服务知道Half Message发送成功后,那么开始第3步执行本地事务。 3、执行本地事务(会有三种情况1、执行成功。2、执行失败。3、网络等原因导致没有响应) 4.1)、如果本地事务成功,那么Product向broker服务器发送Commit,这样B服务就可以消费该message。 4.2)、如果本地事务失败,那么Product向broker服务器发送Rollback,那么就会直接删除上面这条半消息。 4.3)、如果因为网络等原因迟迟没有返回失败还是成功,那么会执行RocketMQ的回调接口,来进行事务的回查
事务消息发送步骤如下:
- 生产者将半事务消息发送至 RocketMQ Broker。
- RocketMQ Broker 将消息持久化成功之后,向生产者返回 Ack 确认消息已经发送成功,此时消息暂不能投递,为半事务消息。
- 生产者开始执行本地事务逻辑。
- 生产者根据本地事务执行结果向服务端提交二次确认结果(Commit或是Rollback),服务端收到确认结果后处理逻辑如下:
- 二次确认结果为Commit:服务端将半事务消息标记为可投递,并投递给消费者。
- 二次确认结果为Rollback:服务端将回滚事务,不会将半事务消息投递给消费者。
- 在断网或者是生产者应用重启的特殊情况下,若服务端未收到发送者提交的二次确认结果,或服务端收到的二次确认结果为Unknown未知状态,经过固定时间后,服务端将对消息生产者即生产者集群中任一生产者实例发起消息回查。
- 需要注意的是,服务端仅仅会按照参数尝试指定次数,超过次数后事务会强制回滚,因此未决事务的回查时效性非常关键,需要按照业务的实际风险来设置 :::
事务消息回查步骤如下: 7. 生产者收到消息回查后,需要检查对应消息的本地事务执行的最终结果。 8. 生产者根据检查得到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤4对半事务消息进行处理。
从上面流程可以得知 只有A服务本地事务执行成功 ,B服务才能消费该message。
为什么要先发送Half Message(半消息)
1)可以先确认 Broker服务器是否正常 ,如果半消息都发送失败了 那说明Broker挂了。 2)可以通过半消息来回查事务,如果半消息发送成功后一直没有被二次确认,那么就会回查事务状态。
什么情况会回查
1)执行本地事务的时候,由于突然网络等原因一直没有返回执行事务的结果(commit或者rollback)导致最终返回UNKNOW,那么就会回查。 2) 本地事务执行成功后,返回Commit进行消息二次确认的时候的服务挂了,在重启服务那么这个时候在Broker端,它还是个Half Message(半消息),这也会回查。
特别注意: 如果回查,那么一定要先查看当前事务的执行情况,再看是否需要重新执行本地事务。如果出现第二种情况而引起的回查,如果不先查看当前事务的执行情况,而是直接执行事务,那么就相当于成功执行了两个本地事务。
为什么说MQ是最终一致性事务
如果A服务本地事务都失败了,那B服务永远不会执行任何操作,因为消息压根就不会传到B服务。
A账户减100 (成功),B账户加100 (失败) 可能存在。
因为A服务只负责当我消息执行成功了,保证消息能够送达到B,至于B服务接到消息后最终执行结果A并不管。
那B服务失败怎么办?
如果B最终执行失败,几乎可以断定就是代码有问题所以才引起的异常,因为消费端RocketMQ有重试机制,如果不是代码问题一般重试几次就能成功。
如果是代码的原因引起多次重试失败后,也没有关系,将该异常记录下来,由人工处理,人工兜底处理后,就可以让事务达到最终的一致性。
如何做到写入消息但是对用户不可见呢?
RocketMQ 事务消息的做法是:如果消息是 half 消息,将备份 原消息的主题与消息消费队列,然后 改变主题 为 RMQ_SYS_TRANS_HALF_TOPIC。
由于消费组未订阅该主题,故消费端无法消费 half 类型的消息,然后 RocketMQ 会开启一个定时任务,从 Topic 为 RMQ_SYS_TRANS_HALF_TOPIC 中拉取消息进行消费,根据生产者组获取一个服务提供者发送回查事务状态请求,根据事务状态来决定是提交或回滚消息。
如果没有从第 5 步开始的 事务反查机制 ,如果出现网路波动第 4 步没有发送成功,这样就会产生 MQ 不知道是不是需要给消费者消费的问题,他就像一个无头苍蝇一样。在 RocketMQ
中就是使用的上述的事务反查来解决的,而在 Kafka
中通常是直接抛出一个异常让用户来自行解决。
在 MQ Server
指向系统 B 的操作已经和系统 A 不相关了,也就是说在消息队列中的分布式事务是——本地事务和存储消息到消息队列才是同一个事务。这样也就产生了事务的最终一致性,因为整个过程是异步的,每个系统只要保证它自己那一部分的事务就行了。
Seata:
Seata
也是从两段提交演变而来的一种分布式事务解决方案,提供了 AT
、TCC
、SAGA
和 XA
等事务模式,这里重点介绍 AT
模式。
既然 Seata
是两段提交,那我们看看它在每个阶段都做了点啥?我们以下单扣库存、扣余额举例。
Seata
分布式事务的几种角色:
-
Transaction Coordinator(TC)
: 全局事务协调者,用来协调全局事务和各个分支事务(不同服务)的状态, 驱动全局事务和各个分支事务的回滚或提交。 -
Transaction Manager(TM)
: 事务管理者,业务层中用来开启/提交/回滚一个整体事务(在调用服务的方法中用注解开启事务)。 -
Resource Manager(RM)
: 资源管理者,一般指业务数据库代表了一个分支事务(Branch Transaction
),管理分支事务与TC
进行协调注册分支事务并且汇报分支事务的状态,驱动分支事务的提交或回滚。
UNDO_LOG
(回滚日志记录表),我们在每个应用分布式事务的业务库中创建这张表,这个表的核心作用就是,将业务数据在更新前后的数据镜像组织成回滚日志,备份在 UNDO_LOG
表中,以便业务异常能随时回滚。第一个阶段
首先 Seata 的 JDBC
数据源代理通过对业务 SQL 解析,提取 SQL 的元数据,也就是得到 SQL 的类型(UPDATE
),表(user
),条件(where name = 'xxx'
)等相关的信息。先查询数据前镜像,根据解析得到的条件信息,生成查询语句,定位一条数据。紧接着执行业务 SQL,根据前镜像数据主键查询出后镜像数据。把业务数据在更新前后的数据镜像组织成回滚日志,将业务数据的更新和回滚日志在同一个本地事务中提交,分别插入到业务表和 UNDO_LOG
表中。
回滚记录数据格式如下:包括 afterImage
前镜像、beforeImage
后镜像、 branchId
分支事务ID、xid
全局事务ID
这样就可以保证,任何提交的业务数据的更新一定有相应的回滚日志。
在本地事务提交前,各分支事务需向 全局事务协调者 TC 注册分支 ( Branch Id) .
为要修改的记录申请 全局锁 ,要为这条数据加锁,利用 SELECT FOR UPDATE 语句。
而如果一直拿不到锁那就需要回滚本地事务。TM 开启事务后会生成全局唯一的 XID,会在各个调用的服务间进行传递。
有了这样的机制,本地事务分支(Branch Transaction
)便可以在全局事务的第一阶段提交,并马上释放本地事务锁定的资源。相比于传统的 XA
事务在第二阶段释放资源,Seata
降低了锁范围提高效率,即使第二阶段发生异常需要回滚,也可以快速 从UNDO_LOG
表中找到对应回滚数据并反解析成 SQL 来达到回滚补偿。
最后本地事务提交,业务数据的更新和前面生成的 UNDO LOG 数据一并提交,并将本地事务提交的结果上报给全局事务协调者 TC.
第二个阶段
第二阶段是根据各分支的决议做提交或回滚:
如果决议是全局提交,此时各分支事务已提交并成功,这时 全局事务协调者(TC)
会向分支发送第二阶段的请求。收到 TC 的分支提交请求,该请求会被放入一个异步任务队列中,并马上返回提交成功结果给 TC。异步队列中会异步和批量地根据 Branch ID
查找并删除相应 UNDO LOG
回滚记录。
RM
服务方收到 TC
全局协调者发来的回滚请求,通过 XID
和 Branch ID
找到相应的回滚日志记录,通过回滚记录生成反向的更新 SQL 并执行,以完成分支的回滚。这里删除回滚日志记录操作,一定是在本地业务事务执行之后
Seata 对业务代码的侵入性非常小,代码中使用只需用 @GlobalTransactional
注解开启一个全局事务即可。