分布式事务
事务?
严格意义上的事务执行需要遵循ACID原则(尽管也是很难完全遵循,不然为什么会有四种隔离级别呢?)
- 原子性(Atomicity),可以理解为一个事务内的所有操作要么都执行,要么都不执行。
- 一致性(Consistency),可以理解为数据是满足完整性约束的,也就是不会存在中间状态的数据,比如你账上有400,我账上有100,你给我打200块,此时你账上的钱应该是200,我账上的钱应该是300,不会存在我账上钱加了,你账上钱没扣的中间状态。
- 隔离性(Isolation),指的是多个事务并发执行的时候不会互相干扰,即一个事务内部的数据对于其他事务来说是隔离的。
- 持久性(Durability),指的是一个事务完成了之后数据就被永远保存下来,之后的其他操作或故障都不会对事务的结果产生影响。
简单来说,事务就是在执行一些操作的时候,要么都执行成功,要么都执行失败,不会出现第三种情况。
有的人可能会说,哎Redis里面不是也是有个事务吗,为什么不能保证里面的操作都能够执行成功呢?
这个详情请看。
分布式事务?
分布式事务顾名思义就是要在分布式系统中实现事务,它其实是由多个本地事务组合而成。
相较于本地事务,分布式事务是更难保证ACID原则的,但为了确保数据一致性,还是提出了几种分布式事务解决方案。
2PC(二阶段提交)
2PC(Two-phase commit protocol),中文叫二阶段提交。二阶段提交是一种强一致性设计。
2PC的话引入了一个事务协调者,事务协调者来协调管理各个事务参与者的提交和回滚。
2PC的话由两个阶段,分别是准备阶段和提交阶段:
- 准备阶段:在准备阶段,协调者会给各个参与者发送
准备
命令,然后各个参与者接到命令之后就执行各自的事务,直到除了提交事务之外的所有事情全部做完了。 - 提交阶段:在所有参与者都返回
准备成功
给协调者后,就会进入提交阶段。提交阶段协调者会发给所有参与者提交事务
命令,然后各个参与者就把各自的事务提交。如果各个事务都提交成功,那么这个分布式事务就执行成功,否则失败。
这里说一下分布式事务执行失败的两个原因:
- 准备阶段有参与者返回失败了。此时协调者就会给所有参与者发送
回滚事务
的命令,并返回分布式事务执行失败。 - 提交阶段有参与者提交失败了。可能提交阶段执行的是提交或者回滚的事务操作,但是有个参与者提交失败了,那么这时候怎么办呢?只能够不断进行重试提交,比如说执行的是回滚操作,如果此时不重试提交了,那么那些还没有进行提交的,在准备阶段成功的参与者就会被一直阻塞。
为什么我们提交阶段只能一直重试?
具体来说,因为2PC是一个同步阻塞协议,像准备阶段,准备阶段会等待所有的参与者都响应成功了,才会去提交阶段。准备阶段的协调者有个超时机制,如果因为网络原因没有收到某个参与者的响应或者说某个参与者挂掉了(服务挂了),那么超时之后协调者就会判断事务失败,并且向所有的参与者发送回滚
命令。
而第二阶段也就是提交阶段不同,他没有超时机制,所以只能够不断重试,不然就会一直阻塞,其他在准备阶段成功的参与者会没法提交,并且协调者也没法返回分布式事务是否成功。
协调者挂了咋办?
因为协调者是一个单点,存在着单点故障问题。比如说协调者在发送回滚事务命令之前挂了,事务没办法执行下去,并且那些在准备阶段成功的参与者都会被阻塞着。像这样的类似情况还有很多,总而言之就是我们需要保证协调者在分布式事务结束之前都是存活
状态。
除了保证存活
状态,其实我们还有另外一种解决方法,就是使用多个协调者,在主协调者挂了之后,通过选举选取从协调者跟上,成为新的主协调者。
这个方法在极端情况下也是会存在数据不一致的情况。假如说某个参与者和协调者一起挂了,跟上来的从协调者问其他参与者都说ok,但不知道这个挂了的参与者是什么一个情况(可以用一个日志记录参与者的地方来查询状态),可能在挂之前提交成功了,那么其他参与者也要提交事务才能够保证数据一致;也有可能挂之前事务没提交成功,参与者恢复之后发现是回滚事务,那么协调者也要向其他参与者发送回滚
命令。
由于存在一些问题,于是就出现了2PC的改良版3PC。
3PC
3PC 的出现是为了解决 2PC 的一些问题,相比于 2PC 它在参与者中也引入了超时机制,并且新增了一个阶段使得参与者可以利用这一个阶段统一各自的状态。
3PC 包含了三个阶段,分别是准备阶段、预提交阶段和提交阶段。
- 准备阶段:跟2PC有所不同的是,3PC的准备阶段不会直接执行事务,会先去询问这个参与者有没有条件去接这个事务,不会一来就干活直接锁资源,这样就不会在没条件的情况下锁资源,导致所有参与者都被阻塞。
- 预提交阶段:预提交阶段的引入目的是统一状态。什么意思呢?就是准备阶段完成后,参与者和协调者发生崩溃的情况,如果是2PC的话,新的协调者不知道那个崩溃的参与者是回滚还是提交事务,如果随意执行可能会导致数据不一致。而预提交阶段在我的理解下就是一个再次确认的过程,知道完成准备阶段的事务是提交还是回滚(得到参与者的回应)。预提交阶段就是一个缓冲,保证了最后的提交阶段之前各个参与者的状态都是一致的。
- 提交阶段:与2PC一致。
3PC多了个参与者超时机制,有什么影响?
2PC是同步阻塞的,就是如果协调者挂在了提交阶段发送提交事务之前,所有的参与者此时都锁定了资源阻塞等待着协调者。
引入了超时机制之后,参与者就不会傻傻的白等了,如果是等待提交命令超时,那么参与者就会提交事务了,因为到了这个阶段大概率是提交的;如果是等待预提交命令超时,那该干啥就干啥,该提交就提交该回滚就回滚,反正这个阶段只是确认各个参与者的状态而已。
然而超时机制也会带来数据不一致的问题,比如在等待提交命令时候超时了,参与者默认执行的是提交事务操作,但是有可能执行的是回滚操作,这样一来数据就不一致了。
当然 3PC 协调者超时还是在的,具体不分析了和 2PC 是一样的。
我们总结一下, 3PC 相对于 2PC 做了一定的改进:引入了参与者超时机制,并且增加了预提交阶段使得故障恢复之后协调者的决策复杂度降低,但整体的交互过程更长了,性能有所下降,并且还是会存在数据不一致问题。
所以 2PC 和 3PC 都不能保证数据100%一致,因此一般都需要有定时扫描补偿机制。
TCC
**2PC 和 3PC 都是数据库层面的,而 TCC 是业务层面的分布式事务。**比如说发短信这类的,不属于数据库操作,这时候TCC就派上用场了。
TCC 指的是Try - Confirm - Cancel
。
- Try 指的是预留,即资源的预留和锁定,注意是预留。
- Confirm 指的是确认操作,这一步其实就是真正的执行了。
- Cancel 指的是撤销操作,可以理解为把预留阶段的动作撤销了。
从思想上看和 2PC 差不多,都是先试探性的执行,如果都可以那就真正的执行,如果不行就回滚。
我们举个例子,比如说A跟B转账:
我们有一个本地方法,里面依次调用
1、首先在 Try 阶段,要先调用远程接口把 B和 A的钱给冻结起来。
2、在 Confirm 阶段,执行远程调用的转账的操作,转账成功进行解冻。
3、如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。
- 当所有try()方法均执行成功时,对全局事物进行提交,即由事物管理器调用每个微服务的confirm()方法
- 当任意一个方法try()失败(预留资源不足,抑或网络异常,代码异常等任何异常),由事物管理器调用每个微服务的cancle()方法对全局事务进行回滚
优点: 跟2PC比起来,实现以及流程相对简单了一些,但数据的一致性比2PC也要差一些
缺点: 缺点还是比较明显的,在2,3步中都有可能失败。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,就是 **TCC 对业务的侵入较大和业务紧耦合。**在一些场景中,一些业务流程可能用TCC不太好定义及处理。
本地消息表
本地消息表也可以实现分布式事务。它的核心思想就是将分布式事务拆分成本地事务。
具体是怎么做的呢?
- 在数据库中新建一个放本地信息的表,专门用于记录消息发送状态。然后在执行业务的时候,将发送的消息信息事先记录在本地消息表中,并且这个操作跟发起方的业务绑定在同一个数据库事务里面,此时本地消息表的状态是
待发送
; - 完成绑定之后,立即提交事务,然后开始发送MQ通知下游服务。如果发送成功,就更改第一步中的本地消息表状态为
已发送
; - 如果发送失败,本地消息表状态不变,保持
待发送
状态;此时应用中假如定时任务扫描本地信息表,如果本地信息表的信息不是已发送
状态就重新发送。如果说消息发送成功,但是子业务系统反馈超时,也会通过重发进行补偿; - 子系统提供幂等接口消费MQ就好,消费成功进行异步回调通知。
举个例子:
假设实现一个转账功能,我们分别有三个独立服务:transfer(转账入口)、bank1(转出银行)、bank2(转入银行)。
1.transfer接口加上事务。
1.1 本地业务操作db
1.2 记录消息信息进入本地消息表
1.3 try-catch代码块发送MQ,成功更改本地消息表状态为已发送,报错就更改为发送失败
1.4 定时任务扫描本地消息表,判断是否重发
2.bank1 消费-扣钱,执行类似transfer业务操作
3.bank2 消费-加钱,执行类似transfer业务操作
RocketMQ事务消息
有一些第三方的MQ是支持事务消息的,比如RocketMQ,他们支持事务消息的方式也是类似于采用的二阶段提交,但是市面上一些主流的MQ都是不支持事务消息的,比如 RabbitMQ 和 Kafka 都不支持。
以阿里的 RocketMQ 中间件为例,其思路大致为:
事务消息发送及提交:
- 发送消息(half消息),半消息不是说一半消息,而是这个消息对消费者来说不可见
- 服务端响应消息写入结果
- 根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)
- 根据本地事务状态执行Commit或者Rollback(Commit操作生成消息索引,消息对消费者可见)
补偿流程:
- 对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查”
- Producer收到回查消息,检查回查消息对应的本地事务的状态
- 根据本地事务状态,重新Commit或者Rollback
最大努力通知
这里举一个短信发送的例子吧:
跟本地信息表方案类似,也是要维护一个表记录状态。
短信发送流程如下:
- 业务方将短信发送请求提交给短信平台
- 短信平台接收到要发送的短信,记录到数据库中,并标记其状态为”已接收"
- 短信平台调用外部短信发送供应商的接口,发送短信。外部供应商的接口也是异步将短信发送到用户手机上,因此这个接口调用后,立即返回,进入第4步。
- 更新短信发送状态为"已发送"
- 短信发送供应商异步通知短信平台短信发送结果。而通知可能失败,因此最多只会通知N次。
- 短信平台接收到短信发送结果后,更新短信发送状态,可能是成功,也可能失败(如手机欠费)。到底是成功还是失败并不重要,重要的是我们知道了这调短信发送的最终结果
- 如果最多只通知N次,如果都失败了的话,那么短信平台将不知道短信到底有没有成功发送。因此短信发送供应商需要提供一个查询接口,以方便短信平台驱动的去查询,进行定期校对。
在这个案例中,短信发送供应商通知短信平台短信发送结果的过程中,就是最典型的最大努力通知型方案,通知了N次就不再通知。通过提供一个短信结果查询接口,让短信平台可以进行定期的校对。而由于短信发送业务的时间敏感度并不高,比较适合采用这个方案。