两阶段提交(Two Phase Commit,下文简称2PC),简单的说,是将事务的提交操作分成了prepare、commit两个阶段。其事务处理方式为:
1、 在全局事务决定提交时,a)逐个向RM发送prepare请求;b)若所有RM都返回OK,则逐个发送commit请求最终提交事务;否则,逐个发送rollback请求来回滚事务;
2、 在全局事务决定回滚时,直接逐个发送rollback请求即可,不必分阶段。
TCC(Try-Confirm-Cancel),则是将业务逻辑分成try、confirm/cancel两个阶段执行,具体介绍见TCC事务机制简介。其事务处理方式为:
1、 在全局事务决定提交时,调用与try业务逻辑相对应的confirm业务逻辑;
2、 在全局事务决定回滚时,调用与try业务逻辑相对应的cancel业务逻辑。
可见,TCC在事务处理方式上,是很简单的:要么调用confirm业务逻辑,要么调用cancel逻辑。这里为什么没有提到try业务逻辑呢?因为try逻辑与全局事务处理无关。
当讨论2PC时,我们只专注于事务处理阶段,因而只讨论prepare和commit,所以,可能很多人都忘了,使用2PC事务管理机制时也是有业务逻辑阶段的。正是因为业务逻辑的执行,发起了全局事务,这才有其后的事务处理阶段。实际上,使用2PC机制时————以提交为例————一个完整的事务生命周期是:begin -> 业务逻辑 -> prepare -> commit。
再看TCC,也不外乎如此。我们要发起全局事务,同样也必须通过执行一段业务逻辑来实现。该业务逻辑一来通过执行触发TCC全局事务的创建;二来也需要执行部分数据写操作;此外,还要通过执行来向TCC全局事务注册自己,以便后续TCC全局事务commit/rollback时回调其相应的confirm/cancel业务逻辑。所以,使用TCC机制时————以提交为例————一个完整的事务生命周期是:begin -> 业务逻辑(try业务) -> commit(comfirm业务)。
综上,我们可以从执行的阶段上将二者一一对应起来:
1、 2PC机制的业务阶段 等价于 TCC机制的try业务阶段;
2、 2PC机制的提交阶段(prepare & commit) 等价于 TCC机制的提交阶段(confirm);
3、 2PC机制的回滚阶段(rollback) 等价于 TCC机制的回滚阶段(cancel)。
因此,可以看出,虽然TCC机制中有两个阶段都存在业务逻辑的执行,但其中try业务阶段其实是与全局事务处理无关的。认清了这一点,当我们再比较TCC和2PC时,就会很容易地发现,TCC不是两阶段提交,而只是它对事务的提交/回滚是通过执行一段confirm/cancel业务逻辑来实现,仅此而已。
1、跨数据库
数据库拆分(水平、垂直)带来的分布式事务->保证跨库操作的原子性
基于单个JVM
2、跨应用
应用拆分带来的分布式事务->保证跨应用业务操作的原子性
跨JVM
跨应用的业务操作原子性要求,其实是比较常见的。比如在第三方支付场景中的组合支付,用户在电商网站购物后,要同时使用余额和红包支付该笔订单,而余额系统和红包系统分别是不同的应用系统,支付系统在调用这两个系统进行支付时,就需要保证余额扣减和红包使用要么同时成功,要么同时失败。
TCC事务的出现正是为了解决应用拆分带来的跨应用业务操作原子性的问题。当然,由于常规的XA事务(2PC,2 Phase Commit, 两阶段提交)性能上不尽如人意,也有通过TCC事务来解决数据库拆分的使用场景(如账务拆分),这个本文后续部分再详述。
故从整个系统架构的角度来看,分布式事务的不同方案是存在层次结构的:分布式事务之说说TCC事务
TCC的机制
明眼一看就知道,TCC应该是三个英文单词的首字母缩写而来。没错,TCC分别对应Try、Confirm和Cancel三种操作,这三种操作的业务含义如下:
Try:预留业务资源
Confirm:确认执行业务操作
Cancel:取消执行业务操作
稍稍对照下关系型数据库事务的三种操作:DML、Commit和Rollback,会发现和TCC有异曲同工之妙。在一个跨应用的业务操作中,Try操作是先把多个应用中的业务资源预留和锁定住,为后续的确认打下基础,类似的,DML操作要锁定数据库记录行,持有数据库资源;Confirm操作是在Try操作中涉及的所有应用均成功之后进行确认,使用预留的业务资源,和Commit类似;而Cancel则是当Try操作中涉及的所有应用没有全部成功,需要将已成功的应用进行取消(即Rollback回滚)。其中Confirm和Cancel操作是一对反向业务操作。
分布式事务之说说TCC事务
简而言之,TCC是应用层的2PC(2 Phase Commit, 两阶段提交),如果你将应用看做资源管理器的话。 详细来说,TCC每项操作需要做的事情如下:1、Try:尝试执行业务。
完成所有业务检查(一致性)
预留必须业务资源(准隔离性)
2、Confirm:确认执行业务。
真正执行业务
不做任何业务检查
只使用Try阶段预留的业务资源
3、Cancel:取消执行业务
释放Try阶段预留的业务资源
用一张图来说明TCC的机制:
分布式事务之说说TCC事务
一个完整的TCC事务参与方包括三部分:
主业务服务:主业务服务为整个业务活动的发起方,如前面提到的组合支付场景,支付系统即是主业务服务。
从业务服务:从业务服务负责提供TCC业务操作,是整个业务活动的操作方。从业务服务必须实现Try、Confirm和Cancel三个接口,供主业务服务调用。由于Confirm和Cancel操作可能被重复调用,故要求Confirm和Cancel两个接口必须是幂等的。前面的组合支付场景中的余额系统和红包系统即为从业务服务。
业务活动管理器:业务活动管理器管理控制整个业务活动,包括记录维护TCC全局事务的事务状态和每个从业务服务的子事务状态,并在业务活动提交时确认所有的TCC型操作的confirm操作,在业务活动取消时调用所有TCC型操作的cancel操作。
可见整个TCC事务对于主业务服务来说是透明的,其中业务活动管理器和从业务服务各自干了一部分工作。
TCC的优点和限制
TCC事务的优点如下:
解决了跨应用业务操作的原子性问题,在诸如组合支付、账务拆分场景非常实用。
TCC实际上把数据库层的二阶段提交上提到了应用层来实现,对于数据库来说是一阶段提交,规避了数据库层的2PC性能低下问题。
TCC事务的缺点,主要就一个:
TCC的Try、Confirm和Cancel操作功能需业务提供,开发成本高。
当然,对TCC事务的这个缺点是否是缺点,是一个见仁见智的事情。
钢性事务与柔性事务
一般而言,满足ACID的事务的为钢性事务,满足BASE理论的为柔性事务。其中,柔性事务大致可以分为以下四种:
两阶段型
补偿型
异步确保型
最大努力通知型
TCC型事务
TCC属于补偿型柔性事务,本质也是一个两阶段型事务,这与JTA是极为相似的,但是与JTA的不同点是,JTA属于资源层事务,而TCC是服务层事务。
在一个长事务( long-running )中 ,一个由两台服务器一起参与的事务,服务器A发起事务,服务器B参与事务,B的事务需要人工参与,所以处理时间可能很长。如果按照ACID的原则,要保持事务的隔离性、一致性,服务器A中发起的事务中使用到的事务资源将会被锁定,不允许其他应用访问到事务过程中的中间结果,直到整个事务被提交或者回滚。这就造成事务A中的资源被长时间锁定,系统的可用性将不可接受。
WS-BusinessActivity提供了一种基于补偿的long-running的事务处理模型。还是上面的例子,服务器A的事务如果执行顺利,那么事务A就先行提交,如果事务B也执行顺利,则事务B也提交,整个事务就算完成。但是如果事务B执行失败,事务B本身回滚,这时事务A已经被提交,所以需要执行一个补偿操作,将已经提交的事务A执行的操作作反操作,恢复到未执行前事务A的状态。这样的SAGA事务模型,是牺牲了一定的隔离性和一致性的,但是提高了long-running事务的可用性。
在JTA事务中,所有需要被事务管理的资源(由不同厂商实现)都必须实现规定接口(比如XAResource中的commit和rollback等),同理,所有需要加入TCC事务的服务也必须提供相应的接口实现,在TCC中这些接口为:try、confirm、cancel(缩写为TCC)。TCC事务管理器会使用try、confirm、cancel接口协调多个服务进行事务处理,如下图所示:
Try: 尝试执行业务
• 完成所有业务检查(一致性)
• 预留必须业务资源(准隔离性)
Confirm:确认执行业务
• 真正执行业务
• 不作任何业务检查
• 只使用Try阶段预留的业务资源
• Confirm操作要满足幂等性
Cancel: 取消执行业务
• 释放Try阶段预留的业务资源
• Cancel操作要满足幂等性
TCC与2PC协议比较:
• 位于业务服务层而非资源层
• 没有单独的准备(Prepare)阶段, Try操作兼备资源操作与准备能力
• Try操作可以灵活选择业务资源的锁定粒度(以业务定粒度)
• 较高开发成本
TCC方案设计
一个好的TCC框架实现至少应该满足以下几个特点:
不与特定的服务框架耦合:TCC作为服务层的分布式事务方案,自然离不开一些常用的微服务框架,比如:dubbo、dubbox、motan、hessian、grpc、thrift以及时下很火的spring cloud等。但是TCC框架本身不应该与特定服务框架绑定,无论服务框架是基于tcp还是http,无论是基于私有协议还是公有协议,TCC框架都应该能做到无缝整合。当然,框架本身可以针对特定的服务框架进行一些部分优化,比如针对dubbo,那么TCC框架本身可以充分利用dubbo隐式传参的特性进行事务上下文的传递,而不用显示的作为服务方法参数传递。
提供基于注解的配置:提供基于注解而不是xml的服务配置方式,这样可以极大的方便服务的开发。注解用于标记try、confirm、cancel接口的具体实现,同时,被注解标记的TCC接口还会被框架提供的切面进行拦截,执行特定的事务逻辑。
@Compensable(confirmMethod = “confirmRecord”, cancelMethod = “cancelRecord”,transactionContextEditor = DubboTransactionContextEditor.class)
@Transactional
public String record(CapitalTradeOrderDto tradeOrderDto) {
return “success”;
}
@Transactional
public void confirmRecord(CapitalTradeOrderDto tradeOrderDto) {
}
@Transactional
public void cancelRecord(CapitalTradeOrderDto tradeOrderDto) {
} 支持多种事务日志持久化机制:事务日志持久化的性能是影响TCC性能的一个很重要因素,因此支持多种持久化机制便于根据特定应用场景进行灵活选择,比如支持基于文件、基于redis(开启AOF)、基于zookeeper、基于mysql等等。使用何种持久化机制,框架应该支持在xml或者注解中进行配置。 支持可配置recovery策略:对于异常的事务(比如Confirm失败),TCC框架应该提供recovery机制,它会对事务日志进行扫描监控,并根据策略进行recovery操作。策略必须是可以配置的(基于xml或者注解),配置项可以有:最大重试次数、recovery时间间隔、支持Cron表达式等。 使用spring:由于spring框架几乎存在于每一个java项目中,因此TCC框架有理由选择spring来进行:依赖注入、aop、spring声明式事务等。 分布式事务是企业集成中的一个技术难点,也是每一个分布式系统架构中都会涉及到的一个东西,特别是在微服务架构中,几乎可以说是无法避免,本文就分布式事务来简单聊一下。
数据库事务
在说分布式事务之前,我们先从数据库事务说起。 数据库事务可能大家都很熟悉,在开发过程中也会经常使用到。但是即使如此,可能对于一些细节问题,很多人仍然不清楚。比如很多人都知道数据库事务的几个特性:原子性(Atomicity )、一致性( Consistency )、隔离性或独立性( Isolation)和持久性(Durabilily),简称就是ACID。但是再往下比如问到隔离性指的是什么的时候可能就不知道了,或者是知道隔离性是什么但是再问到数据库实现隔离的都有哪些级别,或者是每个级别他们有什么区别的时候可能就不知道了。
本文并不打算介绍这些数据库事务的这些东西,有兴趣可以搜索一下相关资料。不过有一个知识点我们需要了解,就是假如数据库在提交事务的时候突然断电,那么它是怎么样恢复的呢? 为什么要提到这个知识点呢? 因为分布式系统的核心就是处理各种异常情况,这也是分布式系统复杂的地方,因为分布式的网络环境很复杂,这种“断电”故障要比单机多很多,所以我们在做分布式系统的时候,最先考虑的就是这种情况。这些异常可能有 机器宕机、网络异常、消息丢失、消息乱序、数据错误、不可靠的TCP、存储数据丢失、其他异常等等…
我们接着说本地事务数据库断电的这种情况,它是怎么保证数据一致性的呢?我们使用SQL Server来举例,我们知道我们在使用 SQL Server 数据库是由两个文件组成的,一个数据库文件和一个日志文件,通常情况下,日志文件都要比数据库文件大很多。数据库进行任何写入操作的时候都是要先写日志的,同样的道理,我们在执行事务的时候数据库首先会记录下这个事务的redo操作日志,然后才开始真正操作数据库,在操作之前首先会把日志文件写入磁盘,那么当突然断电的时候,即使操作没有完成,在重新启动数据库时候,数据库会根据当前数据的情况进行undo回滚或者是redo前滚,这样就保证了数据的强一致性。
接着,我们就说一下分布式事务。
分布式理论
当我们的单个数据库的性能产生瓶颈的时候,我们可能会对数据库进行分区,这里所说的分区指的是物理分区,分区之后可能不同的库就处于不同的服务器上了,这个时候单个数据库的ACID已经不能适应这种情况了,而在这种ACID的集群环境下,再想保证集群的ACID几乎是很难达到,或者即使能达到那么效率和性能会大幅下降,最为关键的是再很难扩展新的分区了,这个时候如果再追求集群的ACID会导致我们的系统变得很差,这时我们就需要引入一个新的理论原则来适应这种集群的情况,就是 CAP 原则或者叫CAP定理,那么CAP定理指的是什么呢?
CAP定理
CAP定理是由加州大学伯克利分校Eric Brewer教授提出来的,他指出WEB服务无法同时满足一下3个属性:
一致性(Consistency) : 客户端知道一系列的操作都会同时发生(生效)
可用性(Availability) : 每个操作都必须以可预期的响应结束
分区容错性(Partition tolerance) : 即使出现单个组件无法可用,操作依然可以完成
具体地讲在分布式系统中,在任何数据库设计中,一个Web应用至多只能同时支持上面的两个属性。显然,任何横向扩展策略都要依赖于数据分区。因此,设计人员必须在一致性与可用性之间做出选择。
这个定理在迄今为止的分布式系统中都是适用的! 为什么这么说呢?
这个时候有同学可能会把数据库的2PC(两阶段提交)搬出来说话了。OK,我们就来看一下数据库的两阶段提交。
对数据库分布式事务有了解的同学一定知道数据库支持的2PC,又叫做 XA Transactions。
MySQL从5.5版本开始支持,SQL Server 2005 开始支持,Oracle 7 开始支持。
其中,XA 是一个两阶段提交协议,该协议分为以下两个阶段:
第一阶段:事务协调器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提交.
第二阶段:事务协调器要求每个数据库提交数据。
其中,如果有任何一个数据库否决此次提交,那么所有数据库都会被要求回滚它们在此事务中的那部分信息。这样做的缺陷是什么呢? 咋看之下我们可以在数据库分区之间获得一致性。
如果CAP 定理是对的,那么它一定会影响到可用性。
如果说系统的可用性代表的是执行某项操作相关所有组件的可用性的和。那么在两阶段提交的过程中,可用性就代表了涉及到的每一个数据库中可用性的和。我们假设两阶段提交的过程中每一个数据库都具有99.9%的可用性,那么如果两阶段提交涉及到两个数据库,这个结果就是99.8%。根据系统可用性计算公式,假设每个月43200分钟,99.9%的可用性就是43157分钟, 99.8%的可用性就是43114分钟,相当于每个月的宕机时间增加了43分钟。
以上,可以验证出来,CAP定理从理论上来讲是正确的,CAP我们先看到这里,等会再接着说。
BASE理论
在分布式系统中,我们往往追求的是可用性,它的重要程序比一致性要高,那么如何实现高可用性呢? 前人已经给我们提出来了另外一个理论,就是BASE理论,它是用来对CAP定理进行进一步扩充的。BASE理论指的是:
Basically Available(基本可用)
Soft state(软状态)
Eventually consistent(最终一致性)
BASE理论是对CAP中的一致性和可用性进行一个权衡的结果,理论的核心思想就是:我们无法做到强一致,但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual consistency)。
有了以上理论之后,我们来看一下分布式事务的问题。
分布式事务
在分布式系统中,要实现分布式事务,无外乎那几种解决方案。
一、两阶段提交(2PC)
和上一节中提到的数据库XA事务一样,两阶段提交就是使用XA协议的原理,我们可以从下面这个图的流程来很容易的看出中间的一些比如commit和abort的细节。
两阶段提交这种解决方案属于牺牲了一部分可用性来换取的一致性。在实现方面,在 .NET 中,可以借助 TransactionScop 提供的 API 来编程实现分布式系统中的两阶段提交,比如WCF中就有实现这部分功能。不过在多服务器之间,需要依赖于DTC来完成事务一致性,Windows下微软搞的有MSDTC服务,Linux下就比较悲剧了。
另外说一句,TransactionScop 默认不能用于异步方法之间事务一致,因为事务上下文是存储于当前线程中的,所以如果是在异步方法,需要显式的传递事务上下文。
优点: 尽量保证了数据的强一致,适合对数据强一致要求很高的关键领域。(其实也不能100%保证强一致)
缺点: 实现复杂,牺牲了可用性,对性能影响较大,不适合高并发高性能场景,如果分布式系统跨接口调用,目前 .NET 界还没有实现方案。
二、补偿事务(TCC)
TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:
Try 阶段主要是对业务系统做检测及资源预留
Confirm 阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。
Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。
举个例子,假入 Bob 要向 Smith 转账,思路大概是:
我们有一个本地方法,里面依次调用
1、首先在 Try 阶段,要先调用远程接口把 Smith 和 Bob 的钱给冻结起来。
2、在 Confirm 阶段,执行远程调用的转账的操作,转账成功进行解冻。
3、如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。
优点: 跟2PC比起来,实现以及流程相对简单了一些,但数据的一致性比2PC也要差一些
缺点: 缺点还是比较明显的,在2,3步中都有可能失败。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理。
三、本地消息表(异步确保)
本地消息表这种实现方式应该是业界使用最多的,其核心思想是将分布式事务拆分成本地事务进行处理,这种思路是来源于ebay。我们可以从下面的流程图中看出其中的一些细节:
基本思路就是:
消息生产方,需要额外建一个消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。
消息消费方,需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。
生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。
这种方案遵循BASE理论,采用的是最终一致性,笔者认为是这几种方案里面比较适合实际业务场景的,即不会出现像2PC那样复杂的实现(当调用链很长的时候,2PC的可用性是非常低的),也不会像TCC那样可能出现确认或者回滚不了的情况。
优点: 一种非常经典的实现,避免了分布式事务,实现了最终一致性。在 .NET中 有现成的解决方案。
缺点: 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。
四、MQ 事务消息
有一些第三方的MQ是支持事务消息的,比如RocketMQ,他们支持事务消息的方式也是类似于采用的二阶段提交,但是市面上一些主流的MQ都是不支持事务消息的,比如 RabbitMQ 和 Kafka 都不支持。
以阿里的 RocketMQ 中间件为例,其思路大致为:
第一阶段Prepared消息,会拿到消息的地址。
第二阶段执行本地事务,第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。
也就是说在业务方法内要想消息队列提交两次请求,一次发送消息和一次确认消息。如果确认消息发送失败了RocketMQ会定期扫描消息集群中的事务消息,这时候发现了Prepared消息,它会向消息发送者确认,所以生产方需要实现一个check接口,RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。
遗憾的是,RocketMQ并没有 .NET 客户端。有关 RocketMQ的更多消息,大家可以查看这篇博客
优点: 实现了最终一致性,不需要依赖本地数据库事务。
缺点: 实现难度大,主流MQ不支持,没有.NET客户端,RocketMQ事务消息部分代码也未开源。
五、Sagas 事务模型
Saga事务模型又叫做长时间运行的事务(Long-running-transaction), 它是由普林斯顿大学的H.Garcia-Molina等人提出,它描述的是另外一种在没有两阶段提交的的情况下解决分布式系统中复杂的业务事务问题。你可以在这里看到 Sagas 相关论文。
我们这里说的是一种基于 Sagas 机制的工作流事务模型,这个模型的相关理论目前来说还是比较新的,以至于百度上几乎没有什么相关资料。
该模型其核心思想就是拆分分布式系统中的长事务为多个短事务,或者叫多个本地事务,然后由 Sagas 工作流引擎负责协调,如果整个流程正常结束,那么就算是业务成功完成,如果在这过程中实现失败,那么Sagas工作流引擎就会以相反的顺序调用补偿操作,重新进行业务回滚。
比如我们一次关于购买旅游套餐业务操作涉及到三个操作,他们分别是预定车辆,预定宾馆,预定机票,他们分别属于三个不同的远程接口。可能从我们程序的角度来说他们不属于一个事务,但是从业务角度来说是属于同一个事务的