一个复杂的系统往往都是从一个小而简的系统发展衍化而来,为了满足日益增长的业务需求,不断的增加系统的复杂度,从单体架构逐步发展为分布式架构,而分布式系统架构的设计主要关注:高性能,高可用,高拓展。
图片来自 Pexels
分布式事务
高可用是指系统无中断的执行功能的能力,代表了系统的可用程度,是进行系统设计时必须要遵守的准则之一。
而高可用的实现方案,无外乎就是冗余,就存储的高可用而言,问题不在于如何进行数据备份,而在于如何规避数据不一致对业务造成的影响。
对于分布式系统而言,要保证分布式系统中的数据一致性就需要一种方案,可以保证数据在子系统中始终保持一致,避免业务出现问题。
这种实现方案就叫做分布式事务,要么一起成功,要么一起失败,必须是一个整体性的事务。
理论基础
在讲解具体方案之前,有必要了解一下分布式中数据设计需要遵循的理论基础,CAP 理论和 BASE 理论,为后面的实践铺平道路。
CAP 理论
CAP,Consistency Availability Partition tolerance 的简写:
因为分布式系统中系统肯定部署在多台机器上,无法保证网络做到 100% 的可靠,所以网络分区一定存在,即 P 一定存在。
在出现网络分区后,就出现了可用性和一致性的问题,我们必须要在这两者之间进行取舍,因此就有了两种架构:
①CP 架构
当网络分区出现后,为了保证一致性,就必须拒接请求,否则无法保证一致性:
上面这种方式就违背了可用性的要求,只满足一致性和分区容错,即 CP,CAP 理论是忽略网络延迟,从系统 A 同步数据到系统 B 的网络延迟是忽略的。
CP 架构保证了客户端在获取数据时一定是最近的写操作,或者获取到异常信息,绝不会出现数据不一致的情况。
②AP 架构
当网络分区出现后,为了保证可用性,系统 B 可以返回旧值,保证系统的可用性:
上面这种方式就违背了一致性的要求,只满足可用性和分区容错,即 AP,AP 架构保证了客户端在获取数据时无论返回的是最新值还是旧值,系统一定是可用的。
CAP 理论关注粒度是数据,而不是整体系统设计的策略。
BASE 理论
BASE 理论指的是基本可用 Basically Available,软状态 Soft State,最终一致性 Eventual Consistency,核心思想是即便无法做到强一致性,但应该采用适合的方式保证最终一致性。
BASE,Basically Available Soft State Eventual Consistency 的简写:
分布式事务协议
X/Open XA 协议
XA 是一个分布式事务协议,由 Tuxedo 提出。XA 规范主要定义了(全局)事务管理器(Transaction Manager)和(局部)资源管理器(Resource Manager)之间的接口。
XA 接口是双向的系统接口,在事务管理器(Transaction Manager)以及一个或多个资源管理器(Resource Manager)之间形成通信桥梁。
XA 协议采用两阶段提交方式来管理分布式事务。XA 接口提供资源管理器与事务管理器之间进行通信的标准接口。
2PC:二阶段提交协议
二阶段提交(Two-phase Commit),是指,为了使基于分布式系统架构下的所有节点在进行事务提交时保持一致性而设计的一种算法(Algorithm)。通常,二阶段提交也被称为是一种协议(Protocol)。
在分布式系统中,每个节点虽然可以知晓自己的操作是成功或者失败,却无法知道其他节点的操作是成功或失败。
当一个事务跨越多个节点时,为了保持事务的 ACID 特性,需要引入一个作为协调者的组件来统一掌控所有节点(称作参与者)的操作结果并最终指示这些节点是否要把操作结果进行真正的提交(比如将更新后的数据写入磁盘等等)。
因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
二阶段提交算法的成立基于以下假设:
二阶段提交分为两阶段:
投票阶段 Prepares:
提交阶段 Commit:
二阶段提交优点:尽量保证了数据的强一致,但不是 100% 一致。
二阶段提交缺点:
而在这部分参与者接到提交事务请求之后就会执行提交事务操作。但是其他部分未接收到提交事务请求的参与者则无法提交事务。从而导致分布式系统中的数据不一致。
二阶段提交的问题:如果协调者在第二阶段发送提交请求之后挂掉,而唯一接受到这条消息的参与者执行之后也挂掉了,即使协调者通过选举协议产生了新的协调者并通知其他参与者进行提交或回滚操作的话,都可能会与这个已经执行的参与者执行的操作不一样。
当这个挂掉的参与者恢复之后,就会产生数据不一致的问题。
3PC:三阶段提交协议
三阶段提交(Three-phase commit),是为解决两阶段提交协议的缺点而设计的。与两阶段提交不同的是,三阶段提交是“非阻塞”协议。
三阶段提交在两阶段提交的第一阶段与第二阶段之间插入了一个准备阶段,使得原先在两阶段提交中,参与者在投票之后,由于协调者发生崩溃或错误,而导致参与者处于无法知晓是否提交或者中止的“不确定状态”所产生的可能相当长的延时的问题得以解决。
三阶段提交的三个阶段:
①询问阶段:CanCommit
协调者向参与者发送 Commit 请求,参与者如果可以提交就返回 Yes 响应,否则返回 No 响应。
②准备阶段:PreCommit
协调者根据参与者在询问阶段的响应判断是否执行事务还是中断事务:
参与者执行完操作之后返回 ACK 响应,同时开始等待最终指令。
③提交阶段:DoCommit
协调者根据参与者在准备阶段的响应判断是否执行事务还是中断事务:
协调者收到所有参与者的 ACK 响应,完成事务。
解决二阶段提交时的问题:在三阶段提交中,如果在第三阶段协调者发送提交请求之后挂掉,并且唯一的接受的参与者执行提交操作之后也挂掉了,这时协调者通过选举协议产生了新的协调者。
在二阶段提交时存在的问题就是新的协调者不确定已经执行过事务的参与者是执行的提交事务还是中断事务。
但是在三阶段提交时,肯定得到了第二阶段的再次确认,那么第二阶段必然是已经正确的执行了事务操作,只等待提交事务了。
所以新的协调者可以从第二阶段中分析出应该执行的操作,进行提交或者中断事务操作,这样即使挂掉的参与者恢复过来,数据也是一致的。
所以,三阶段提交解决了二阶段提交中存在的由于协调者和参与者同时挂掉可能导致的数据一致性问题和单点故障问题,并减少阻塞。
因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行提交事务,而不会一直持有事务资源并处于阻塞状态。
三阶段提交的问题:在提交阶段如果发送的是中断事务请求,但是由于网络问题,导致部分参与者没有接到请求。
那么参与者会在等待超时之后执行提交事务操作,这样这些由于网络问题导致提交事务的参与者的数据就与接受到中断事务请求的参与者存在数据不一致的问题。
所以无论是 2PC 还是 3PC 都不能保证分布式系统中的数据 100% 一致。
解决方案
举个栗子:在电商网站中,用户对商品进行下单,需要在订单表中创建一条订单数据,同时需要在库存表中修改当前商品的剩余库存数量。
两步操作一个添加,一个修改,我们一定要保证这两步操作一定同时操作成功或失败,否则业务就会出现问题。
建立时:业务量不大,用户少,系统只是一个单体架构,订单表与库存表都在一个数据库中,这时可以使用 MySQL 的本地事务保证数据一致性。
发展期:业务发展迅速,用户量变多,单数据已经出现了性能瓶颈,按照业务纬度进行分库,分为订单库和库存库,由于跨库跨机器,MySQL 的本地事务不能再保证订单库和库存库的数据一致性。
成熟期:业务拓展,单体架构已经满足不了需求,进而衍化成了分布式系统,这时的订单和库存已经拆分为了两个子系统提供服务,子系统间使用 RPC 进行通信。
但是无论系统发展成什么样,我们都要保证业务不出问题,保证订单和库存的数据一致,这时候要思考下在服务之间我们应如何保证数据一致。
强一致性分布式事务
单体架构多数据源,在业务开发中,肯定是先执行对订单库的操作,但是不提交事务,再执行对库存库的操作,也不提交事务,如果两个操作都成功,在一起提交事务,如果有一个操作失败,则两个都进行回滚。
基于 2PC/XA 协议实现的 JTA:我们已经知道了 2PC 和 XA 协议的原理,而 JTA 是 JAVA 规范,是 XA 在 Java 上的实现。
JTA(Java Transaction Manager):
JTA 主要的原理是二阶段提交,当整个业务完成了之后只是第一阶段提交,在第二阶段提交之前会检查其他所有事务是否已经提交。
如果前面出现了错误或是没有提交,那么第二阶段就不会提交,而是直接回滚,这样所有的事务都会做回滚操作。基于 JTA 这种方案实现分布式事务的强一致性。
JTA 的特点:
实现可以使用基于 JTA 实现的 Jar 包 Atomikos 例子可以自己百度一下。
正常架构设计中是否应该出现这种跨库的操作,我觉得是不应该的,如果按业务拆分将数据源进行分库,我们应该同时将服务也拆分出去才合适,应遵循一个系统只操作一个数据源(主从没关系),避免后续可能会出现的多个系统调用一个数据源的情况。
最终一致性分布式事务方案
JTA 方案适用于单体架构多数据源时实现分布式事务,但对于微服务间的分布式事务就无能为力了,我们需要使用其他的方案实现分布式事务。
①本地消息表
本地消息表的核心思想是将分布式事务拆分成本地事务进行处理。
以本文中例子,在订单系统新增一条消息表,将新增订单和新增消息放到一个事务里完成,然后通过轮询的方式去查询消息表,将消息推送到 MQ,库存系统去消费 MQ。
执行流程:
订单系统中的消息有可能由于业务问题会一直重复发送,所以为了避免这种情况可以记录一下发送次数,当达到次数限制之后报警,人工接入处理;库存系统需要保证幂等,避免同一条消息被多次消费造成数据一致。
本地消息表这种方案实现了最终一致性,需要在业务系统里增加消息表,业务逻辑中多一次插入的 DB 操作,所以性能会有损耗,而且最终一致性的间隔主要由定时任务的间隔时间决定。
②MQ 消息事务
消息事务的原理是将两个事务通过消息中间件进行异步解耦。
订单系统执行自己的本地事务,并发送 MQ 消息,库存系统接收消息,执行自己的本地事务。
乍一看,好像跟本地消息表的实现方案类似,只是省去了对本地消息表的操作和轮询发送 MQ 的操作,但实际上两种方案的实现是不一样的。
消息事务一定要保证业务操作与消息发送的一致性,如果业务操作成功,这条消息也一定投递成功。
消息事务依赖于消息中间件的事务消息,基于消息中间件的二阶段提交实现的,RocketMQ 就支持事务消息。
执行流程:
这种方案也是实现了最终一致性,对比本地消息表实现方案,不需要再建消息表,不再依赖本地数据库事务了,所以这种方案更适用于高并发的场景。
③最大努力通知
最大努力通知相比前两种方案实现简单,适用于一些最终一致性要求较低的业务,比如支付通知,短信通知这种业务。
以支付通知为例,业务系统调用支付平台进行支付,支付平台进行支付,进行操作支付之后支付平台会尽量去通知业务系统支付操作是否成功,但是会有一个最大通知次数。
如果超过这个次数后还是通知失败,就不再通知,业务系统自行调用支付平台提供一个查询接口,供业务系统进行查询支付操作是否成功。
执行流程:
这种方案也是实现了最终一致性。
④补偿事务 TCC
TCC,Try-Confirm-Cancel 的简称,针对每个操作,都需要有一个其对应的确认和取消操作。
当操作成功时调用确认操作,当操作失败时调用取消操作,类似于二阶段提交,只不过是这里的提交和回滚是针对业务上的,所以基于 TCC 实现的分布式事务也可以看做是对业务的一种补偿机制。
TCC 的三阶段:
在 Try 阶段,是对业务系统进行检查及资源预览,比如订单和存储操作,需要检查库存剩余数量是否够用,并进行预留,预留操作的话就是新建一个可用库存数量字段,Try 阶段操作是对这个可用库存数量进行操作。
比如下一个订单减一个库存:
执行流程:
基于 TCC 实现分布式事务,代码逻辑相对复杂一些,需要将原来的接口的逻辑拆分为:Try,Confirm ,Cancel 三个接口的逻辑。
基于 TCC 实现的分布式事务框架:
读完之后应该对分布式事务有了一个大致的了解,在实际生产中我们要尽量避免使用分布式事务,能转化为本地事务就用本地事务,如果必须使用分布式事务,还需要从业务角度多思考使用哪种方案更适合,总之行动之前多思考。