mysql之分布式事务

Author Avatar
Sean Yu 12月 04, 2020
  • 在其它设备中阅读本文章

参考 http://www.calvinneo.com/2017/09/20/distributed-system-consistency-and-consensus/ http://www.calvinneo.com/2019/03/12/2pc-3pc/ https://blog.csdn.net/bjweimengshu/article/details/86698036 https://matt33.com/2018/07/08/distribute-system-consistency-protocol/ https://segmentfault.com/a/1190000022510975 https://cloud.tencent.com/developer/article/1379434 http://arick.net/content/14

cap

  • 一致性 c
  • 可用性 a
  • 分区容错 p

base

  • BA:Basically Available 基本可用,分布式系统在出现故障的时候,允许损失部分可用性,即保证核心可用。
  • S:Soft State 软状态,允许系统存在中间状态,而该中间状态不会影响系统整体可用性。
  • E:Consistency 最终一致性,系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。

两阶段提交协议Two-Phase commit(2PC)

二阶段提交协议(Two-phase Commit,即2PC)是常用的分布式事务解决方案,它可以保证在分布式事务中,要么所有参与进程都提交事务,要么都取消事务,即实现 ACID 的原子性(A)。在数据一致性中,它的含义是:要么所有副本(备份数据)同时修改某个数值,要么都不更改,以此来保证数据的强一致性。

2PC 要解决的问题可以简单总结为:在分布式系统中,每个节点虽然可以知道自己的操作是成功还是失败,却是无法知道其他节点的操作状态。当一个事务需要跨越多个节点时,为了保持事务的 ACID 特性,需要引入一个作为协调者的组件来统一掌控所有节点(参与者)的操作结果,并最终指示这些节点是否要把操作结果进行真正的提交(比如将更新后的数据写入磁盘等等)。因此,二阶段提交的算法思路可以概括为: 参与者将操作结果通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。

协议内容

  • 第一阶段(投票阶段)
    首先协调者向所有的参与者发出提交请求VOTE_REQUEST,参与者按照事务的标准流程写UNDO和REDO等日志,并在本地执行事务。如果事务执行顺利,则不提交(尽管事务中的全部操作已经正确完成),返回一个VOTE_COMMIT给协调者,表示自己成功执行了事务。如果事务执行出现错误,则返回一个VOTE_ABORT。
  • 第二阶段(执行提交阶段)
    假设协调者没有宕机,相应会出现两种状态:
    • 成功,发生在所有的参与者节点都返回VOTE_COMMIT
      此时协调者向所有参与者发送GLOBAL_COMMIT,参与者收到之后正式提交事务并释放资源,然后返回ACK确认。
    • 失败,发生在任意参与者节点返回VOTE_ABORT,或者有的参与者timeout
      此时协调者向所有参与者发送GLOBAL_ROLLBACK,参与者收到之后UNDO回前像状态,然后返回ACK确认。

image.png

image.png

协调者宕机/分区情况对一致性的影响

OK,刚才协调者没有宕机,看起来很美好,可是如果协调者宕机了呢?

我们考虑协调者将commit信息传递给了一部分参与者的情况,由于2PC是阻塞的,这意味着所有没有收到commit消息的节点都会阻塞在等到GLOBAL_COMMIT消息这里。如果协调者又重启或者从分区中恢复,那么参与者仍然有机会提交。
但是如果协调者永久地宕机了,此时整个事务就不在Safe了,因为并非所有的节点都能一致提交或者回滚。但仍然可以认为2PC是Safe的协议,因为它实际上是不假设永久宕机的情况的。

二阶段提交协议的不足

总而言之,2PC存在两个显著问题,阻塞和不能处理网络分区。

  • 性能问题。从流程上我们可以看得出,其最大缺点就在于它的执行过程中间,节点都处于阻塞状态。各个操作数据库的节点此时都占用着数据库资源,只有当所有节点准备完毕,事务协调者才会通知进行全局提交,参与者进行本地事务提交后才会释放资源。这样的过程会比较漫长,对性能影响比较大。
  • 协调者单点故障问题。事务协调者是整个XA模型的核心,一旦事务协调者节点挂掉,会导致参与者收不到提交或回滚的通知,从而导致参与者节点始终处于事务无法完成的中间状态。
  • 网络分区。丢失消息导致的数据不一致问题。在第二个阶段,如果发生局部网络问题,一部分事务参与者收到了提交消息,另一部分事务参与者没收到提交消息,那么就会导致节点间数据的不一致问题。

