现如今国内互联网大厂分布式系统和微服务架构盛行,C端一个简单操作可能是由多个后端服务和数据库实例协同完成的。在一致性要求较高的场景下,多个服务独立操作并保证一致性的问题显得尤为棘手。本文主要介绍目前分布式事务的一些主要原理和实现方案。具体到业务系统可根据业务要求及公司基础设施情况选择最适合自己的方式。
CAP
CAP 定理(也称为 Brewer 定理),是由计算机科学家 Eric Brewer 提出的,即在分布式计算机系统不可能同时提供以下全部三个保证:
一致性(Consistency):所有节点同一时间看到是相同的数据;
可用性(Availability):不管是否成功,确保每一个请求都能接收到响应;
分区容错性(Partition tolerance):系统任意分区后,在网络故障时,仍能操作
显然,为了保障性能和可靠性,我们将数据复制多份,分布到多个节点上,同时也带来了一个难点,那就是如何保持各个副本数据的一致性。换句话说,我们选择了 AP ,则必须要牺牲掉 C 了。
其实,数据的一致性也分几种情况,大致可以分为:
Weak 弱一致性:当你写入一个新值后,读操作在数据副本上可能读出来,也可能读不出来。比如:某些存储系统,搜索引擎,实时游戏,语音聊天等,这些数据本文对完整性要求不高,数据是否一致关系也不大。
Eventually 最终一致性:当你写入一个新值后,并不一定能马上读出来,但在某个时间窗口之后保证最终能读出来。比如:DNS,电子邮件,消息中间件等系统,大部分分布式系统技术都采用这类模式。
Strong 强一致性:新的数据一旦写入,在任意副本任意时刻都能读到新值。比如:文件系统,RDBMS都是强一致性的。
也就是说,在设计分布式系统时,我们并不一定要求是强一致性的,根据应用场景可以选择弱一致性或者是最终一致性。比如数据库的Master-Slave 复制及Master-Master 多主复制都是通过异步复制数据保证最终一致性。
本地消息表+消息中间件
本地消息表这个方案最初是ebay提出的 ebay的完整方案https://queue.acm.org/detail.cfm?id=1394128。此方案的核心是将需要分布式处理的任务通过消息日志的方式来异步执行。消息日志可以存储到本地文本、数据库或消息队列,再通过业务规则自动或人工发起重试。人工重试更多的是应用于支付场景,通过对账系统对事后问题的处理。
当我们 本地消息表实现分布式事务 的最终一致性的时候, 我们其实需要明白 我们首先需要在本地数据库 新建一张本地消息表,然后我们必须还要一个MQ(不一定是mq,但必须是类似的中间件)。消息表应该包括这些字段: id, biz_id, biz_type, msg, msg_result, msg_desc,atime,try_count。分别表示uuid,业务id,业务类型,消息内容,消息结果(成功或失败),消息描述,创建时间,重试次数, 其中biz_id,msg_desc字段是可选的。
消息生产方(也就是发起方),需要额外建一个消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送,重试不成功就回滚。
消息消费方(也就是发起方的依赖方),需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。
生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。
两阶段提交
分布式事务最常用的解决方案就是二阶段提交。在分布式系统中,每个节点虽然可以知晓自己的操作时成功或者失败,却无法知道其他节点的操作的成功或失败。当一个事务跨越多个节点时,为了保持事务的ACID特性,需要引入一个作为协调者的组件来统一掌控所有参与者节点的操作结果并最终指示这些节点是否要把操作结果进行真正的提交,协调者可以看做成事务的发起者,同时也是事务的一个参与者。因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。所谓的两个阶段是指:第一阶段:准备阶段(投票阶段)和第二阶段:提交阶段(执行阶段)。
第一阶段:投票阶段
该阶段的主要目的在于打探数据库集群中的各个参与者是否能够正常的执行事务,具体步骤如下:
协调者向所有的参与者发送事务执行请求,并等待参与者反馈事务执行结果。
事务参与者收到请求之后,执行事务,但不提交,并记录事务日志。
参与者将自己事务执行情况反馈给协调者,同时阻塞等待协调者的后续指令。
第二阶段:事务提交阶段
在第一阶段协调者的询盘之后,各个参与者会回复自己事务的执行情况,这时候存在三种可能:
所有的参与者回复能够正常执行事务。
一个或多个参与者回复事务执行失败。
协调者等待超时。
对于第一种情况,协调者将向所有的参与者发出提交事务的通知,具体步骤如下:
协调者向各个参与者发送commit通知,请求提交事务。
参与者收到事务提交通知之后,执行commit操作,然后释放占有的资源。
参与者向协调者返回事务commit结果信息。
对于第二、三种情况,协调者均认为参与者无法正常成功执行事务,为了整个集群数据的一致性,所以要向各个参与者发送事务回滚通知,具体步骤如下:
协调者向各个参与者发送事务rollback通知,请求回滚事务。
参与者收到事务回滚通知之后,执行rollback操作,然后释放占有的资源。
参与者向协调者返回事务rollback结果信息。
两阶段提交协议解决的是分布式数据库数据强一致性问题,其原理简单,易于实现,但是缺点也是显而易见的,主要缺点如下:
单点问题:协调者在整个两阶段提交过程中扮演着举足轻重的作用,一旦协调者所在服务器宕机,那么就会影响整个数据库集群的正常运行,比如在第二阶段中,如果协调者因为故障不能正常发送事务提交或回滚通知,那么参与者们将一直处于阻塞状态,整个数据库集群将无法提供服务。
同步阻塞:两阶段提交执行过程中,所有的参与者都需要听从协调者的统一调度,期间处于阻塞状态而不能从事其他操作,这样效率及其低下。
数据不一致性:两阶段提交协议虽然为分布式数据强一致性所设计,但仍然存在数据不一致性的可能,比如在第二阶段中,假设协调者发出了事务commit的通知,但是因为网络问题该通知仅被一部分参与者所收到并执行了commit操作,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性。
三阶段提交
针对两阶段提交存在的问题,三阶段提交协议通过引入一个“预询盘”阶段,以及超时策略来减少整个集群的阻塞时间,提升系统性能。三阶段提交的三个阶段分别为:can_commit,pre_commit,do_commit。
第一阶段:can_commit
该阶段协调者会去询问各个参与者是否能够正常执行事务,参与者根据自身情况回复一个预估值,相对于真正的执行事务,这个过程是轻量的,具体步骤如下:
协调者向各个参与者发送事务询问通知,询问是否可以执行事务操作,并等待回复。
各个参与者依据自身状况回复一个预估值,如果预估自己能够正常执行事务就返回确定信息,并进入预备状态,否则返回否定信息。
第二阶段:pre_commit
本阶段协调者会根据第一阶段的询盘结果采取相应操作,询盘结果主要有三种:
所有的参与者都返回确定信息。
一个或多个参与者返回否定信息。
协调者等待超时。
针对第一种情况,协调者会向所有参与者发送事务执行请求,具体步骤如下:
协调者向所有的事务参与者发送事务执行通知。
参与者收到通知后,执行事务,但不提交。
参与者将事务执行情况返回给客户端。
在上面的步骤中,如果参与者等待超时,则会中断事务。 针对第二、三种情况,协调者认为事务无法正常执行,于是向各个参与者发出abort通知,请求退出预备状态,具体步骤如下:
协调者向所有事务参与者发送abort通知。
参与者收到通知后,中断事务。
第三阶段:do_commit
如果第二阶段事务未中断,那么本阶段协调者将会依据事务执行返回的结果来决定提交或回滚事务,分为三种情况:
所有的参与者都能正常执行事务。
一个或多个参与者执行事务失败。
协调者等待超时。
针对第一种情况,协调者向各个参与者发起事务提交请求,具体步骤如下:
协调者向所有参与者发送事务commit通知。
所有参与者在收到通知之后执行commit操作,并释放占有的资源。
参与者向协调者反馈事务提交结果。
针对第二、三种情况,协调者认为事务无法正常执行,于是向各个参与者发送事务回滚请求,具体步骤如下:
协调者向所有参与者发送事务rollback通知。
所有参与者在收到通知之后执行rollback操作,并释放占有的资源。
参与者向协调者反馈事务提交结果。
在本阶段如果因为协调者或网络问题,导致参与者迟迟不能收到来自协调者的commit或rollback请求,那么参与者将不会如两阶段提交中那样陷入阻塞,而是等待超时后继续commit。相对于两阶段提交虽然降低了同步阻塞,但仍然无法避免数据的不一致性。
阿里分布式事务框架Seata
下面介绍的是 Seata 的 AT 模式,就是自动化事务,使用非常简单,对业务代码没有侵入性。Seata 还支持 TCC 和 Saga 模式,但支持的主要方式是 AT。Seata 是阿里开源的分布式事务框架,属于二阶段提交模式。
Business 是业务入口,在程序中会通过注解来说明他是一个全局事务,这时他的角色为 TM(事务管理者)。Business 会请求 TC(事务协调器,一个独立运行的服务),说明自己要开启一个全局事务,TC 会生成一个全局事务ID(XID),并返回给 Business。Business 得到 XID 后,开始调用微服务,例如调用 Storage。
Storage 会收到 XID,知道自己的事务属于这个全局事务。Storage 执行自己的业务逻辑,操作本地数据库。Storage 会把自己的事务注册到 TC,作为这个 XID 下面的一个分支事务,并且把自己的事务执行结果也告诉 TC。此时 Storage 的角色是 RM(资源管理者),资源是指本地数据库。Order、Account 的执行逻辑与 Storage 一致。
在各个微服务都执行完成后,TC 可以知道 XID 下各个分支事务的执行结果,TM(Business) 也就知道了。Business 如果发现各个微服务的本地事务都执行成功了,就请求 TC 对这个 XID 提交,否则回滚。
TC 收到请求后,向 XID 下的所有分支事务发起相应请求。各个微服务收到 TC 的请求后,执行相应指令,执行结果上报 TC。
每个分支事务对应的数据库中都需要有一个回滚日志表 UNDO_LOG,在真正修改数据库记录之前,都会先记录修改前的记录值,以便之后回滚,业务数据的更新和 UNDO LOG 一并提交。在收到回滚请求后,就会根据 UNDO_LOG 生成回滚操作的 SQL 语句来执行。如果收到的是提交请求,就把 UNDO_LOG 中的相应记录删除掉。
总结
分布式事务主要解决的问题是在分布式部署及微服务架构的情况下保证数据一致性,个人理解最终方案把握以下原则就可以了,那就是:大事务=小事务(原子事务)+异步(消息通知),解决分布式事务的最好办法其实就是不考虑分布式事务,将一个大的业务进行拆分,整个大的业务流程,转化成若干个小的业务流程,然后通过设计补偿流程从而考虑最终一致性。目前常见的实现方案有两阶段型、补偿型、异步确保型和最大努力通知型。
参考文章:
网友评论