转载自:http://ifeve.com/distribute-transaction-2pc/
http://coolshell.cn/articles/10910.html
http://www.jianshu.com/p/1156151e20c8
https://blog.youkuaiyun.com/suifeng3051/article/details/52691210
文章目录
一、普通事务与分布式事务
1.1 普通事务
普通事务就是一般所说的 数据库事务。
事务 是数据库管理系统执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。当事务被提交给了DBMS(数据库管理系统),DBMS需要确保 该事务中的所有操作都成功完成且其结果被永久保存在数据库中,如果事务中有的操作没有成功完成,则事务中的所有操作都需要被回滚,回到事务执行前的状态。
1.1.1 事务的ACID特性
原子性(Atomicity):所谓的原子性就是说,在整个事务中的所有操作,要么全部完成,要么全部不做,没有中间状态。事务在执行中发生错误,所有的操作都会被回滚。
一致性(Consistency):事务的执行必须保证系统的一致性,就拿转账为例,A有500元,B有300元,如果在一个事务里A成功转给B 50元,那么不管并发多少,不管发生什么,只要事务执行成功了,那么最后A账户一定是450元,B账户一定是350元。
隔离性(Isolation):所谓的隔离性就是说,事务与事务之间不会互相影响,一个事务的中间状态不会被其他事务感知。
持久性(Durability):所谓的持久性,就是说一旦事务完成了,那么事务对数据所做的变更就完全保存在了数据库中,即使发生停电、系统宕机也是如此。
1.2 分布式事务(Distributed Transaction DT)
分布式事务 顾名思义就是在 分布式环境下运行的事务,对于分布式事务来说,事务的每个操作步骤是运行在不同机器上的服务的。分布式事务处理的关键是 必须可以知道事务在任何地方所做的所有动作,提交或回滚事务的动作必须产生统一的结果(全部提交或全部回滚)。
在现如今的大型互联网平台中,基本上都是采用分布式架构,所以分布式事务是非常常见的。比如一个电商平台的下单场景,一般对于用户下单会有两个步骤,一是订单业务下订单操作,二是库存业务减库存操作,但这两个业务一般会运行在不同的机器上,这就是一个典型的分布式事务场景。还有一个常见的场景就是支付宝向余额宝转账,而支付宝和余额宝不是一个系统,怎么保证这两个系统之间的一致性就是分布式事务所关注的问题。
1.2.1 分布式系统中的CAP定律
为了更方便的理解分布式事务,介绍一下一个分布式系统的CAP定律。在分布式系统里面有一个CAP定律,这个定理的内容是指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。
- 一致性(C):在分布式系统中的所有数据备份,在同一时刻是否是同样的值。
- 可用性(A):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。或者换个说法,系统提供的服务必须一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果。
- 分区容错性(P):分布式系统在遇到任何网络分区故障的时候,仍然需要能够保证对外提供满足一致性和可用性的服务,除非是整个网络环境都发生了故障。以实际效果而言,分区相当于对通信的时限要求,系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。
需要明确的一点是,对于一个分布式系统而言,分区容错性是一个最基本的要求。因为既然是一个分布式系统,那么分布式系统中的组件必然需要被部署到不同的节点,否则也就无所谓分布式系统了,因此必然出现子网络。而对于分布式系统而言,网络问题又是一个必定会出现的异常情况,因此分区容错性也就成为了一个分布式系统必然需要面对和解决的问题。因此系统架构师往往需要把精力花在如何根据业务特点在C(一致性)和A(可用性)之间寻求平衡。
1.2.2 一致性理论
为了下一步讨论分布式事务特性,先简单介绍下数据一致性的基础理论。
-
强一致:当更新操作完成之后,任何多个后续进程或者线程的访问都会返回最新的更新过的值。这种是对用户最友好的,就是用户上一次写什么,下一次就保证能读到什么。根据 CAP 理论,这种实现需要牺牲可用性。
-
弱一致性:系统并不保证续进程或者线程的访问都会返回最新的更新过的值。系统在数据写入成功之后,不承诺立即可以读到最新写入的值,也不会具体的承诺多久之后可以读到。
-
最终一致性:弱一致性的特定形式。系统保证在没有后续更新的前提下,系统最终返回上一次更新操作的值。在没有故障发生的前提下,不一致窗口的时间主要受通信延迟,系统负载和复制副本的个数影响。DNS 是一个典型的最终一致性系统。
1.2.3 分布式事务特性—最终一致性(BASE)
在互联网大型分布式平台场景中,为了保障系统的可用性,一般会把强一致性的需求转换成最终一致性的需求。所以,对于大部分分布式事务场景,仅需要保证最终一致性即可。这种思路,即经典的BASE (Basically Available, Soft state, Eventually consistent)方案。
- BA: Basic Availability 基本业务可用性,分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。
- S: Soft state 软状态,允许系统中存在中间状态,这个状态不影响系统可用性,这里指的是CAP中的不一致。
- E: Eventual consistency 最终一致性,指经过一段时间后,所有节点上的数据都将会达到一致。
二、分布式事务解决方案
2.1 二阶段提交协议(2PC)
在分布式系统中,每个节点虽然可以知晓自己的操作是成功或者失败,却无法知道其他节点的操作是成功或失败。当一个事务跨越多个节点时,为了保持事务的ACID特性,需要引入一个作为 协调者 的组件来统一掌控所有节点(称作参与者)的操作结果,并最终指示这些节点是否要把操作结果进行真正的提交(比如将更新后的数据写入磁盘等等)。
因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈决定各个参与者是否要提交操作还是中止操作。
二阶段提交算法的成立基于以下假设:
- 该分布式系统中,存在一个节点作为协调者,其他节点作为参与者。且节点之间可以进行网络通信。
- 所有节点都采用预写式日志,且日志被写入后即被保持在可靠的存储设备上,即使节点损坏不会导致日志数据的消失。
- 所有节点不会永久性损坏,即使损坏后仍然可以恢复。
两阶段提交协议把分布式事务分成两个过程,一个是准备阶段,一个是提交阶段:
1.准备阶段:协调者向所有参与者发起指令,参与者评估自己的状态,如果参与者评估指令可以完成,参与者会写redo或者undo日志(Write-Ahead Log的一种),然后锁定资源,执行操作,但是并不提交,并反馈给协调者;
2.提交阶段:如果每个参与者明确返回准备成功,也就是预留资源和执行操作成功,协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何一个参与者明确返回准备失败,也就是预留资源或者执行操作失败,协调者向参与者发起中止指令,参与者取消已经变更的事务,执行undo日志,释放锁定的资源。
两阶段提交协议在准备阶段锁定资源,是一个重量级的操作,并能保证强一致性,但是实现起来复杂、成本较高,不够灵活,更重要的是它有如下致命的问题:
-
阻塞:从上面的描述来看,对于任何一次指令必须收到明确的响应,才会继续做下一步,否则处于阻塞状态,占用的资源被一直锁定,不会被释放;
-
单点故障:如果协调者宕机,参与者没有了协调者指挥,会一直阻塞,尽管可以通过选举新的协调者替代原有协调者,但是如果之前协调者在发送一个提交指令后宕机,而提交指令仅仅被一个参与者接受,并且参与者接收后也宕机,新上任的协调者是无法处理这种情况;
-
脑裂:协调者发送提交指令,有的参与者接收到执行了事务,有的参与者没有接收到事务,就没有执行事务,多个参与者之间是不一致的。
上面所有的这些问题,都是需要人工干预处理,没有自动化的解决方案,因此两阶段提交协议在正常情况下能保证系统的强一致性,但是在出现异常情况下,当前处理的操作处于错误状态,需要管理员人工干预解决,因此可用性不够好,这也符合CAP协议的一致性和可用性不能兼得的原理。
2.2 三阶段提交协议(3PC)
二阶段协议存在的问题:主要是同步阻塞问题。在二阶段协议中的第一阶段,所有参与者接受到事务协调器的事务准备请求后,会在本地开启并执行事务,但是没有提交事务。之后所有参与者等待第二阶段事务协调器发出事务提交或者回滚后才会提交或者回滚事务。而在这期间所有参与者开启的本地事务一直存在,也就是一直把相应的资源锁定了(比如本地事务要更新一行数据,则在开启事务后,事务提交或者回滚之前都一直通过行锁锁定了这行数据),导致其他需要访问这行数据的事务阻塞等待。
假如在第一阶段事务协调器给10个参与者发送准备请求,其中9个参与者正确接受了,并开启了本地事务锁定了具体的资源,而剩下一个参与者 或者由于网络问题没有收到准备请求,或者接受到了但是本事事务执行失败,或者执行正常但是给事务协调器的回执由于网络原因没有被协调器收到等,则事务协调器发现其中一个参与者返回准备失败或者等待超时后还没收到那一个参与者的回执则会通知所有的参与者执行回滚操作。也就是在具体回滚前,其他9个参与者白白的锁定了本地资源,这显然很浪费。
三阶段协议把二阶段的第一阶段在细分为2阶段,具体内容如下:
- 第一阶段 canCommit
事务发起方发起事务后,事务协调器会给所有的事务参与者发起 canCommit 的请求,参与者收到后根据自己的情况判断是否可以执行提交,如果可以则返回ok,否者返回fail,但不开启本地事务并执行。具体参与者是如何判断本地是否可以执行提交协议并没有具体规定,需要协议实现者自己规定,比如可能判断参与者是否存在(网络是否OK)或者本地数据库连接是否可用来判断。
如果协调器发现有些参与者返回fail或者等待超时后参与者还没返回,则给所有事务参与者发起中断操作,具体中断操作做什么协议也没有具体规定。如果协调器发现所有参与者返回可以提交,则进入第二阶段。 - 第二阶段 preCommit
事务协调器向所有参与者发起准备事务请求,参与者接受到后,开启本地事务并执行,但是不提交。剩下的与二阶段协议的第一阶段一致。 - 第三阶段doCommit
与二阶段协议中的第二阶段一致。
三阶段协议与二阶段协议最大不同在于三阶段协议把二阶段协议的第一阶段拆分为了两个阶段,其中第一阶段并不锁定资源,而是询问参与者是否可以提交,等所有参与者回复OK后在具体执行第二阶段锁定资源,询问阶段可以确保尽可能早的发现无法执行操作而需要中止的行为,但是它并不能发现所有的这种行为,只会减少这种情况的发生。理论上如果第一阶段返回都OK,则第二阶段和三阶段执行成功的概率就很大,另外如果第一阶段有些参与者返回了fail,由于这时候其他参与者还没有锁定资源,所以不会造成资源的阻塞。
2.3 TCC编程模式
TCC是通过补偿机制实现最终一致性,TCC编程模式本质上也是一种二阶段协议,不同在于TCC编程模式需要与具体业务耦合,下面首先看下TCC编程模式步骤:
- 所有事务参与方都需要实现try,confirm,cancle接口。
- 事务发起方 向 事务协调器 发起事务请求,事务协调器调用所有事务参与者的try方法完成资源的预留,这时候并没有真正执行业务,而是为后面具体要执行的业务预留资源,这里完成了一阶段。
- 如果事务协调器发现有参与者的try方法预留资源时候发现资源不够,则调用参与方的cancle方法回滚预留的资源,需要注意cancle方法需要实现业务幂等,因为有可能调用失败(比如网络原因参与者接受到了请求,但是由于网络原因事务协调器没有接受到回执)会重试。
- 如果事务协调器发现所有参与者的try方法返回都OK,则事务协调器调用所有参与者的confirm方法,不做资源检查,直接进行具体的业务操作。
- 如果协调器发现所有参与者的confirm方法都OK了,则分布式事务结束。
- 如果协调器发现有些参与者的confirm方法失败了,或者由于网络原因没有收到回执,则协调器会进行重试。这里如果重试一定次数后还是失败,会怎么样那?常见的是做事务补偿。
2.4 本地消息(事务)表
此方案的核心是将需要分布式处理的任务通过消息日志的方式来异步执行。消息日志可以存储到本地文本、数据库或消息队列,再通过业务规则自动或人工发起重试。人工重试更多的是应用于支付场景,通过对账系统对事后问题的处理,达到最终的一致。
举个例子。假设系统中有两个表user(id, name, amt_sold, amt_bought)及transaction(xid, seller_id, buyer_id, amount)。其中user表记录用户交易汇总信息,transaction表记录每个交易的详细信息。
begin;
INSERT INTO transaction VALUES(xid, $seller_id, $buyer_id, $amount);
UPDATE user SET amt_sold = amt_sold + $amount WHERE id = $seller_id;
UPDATE user SET amt_bought = amt_bought + $amount WHERE id = $buyer_id;
commit;
即在transaction表中记录交易信息,然后更新卖家和买家的状态。
假设transaction表和user表存储在不同的节点上,那么上述事务就是一个分布式事务。对于一个分布式事务,需要考虑将其拆分两个独立的子事务,每个子事务都有一张本地消息表。
对于transaction表插入的业务,先启动一个事务,插入transaction表后,并不直接去更新user表,而是将更新业务以消息的形式插入到本地消息表message。
begin;
INSERT INTO transaction VALUES(xid, $seller_id, $buyer_id, $amount);
put_to_queue 'update user(“seller”, $seller_id, amount)';
put_to_queue 'update user(“buyer”, $buyer_id, amount)';
commit;
对于user表更新业务,也需要新建一个message_applied(msg_id)表来记录被成功应用的消息,然后发起一个异步任务轮询队列内容进行处理:
for each message in queue
begin;
//先检查此消息是否已处理
SELECT count(*) as cnt FROM message_applied WHERE msg_id = message.id;
if cnt = 0 then
//若没有处理,对user表做更新操作
if message.type = “seller” then
UPDATE user SET amt_sold = amt_sold + message.amount WHERE id = message.user_id;
else
UPDATE user SET amt_bought = amt_bought + message.amount WHERE id = message.user_id;
end
//插入应用的消息,标记此消息已处理
INSERT INTO message_applied VALUES(message.id);
end
commit;
end
来仔细分析一下上面代码:
1. 消息队列与transaction使用同一实例,因此第一个事务不涉及分布式操作;
2. message_applied与user表在同一个实例中,也能保证一致性;
3. 第二个事务结束后,系统可能出故障,出故障后系统会重新从消息队列中取出这一消息,但通过message_applied表可以检查出来这一消息已经被应用过,跳过这一消息实现正确的行为。
2.5 采用消息中间件
解决分布式事务问题还有一种方案,也是现在大型互联网平台普遍采用的方案,就是利用消息中间件。
消息中间件也可称作消息系统(MQ),它本质上是一个暂存转发消息的一个中间件。在分布式应用当中,可以把一个业务操作转换成一个消息,比如支付宝转账余额宝操作,支付宝系统执行减掉账户金额操作之后向消息系统发一个消息,余额宝系统订阅这条消息然后进行增加账户金额操作。
实质上,基于消息中间件的两阶段提交是将一个分布式事务拆成一个消息事务(A系统的本地操作+发消息)+ B系统的本地操作,其中B系统的操作由消息驱动,只要消息事务成功,那么A操作一定成功,消息也一定发出来了,这时候B会收到消息去执行本地操作,如果本地操作失败,消息会重投,直到B操作成功,这样就变相地实现了A与B的分布式事务。
但是在分布式业务之间引入消息中间件还存在一个问题,就是如何保证业务系统与消息系统之间消息传递的可靠性。在分布式业务场景中,可靠性永远是最重要的。如果采用消息中间件,保证业务之间消息的发送与接收的可靠性是非常重要的问题。
当采用消息中间件时,消息的可靠性体现在两个方面:
- 消息的发送者端 (生产者):发送者端完成操作后一定能将消息成功发送到消息系统。
- 消息的接收者端(消费者):消费者端仅且能够从消息系统成功消费一次消息。
2.5.1 发送端保证消息的可靠性
可以利用本地事务:主要原理是通过本地消息表做中间表。在数据库中建一张消息表,将消息数据与业务数据保存在同一数据库实例里,这样就可以利用本地数据库的事务机制,保证业务操作和保存消息完全一致:
Begin transaction
update A set amount=amount-10000 where userId=1;
insert into message(userId, amount,status) values(1, 10000, 1);
End transaction
commit;
通过本地事务一定能保证扣完款后消息能保存下来。当上述事务提交成功后,再通过消息中间件实时扫描这张消息表,把消息表中的数据转移到消息中间件,若转移消息成功则删除消息表中的数据,若转移失败继续重试。
通常情况下,在使用非事务消息支持的MQ产品时,很难将业务操作与对MQ的操作放在一个本地事务域中管理。通俗点描述,以“支付宝转账”为例,很难保证在支付宝扣款完成之后对MQ投递消息的操作就一定能成功。先从消息生产者这端来分析,请看伪代码:
try{
//1. 操作数据库
boolean result = dao.update(mode1);
//2.如果第一步成功,则投递消息
if(result){
mq.append(model1)
}
}catch(Execption ex){
rollback();
}
根据上述代码,来分析下可能出现的情况:
- 操作数据库成功,向MQ中投递消息也成功
- 操作数据库失败,不会向MQ中投递消息了
- 操作数据库成功,但是向MQ中投递消息时失败,向外抛出了异常,刚刚执行的更新数据库的操作将被回滚
所以这种方式基本上也能保证发送者发送消息的可靠性。
除了上面介绍的通过异常捕获和回滚的方式外,阿里巴巴的RocketMQ中间件就支持一种事务消息机制,能够确保本地操作和发送消息达到本地事务一样的效果。
2.5.2 接收者端保证消息的可靠性
1.保证消费者不重复消费消息
什么情况下会产生重复消费的情况呢?比如消费者接收到消息并完成了本地事务(如减库存操作),此时还要返回消息系统一个通知,告诉消息系统把这条消息删除掉。然后不巧恰恰在此时网络出现了问题,返回给消息系统删除消息的通知丢失,则消费者端会再次消费这条消息,导致了重复消费。
那么该怎么处理这种情况呢?
- 消费端处理消息的业务逻辑保持幂等性
- 保存消费者消费的状态,即保证每条消息都有唯一编号,并且保证消息处理成功后一定能写入到一张去重日志表
关于第1条幂等性,只要业务操作保持幂等性,不管来多少条重复消息,最后处理的结果都一样。这个很明显应该在消费端实现,不属于消息系统要实现的功能。
关于第2条,原理就是利用一张日志表来记录已经处理成功的消息的ID,如果新到的消息ID已经在日志表中,那么就不再处理这条消息。当然这个可以消息系统实现,也可以业务端实现。正常情况下出现重复消息的概率不一定大,且由消息系统实现的话,肯定会对消息系统的吞吐量和高可用有影响,所以,一般消费状态的保存都是在消费者端进行保存。
2. 解决消费者消费超时和失败
再回到转账的例子,如果A的账户的余额已经减少,且消息已经发送成功,消费端开始消费这条消息,这个时候就会出现消费失败和消费超时两个问题?解决超时问题的思路就是一直重试,直到消费端消费消息成功,整个过程中有可能会出现消息重复的问题,按照前面的思路解决即可。
上面基本上可以解决超时问题,但是如果消费失败怎么办?如果按照事务的流程,如果事务中的某个步骤操作失败了的话,就要回滚之前的所有操作。如果消息系统要实现这个回滚流程的话,系统复杂度将大大提升。而且一般通过消息系统的处理流程都是一个异步操作,也就是说,但当用户下单时我们不会等到整个流程完成之后才返回给用户结果,而是直接返回给用户下单成功的结果,后端再慢慢处理。如果进行回滚操作的话,那么就会出现用户明明下单成功了过段时间一看又失败了这种情况,这是不允许的。
所以针对消费失败这种情况,最好的办法就是通过 报警系统 及时发现失败情况然后再人工处理。其实为了交易系统更可靠,一般会在类似交易这种高级别的服务代码中,加入详细日志记录的,一旦系统内部引发类似致命异常要及时通过短信(钉钉、邮件)通知给业务方。同时,应该设计一个报警系统在后台实时扫描和分析此类日志,检查出这种特殊的情况,通过短信(钉钉、邮件)及时通知相关人员。
再来思考一个问题,假如数据库在提交事务的时候突然断电,那么它是怎么样恢复的呢?
为什么要提到这个知识点呢? 因为分布式系统的核心就是处理各种异常情况,这也是分布式系统复杂的地方,因为分布式的网络环境很复杂,这种“断电”故障要比单机多很多,所以在做分布式系统的时候,最先考虑的就是这种情况。这些异常可能有 机器宕机、网络异常、消息丢失、消息乱序、数据错误、不可靠的TCP、存储数据丢失、其他异常等等…
那么本地事务数据库断电的这种情况,它是怎么保证数据一致性的呢?使用SQL Server来举例,SQL Server数据库是由两个文件组成的,一个数据库文件和一个日志文件,通常情况下,日志文件都要比数据库文件大很多。数据库进行任何写入操作的时候都是要先写日志的,同样的道理,在执行事务的时候数据库首先会记录下这个事务的redo操作日志,然后才开始真正操作数据库,在操作之前首先会把日志文件写入磁盘,那么当突然断电的时候,即使操作没有完成,在重新启动数据库时候,数据库会根据当前数据的情况进行undo回滚或者是redo前滚,这样就保证了数据的强一致性。
1905

被折叠的 条评论
为什么被折叠?



