1 一致性问题
一致性问题是一个比较宽泛的问题,常见于传统数据库、分布式存储、分布式注册中心等各类领域之中:
- 传统单机数据库(MySQL, Oracle)需要保证ACID,同一组事务内的多条写操作需要保持一致性,不能被会话读到中间状态。
- 分布式存储系统(HBase, Cassandra)对同一个key的数据会进行多副本冗余存储,需要保证多副本的数据一致性。
- 常见的注册中心(etcd, zookeeper),集群内的每台server都会提供服务,相互之间也需要保持数据的一致性。
针对上述每个场景,在其对应的领域内都有一些通用的解决办法来保证他们的一致性。
不同于上述这些场景,本文将要讨论的是业务系统的一致性。随着业务系统的增长,必然会遇到系统的拆分、数据库的分库分表等情况,原来单一系统或者单台数据库能通过数据库事务解决的问题,在分布式系统中会遇到相互协调、网络超时等更多的问题。
举一个业务场景为例,用户通过手机支付购买商家的商品,在支付过程中需要在一系列的系统进行处理:支付系统中需要创建支付单,在账务系统进行用户余额扣减,随后通知上游系统完成支付。在这个支付过程中,三种系统之间需要严格保证一致性。不能出现支付单成功但是余额没扣,也不能出现余额扣除后商家不发货的情况。
其实这种一致性场景在各类系统中都非常常见,上述问题可以简化为下图:每一组业务处理流程中会包含多组动作(A、B…),每个动作可能关联到分布式环境下的不同系统。但是这些动作之间需要保证一致性:A和B动作必须同时成功或失败,不能出现A动作成功B动作失败这类问题。
此类一致性问题看似简单,用单机数据库事务就可以解决,为什么在分布式系统中会变得如此复杂呢?
分布式系统属于异步系统(Asynchronous system model):不同进程的处理器速度可能差别很大,时钟偏移可能很大,消息传播延迟可能很大(可能很大意味着没有最大值限制)。这样就带来一个很大的问题:超时。超时一定有可能发生,但是超时又无法判断究竟是成功还是失败了,导致整个业务状态异常。而单台计算机属于同步系统(Synchronous system model),即使不同进程的处理器速度差异、时钟偏移延迟、消息延迟都有最大值的。
需要额外说明的是,一致性的两种翻译(consistency/consensus)是不同的概念,不同于consistency,consensus问题是为了解决若干进程对同一个变量达成一致的问题,例如集群的leader选举问题。consensus问题通常使用Paxos或Raft协议来实现,虽然他们有时候会被翻译成“一致性算法”,但是实际上是从consensus algorithm翻译而来因此叫做“一致性”并不准确。这类问题不在本文讨论的话题范围内。
2 理论基础
还是要搬出老生常谈的两个定理来解释一下:
2.1 CAP定理
2000年7月,加州大学伯克利分校的Eric Brewer教授在ACM PODC会议上提出CAP猜想[1]。2年后,麻省理工学院的Seth Gilbert和Nancy Lynch从理论上证明了CAP。之后,CAP理论正式成为分布式计算领域的公认定理。
CAP定理是分布式系统的重要基础定理。理论指出,在分布式存储系统中,以下三个特点至多只能同时满足两点:
- Consistency 一致性:每次读取获得的都是最新写入的数据,即写操作之后的读操作,必须返回该值
- Availability 可用性:服务在正常响应时间内一直可用,返回的状态都是成功
- Partition-tolerance 分区容错性:即使遇到某节点或网络故障的时候,系统仍能够正常提供服务
尽管CAP狭义上针对的是分布式存储系统,但它一样可以应用于普遍的分布式系统。由于分区容错性(P)是分布式系统最重要的特点,因此CAP可以理解为:当网络发生分区(P)时,要么选择C一致性,要么选择A可用性。
举例来说,具体到上文描述的用户支付的例子中,当网络存在异常时,要么用户可能暂时无法支付,要么用户的余额可能不会立刻扣减。这两种选择就是在架构设计中对可用性和一致性的权衡。
2.2 BASE定理
BASE定理来自于eBay架构师Dan Pritchett发表在ACM的文章BASE: An Acid Alternative[2]。正如文章题目所说,大部分系统的可用性(对应CAP中的A)十分重要,所以ACID(强一致性,对应CAP的C)在分布式系统中需要适当的舍弃,强一致性的一个替代方案就是BASE:Basically Available(基本可用)、Soft state(软状态)、Eventually consistent(最终一致性)。因此BASE并不是一个明确的AP或者CP的方案,而是在A和C之间做出更倾向于A的权衡,核心思路就是用最终一致性替代强一致性。
Basically Available,指的并不是牺牲可用性,而是通过数据分片的方式将服务拆分到不同的系统中,这样即使出现故障也只会影响一个分片而不是所有用户。也有说法是说在遇到局部故障或者流量暴增的时候,可以通过增加延时或者适当降级的方式,忍受一定的功能损失。
Soft state指的是允许数据存在中间状态,也就是事务的隔离性可能不能保证。但是业务层面可以通过很多方式来弱化中间状态可能带来的影响,例如余额增加冻结的状态,下文有例子提到。
Eventually consistent最终一致性顾名思义,所有的数据/系统状态,在经过一段时间的同步或者补偿之后,最终都能够达到一个一致的状态。也有人给最终一致性又细分了几种一致性类型,这里不再详述。
BASE的思想有助于我们在应用场景中,不再拘泥于CAP的三选二,而是在可用性和一致性之间适当的取舍,给出了很多启发。
3 一致性问题的解决方案
解决一致性问题的方案以分布式事务为主,分布式事务的实现方式也多种多样,基于不同使用场景可以选择不同的方式,下面会逐一介绍。
3.1 基于2PC的分布式事务
3.1.1 介绍
2PC(Two-phase commit protocol)是指在计算机网络以及数据库领域内,为了使基于分布式系统架构下的所有节点在进行事务提交时保持一致性而设计的一种演算法[3]。2PC理论中存在两种角色,一个节点作为协调者(Coordinator),其他节点作为参与者(Participants),事务整体执行分成两个阶段,首先参与者将操作成败通知协调者,然后由协调者根据所有参与者的反馈决定各参与者本地是否要提交操作还是中止操作。
2PC理论最广泛的应用就是的XA分布式事务规范,相当于是2PC的一种细化方案,XA规范是标准化组织 X/Open基于分布式事务处理模型(DTP distributed transaction processing)制定的接口标准[4]。XA主要适用场景在数据库领域,大部分的数据库都支持XA协议,例如MySQL支持XA事务的官方介绍:https://dev.mysql.com/doc/refman/5.5/en/xa.html。
XA规范定义了一个事务管理器(对应2PC的协调者)和多个资源管理器(对应2PC的参与者),例如XA的MySQL实现中资源管理器就是MySQL服务器,事务管理器就是连接到MySQL服务器的客户端程序。一次XA事务的执行过程如下:
- 准备阶段:事务管理器给每个资源管理器(数据库)发送Prepare消息,每个资源管理器要么直接返回失败,要么在本地执行事务,写本地的redo和undo日志,但不提交。
- 提交阶段:事务管理器收到所有资源管理器的Prepare结果,如果存在失败或超时,直接给每个资源管理器发送回滚(Rollback)消息;否则,发送提交(Commit)消息。
3.1.2 异常处理
那么,如果在2PC的过程中出现了节点故障需要怎么处理,我们分析一下:[5]
- 参与者故障
- 参与者若在一阶段回执Prepare消息之前故障,则整体事务直接返回失败。
- 参与者若在一阶段回执Prepare消息后故障,则从故障中恢复的节点可以从日志中恢复,此时需要与协调者或其他节点联系,以决定事务是回滚还是提交。
- 参与者若在二阶段收到Commit或Rollback消息后故障,则从故障中恢复的节点可以从日志中恢复,在本地执行redo或undo。
- 协调者故障
- 协调者如果宕机,多数情况下只能等待恢复,引入协调者备份,同步协调者操作日志。但是此时参与者资源无法释放,会带来可用性问题。当然也可以引入协调者集群,但是选举出来的新的协调者并不知道协调者的最新状态,需要再次询问所有参与者,假设此时参与者也有一个故障了,那就只能等待全部恢复了。
3.1.3 2PC的变种
2PC在CAP理论中属于CP的一种方案,保障了“强一致性”,即分布式事务执行成功之后,确保所有参与者的数据都完成了更新。但缺点是因为两个阶段长期持有锁,带来了性能损耗,同时如果协调者故障会直接影响可用性。
同时由于数据库本地事务本身就是begin-commit多阶段的,实践上2PC天然适合数据库领域。其他非数据库的场景虽然理论上可行,但实际上支持的太少,大部分实现框架都是要基于本地OLTP数据库实现的,所以并不适合非关系型数据库场景。好处在于,正是因为对接标准的DB接口,2PC的实现框架对于开发者来说普遍比较友好,侵入性低,不需要额外开发回滚等新接口,也不需要在本地额外创建数据日志表等,通常做一些数据源和接口层面的配置即可使用。
针对2PC在可用性方面的缺陷,业界有很多2PC的变种来进行优化。例如3PC,3PC把2PC的二阶段拆分成了preCommit和doCommit两个阶段,同时引入了参与者的超时机制,这样的话参与者可以在网络时效的情况下也能做到自动提交或者回滚:参与者响应第一阶段canCommit之后,等待第二阶段preCommit指令,若等待超时,则自动回滚;参与者相应第二阶段preCommit之后,等待第三阶段doCommit指令,若等待超时,则自动提交。
如果没有节点故障或者超时,3PC其实和2PC效果上没有差别。3PC主要优化的是协调者和一个参与者在提交阶段同时故障的情况(也可以说是解决了协调者单点故障的问题,参见上文协调者故障),增强了整体的可用性。3PC不是完美的,缺点一是增加了一个阶段带来的性能额外损耗,二是如果在第三阶段参与者意外超时可能造成数据不一致。
再举一个2PC变种的例子,阿里内部TXC,对应开源框架Seata[6]的AT模式(这种模式也有叫做FMT的),为了解决XA带来的性能问题,TXC在++第一阶段就提交++了各个资源的事务,并在第二阶段如果有需要回滚则TXC会以++反向补偿++机制来让事务各个参与者保持数据的一致性,这个反向补偿具体就是在SQL执行前进行拦截,保留了数据更改前的快照,因此这种模式需要解析SQL,对SQL语句和DB厂商有限制。相当于为了提高性能,牺牲了强一致性,可能会出现脏读。
3.2 基于TCC的补偿型事务
3.2.1 介绍
TCC的概念最早可能是在2007年《Life beyond Distributed Transactions: an Apostate’s Opinion》论文[7]提出,本意是为了解决在大规模分布式系统下分布式事务的性能和可用性问题,提出了一种设计范式。TCC和两阶段提交有一些相似,但是有一条本质差别,它将事务框架从资源层提升到了服务层,以解决两阶段提交的性能问题。TCC对开发者的使用侵入性较大,要求按照该框架编程,将业务逻辑的每个分支都分为Try、Confirm、Cancel三个操作集。通过上层业务以较多的代码获取较高的性能,实现了最终一致性。
谈到TCC,还是要提到鲁肃,他成功的将这种模式在支付宝内部大规模实践,并在业内做过一些分享[8]。支付宝内部的分布式事务框架XTS(开源版已整合到Seata)就是使用TCC模式支撑了支付核心业务[9]。下面引用一下分享原图来介绍一下支付宝使用的TCC分布式事务的细节:
TCC在事务执行的流程上和2PC类似,分为发起方(对应图中主业务服务,负责发起分布式事务)和参与方(对应图中从业务服务,可以有多台,负责提供TCC操作接口)两个角色。TCC是三个单词Try、Confirm、Cancel的缩写,分别对应了分布式事务参与者需要提供的3个接口。整个执行过程也是分两阶段,第一阶段执行所有参与方的Try操作,若全部成功则执行发起方的本地事务,并在第二阶段执行所有参与方的Confirm操作;否则执行第二阶段所有参与方的Cancel操作:
- 一阶段Try操作,负责资源的检查,同时必须要预留资源,而且预留的资源要支持提交和回滚。
- 二阶段Confirm操作,负责使用Try操作预留的资源真正完成业务动作,同时接口需要保证幂等性。
- 二阶段Cancel操作,负责释放Try操作预留的资源,同时接口需要保证幂等性。
上图中的业务活动管理器负责记录事务活动日志(以便于故障恢复)和协调参与者操作。为了简化流程,大部分实际场景下主业务服务就承担了业务活动管理器的职责。
3.2.2 示例
举一个具体的例子来理解一下(具体实现以TXC的实现为例):还是文章开头的场景,张三给李四做一笔支付10元的操作,可能涉及到支付和账务两个系统,各自有各自的数据库和服务。那么就要求支付单创建了一定能转账成功,转账成功的话一定要有支付单据。
我们把支付系统当作发起方,账务系统当作参与方。那么TCC的一阶段,会执行所有参与方的Try操作,如果所有Try操作都成功,那么会记录日志并继续执行发起方的业务操作。在TCC的二阶段时,会根据一阶段Try的结果来决定整体Confirm还是Cancel,此时发起方会继续调用参与方的接口来完成二阶段操作。
TCC是一种服务层的事务模式,因此业务在使用时就要充分考虑到分布式事务中间状态的处理方式。在这个例子里,Try操作必须起到真正预留资源的作用,也就是说张三的余额既不能真正的扣除(因为还没有完成事务,confirm还是cancel不能确定,当然这里插一句题外话,在性能要求高的场景下不用分布式事务,扣减之后再充退补偿也是一种方式),也要保证张三这10元钱不会在事务的进行中突然被别的业务划走,因此实际上会产生一个“冻结余额”的业务概念,冻结余额就是资源预留的业务层面的体现。李四的余额也是一样,即将转给李四但是还未到账的金额叫做“未达金额”,代表了李四预留的应收款项。
由于TCC保证的是最终一致性,事务的中间状态可能会被业务感知到。因此在账务模型里,可以设计成:可用余额 = 账户余额 - 冻结金额 + 未达余额,这样做既可以把事务中的资金隔离出来,也避免了在途金额给用户带来额外的理解成本。
TCC的一阶段是在发起方的本地事务里执行的,以便于一阶段出现异常时发起方的业务逻辑回滚。同时在本地事务里,发起方还需要记录事务活动日志(也可以另起一个中心服务来记录日志,但这样做会变得更复杂),以便于二阶段的提交和回滚。
3.2.3 异常处理
- 如果在一阶段,即发起方的本地事务中失败(例如try接口调用超时,或者发起方业务逻辑异常),则本地事务直接回滚,然后继续触发二阶段的Cancel。
- 如果在一阶段提交后,二阶段未能成功提交或回滚(因为二阶段是异步处理的,可能由于服务重启等情况被中断),则此时需要引入一个新的恢复系统,从发起方的DB中,扫描出异常的事务记录,并不断重试。
在实际应用过程中,会出现“悬挂事务”这种情况,悬挂事务指的是一阶段操作锁定了特定的资源, 但这些数据没有在二阶段正常提交或回滚。悬挂事务出现的原因有很多,最常见的原因是一阶段try操作超时然后立即进入二阶段回滚,导致了二阶段的cancel早于try执行(这种情况也叫空回滚,这种情况也有解法但是难以根治);除此之外业务代码bug等其他因素也会引起悬挂事务。因此分布式事务的异常处理不是一劳永逸,需要部署监控核对等方式来谨慎地兜底。
3.3 基于可靠消息的一致性模型
为什么可靠消息也可以保证系统间的一致性?得益于消息中间件自身的特性,通过异步+持久化+重试的机制保障了消息一定会被订阅者消费,只要保证业务操作和消息发送之间的原子性(通过本地事务表或者消息回查的方式),就可以保证消息发送者和消费者之间的系统一致性了。但是消息的模式有一大局限,消息只能保证一定被消费执行,无法提供全局的回滚,因此仅限于一定能成功的业务场景。
什么叫“一定能成功”的业务场景,比如以文章开头的支付为例,扣减余额就不是一个一定能成功的场景,因为用户可能余额不足可能账户被封等各种原因需要被校验,此时需要通过分布式事务来保证较强的一致性。但如果是账务处理完毕后,让对账系统记录对账流水、或者是给商家发送站内信通知,这些操作原则上都是“一定能成功”的,可以也应该通过可靠消息来实现。
所以与其叫事务消息,我个人更倾向于叫它可靠消息,因为并不能像事务一样回滚,但是同样可以保证一致性。
通过可靠消息实现一致性,是一个非常常见的做法,在上面的BASE理论的原文中,提出的例子就是用可靠消息来实现最终一致性。
3.3.1 基于事务消息
这里我们直接参考阿里云RocketMQ的官方文档[10]为例:
整个流程需要有两个参与者,一个是消息发送方,一个是消息订阅方。消息发送方的本地事务和消息订阅方的消费需要保证一致性。类似于两阶段提交,事务消息也是把一次消息发送拆成了两个阶段:首先消息发送方会发送一个“半事务消息”,然后再执行本地事务,根据本地事务执行的结果,来给消息服务端再发送一个commit或rollback的确认消息。只有服务端收到commit消息后,才会真正的发送消息给订阅方。
异常处理:
以下是可能出现的异常情况,都能比较简单的解决掉
- 1~2发送半事务消息的过程中,如果发送消息失败,则整个业务流程失败即可,不存在一致性问题。但如果发送消息超时(此时可能消息服务端已经持久化成功了),消息发送方也会认为失败然后终止业务流程。后续消息服务端未收到4的确认,会通过回查来确认事务未提交,从而终止commit。
- 3执行本地事务时出现异常,则会通过Rollback指令告诉消息服务端停止投递二阶段的消息。
- 4发送二阶段确认消息时,如果超时或者异常无需特殊处理,本地事务也不需要回滚,因为后续消息服务端会主动回查状态进行补偿
虽然支持事务消息的中间件其实并不多,而且开发者还需要多开发一个事务状态回查的接口。但事务消息依然是解决一致性问题的比较简洁的方式,没有分布式事务那么复杂,代码侵入性也不大。
3.3.2 基于本地消息
本地事务+本地消息表也是实现可靠消息的一种方式,这种模式不需要依赖指定的消息中间件,但是需要在本地的数据库中维护本地消息表。通过将发送消息的动作转换为本地消息表的insert操作,然后用定时任务捞取本地消息表来发送消息,来实现本地事务和消息消费方的一致性。
虽然实现上比较简单,但是本地消息表需要和本地业务操作在同一个OLTP数据库中,对于分库分表等分布式存储方案可能不太适合。
如上图,消息发送方需要首先在本地数据库里创建一个消息表,表结果可以自行定义。消息发送方的业务逻辑和消息表的插入需要在同一个事务里执行,随后需要额外配置一个定时任务来周期性的捞取和重试消息表中未处理的消息,再真正投递给消息服务器。
异常处理:
本地消息的异常情况更加简单,如果是本地事务执行过程中的异常,事务会自动回滚,也不用担心会有消息被发送出去。而只要事务成功提交了,定时任务就会捞起未处理的消息并发送,即使发送失败也会在下个定时周期重试补偿。此时唯一需要关注的点在于消息接收方需要针对消息体进行幂等处理,保证同一个消息体只会被真正消费一次。
3.4 Saga模式
Saga模式的理论最初来自于普林斯顿大学的Hector Garcia-Molina和Kenneth Salem发表的一篇论文[11]。最初是为了解决长事务带来的性能损耗,可以拆分成若干个子事务,然后通过依次执行或补偿来实现一致性。如今的Saga模式,更多地用于分布式事务场景中管理跨微服务的数据一致性。 Saga模式由一系列子事务组成,每个子事务会更新各自的服务并发布消息触发下一个子事务。 如果某个子事务步骤失败,则Saga会依次向前执行补偿动作以抵消之前的子事务。[12]
如下图,
在Saga的实现上,既可以通过一个集中的协调者,来记录每个子事务的运行情况,然后推进提交或回滚;也可以用消息广播订阅的方式,让子事务的参与者们自行协调,因为消息中间件本身自带重试的能力,也无需部署另一个恢复服务来处理异常状况。目前开源社区中,可以通过Apache Camel的Saga模式、或者华为的Apache ServiceComb等来搭建一个Saga模式的项目。具体的实现本文就不深入探讨了。
Saga模式看上去简单也容易理解,但是有一大缺陷是缺乏隔离性。虽然前面几种方案,不管是2PC的变种、TCC、还是可靠消息都或多或少的破坏了隔离性,但是破坏的程度都不大,因为TCC可以通过业务层面预留资源的方式降低隔离性的影响,可靠消息因为一定可以成功所以不会出现读到被回滚的数据的可能性。而Saga模式一旦需要回滚,前面已经执行成功的小事务如果被读到,就可能出现难以预料的后果,所以使用方需要自行加锁或者通过业务层处理。根据我的经验来看,Saga模式在实际的业务场景中的应用并不多。
和Saga模式类似,我个人认为还有另一种重试模式更适合轻量级的业务场景来使用。Saga模式本质上是对业务流程的正向+逆向编排,所以它的实现框架通常需要实现一个状态机引擎(DSL描述),然后通过一种调度机制来触发流程的推进和重试。由于很多异步场景都是“一定能成功”的,对隔离性要求比较强的场景也不会使用Saga模式,所以状态机+定时任务重试这种模式我发现在实际应用里非常常见,也非常简单好用:
重试模式依然是把业务流程拆分成若干个子事务来处理,和Saga模式不同的是,我们认为,把需要校验的逻辑前置处理,后续的执行中绝大多数异常都是可以通过重试来解决的。因此没有要求每个子事务再开发一套回滚补偿的逻辑,而是直接定时重试,直到成功。当然为了避免极端情况出现的多次重试失败,我们会设置一些重试次数和报警的机制。重试模式更像是一种设计模式,我认为也不需要一些特定的框架和中间件,定时任务的调度、流程引擎这些都是业务开发中非常常见的基础组件,结合公司的技术栈和业务场景自行选择合适的框架即可。
4 小结
针对系统之间的一致性问题,老生常谈的方案就是分布式事务。如果业务场景对一致性要求很高,如支付、库存,这种场景的确需要分布式事务——通常会采用2PC的变种或者TCC来实现。
分布式事务的确对于提高一致性的强度有很大帮助,但是开发难度和复杂度会比较高,对于一些普通的业务场景来说性价比不高。参考BASE定理,更多的场景适合用可靠消息或者重试模式来实现一致性。
最后,业务场景无论是用哪一种方式来保证一致性,建议都通过核对等方式进行校验和兜底,以防止极端情况或者系统bug导致的预期外问题。
5 参考资料
[1] Fox, A. and Brewer, E. A. 1999. Harvest, yield, and scalable tolerant systems. In 6th Workshop on Hot Topics in Operating Systems (HOTOS-VI). Rio Rico, AZ. 174–178.
[2] Pritchett, Dan. Base an acid alternative[J]. Queue, 2008, 6(3):48-55.
[3] 二阶段提交 - 维基百科,自由的百科全书
[4] 官方文档:Distributed Transaction Processing: The XA Specification
[5] Tutorials and Notes: How does 2PC protocol handles failures in distributed database?
[6] Seata官网
[7] Helland P . Life beyond Distributed Transactions: an Apostate’s Opinion[C]// Conference on Cidr. DBLP, 2007.
[8] 大规模SOA系统中的分布事务处理(程立)
[9] 蚂蚁金服大规模分布式事务实践和开源介绍
[10] 收发事务消息-消息队列RocketMQ版-阿里云
[11] Garcia-Molina, H., Salem, K.: Sagas. ACM SIGMOD Transactions, pp. 249–259 (1987)
[12] Saga distributed transactions - Azure Design Patterns