目录
2.2、TCC(同2.1.2、2PC-事务补偿方案(TCC))
现今互联网界,分布式系统和微服务架构盛行。一个简单操作,在服务端非常可能是由多个服务和数据库实例协同完成的。在一致性要求较高的场景下,多个独立操作之间的一致性问题显得格外棘手。
基于水平扩容能力和成本考虑,传统的强一致的解决方案(e.g.单机事务)纷纷被抛弃。其理论依据就是响当当的CAP原理。往往为了可用性和分区容错性,忍痛放弃强一致支持,转而追求最终一致性。
分布式事务可以简单的分为两种:
- 一种是一台服务上操作多个数据源,也就是数据库的分布式事务;
- 另一种是多台服务之间的调用,需要确保操作数据库的原子性,可能多台服务操作的是同一个数据源,也可能是每台服务对应一个数据源(也就是现在流行的微服务架构),微服务的分布式事务
服务场景:
1.分布式系统的特性
在分布式系统中,同时满足CAP定律中的一致性 Consistency、可用性 Availability和分区容错性 Partition Tolerance三者是不可能的。在绝大多数的场景,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性。
ACID特性
-
Atomicity 原子性:一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。
-
Consistency 一致性:
事务的执行不能破坏数据库数据的完整性和一致性,一个事务在执行之前和执行之后,数据库都必须处于一致性状态。
如果数据库系统在运行过程中发生故障,有些事务尚未完成就被迫中断,这些未完成的事务对数据库所作的修改有一部分已写入物理数据库,这是数据库就处于一种不正确的状态,也就是不一致的状态
业务上:对于业务层面来说,一致性是保持业务的一致性。这个业务一致性需要由开发人员进行保证。
-
Isolation 隔离性:数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。
-
Durability 持久性:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
CAP原则
CAP原则又称CAP定理,指的是在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容忍性(Partition tolerance)。CAP 原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾。
-
Consistency:强一致性就是在客户端任何时候看到各节点的数据都是一致的(All nodes see the same data at the same time)。
-
Availability:高可用性就是在任何时候都可以读写(Reads and writes always succeed)。
-
Partition Tolerance:分区容错性是在网络故障、某些节点不能通信的时候系统仍能继续工作(The system continue to operate despite arbitrary message loss or failure of part of the the system)。以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。
由CAP定理可知,任何大型的分布式系统/微服务在一致性C、可用性A和分区容忍P这三点上只能保证其中的两点。或者更准确的意思是: 在满足分区容忍性P的前提下,无法同时满足高可用性A和强一致性C(往往为了可用性和分区容错性,忍痛放弃强一致支持,转而追求最终一致性)
比如上图,服务A依赖于服务B, 分区容错性P也就是其中一个节点(假如节点B)挂掉了,但是整个系统还能用, 那么在P的前提下,有两种情况:
-
情况一, 满足强一致性C: 这种要求需要节点AB的数据完全一致以后再返回, 在服务A操作了某个数据,需要同步服务B,A需要一直等网络波动正常了,或者服务B起来了,再将请求返回, 这样无疑访问的效率很差, 而且A、B、C三个节点中任何一个宕机,都会导致数据不可用, 所以可用性差
-
情况二, 满足高可用A: 这样的话要确保查询的请求能立即返回结果, 不管是成功还是失败, 但是同样的, 如果在查询A节点的时候, B节点挂了或者网络波动, A急匆匆返回结果, 自然数据就不一致了.
基于以上的原因, 绝大部分系统都将强一致性C需求转化成最终一致性C的需求,并通过幂等机制保证了数据的最终一致性。
一个幂等操作的特点是指其任意多次执行所产生的影响均与一次执行的影响相同;
可能会发生重复请求或消费的场景,在微服务架构中是随处可见的。以下是几个常见场景:
网络波动:因网络波动,可能会引起重复请求
分布式消息消费:任务发布后,使用分布式消息服务来进行消费
用户重复操作:用户在使用产品时,可能会无意的触发多笔交易,甚至没有响应而有意触发多笔交易
未关闭的重试机制:因开发人员、测试人员或运维人员没有检查出来,而开启的重试机制(如Nginx重试、RPC通信重试或业务层重试等)
CAP定律是不是普通的三选二呢?
是误解,一般来说 P 是前提。所以基本是CA里选,不是任意3选2.为什么呢?P 意指分区容忍性。 这个分区容忍性什么意思,很多人容易望文生义,不要见风就是雨,理解成别的什么意思。所谓分区指的是网络分区的意思,这个一样还是容易望文生义。详细一点解释,比如你有A B两台服务器,它们之间是有通信的,突然,不知道为什么,它们之间的网络链接断掉了。好了,那么现在本来AB在同一个网络现在发生了网络分区,变成了A所在的A网络和B所在的B网络。所谓的分区容忍性,就是说一个数据服务的多台服务器在发生了上述情况的时候,依然能继续提供服务。所以显而易见的,P是大前提,如果P发生了,咱们的数据服务直接不服务了,还谈个毛的可用性和一致性呢。因此CAP要解释成,当P发生的时候,A和C只能而选一。举个简单的例子,A服务器B服务器同步数据,现在A B之间网络断掉了,那么现在发来A一个写入请求,但是B却没有相关的请求,显然,如果A不写,保持一致性,那么我们就失去了A的服务,但是如果A写了,跟B的数据就不一致了,我们自然就丧失了一致性。
BASE理论
BASE理论是对CAP中的一致性和可用性进行一个权衡的结果,理论的核心思想就是:我们无法做到强一致,但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性。
- Basically Available(基本可用)
- Soft state(软状态)
- Eventually consistent(最终一致性)
柔性事务 vs 刚性事务
刚性事务是指严格遵循ACID原则的事务, 例如单机环境下的数据库事务.
柔性事务是指遵循BASE理论的事务, 通常用在分布式环境中, 常见的实现方式有:
①两阶段提交(2PC)
②TCC补偿型提交
③基于消息的异步确保型
④最大努力通知型
2、解决方案
2.1、两阶段提交(2PC)
第一阶段: 准备阶段:协调者向参与者发起指令,参与者评估自己的状态,如果参与者评估指令可以完成,则会写redo或者undo日志,让后锁定资源,执行操作,但并不提交。
第二阶段:如果每个参与者明确返回准备成功,则协调者向参与者发送提交指令,参与者释放锁定的资源,如何任何一个参与者明确返回准备失败,则协调者会发送中指指令,参与者取消已经变更的事务,释放锁定的资源。
两阶段提交方案应用非常广泛,几乎所有商业OLTP数据库都支持XA协议。但是两阶段提交方案锁定资源时间长,对性能影响很大,基本不适合解决微服务事务问题。
缺点:如果协调者宕机,参与者没有协调者指挥,则会一直阻塞。
为解决分布式系统的数据一致性问题出现了两阶段提交协议(2 Phase Commitment Protocol),两阶段提交由协调者和参与者组成,共经过两个阶段和三个操作,部分关系数据库如Oracle、MySQL支持两阶段提交协议,本节讲解关系数据库两阶段提交协议:
1)第一阶段:准备阶段(prepare)
协调者通知参与者准备提交订单,参与者开始投票。
协调者完成准备工作向协调者回应Yes。
2)第二阶段:提交(commit)/回滚(rollback)阶段
协调者根据参与者的投票结果发起最终的提交指令。
如果有参与者没有准备好则发起回滚指令。
一个下单减库存的例子:1、应用程序连接两个数据源。
2、应用程序通过事务协调器向两个库发起prepare,两个数据库收到消息分别执行本地事务(记录日志),但不提交,如果执行成功则回复yes,否则回复no。
3、事务协调器收到回复,只要有一方回复no则分别向参与者发起回滚事务,参与者开始回滚事务。
4、事务协调器收到回复,全部回复yes,此时向参与者发起提交事务。如果参与者有一方提交事务失败则由事务协调器发起回滚事务。
2PC的优点:实现强一致性,部分关系数据库支持(Oracle、MySQL等)。
缺点:整个事务的执行需要由协调者在多个节点之间去协调,增加了事务的执行时间,性能低下。
解决方案有:springboot+Atomikos or Bitronix
我们以mysql的InnoDB引擎为例,由于mysql中有两套日志机制,一套是存储层的redo log,另一套是server层的binlog,每次更新数据都要对两个日志进行更新。为了防止写日志时只写了其中一个而没有写另外一个,mysql使用了一个叫两阶段提交的方式保证事务的一致性。具体是这样的:
假设创建一个这样的数据库:
mysql> create table T(ID int primary key, c int);
,
然后执行一条这样的更新语句:mysql> update T set c=c+1 where ID=2;
这条更新语句的执行流程是这样子的:
- 首先执行器会找引擎取ID=2这一行数据
- 拿到数据后会把数据进行+1操作,然后调用引擎接口把新数据写入
- 引擎将数据更新到内存中,并将操作记录到redo log里,此时redo log处于prepare状态。但它不会提交事务,只是通知执行器已经完成任务,可以随时提交。
- 执行器生成这个操作的binlog,并把binlog写入磁盘
- 最后执行器调用引擎的事务接口,把redo log改为提交状态,更新完成。
在上述过程中,redo log写完后没有直接提交,而是处于prepare状态,等通知执行器并把binlog写完后,redo log再进行提交。这个过程就是两阶段提交,这是一个精妙的设计。
2.1.1、2PC-XA方案
基于底层(数据库)的2PC
XA是X/Open组织提出的分布式事务的架构,指的就是2pc提交的模式.它定义了(全局)事务管理器(Transaction Manager)和(局部)资源管理器(Resource Manager)之间的接口。
JTA是基于XA上的实现, 跟JDBC,JMS等j2ee规范一样,是一套规范,是定义好的接口, 用于分布式事务中控制多个数据库的事务.
我们使用atomiko + spring实现JTA分布式事务
具体代码Demo参考文章:
######################################################################
XA协议采用两阶段提交方式来管理分布式事务。该协议分为预备和提交两个阶段:
- 预备:负责执行业务逻辑
- 提交:负责事务的commit
即两阶段提交事务方案。
需要数据库厂商支持; JAVA组件有atomikos等。
较适合单块应用中,跨多库的分布式事务(就是一个应用中直接操作多个数据源,不是操作多个微服务接口),而且因为严重依赖于数据库层面来搞定复杂的事务,效率很低,不适合高并发场景。
如果要玩,那么基于Spring + JTA就可以搞定:
互联网公司基本都不用,因为某个系统内部如果出现跨多库的操作,是不合规的。现在的微服务,一个大的系统分成几十甚至上百个服务。一般规约每个服务只能操作自己对应的一个数据库。
如果你要操作别的服务对应的库,不允许直连别的服务的库。
要操作别人的服务的库,必须通过调用别的服务的接口
2.1.2、2PC-事务补偿方案(TCC)
基于业务层的2PC
注:TCC中的两个阶段都存在业务逻辑代码中的执行
TCC事务补偿是基于2PC实现的业务层事务控制方案,它是Try、Confirm和Cancel三个单词的首字母,含义如下:
1、Try 检查及预留业务资源完成提交事务前的检查,并预留好资源。
2、Confirm 确定执行业务操作
对try阶段预留的资源正式执行。
3、Cancel 取消执行业务操作
对try阶段预留的资源释放。
下边用一个下单减库存的业务为例来说明
1、Try
下单业务由订单服务和库存服务协同完成,在try阶段订单服务和库存服务完成检查和预留资源。
订单服务检查当前是否满足提交订单的条件(比如:当前存在未完成订单的不允许提交新订单)。
库存服务检查当前是否有充足的库存,并锁定资源。
2、Confirm
订单服务和库存服务成功完成Try后开始正式执行资源操作。
订单服务向订单写一条订单信息。
库存服务减去库存。
3、Cancel
如果订单服务和库存服务有一方出现失败则全部取消操作。
订单服务需要删除新增的订单信息。
库存服务将减去的库存再还原。
优点:最终保证数据的一致性,在业务层实现事务控制,灵活性好。
缺点:开发成本高,每个事务操作每个参与者都需要实现try/confirm/cancel三个接口。
注意:TCC的try/confirm/cancel接口都要实现幂等性,在为在try、confirm、cancel失败后要不断重试。
什么是幂等性?
幂等性是指同一个操作无论请求多少次,其结果都相同。
幂等操作实现方式有:
1、操作之前在业务方法进行判断如果执行过了就不再执行。
2、缓存所有请求和处理的结果,已经处理的请求则直接返回结果。
3、在数据库表中加一个状态字段(未处理,已处理),数据操作时判断未处理时再处理。
其实有的地方说TCC不属于两阶段提交,只能说有点类似:
当讨论2PC时,我们只专注事务处理阶段,因而只讨论prepare和commit,所以,可能很多人都忘了,使用2PC事务管理机制也是有业务逻辑阶段的。正是因为业务逻辑的执行,发起了全局事务,这才有其后的事务处理阶段。所以,实际上,使用2PC机制时,以提交为例:
一个完整的事务周期:
begin–>业务逻辑–>prepare–>commit。
再看TCC,也不外如是:要发起全局事务,同样也必须通过业务逻辑来实现。该业务逻辑一来通过执行触发TCC全局事务的创建;二来也需要执行部分数据写操作;此外还要通过执行想TCC全局事务注册自己,以便后续TCC全局事务commit/rollback时回调其相应的confirm/canal业务逻辑。所以,使用TCC机制时,以提交为例:
一个完整的生命周期:
begin–>业务逻辑(Try业务)–>commit(confirm业务)。
综上所述:将两个机制做对应:
1、2PC机制的业务阶段 === TCC机制的try业务阶段
2、2PC机制的提交阶段(prepare & commit) ==== TCC机制的提交阶段(confirm)
3、2PC机制的回滚阶段(rollback) === TCC机制的回滚阶段(cancel)
结论
TCC中的两个阶段都存在业务逻辑的执行,但其中try业务阶段其实是与全局事务处理无关的。当认清这一点之后,再比较TCC与2PC,就会发现,TCC不是两阶段提交,而只是它对事务的提交与回滚是通过confirm/cancel业务逻辑来实现的
2.2、TCC(同2.1.2、2PC-事务补偿方案(TCC))
TCC类似经典两阶段提交协议(2PC)的强一致性。具体上面一段:2.1.2、2PC-事务补偿方案(TCC)
与2PC协议比较 ,TCC拥有以下特点:
- 位于业务服务层而非资源层 ,由业务层保证原子性
- 没有单独的准备(Prepare)阶段,降低了提交协议的成本
- Try操作 兼备资源操作与准备能力
- Try操作可以灵活选择业务资源的锁定粒度,而不是锁住整个资源,提高了并发度
当然,TCC需要较高的开发成本,每个子业务都需要有响应的comfirm、Cancel操作,即实现相应的补偿逻辑。
2.3、三阶段提交(3PC)
核心:在2pc的基础上增加了一个询问阶段(第一阶段),确认网络,避免阻塞,二三阶段就是上面的2pc。
三阶段提交协议是两阶段提交协议的改进版本。它通过超时机制解决了阻塞的问题,并且把两个阶段增加为三个阶段:
询问阶段:协调者询问参与者是否可以完成指令,协调者只需回答是还是不是,而不需要做真正的操作,这个阶段超时导致中止
准备阶段:如果在询问阶段所有的参与者都返回可以执行操作,协调者向参与者发送预执行请求,然后参与者写redo和undo日志,执行操作,但是不提交操作;如果在询问阶段任何参与者返回不能执行操作的结果,则协调者向参与者发送中止请求,这里的逻辑与两阶段提交协议的的准备阶段是相似的,这个阶段超时导致成功
提交阶段:如果每个参与者在准备阶段返回准备成功,也就是预留资源和执行操作成功,协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何一个参与者返回准备失败,也就是预留资源或者执行操作失败,协调者向参与者发起中止指令,参与者取消已经变更的事务,执行undo日志,释放锁定的资源,这里的逻辑与两阶段提交协议的提交阶段一致
2.4、本地消息表
该方案的核心思想在于分布式系统在处理任务时 , 通过消息日志的方式来异步执行。消息日志可以存储至本地文本、数据库或消息队列,然后再通过业务规则定时任务或人工自动重试。
以在线支付系统的跨行转账为例:
第一步,伪代码如下,对用户id为A的账户扣款1000元,通过本地事务将事务消息(包括本地事务id、支付账户、收款账户、金额、状态等)插入至消息表:
Begin transaction
update user_account set amount = amount - 1000 where userId = 'A'
//更新状态到本地消息表
insert into trans_message(xid,payAccount,recAccount,amount,status)
values(uuid(),'A','B',1000,1);
end transactioncommit;
第二步,通知对方用户id为B,增加1000元,通常通过消息MQ的方式发送异步消息,对方订阅并监听消息后自动触发转账的操作;这里为了保证幂等性,防止触发重复的转账操作,需要在执行转账操作方新增一个trans_recv_log表用来做幂等,在第二阶段收到消息后,通过判断trans_recv_log表来检测相关记录是否被执行,如果未被执行则会对B账户余额执行加1000元的操作,并会将该记录增加至trans_recv_log,事件结束后通过回调更新trans_message的状态值。
Begin transaction
/**读取消息, B账户加1000
.....
*/
update trans_message set status = 0 where xid = ?
end transactioncommit;
##################################################################
这种实现方式的思路是源于ebay,其基本的设计思想是将远程分布式事务拆分成一系列的本地事务。
举个经典的跨行转账的例子来描述。
第一步伪代码如下,扣款1W,通过本地事务保证了凭证消息插入到消息表中。
1 2 3 4 5 |
|
仔细思考,其实我们可以消息消费方,也通过一个“消费状态表”来记录消费状态。在执行“加款”操作之前,检测下该消息(提供标识)是否已经消费过,消费完成后,通过本地事务控制来更新这个“消费状态表”。这样子就避免重复消费的问题。
总结:上诉的方式是一种非常经典的实现,基本避免了分布式事务,实现了“最终一致性”。但是,关系型数据库的吞吐量和性能方面存在瓶颈,频繁的读写消息会给数据库造成压力。所以,在真正的高并发场景下,该方案也会有瓶颈和限制的。
第二步,通知对方银行账户上加1W了,通常采用两种方式:
-
采用时效性高的MQ,由对方订阅消息并监听,有消息时自动触发事件。
-
采用定时轮询扫描的方式,去检查消息表的数据。
2.5、消息中间件
消息中间件-非事务消息中间件
非事务性的消息中间件
还是以上述提到的跨行转账为例,我们很难保证在扣款完成之后对MQ投递消息的操作就一定能成功。这样一致性似乎很难保证。
1 2 3 4 5 6 7 8 |
|
我们来分析下可能的情况:
-
操作数据库成功,向MQ中投递消息也成功,皆大欢喜。
-
操作数据库失败,不会向MQ中投递消息了。
-
操作数据库成功,但是向MQ中投递消息时失败,向外抛出了异常,刚刚执行的更新数据库的操作将被回滚。
从上面分析的几种情况来看,基本上能保证发送者发送消息的可靠性。我们再来分析下消费者端面临的问题:
-
消息出列后,消费者对应的业务操作要执行成功。如果业务执行失败,消息不能失效或者丢失。需要保证消息与业务操作一致。
-
尽量避免消息重复消费。如果重复消费,也不能因此影响业务结果。
消息中间件-支持事务的消息中间件
除了上面介绍的通过异常捕获和回滚的方式外,还有没有其他的思路呢?
阿里巴巴的RocketMQ中间件就支持一种事务消息机制,能够确保本地操作和发送消息达到本地事务一样的效果。
-
第一阶段,RocketMQ在执行本地事务之前,会先发送一个Prepared消息,并且会持有这个消息的地址。
-
第二阶段,执行本地事物操作。
-
第三阶段,确认消息发送,通过第一阶段拿到的地址去访问消息,并修改状态,如果本地事务成功,则修改状态为已提交,否则修改状态为已回滚。
但是如果第三阶段的确认消息发送失败了怎么办?RocketMQ会定期扫描消息集群中的事物消息,如果发现了prepare状态的消
息,它会向消息发送者确认本地事务是否已执行成功,如果成功是回滚还是继续发送确认消息呢。RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。
目前主流的开源MQ(ActiveMQ、RabbitMQ、Kafka)均未实现对事务消息的支持,比较遗憾的是,RocketMQ事务消息部分的代码也并未开源,需要自己去实现。
##############################################
Apache开源的RocketMQ中间件能够支持一种事务消息机制,确保本地操作和发送消息的异步处理达到本地事务的结果一致。
第一阶段,RocketMQ在执行本地事务之前,会先发送一个Prepared消息,这个消息保存在broker中,不会被消费者看到,并且会持有这个消息的接口回查地址。
第二阶段,执行本地事物操作。
第三阶段,确认消息发送,通过第一阶段拿到的接口地址URL执行回查,并修改状态,如果本地事务成功,则修改状态为已提交,否则修改状态为已回滚。
几个要解决的问题:
1 . 如果第三阶段发送失败:
如果第三阶段的确认消息发送失败了怎么办?RocketMQ会定期扫描消息集群中的事物消息,如果发现了prepare状态的消息(既不是提交也不是回滚的中间状态),它会向消息发送者确认本地事务是否已执行成功, 然后再根据我们配置文件配置的处理策略来决定是继续发送还是回滚
2 . 保证消费者不重复消费消息
RocketMQ、Kafka都不保证消息不重复,如果你的业务需要保证严格的不重复消息,那么就需要在我们的业务端保存消费状态,进行去重。
- 消费端处理消息的业务逻辑保持幂等性
- 保存消费者消费的状态即保证每条消息都有唯一编号,在消费者那边保证消息处理成功后,将状态写入到去重表中,每次消费消息都查询去重表中是否已经存在这个id的消费记录
3 . 解决消费失败:报警系统+人工处理
如果再消费者那边出现了逻辑业务上的异常Exception, 在普通情况下可以考虑回滚来解决, 但是在消息中间件这个系统下,系统复杂度将大大提升,且很容易出现Bug,估计出现Bug的概率会比消费失败的概率大很多。
所以针对消费失败这种情况,最好的办法就是通过报警系统及时发现失败情况然后再人工处理。其实为了交易系统更可靠,我们一般会在类似交易这种高级别的服务代码中,加入详细日志记录的,一旦系统内部引发类似致命异常要及时通过短信(钉钉、邮件)通知给业务操作人员。