image.png

三阶段提交协议

三阶段提交又称3PC,其在两阶段提交的基础上增加了CanCommit阶段,并引入了超时机制。一旦事务参与者迟迟没有收到协调者的Commit请求,就会自动进行本地commit,这样相对有效地解决了协调者单点故障的问题。

但是性能问题和不一致问题仍然没有根本解决。下面我们还是一起看下三阶段流程的是什么样的?

协议内容

  • CanCommit阶段
    这个类似于2PC的投票阶段,协调者发出询问是否可以提交,Yes为可以提交,No相反。
  • PreCommit阶段
    需要分为三种情况讨论:
    • 如果上阶段全部为Yes
      协调者发送PreCommit请求并进入Prepared状态
      参与者接受到PreCommit后确保事务操作全部执行并记录UNDO与REDO,返回ACK
    • 如果上阶段有No
      协调者发送abort请求
      参与者接受到abort后,REDO,中断事务,发送ACK
    • 例外情况:参与者未收到协调者的消息
      这可以认为是协调者的timeout,此时中断事务
      注意到这里参与者是可以处理协调者的timeout的
  • DoCommit阶段
    这是真正的事务提交阶段,同样分为三种情况
    • 协调者收全上阶段ACK
      协调者发送DoCommit请求
      参与者接受到DoCommit后提交事务,返回ACK
    • 协调者未收到上阶段ACK
      这发生在协调者没有收到一些参与者的ACK(网络分区或该参与者abort)
      协调者发送abort请求
      参与者接受到abort,使用同上阶段的方式中断事务
    • 例外情况:参与者未收到协调者的消息
      这又是一个协调者的timeout,此时提交事务
      为什么选择提交事务而不是中断事务?因为此时提交事务成功的可能性非常非常大了,但仍有例外,例如:
      进入PreCommit后,协调者发出的是abort请求,如果只有一个收到并进行了abort操作,而其他对于系统状态未知的节点会根据3PC选择继续Commit,这仍然会导致不一致,不过这个概率就显然非常小了

image.png

换言之, 3PC在2PC的Commit阶段里增加了一个barrier(即相当于告诉其他所有voter, 我收到了Propose的结果啦). 在这个barrier之前coordinator掉线的话, 其他voter可以得出结论不是每个voter都收到Propose Phase的结果, 从而放弃或选出新的coordinator; 在这个barrier之后coordinator掉线的话, 每个voter会放心的commit, 因为他们知道其他voter也都做同样的计划.

相比较2PC而言,3PC对于协调者(Coordinator)和参与者(Partcipant)都设置了超时时间,而2PC只有协调者才拥有超时机制。这解决了一个什么问题呢?这个优化点,主要是避免了参与者在长时间无法与协调者节点通讯(协调者挂掉了)的情况下,无法释放资源的问题,因为参与者自身拥有超时机制会在超时后,自动进行本地commit从而进行释放资源。而这种机制也侧面降低了整个事务的阻塞时间和范围。

另外,通过CanCommit、PreCommit、DoCommit三个阶段的设计,相较于2PC而言,多设置了一个缓冲阶段保证了在最后提交阶段之前各参与节点的状态是一致的。

以上就是3PC相对于2PC的一个提高(相对缓解了2PC中的前两个问题),但是3PC依然没有完全解决数据不一致的问题。

3PC的缺陷

3PC可以有效的处理fail-stop的模式, 但不能处理网络划分(network partition)的情况—节点互相不能通信. 假设在PreCommit阶段所有节点被一分为二, 收到preCommit消息的voter在一边, 而没有收到这个消息的在另外一边. 在这种情况下, 两边就可能会选出新的协调者而做出不同的决定.

image.png

