1 分布式事务的概念
事务在分布式计算领域也得到了广泛的应用。在单机数据库中,我们很容易能够实现一套满足ACID特性的事务处理系统,但是在分布式数据库中,数据分散在各台不同的机器上,如何对这些数据进行分布式事务处理具有非常大的挑战。
分布式事务的分布式,是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于分布式系统的不同节点之上。通常一个分布式事务会涉及对多个数据源或业务系统的操作。
一个最典型的分布式事务场景是跨行的转账操作。该操作涉及调用两个异地的银行服务。其中一个是本地银行提供的取款服务,另一个是目标银行提供的存款服务,这两个服务本身是无状态且相互独立的,共同构成了一个完整的分布式事务。取款和存款两个步骤要么都执行,要么都不执行。否则,如果从本地银行取款成功,但是因为某种原因存款服务失败了,那么必须回滚到取款之前的状态,否则就会导致数据不一致。
从上面的例子可以看出,一个分布式事务可以看作是由多个分布式操作序列组成的,例如上面例子中的取款服务和存款服务,通常可以把这一系列分布式的操作序列称为子事务。由于分布式事务中,各个子事务的执行是分布式的,因此要实现一种能够保证ACID特性的分布式事务处理系统就显得格外复杂。
分布式事务=分布式+事务,这是分布式事务本身最直观,也最重要的标签。我们要想理解分布式事务的理论基础,就要首先从这两个角度来解读:
1.1 分布式事务是个事务
首先,分布式事务是个事务,既然是事务,那么我们会希望它能够满足传统事务的ACID四个特性:
1.1.1 传统事务要拥有ACID特性
Atomic(原子性)
- 事务的原子性是指事务必须是一个原子的操作序列单元。事务中包含的各项操作在一次执行过程中,要么全部执行,要么全部不执行。
- 任何一项操作失败都将导致整个事务失败,同时其他已经被执行的操作都将被撤销并回滚。只有所有的操作全部成功,整个事务才算是成功完成。
Consistency(一致性)
- 事务的一致性是指事务的执行不能破坏数据库数据的完整性和一致性,一个事务在执行前后,数据库都必须处于一致性状态。换句话说,事务的执行结果必须是使数据库从一个一致性状态转变到另一个一致性状态。
- 假设银行的转账操作就是一个事务。假设A和B原来账户都有100元。此时A转账给B50元,转账结束后,应该是A账户减去50元变成50元,B账户增加50元变成150元。A、B的账户总和还是200元。转账前后,数据库就是从一个一致性状态(A100元,B100元,A、B共200元)转变到另一个一致性状态(A50元,B150元,A、B共200元)。假设转账结束后只扣了A账户,没有增加B账户,这时数据库就处于不一致的状态。
Isolation(隔离性)
事务的隔离性是指在并发环境中,并发的事务是相互隔离的,事务之间互不干扰。
在标准的SQL规范中,定义的4个事务隔离级别,不同隔离级别对事务的处理不同。4个隔离级别分别是:读未提交、读已提交、可重复读和串行化。
事务隔离级别越高,就越能保证数据的完整性和一致性,但同时对并发性能的影响也越大。
通常,对于绝大多数的应用来说,可以优先考虑将数据库系统的隔离级别设置为授权读取,这能够在避免脏读的同时保证较好的并发性能。尽管这种事务隔离级别会导致不可重复读、幻读和第二类丢失更新等并发问题,但较为科学的做法是在可能出现这类问题的个别场合中,由应用程序主动采用悲观锁或乐观锁来进行事务控制。
Durability(持久性)
- 事务的持久性又称为永久性,是指一个事务一旦提交,对数据库中对应数据的状态变更就应该是永久性的。即使发生系统崩溃或机器宕机等故障,只要数据库能够重新启动,那么一定能够将其恢复到事务成功结束时的状态。
1.2 分布式事务是分布式的
其次,分布式事务是分布式的,既然是分布式的系统,那么它必然无可避免的要收到CAP理论的约束:
1.2.1 分布式系统要受CAP理论约束
CAP理论:一个分布式系统不可能同时满足一致性(C:Consistency)、可用性(A:Availability)和分区容错性(P:Partition tolerance)这三个基本要求,最多只能满足其中的两项。
- 一致性
- 在分布式环境中,一致性是指数据在多个副本之间是否能够保持一致的特性(这点跟ACID中的一致性含义不同)。
- 对于一个将数据副本分布在不同节点上的分布式系统来说,如果对第一个节点的数据进行了更新操作并且更新成功后,却没有使得第二个节点上的数据得到相应的更新,于是在对第二个节点的数据进行读取操作时,获取的依然是更新前的数据(称为脏数据),这就是典型的分布式数据不一致情况。
- 在分布式系统中,如果能够做到针对一个数据项的更新操作执行成功后,所有的用户都能读取到最新的值,那么这样的系统就被认为具有强一致性(或严格的一致性)。
- 可用性
- 可用性是指系统提供的服务必须一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果,如果超过了这个时间范围,那么系统就被认为是不可用的。
- 『有限的时间内』是一个在系统设计之初就设定好的运行指标,不同的系统会有很大的差别。比如对于一个在线搜索引擎来说,通常在0.5秒内需要给出用户搜索关键词对应的检索结果。而对应Hive来说,一次正常的查询时间可能在20秒到30秒之间。
- 『返回结果』是可用性的另一个非常重要的指标,它要求系统在完成对用户请求的处理后,返回一个正常的响应结果。正常的响应结果通常能够明确地反映出对请求的处理结果,及成功或失败,而不是一个让用户感到困惑的返回结果。
- 让我们再来看看上面提到的在线搜索引擎的例子,如果用户输入指定的搜索关键词后,返回的结果是一个系统错误,比如”OutOfMemoryErroe”或”System Has Crashed”等提示语,那么我们认为此时系统是不可用的。
- 分区容错性
- 分区容错性要求一个分布式系统需要具备如下特性:分布式系统在遇到任何网络分区故障的时候,仍然能够保证对外提供满足一致性和可用性的服务,除非是整个网络环境都发生了故障。
- 网络分区是指在分布式系统中,不同的节点分布在不同的子网络(机房或异地网络等)中,由于一些特殊的原因导致这些子网络之间出现网络不连通的状况,但各个子网络的内部网络是正常的,从而导致整个系统的网络环境被切分成了若干个孤立的区域。
需要明确的一点是:对于一个分布式系统而言,分区容错性可以说是一个最基本的要求。因为既然是一个分布式系统,那么分布式系统中的组件必然需要被部署到不同的节点,否则也就无所谓的分布式系统了,因此必然出现子网络。
而对于分布式系统而言,网络问题又是一个必定会出现的异常情况,因此分区容错性也就成为了一个分布式系统必然需要面对和解决的问题。因此系统架构师往往需要把精力花在如何根据业务特点在C(一致性)和A(可用性)之间寻求平衡。
比如Cassandra、Dynamo等中间件,他们的实现默认优先选择AP,弱化C;
而HBase、MongoDB等中间件,他们的实现默认优先选择CP,弱化A。
1.2.2 一致性和可用性权衡的总结——BASE理论
BASE是Basically Available(基本可用)、Soft state(软状态)和Eventually consistent(最终一致性)三个短语的简写,由eBay架构师Dan Pritchett提出的,是对CAP中一致性和可用性权衡的结果,其来源于对大规模互联网分布式系统实践的总结,是基于CAP定律逐步演化而来。
BASE理论核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。
- 基本可用
- 基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性——但请注意,这绝不等价于系统不可用。比如
- 响应时间上的损失:正常情况下,一个在线搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障(比如系统部分机房发生断电或断网故障),查询结果的响应时间增加到了1~2秒。
- 功能上的损失:正常情况下,在一个电子商务网站(比如淘宝)上购物,消费者几乎能够顺利地完成每一笔订单。但在一些节日大促购物高峰的时候(比如双十一、双十二),由于消费者的购物行为激增,为了保护系统的稳定性(或者保证一致性),部分消费者可能会被引导到一个降级页面
- 基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性——但请注意,这绝不等价于系统不可用。比如
- 弱状态
- 弱状态是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同的数据副本之间进行数据同步的过程存在延时。
- 最终一致性
- 最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
- 最终一致性是一种特殊的弱一致性:系统能够保证在没有其他新的更新操作的情况下,数据最终一定能够达到一致的状态,因此所有客户端对系统的数据访问都能够获取到最新的值。同时,在没有发生故障的前提下,数据到达一致状态的时间延迟,取决于网络延迟、系统负载和数据复制方案设计等因素。
- 在实际工程实践中,最终一致性存在以下五类主要的变种:
- 因果一致性(Causal consistency)
- 如果进程A通知进程B它已更新了一个数据项,那么进程B的后续访问将返回更新后的值,且一次写入将保证取代前一次写入。与进程A无因果关系的进程C的访问遵守一般的最终一致性规则。
- 读己之所写(Read your writes)
- 当进程A自己更新一个数据项之后,它总是访问到更新过的值,绝不会看到旧值。这是因果一致性模型的一个特例。
- 会话一致性(Session consistency)
- 这是上一个模型的实用版本,它把访问存储系统的进程放到会话的上下文中。只要会话还存在,系统就保证“读己之所写”一致性。如果由于某些失败情形令会话终止,就要建立新的会话,而且系统的保证不会延续到新的会话。
- 单调读一致性(Monotonic read consistency)
- 如果某个进程已经看到过数据对象的某个值,那么该进程任何后续访问都不会返回在那个值之前的值。
- 单调写一致性(Monotonic write consistency)
- 系统保证来自同一个进程的写操作顺序执行。要是系统不能保证这种程度的一致性,就非常难以编程了。
以上就是最终一致性的五种常见的变种,在实际系统实践中,可以将其中的若干个变种互相结合起来,以构建一个具有最终一致性特性的分布式系统。
事实上,最终一致性并不是只有那些大型分布式系统才涉及的特性,许多现代的关系型数据库都采用了最终一致性模型。在现代关系型数据库中(比如MySQL和PostgreSQL),大多都会采用同步或异步方式来实现主备数据复制技术。在同步方式中,数据的复制过程通常是更新事务的一部分,因此在事务完成后,主备数据库的数据就会达到一致。而在异步方式中,备库的更新往往会存在延时,这取决于事务日志在主备数据库之间传输的时间长短。如果传输时间过长或者甚至在日志传输过程中出现异常导致无法及时将事务应用到备库上,那么很显然,从备库中读取的数据将是旧的,因此就出现了数据不一致的情况。
当然,无论是采用多次重试还是人为数据订正,关系型数据库还是能够保证最终数据达到一致,这就是系统提供最终一致性保证的经典案例。
- 系统保证来自同一个进程的写操作顺序执行。要是系统不能保证这种程度的一致性,就非常难以编程了。
- 因果一致性(Causal consistency)
1.3 ACID和CAP妥协下的柔性事务
可以看到,ACID特性和CAP理论,在关于一致性问题上都有论述,只不过
- ACID中的C论述的是:一个事务在执行前后,数据库的数据都必须处于一致性状态,如转账过程,金钱总量应该保持不变。
- CAP中的C论述的是:同一个数据在多个分布式副本之间是否能够保持一致,如某个用户的余额,在各个副本之间值应该一致。
我们需要注意到他们论述的点其实是不同的。
同时,我们还要注意到,虽然分布式系统受限于CAP理论而时常要在A和C中做取舍,但对于分布式事务系统来说,C的重要性是高于A的,故而市面上成熟的分布式事务解决方案,都是在努力事务ACID特性的基础上,尽量在分布式的情况下(也就是满足分区容错性的情况下)达到较好的数据一致性。
我们一般来说,根据数据一致性的实效,以及ACID/CAP取舍的类型,可将事务分为:
- 刚性事务:遵循ACID原则,强一致性。本地事务,基本都是刚性事务。
- 柔性事务:遵循BASE理论,最终一致性;与刚性事务不同,柔性事务允许一定时间内,不同节点的数据不一致,但要求最终一致。
受限于分布式的局限,分布式事务的实现目前都是柔性事务,换句话说,我们还无法实现完全满足ACID强一致性的分布式事务。
2 分布式事务的解决方案
经过上文的论述,我们有了一定的理论基础,明确了我们希望的分布式事务应该是什么样的。我们往往为了可用性和分区容错性,忍痛放弃强一致支持的刚性事务,转而追求最终一致性的柔性事务。
那么如何实现能够基本满足ACID特性和CAP理论的分布式事务呢?我们接下来介绍几种成熟的柔性事务实现。
- XA协议:更偏向于在数据库层面解决数据库之间的分布式事务
- 1.1 2PC(两段式提交)
- 1.2 3PC(三段式提交)
- TCC两阶段补偿型事务:更偏向于在应用层面解决分布式系统中的补偿形分布式事务
- 最大努力通知:最简单的一种柔性事务,适用于一些最终一致性时间敏感度低,且被动方处理结果不影响主动方的处理结果的业务。
- 本地消息表:将分布式事务拆分成本地事务进行处理的一种思路
- 事务消息机制:借助RocketMQ支持分布式事务消息的特性实现发送端的业务逻辑和消息发送的事务绑定,其本质可以看做是本地消息表的RocketMQ实现。注意,消费端的原子性和回滚则要自己保证,综合来看,更类似最大努力通知模型。
- Saga事务模型:让所有事件按照顺序推进,则一定可以达到一致性,如果发生异常,则逆序依次调用反向的补偿逻辑做回滚。
2.1 XA协议
在分布式系统中,每个节点都能明确知道自身事务操作结果,但无法直接获取到其他分布式节点的操作结果。所以当一个事务要横跨多个节点时,为了保证事务处理的ACID特性而引入了协调者组件来统一调度所有分布式节点(参与者)的执行逻辑,协调者调度参与者的行为并最终决定是否把参与者的事务进行真正的提交。
XA协议是体现和贯彻协调者角色的一种很经典分布式事务协议,由Tuxedo提出,XA的目的是保证分布式事务的ACID特性,就像本地事务一样。
XA大致分为两部分:事务管理器(协调者角色)和本地资源管理器。其中本地资源管理器往往由数据库实现,比如Oracle、DB2这些商业数据库都实现了XA接口,而事务管理器作为全局的调度者,负责各个本地资源的提交和回滚。
XA协议为了保证分布式事务能够在保持ACID特性的同时保证分布式系统之间的数据一致性,提供了两种分布式事务的实现:2PC和3PC协议。
2.1.1 2PC
2.1.1.1 简介
- 2PC(Two-Phase Commit 两阶段提交):完成参与者的协调,统一决定事务的提交或回滚,使基于分布式系统架构下的所有节点在进行事务处理过程中能够保持原子性和数据一致性。
- 目前绝大部分的关系型数据库都是采用二阶段提交协议来完成分布式事务处理的。
2.1.1.2 协议内容
投票,尝试让协调者们提交事务
- 事务询问:协调者向所有参与者发送事务内容,询问是否可以执行事务提交操作,等待响应
- 执行事务:参与者节点执行事务操作,并记录Undo和Redo信息到事务日志
- 参与者响应:若参与者成功执行事务,则向协调者反馈Yes响应,否则反馈No响应
根据协调者反馈决定事务执行结果
- 如果所有参与者的反馈都是Yes响应,那么执行事务提交
- 发送提交请求:协调者向所有参与者发送Commit请求
- 事务提交:参与者接受到Commit请求后执行事务提交操作并释放占用的事务资源
- 反馈事务提交结果:参与者完成事务提交后向协调者发送Ack消息
- 完成事务:协调者收到所有参与者的Ack响应后,完成事务提交
- 如果任何一个参与者返回了N响应或者协调者等待超时后就会中断事务
- 发送回滚请求:协调者向所有参与者发送Rollback请求
- 事务回滚:参与者受到请求后通过Undo信息执行事务回滚操作并释放占用的事务资源
- 反馈事务回滚结果:参与者回滚事务后向协调者发送Ack消息
- 中断事务:协调者接收到所有参与者的Ack响应后,完成事务中断
2.1.1.3 优缺点
- 如果所有参与者的反馈都是Yes响应,那么执行事务提交
- 优点
- 原理简单,实现方便,有许多现成的实现框架
- 缺点
- 3PC(Three-Phase Commit 三阶段提交)将二阶段提交的提交事务请求过程一分为二,形成CanCommit、PreCommit、doCommit三个阶段
2.1.2.2 内容
CanCommit
- 事务询问:协调者向所有参与者发送包含事务内容的CanCommit请求,询问是否可以执行事务提交操作,等待响应
- 参与者响应:参与者接收到CanCommit请求后判断自身能够顺利执行事务,能则返回Yes响应并进入预备状态,否则返回No响应
PreCommit
- 如果所有参与者反馈都为Yes响应,则执行事务预提交
- 发送预提交请求:协调者向所有参与者节点发出PreCommit请求,并进入Prepared阶段
- 事务预提交:参与者接收到PreCommit请求后预执行事务操作(还未提交),并记录Undo和Redo信息到事务日志中
- 参与者响应事务执行结果:若参与者成功执行事务后则返回Ack响应给协调者,等待最终命令,提交(commit)或者中断(abort)
- 如果任何一个参与者反馈了No响应或者协调者等待所有协调者的响应超时则中断事务
- 发送中断请求:协调者向所有参与者节点发出Abort请求
- 中断事务:无论收到Abort请求或者等待协调者请求超时,参与者都会中断事务
- 如果所有参与者反馈都为Yes响应,则执行事务预提交
DoCommit
- 执行提交
- 发送提交请求:当协调者收到所有参与者反馈的Ack响应,向所有参与者发送DoCommit请求,从预提交状态转到提交状态
- 事务提交:参与者接收到DoCommit请求后,正式执行事务提交操作,并释放占用的事务资源
- 反馈事务提交结果:参与者完成事务提交后向协调者发送Ack消息
- 完成事务:协调者接受到所有参与者反馈的Ack响应后,完成事务
- 中断事务
- 发送中断请求:协调者向所有参与者节点发出Abort请求
- 事务回滚:参与者接收到Abort请求后,利用Undo信息执行事务回滚操作,并释放占用的事务资源
- 反馈事务回滚结果:参与者完成事务回滚后向协调者发送Ack消息
- 中断事务:协调者接收到所有参与者反馈的Ack响应后,中断事务
ps1.需要注意的是,在这一阶段,可能发生两种故障,协调者工作异常,或者协调者与参与者之间网络异常。无论出现何种情况,都会导致参与者无法及时接收到协调者发送的doCommit或者Abort请求,针对这样的异常,参与者在等待超时后,继续进行事务提交。
- 执行提交
2.1.2.3 优缺点
- 优点
- 降低参与者的阻塞范围,能够在出现单点故障后继续达成一致
- 缺点
- 接受者接收到PreCommit消息后,如果出现网络分区导致协调者和参与者无法正常通信,这时参与者仍会进行事务提交,造成数据的不一致
2.1.3 2PC和3PC的区别总结
2PC图示
- 提交成功
- 中断事务
- 提交成功
3PC 图示
与两阶段提交不同的是,三阶段提交有如下改动点。
- 引入超时机制。同时在协调者和参与者中都引入超时机制。
- 在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。
- 3PC的第三阶段,参与者等待协调者反馈超时时,会默认执行。
总结
- 相对于2PC,3PC主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。而不会一直持有事务资源并处于阻塞状态。但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。
- 默认执行其实是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了PreCommit请求,那么协调者产生PreCommit请求的前提条件是他在第二阶段开始之前,收到所有参与者的CanCommit响应都是Yes。(一旦参与者收到了PreCommit,意味他知道大家其实都同意修改了)所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到commit或者abort响应,但是他有理由相信:成功提交的几率很大。
2.2 TCC两阶段补偿型事务
2.2.1 简介
TCC方案是可能是目前最火的一种柔性事务方案了。关于TCC(Try-Confirm-Cancel)的概念,最早是由Pat Helland于2007年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。在该论文中,TCC还是以Tentative-Confirmation-Cancellation命名。正式以Try-Confirm-Cancel作为名称的是Atomikos公司,其注册了TCC商标。
国内最早关于TCC的报道,应该是InfoQ上对阿里程立博士的一篇采访。经过程博士的这一次传道之后,TCC在国内逐渐被大家广为了解并接受。
Atomikos公司在商业版本事务管理器ExtremeTransactions中提供了TCC方案的实现,但是由于其是收费的,因此相应的很多的开源实现方案也就涌现出来,如:TCC-transaction、ByteTCC、spring-cloud-rest-tcc、ByteTCC、Himly。
2.2.2 内容
TCC是三个英文单词的首字母缩写而来。没错,TCC分别对应Try、Confirm和Cancel三种操作,这三种操作的业务含义如下:
- Try:预留业务资源
- Confirm:确认执行业务操作
- Cancel:取消执行业务操作
我们以一个经典电商系统下的支付订单场景为例:
那对一个订单支付之后,我们需要做下面的步骤:
- 更改订单的状态为“已支付”
- 扣减商品库存
- 给会员增加积分
- 创建销售出库单通知仓库发货
上述这几个步骤,要么一起成功,要么一起失败,必须是一个整体性的事务。
那么TCC如何实现呢?
2.2.2.1 Try
Try操作的核心是预留业务资源,比如
- 别直接把订单状态修改为已支付,可以先把订单状态修改为 UPDATING,也就是修改中的意思。
- 库存服务也别直接扣减库存啊,而改为冻结掉库存。你可以把可销售的库存:100-2=98,设置为98没问题,然后在一个单独的冻结库存的字段里,设置一个2,也就是说,有2个库存是给冻结了。
- 同理,别直接给用户增加会员积分,可以先在积分表里的一个预增加积分字段加入积分。
- 销售出库单可以创建,但是也设置一个中间状态“UNKNOWN”表示未确认。
2.2.2.2 Confirm
完成了Try操作后,接下来就分成两种情况了,第一种情况是比较理想的,那就是各个服务执行自己的Try操作都成功了,那么紧接着进入Confirm阶段。
订单,库存,积分,出库四个模块都感知到了try操作的成功,这时confirm操作执行:
- 正式把订单的状态设置为“已支付”。
- 冻结库存字段的2个库存扣掉变为0。
- 将预增加字段的10个积分扣掉,然后加入实际的会员积分字段中。
- 将销售出库单的状态正式修改为“已创建”,可以供仓储管理人员查看和使用,而不是停留在之前的中间状态“UNKNOWN”了。
这里简单提一句,如果你要玩TCC分布式事务,必须引入一款TCC分布式事务框架,比如国内开源的 ByteTCC、Himly、TCC-transaction。否则的话,感知各个阶段的执行情况以及推进执行下一个阶段的这些事情,不太可能自己手写实现,太复杂了。
2.2.2.3 Cancel
Confirm是try都成功后的操作,那么cancel就是try操作异常后才会进入的阶段。如积分服务吧,它执行出错了,订单服务内的TCC事务框架是可以感知到的,然后它会决定对整个TCC分布式事务进行回滚。
- 将订单的状态设置为“CANCELED”,也就是这个订单的状态是已取消。
- 将冻结库存扣减掉2,加回到可销售库存里去,98 + 2 = 100。
- 将预增加积分字段的10个积分扣减掉。
- 将销售出库单的状态修改为“CANCELED”,即已取消。
2.2.3 TCC是补偿形事务
TCC中的两阶段提交(try+confirm或者try+cancel)并没有对开发者完全屏蔽,也就是说从代码层面,开发者是可以感受到两阶段提交的存在。如上述案例:在第一阶段,相关模块需要提供try接口,为积分库存等预留字段分配资源。在第二阶段,各模块需要提供confirm/cancel接口(确认/取消预留)。开发者明显的感知到了两阶段提交过程的存在。try、confirm/cancel在执行过程中,一般都会开启各自的本地事务,来保证方法内部业务逻辑的ACID特性。其中:
try过程的本地事务,是保证资源预留的业务逻辑的正确性。
confirm/cancel执行的本地事务逻辑确认/取消预留资源,以保证最终一致性,也就是所谓的补偿型事务(Compensation-Based Transactions)。
由于是多个独立的本地事务,因此不会对资源一直加锁。
另外,这里提到confirm/cancel执行的本地事务是补偿性事务,关于什么是补偿性事务,atomikos官网上有以下描述:
红色框中的内容,是对补偿性事务的解释。大致含义是,”补偿是一个独立的支持ACID特性的本地事务,用于在逻辑上取消服务提供者上一个ACID事务造成的影响,对于一个长事务(long-running transaction),与其实现一个巨大的分布式ACID事务,不如使用基于补偿性的方案,把每一次服务调用当做一个较短的本地ACID事务来处理,执行完就立即提交”。
在这里,笔者理解为confirm和cancel就是补偿事务,用于取消try阶段本地事务造成的影响。因为第一阶段try只是预留资源,之后必须要明确的告诉服务提供者,这个资源你到底要不要,对应第二阶段的confirm/cancel。
现在应该明白为什么把TCC叫做两阶段补偿性事务了,提交过程分为2个阶段,第二阶段的confirm/cancel执行的事务属于补偿事务。
2.2.4 优缺点
- 优点
- 解决了跨应用业务操作的原子性问题,在诸如组合支付、账务拆分场景非常实用。
- TCC实际上把数据库层的二阶段提交上提到了应用层来实现,对于数据库来说是一阶段提交,规避了数据库层的2PC性能低下问题。
- 缺点
- TCC的Try、Confirm和Cancel操作功能需业务提供,开发成本高。
2.3 最大努力通知
2.3.1 简介
最大努力通知型( Best-effort delivery)是最简单的一种柔性事务,适用于一些最终一致性时间敏感度低的业务,且被动方处理结果不影响主动方的处理结果。典型的使用场景如银行通知、商户通知等。
最大努力通知型的实现方案,一般符合以下特点:
- 不可靠消息:业务活动执行方,在完成业务处理之后,向业务活动的触发方发送消息,直到通知N次后不再通知,允许消息丢失(不可靠消息)。
- 定期校对:业务活动的触发方,根据定时策略,向业务活动执行方查询(执行方提供查询接口),恢复丢失的业务消息。
2.3.2 内容
举例来说:设计一个短信发送平台,背景是公司内部有多个业务都有发送短信的需求,如果每个业务独立实现短信发送功能,存在功能实现上的重复。因此专门做了一个短信平台项目,所有的业务方都接入这个短信平台,来实现发送短信的功能。简化后的架构如下所示:
短信发送流程如下:
- 业务方将短信发送请求提交给短信平台
- 短信平台接收到要发送的短信,记录到数据库中,并标记其状态为”已接收”
- 短信平台调用外部短信发送供应商的接口,发送短信。外部供应商的接口也是异步将短信发送到用户手机上,因此这个接口调用后,立即返回,进入第4步。
- 更新短信发送状态为”已发送”
- 短信发送供应商异步通知短信平台短信发送结果。而通知可能失败,因此最多只会通知N次。
- 短信平台接收到短信发送结果后,更新短信发送状态,可能是成功,也可能失败(如手机欠费)。到底是成功还是失败并不重要,重要的是我们知道了这调短信发送的最终结果
- 如果最多只通知N次,如果都失败了的话,那么短信平台将不知道短信到底有没有成功发送。因此短信发送供应商需要提供一个查询接口,以方便短信平台驱动的去查询,进行定期校对。
在这个案例中,短信发送供应商通知短信平台短信发送结果的过程中,就是最典型的最大努力通知型方案,尽最大的努力通知了N次就不再通知。通过提供一个短信结果查询接口,让短信平台可以进行定期的校对。而由于短信发送业务的时间敏感度并不高,比较适合采用这个方案。
需要注意的是,定期校对的步骤很重要,短信结果查询接口很重要,必须要进行定期校对。因为后期要进行对账,比如一个月的短信发送总量在高峰期可以达到1亿条左右,即使一条短信只要5分钱,一个月就有500W。
2.3.3 优缺点
- 优点
- 原理简单,实现方便,目前也有现成的实现框架
- 缺点
- 即便柔性事务都只能保证数据的最终一致性,最大努力通知模型的最终时间也可能是最长的,因为消息发送的不确定性,可能会导致通知迟迟无法被消费,只适用于最终一致性时间敏感度低的业务。
- 回滚逻辑需要业务编写补偿逻辑来实现,比较费力。
2.4 本地消息表
在描述本地消息表之前,我们要先了解一个概念:
消息发送一致性:是指产生消息的业务动作与消息发送的一致,本地业务逻辑执行与消息发送是原子性的。也就是说,如果业务操作成功,那么由这个业务操作所产生的消息一定要成功投递出去(一般是发送到kafka、rocketmq、rabbitmq等消息中间件中),否则就丢消息。
以购物场景为例,张三购买物品,账户扣款100元的同时,需要保证在下游的会员服务中给该账户增加100积分。如果扣款100元的业务逻辑执行失败了,但是通知增加积分的消息却没有回滚,而是发送出去了,那就会导致积分无故增加。同样的,如果扣款成功了,但是消息通知失败了,扣款却没有回滚的话,也会导致该增加的积分没有增加。
2.4.1 简介
本地消息表这种实现方式应该是业界使用最多的,这种实现方式的思路,其实是源于 ebay,后来通过支付宝等公司的布道,在业内广泛使用。其基本的设计思想是将远程分布式事务拆分成一系列的本地事务。如果不考虑性能及设计优雅,借助关系型数据库中的表即可实现。
2.4.2 内容
我们可以从下面的流程图中看出其中的一些细节:
举例说明:下单购买商品
支付服务器:前提是有个本地消息表A
- 1.1 当你支付的时候,你需要把你支付的金额扣减,并且把消息落到本地消息表A,这两个操作要放入同一个事务(依靠数据库本地事务保证一致性)。
- 1.2 消息落表后,发送MQ通知到商品库存服务器,发送成功后,更新表A中的状态。
- 1.3 除此之外,支付服务器还有一个定时任务去轮询这个本地事务表A,把没有发送的消息,重试发送给商品库存服务器。
商品库存服务器:前提是有个本地消息表B
- 2.1 MQ到达商品服务器之后,将接收的消息写入这个服务器的本地消息表B,然后进行扣减库存这两个操作要放入同一个事务(依靠数据库本地事务保证一致性)。扣减成功后,更新事务表B中的状态。
- 2.2 发送反馈消息给支付服务器,如果执行成功了,就反馈成功消息。如果执行失败,则反馈失败消息。
- 2.3 除此之外,商品库存服务器还有一个定时任务去轮询这个本地事务表B,把没有发送的消息,重试发送给支付服务器。
如果支付服务器接收到成功的回馈,那么事务成功。如果接收到失败的反馈,则执行回滚操作,即调用补偿接口进行反向操作。
本地消息表模型,通过将业务和消息落表的操作放入同一个本地事务,利用本地事务的ACID特性,来确保发送方/接收方的自身业务逻辑的连贯性和紧密型。
换句话说,只有发送方的业务逻辑执行成功,发送方才会将消息落表,以及发出通知,因为这些步骤在一个本地事务里面,要么都失败,要么都成功。
同理,接收方的业务逻辑执行,接收消息的落表,以及消息表状态的翻转,也都在一个本地事务里面,所以如果接收方发出了通知,那证明接收方的业务逻辑肯定已经执行了。
当两端自身的逻辑都具有连贯性和紧密型,那剩下的只要确保消息可靠就行了。mq的重试机制,以及两方的定时校验机制,都是这种可靠性的保障。
2.4.3 优缺点
- 优点
- 一种非常经典的实现,将整个分布式事务分割成多个端的本地事务,利用本地事务的可靠性来保证分布式事务在各个端的可靠性,从而使我们的精力只要集中要消息通知和校检上。
- 缺点
- 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。
- 回滚逻辑需要业务编写补偿逻辑来实现,比较费力。
2.5 事务消息机制
2.5.1 简介
前文讨论本地消息表的时候,我们提到了消息发送一致性,使用本地消息表,将业务逻辑和本地消息表的读写用本地事务来保证,这确实是一个办法。但这种办法需要额外建消息表,还需要手动编写落表逻辑和业务逻辑绑定的代码,耦合较重。有什么更优雅的,但同样能保证消息发送一致性的实现吗?答案就是本章讨论的事务消息机制。
从Apache RocketMQ发布的4.3版本开始,RocketMQ开源了社区最为关心的分布式事务消息,而且实现了对外部组件的零依赖。
RocketMQ事务消息设计则主要是为了解决Producer端的消息发送与本地事务执行的原子性问题,RocketMQ的设计中broker与producer端的双向通信能力,使得broker天生可以作为一个事务协调者存在;而RocketMQ本身提供的存储机制,则为事务消息提供了持久化能力;RocketMQ的高可用机制以及可靠消息设计,则为事务消息在系统在发生异常时,依然能够保证事务的最终一致性达成。
2.5.2 内容
事务消息的逻辑,是由发送端Producer进行保证(消费端无需考虑)
- 首先,发送一个事务消息,这个时候,RocketMQ将消息状态标记为Prepared,注意此时这条消息消费者是无法消费到的。
- 接着,执行业务代码逻辑,可能是一个本地数据库事务操作
- 最后,确认发送消息,根据本地业务执行结果返回commit或者是rollback。
- 3.1 如果本地业务执行成功,消息是commit,这个时候,RocketMQ将消息状态标记为可消费,这个时候消费者,才能真正的保证消费到这条数据。
- 3.2 如果消息是rollback,RocketMQ将删除该prepare消息不进行下发。
如果发送端发送的确认消息发送失败了怎么办?RocketMQ会定期扫描消息集群中的事务消息,如果发现了Prepared消息,它会向消息发送端(生产者)确认。RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。
消费端的消费成功机制由RocketMQ保证。如果发送的消息消费超时了就一直重试。
但值得注意的是,如果消费端接到通知,然后执行消费端业务逻辑失败了的话,阿里提供给我们的解决方法是:人工解决。也就是说,两端之间的原子性,需要人工做补偿逻辑,该机制无法保证。
2.5.3 优缺点
- 优点
- 依靠成熟的消息中间件的事务消息机制,不用耦合太多其他逻辑在业务逻辑中,就可以保证消息发送一致性,实现简单。
- 缺点
- 发送端和消费端之间的原子性无法保证,如果发送回滚,需要人工介入。
2.6 Saga事务模型
2.6.1 简介
Saga事务模型又叫做长时间运行的事务(Long-running-transaction), 它是由普林斯顿大学的H.Garcia-Molina等人于1987年提出,是一种异步的分布式事务解决方案,其理论基础在于,其假设所有事件按照顺序推进,总能达到系统的最终一致性,因此saga需要服务分别定义提交接口以及补偿接口,当某个事务分支失败时,调用其它的分支的补偿接口来进行回滚。
2.6.2 内容
saga的具体实现分为两种:Choreography以及Orchestration:
Choreography:更接近Saga模型的初衷的一种实现:所有事件按照顺序推进,总能达到系统的最终一致性
这种模式下不存在协调器的概念,每个节点均对自己的上下游负责,在监听处理上游节点事件的同时,对下游节点发布事件。
Orchestration:存在中心节点的模式
该中心节点,即协调器知道整个事务的分布状态,相比于无中心节点方式,该方式有着许多优点:
- 能够避免事务之间的循环依赖关系,由协调器来管理整个事务链条。
- 参与者只需要执行命令/回复(其实回复消息也是一种事件消息),无需关心和维护自己的上下游是谁,降低参与者的复杂性。
- 开发测试门槛低。
- 扩展性好,在添加新步骤时,事务复杂性保持线性,回滚更容易管理。
基于上述优势,因此大多数saga模型实现均采用了这种思路。
2.6.3 优缺点
- 优点
- 降低了事务粒度,使得事务扩展更加容易,同时采用了异步化方式提升性能。
- 缺点
- 很多时候很难定义补偿接口,回滚代价高,而且由于在执行过程中采用了先提交后补偿的思路进行操作,所以单个子事务在并发提交时的隔离性很难保证。
3 分布式事务解决方案总结
3.1 XA协议和TCC的区别
作为最热门的两种解决方案,XA协议和TCC的区别我们需要重点知晓。
TCC与XA两阶段提交有着异曲同工之妙,下图列出了二者之间的对比:
在阶段1:
- 在XA中,各个RM准备提交各自的事务分支,事实上就是准备提交资源的更新操作(insert、delete、update等);而在TCC中,是主业务活动请求(try)各个从业务服务预留资源。
在阶段2:
- XA根据第一阶段每个RM是否都prepare成功,判断是要提交还是回滚。如果都prepare成功,那么就commit每个事务分支,反之则rollback每个事务分支。
TCC中,如果在第一阶段所有业务资源都预留成功,那么confirm各个从业务服务,否则取消(cancel)所有从业务服务的资源预留请求。
TCC两阶段提交与XA两阶段提交的区别是:
- XA是资源层面的分布式事务,强一致性,在两阶段提交的整个过程中,一直会持有资源的锁。
- XA事务中的两阶段提交内部过程是对开发者屏蔽的,回顾我们之前讲解JTA规范时,通过UserTransaction的commit方法来提交全局事务,这只是一次方法调用,其内部会委派给TransactionManager进行真正的两阶段提交,因此开发者从代码层面是感知不到这个过程的。
- 而事务管理器在两阶段提交过程中,从prepare到commit/rollback过程中,资源实际上一直都是被加锁的。如果有其他人需要更新这两条记录,那么就必须等待锁释放。
- TCC是业务层面的分布式事务,最终一致性,在TCC整个过程中,不会一直持有资源的锁。
- TCC中的两阶段提交并没有对开发者完全屏蔽,也就是说从代码层面,开发者是可以感受到两阶段提交的存在。如上述航班预定案例:在第一阶段,航空公司需要提供try接口(机票资源预留)。
- 在第二阶段,航空公司提需要提供confirm/cancel接口(确认购买机票/取消预留)。开发者明显的感知到了两阶段提交过程的存在。try、confirm/cancel在执行过程中,一般都会开启各自的本地事务,来保证方法内部业务逻辑的ACID特性。其中:
- try过程的本地事务,是保证资源预留的业务逻辑的正确性。
- confirm/cancel执行的本地事务逻辑确认/取消预留资源,以保证最终一致性,也就是所谓的补偿型事务(Compensation-Based Transactions)。
3.2 最大努力通知和本地消息表的区别
虽然都是利用mq,但是本地消息表利用本地事务来绑定业务逻辑和消息发送,使得mq两端的操作(发送前和接收后)是绝对可靠的,原子的。保证了消息发送一致性。
而最大努力通知模型,业务逻辑和发送消息之间没有这种紧密的可靠性保证,一切只能在业务上自己去实现代码来保证可靠。