除了网络划分以外, 3PC也不能处理fail-recover的错误情况. 简单说来当协调者收到preCommit的确认前crash, 于是其他某一个voter接替了原协调者的任务而开始组织所有voter commit. 而与此同时原协调者重启后又回到了网络中, 开始继续之前的回合—发送abort给各位voter因为它并没有收到preCommit. 此时有可能会出现原协调者和继任的协调者给不同节点发送相矛盾的commit和abort指令, 从而出现个节点的状态分歧.

xa事务

XA事务中需要有一个事务协调器来保证所有的事务参与者都完成了准备工作(第一阶段)。如果协调器收到所有的参与者都准备好的消息,就会告诉所有的事务可以提交了,这是第二阶段。

MySQL在这个XA事务过程中扮演一个参与者的角色,而不是协调者。

实际上,在MySQL中有两种XA事务。一方面,MySQL可以参与到外部的分布式事务中;另一方面,还可以通过XA事务来协调存储引擎和二进制日志。

内部xa事务

MySQL中各个存储引擎是完全独立的,彼此不知道对方的存在,所以一个跨存储引擎的事务就需要一个外部的协调者。如果不使用XA协议,例如,跨存储引擎的事务提交就只是顺序地要求每个存储引擎各自提交。如果在某个存储提交过程中发生系统崩溃,就会破坏事务的特性(要么就全部提交,要么就不做任何操作)。

如果将MySQL记录的二进制日志操作看作一个独立的“存储引擎”,就不难理解为什么即使是一个存储引擎参与的事务仍然需要XA事务了。在存储引擎提交的同时,需要将“提交”的信息写入二进制日志,这就是一个分布式事务,只不过二进制日志的参与者是MySQL本身。

XA事务为MySQL带来巨大的性能下降。从MySQL5.0开始,它破坏了MySQL内部的“批量提交”(一种通过单磁盘I/O操作完成多个事务提交的技术),使得MySQL不得不进行多次额外的fsync()调用。具体的,一个事务如果开启了二进制日志,则不仅需要对二进制日志进行持久化操作,InnoDB事务日志还需要两次日志持久化操作。换句话说,如果希望有二进制日志安全的事务实现,则至少需要做三次fsync()操作。

外部xa事务

MySQL能够作为参与者完成一个外部的分布式事务。但它对XA协议支持并不完整,例如,XA协议要求在一个事务中的多个连接可以做关联,但目前的MySQL版本还不能支持。

因为通信延迟和参与者本身可能失败,所以外部XA事务比内部消耗会更大。如果在广域网中使用XA事务,通常会因为不可预测的网络性能导致事务失败。如果有太多不可控因素,例如,不稳定的网络通信或者用户长时间地等待而不提交,则最好避免使用XA事务。任何可能让事务提交发生延迟的操作代价都很大,因为它影响的不仅仅是自己本身,它还会让所有参与者都在等待。

通常,还可以使用别的方式实现高性能的分布式事务。例如,可以在本地写入数据,并将其放入队列,然后在一个更小、更快的事务中自动分发。还可以使用MySQL本身的复制机制来发送数据。我们看到很多应用程序都可以完全避免使用分布式事务。

也就是说,XA事务是一种在多个服务器之间同步数据的方法。如果由于某些原因不能使用MySQL本身的复制,或者性能并不是瓶颈的时候,可以尝试使用。

xa事务具体步骤

image.png

假设一个表test定义如下,Proxy根据主键”a”算Hash决定一条记录应该分布在哪个节点上:

create table test(a int primay key, b int) engine = innodb;

应用发到Proxy的一个事务如下:

begin;
insert into test values (1, 1);
update test set b = 1 where a = 10;
commit;

Proxy收到这个事务需要将它转成XA事务发送到后端的数据库以保证这个事务能够安全的提交或回滚,一般的Proxy的处理步骤 如下:

  • Proxy先收到begin,它只需要设置一下自己的状态不需要向后端数据库发送
  • 当收到 insert 语句时Proxy会解析语句,根据“a”的值计算出该条记录应该位于哪个节点上,这里假设是“分库1”
  • Proxy就会向分库1上发送语句xa start ‘xid1’,开启一个XA事务,这里xid1是Proxy自动生成的一个全局事务ID;同时原来 的insert语句insert into values(1,1)也会一并发送到分库1上。
  • 这时Proxy遇到了update语句,Proxy会解析 where条件主键的值来决定该条语句会被发送到哪个节点上,这里假设是“分库2”
  • Proxy就会向分库2上发送语句xa start ‘xid1’,开启一个XA事务,这里xid1是Proxy之前已经生成的一个全局事务ID;同时原来 的update语句update test set b = 1 where a = 10也会一并发送到分库2上。
  • 最后当Proxy解析到commit语句时,就知道一个用户事务已经结束了,就开启提交流程
  • Proxy会向分库1和分库2发送 xa end ‘xid1’;xa prepare ‘xid1’语句,当收到执行都成功回复后,则继续进行到下一步,如果任何一个分 库返回失败,则向分库1和分库2 发送 xa rollback ‘xid1’,回滚整个事务
  • 当 xa prepare ‘xid1’都返回成功,那么 proxy会向分库1和分库2上发送 xa commit ‘xid1’,来最终提交事务。

这里有一个可能的优化,即在步骤4时如果Proxy计算出update语句发送的节点仍然是“分库1”时,在遇到commit时,由于只涉 及到一个分库,它可以直接向“分库1”发送 xa end ‘xid1’; xa commit ‘xid1’ one phase来直接提交该事务,避免走 prepare阶段来提高效率。

TCC

TCC(Try-Confirm-Cancel)又称补偿事务。其核心思想是:”针对每个操作都要注册一个与其对应的确认和补偿(撤销操作)”。它分为三个操作:

  • Try阶段:主要是对业务系统做检测及资源预留。
  • Confirm阶段:确认执行业务操作。
  • Cancel阶段:取消执行业务操作。

TCC事务的处理流程与2PC两阶段提交类似,不过2PC通常都是在跨库的DB层面,而TCC本质上就是一个应用层面的2PC,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据库操作的粒度,使得降低锁冲突、提高吞吐量成为可能。

而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。此外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。为了满足一致性的要求,confirm和cancel接口还必须实现幂等。

image.png

tcc场景案例

有一次,笔者买彩票中奖了(纯属虚构),准备从合肥出发,到云南大理去游玩,然后使用美团App(机票代理商)来订机票。发现没有从合肥直达大理的航班,需要到昆明进行中转。如下图:

image.png

从图中我们可以看出来,从合肥到昆明乘坐的是四川航空,从昆明到大理乘坐的是东方航空。

由于使用的是美团App预定,当我选择了这种航班预定方案后,美团App要去四川航空和东方航空各帮我购买一张票。如下图:

image.png

考虑最简单的情况:美团先去川航帮我买票,如果买不到,那么东航也没必要买了。如果川航购买成功,再去东航购买另一张票。

现在问题来了:假设美团先从川航成功买到了票,然后去东航买票的时候,因为天气问题,东航航班被取消了。那么此时,美团必须取消川航的票,因为只有一张票是没用的,不取消就是浪费我的钱。那么如果取消会怎样呢?如果读者有取消机票经历的话,非正常退票,肯定要扣手续费的。在这里,川航本来已经购买成功,现在因为东航的原因要退川航的票,川航应该是要扣代理商的钱的。

那么美团就要保证,如果任一航班购买失败,都不能扣钱,怎么做呢?

两个航空公司都为美团提供以下3个接口:机票预留接口、确认接口、取消接口。美团App分2个阶段进行调用,如下所示:

image.png

在第1阶段:

美团分别请求两个航空公司预留机票,两个航空公司分别告诉美图预留成功还是失败。航空公司需要保证,机票预留成功的话,之后一定能购买到。

在第2阶段:

如果两个航空公司都预留成功,则分别向两个公司发送确认购买请求。

如果两个航空公司任意一个预留失败,则对于预留成功的航空公司也要取消预留。这种情况下,对于之前预留成功机票的航班取消,也不会扣用户的钱,因为购买并没实际发生,之前只是请求预留机票而已。

通过这种方案,可以保证两个航空公司购买机票的一致性,要不都成功,要不都失败,即使失败也不会扣用户的钱。如果在两个航班都已经已经确认购买后,再退票,那肯定还是要扣钱的。

当然,实际情况肯定这里提到的肯定要复杂,通常航空公司在第一阶段,对于预留的机票,会要求在指定的时间必须确认购买(支付成功),如果没有及时确认购买,会自动取消。假设川航要求10分钟内支付成功,东航要求30分钟内支付成功。以较短的时间算,如果用户在10分钟内支付成功的话,那么美团会向两个航空公司都发送确认购买的请求,如果超过10分钟(以较短的时间为准),那么就不能进行支付。

再次强调,这个案例,可以算是Composite Transactions for SOA中航班预定案例的汉化版。而实际美团App是如何实现这种需要中转的航班预定需求,笔者并不知情。

另外,注意这只是一个案例场景,实际情况中,你是很难去驱动航空公司进行接口改造的。

Whatever,这个方案提供给我们一种跨服务条用保证事务一致性的一种解决思路,可以把这种方案当做TCC的雏形。

tcc与xa区别

XA是资源层面的分布式事务,强一致性,在两阶段提交的整个过程中,一直会持有资源的锁。

XA事务中的两阶段提交内部过程是对开发者屏蔽的,回顾我们之前讲解JTA规范时,通过UserTransaction的commit方法来提交全局事务,这只是一次方法调用,其内部会委派给TransactionManager进行真正的两阶段提交,因此开发者从代码层面是感知不到这个过程的。而事务管理器在两阶段提交过程中,从prepare到commit/rollback过程中,资源实际上一直都是被加锁的。如果有其他人需要更新这两条记录,那么就必须等待锁释放。

TCC是业务层面的分布式事务,最终一致性,不会一直持有资源的锁。

TCC中的两阶段提交并没有对开发者完全屏蔽,也就是说从代码层面,开发者是可以感受到两阶段提交的存在。如上述航班预定案例:在第一阶段,航空公司需要提供try接口(机票资源预留)。在第二阶段,航空公司提需要提供confirm/cancel接口(确认购买机票/取消预留)。开发者明显的感知到了两阶段提交过程的存在。try、confirm/cancel在执行过程中,一般都会开启各自的本地事务,来保证方法内部业务逻辑的ACID特性。其中:

  • try过程的本地事务,是保证资源预留的业务逻辑的正确性。
  • confirm/cancel执行的本地事务逻辑确认/取消预留资源,以保证最终一致性,也就是所谓的补偿型事务(Compensation-Based Transactions)。

由于是多个独立的本地事务,因此不会对资源一直加锁。

基于消息MQ最终一致性方案

image.png

  • 事务消息与普通消息的区别就在于消息生产环节,生产者首先预发送一条消息到MQ(这也被称为发送half消息)
  • MQ接受到消息后,先进行持久化,则存储中会新增一条状态为待发送的消息
  • 然后返回ACK给消息生产者,此时MQ不会触发消息推送事件(延迟消费)
  • 生产者预发送消息成功后,执行本地事务
  • 执行本地事务,执行完成后,发送执行结果给MQ
  • MQ会根据结果删除或者更新消息状态为可发送
  • 如果消息状态更新为可发送,则MQ会push消息给消费者,后面消息的消费和普通消息是一样的

注意点:由于MQ通常都会保证消息能够投递成功,因此,如果业务没有及时返回ACK结果,那么就有可能造成MQ的重复消息投递问题。因此,对于消息最终一致性的方案,消息的消费者必须要对消息的消费支持幂等,不能造成同一条消息的重复消费的情况。

支持事务消息的MQ

现在目前较为主流的MQ,比如ActiveMQ、RabbitMQ、Kafka、RocketMQ等,只有RocketMQ支持事务消息。对于RocketMQ而言,它的解决方案非常的简单,就是其内部实现会有一个定时任务,去轮训状态为待发送的消息,然后给producer发送check请求,而producer必须实现一个check监听器,监听器的内容通常就是去检查与之对应的本地事务是否成功(一般就是查询DB),如果成功了,则MQ会将消息设置为可发送,否则就删除消息。

RocketMQ官网上的图:
image.png

基于本地消息的最终一致性

image.png

基于本地消息的最终一致性方案的最核心做法就是在执行业务操作的时候,记录一条消息数据到DB,并且消息数据的记录与业务数据的记录必须在同一个事务内完成,这是该方案的前提核心保障。在记录完成后消息数据后,后面我们就可以通过一个定时任务到DB中去轮训状态为待发送的消息,然后将消息投递给MQ。这个过程中可能存在消息投递失败的可能,此时就依靠重试机制来保证,直到成功收到MQ的ACK确认之后,再将消息状态更新或者消息清除;而后面消息的消费失败的话,则依赖MQ本身的重试来完成,其最后做到两边系统数据的最终一致性。基于本地消息服务的方案虽然可以做到消息的最终一致性,但是它有一个比较严重的弊端,每个业务系统在使用该方案时,都需要在对应的业务库创建一张消息表来存储消息。针对这个问题,我们可以将该功能单独提取出来,做成一个消息服务来统一处理,因而就衍生出了我们下面将要讨论的方案。

自己理解下

  • server a中,用同一个事务,分别执行业务a和插入消息表
  • 用一个定时任务来轮训消息表,发送到mq
  • server b中,业务b消费消息,返回ack到mq
  • server a消费ack mq,然后更新本地消息表

其中重点是第一步必须在一个事务下,server b要保证幂等

独立消息服务的最终一致性

image.png

独立消息服务最终一致性与本地消息服务最终一致性最大的差异就在于将消息的存储单独地做成了一个RPC的服务,这个过程其实就是模拟了事务消息的消息预发送过程,如果预发送消息失败,那么生产者业务就不会去执行,因此对于生产者的业务而言,它是强依赖于该消息服务的。不过好在独立消息服务支持水平扩容,因此只要部署多台,做成HA的集群模式,就能够保证其可靠性。

在消息服务中,还有一个单独地定时任务,它会定期轮训长时间处于待发送状态的消息,通过一个check补偿机制来确认该消息对应的业务是否成功,如果对应的业务处理成功,则将消息修改为可发送,然后将其投递给MQ;如果业务处理失败,则将对应的消息更新或者删除即可。因此在使用该方案时,消息生产者必须同时实现一个check服务,来供消息服务做消息的确认。对于消息的消费,该方案与上面的处理是一样,都是通过MQ自身的重发机制来保证消息被消费。

自己理解下:

  • server a发送 待确认消息 到 消息中间件的 待确认数据表中
  • server a执行业务a
  • 消息中间件 轮训server a的check接口,根据业务a的结果,把 待确认消息 移到 可发送mq中
  • server b消费 可发送mq 中的消息,执行业务b,返回ack到mq中间件
  • 中间件 根据ack来决定,是否删除待确认数据表,以及是否通知server a回滚业务a
  • 中间件要处理未收到ack的超时重发,server b要处理幂等

其实就是本地存消息的改造,移到了中间件里去做。

貌似所有基于消息中间件的实现,最大的问题就是,server a上的事务必须提交一次,然后再根据server b的ack决定是否撤销操作,其实不是一个事务。因此server b掉线了只能重试,失败了只能让a再撤销业务调教一次。

消息重复发送问题和业务接口幂等性设计

image.png

改进图

image.png

  • 预发送消息:主动方应用系统预发送消息,由消息服务子系统存储消息,如果存储失败,那么也就无法进行业务操作。如果返回存储成功,然后执行业务操作。
  • 执行业务操作:执行业务操作如果成功的时候,将业务操作执行成功的状态发送到消息服务子系统。消息服务子系统修改消息的标识为“可发送”状态。
  • 发送消息到实时消息服务:当消息的状态发生改变的时候,立刻将消息发送到实时消息服务中。接下来,消息将会被消息业务的消费端监听到,然后被消费。
  • 消息状态子系统:相当于定时任务系统,在消息服务子系统中定时查找确认超时的消息,在主动方应用系统中也去定时查找没有处理成功的任务,进行相应的处理。
  • 消息消费:当消息被消费的时候,向实时消息服务发送ACK,然后实时消息服务删除消息。同时调用消息服务子系统修改消息为“被消费”状态。
  • 消息恢复子系统:当消费方返回消息的时候,由于网络中断等其他原因导致消息没有及时确认,那么需要消息恢复子系统定时查找出在消息服务子系统中没有确认的消息。将没有被确认的消息放到实时消息服务中,进行重做,因为被动方应用系统的接口是幂等的。