关于分布式事务

时间:2020-12-06 11:14:58

一、普通事务与分布式事务

1.1 普通事务

普通事务就是一般所说的数据库事务,大家对数据库事务应该都很了解,这里再简单介绍下。

事务是数据库管理系统执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。当事务被提交给了DBMS(数据库管理系统),则DBMS(数据库管理系统)需要确保该事务中的所有操作都成功完成且其结果被永久保存在数据库中,如果事务中有的操作没有成功完成,则事务中的所有操作都需要被回滚,回到事务执行前的状态;同时,该事务对数据库或者其他事务的执行无影响,所有的事务都好像在独立的运行。

事务的ACID特性

原子性(A):所谓的原子性就是说,在整个事务中的所有操作,要么全部完成,要么全部不做,没有中间状态。对于事务在执行中发生错误,所有的操作都会被回滚,整个事务就像从没被执行过一样。
一致性(C):事务的执行必须保证系统的一致性,就拿转账为例,A有500元,B有300元,如果在一个事务里A成功转给B50元,那么不管并发多少,不管发生什么,只要事务执行成功了,那么最后A账户一定是450元,B账户一定是350元。
隔离性(I):所谓的隔离性就是说,事务与事务之间不会互相影响,一个事务的中间状态不会被其他事务感知。
持久性(D):所谓的持久性,就是说一单事务完成了,那么事务对数据所做的变更就完全保存在了数据库中,即使发生停电,系统宕机也是如此。

这里我提一点,也许有人会有疑虑,有了原子性为什么还要提一致性呢,原子性不能保证一致性吗?这就涉及到并发事务的概念。对于单个事务来说,原子性就能保证一致性,但是对于多个并发执行的事务,即使每个事务都是原子执行的,但它们同时执行的话,最终效果可能会不一致。举个例子,加入有A和B两个账户,各有200元钱。事务1是A账户向B账户转账100,则事务1将会执行如下语句:

update A set amount=amount-100 where userId=1;
update B set amount=amount+100 where userId=1;

但是在同一时刻又有一个事务2,事务2是账户C也给账户B转账100元,则事务2会执行如下语句:

update C set amount=amount-100 where userId=1;
update B set amount=amount+100 where userId=1;

如果事务1和事务2同时执行的一致性结果应该是B账户里面有300元钱,但如果事务不能保证一致性的话,事务1和事务2同时执行时,两个事务读到的账户B的当前金额都可能是100(脏读),不管两个事务谁先执行完成,最终的执行结果都是B账户金额为200。因此,要达到事务的一致性,除了要保证单个事务的原子性之外,还要保证事务之间的隔离性

1.2 分布式事务(Distributed Transaction DT )

分布式事务顾名思义就是在分布式环境下运行的事务,对于分布式事务来说,事务的每个操作步骤是运行在不同机器上的服务的。分布式事务处理的关键是必须有一种方法可以知道事务在任何地方所做的所有动作,提交或回滚事务的决定必须产生统一的结果(全部提交或全部回滚)

在现如今的大型互联网平台中,基本上都是采用分布式的SOA架构,所以分布式事务是非常常见的。比如一个电商平台的下单场景,一般对于用户下单会有两个步骤,一是订单业务采取下订单操作,二是库存业务采取减库存操作,但在大型电子商务平台上这两个业务一般会运行在不同的机器上,这就是一个典型的分布式事务场景。还有一个常见的场景就是支付宝向余额宝转账,而支付宝和余额宝不是一个系统,怎么保证这两个系统之间的一致性就是分布式事务所关注的问题。

1.2.1 分布式系统CAP定律

为了更方便的理解分布式事务,这里插一个分布式系统的CAP定律。在分布式系统里面有一个CAP定律,这个定理的内容是指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。

  • 一致性(C):在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)
  • 可用性(A):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)
  • 分区容错性(P):以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。

CAP定律是NoSQL数据库的基石,而CAP理论就是说在分布式存储系统中,最多只能实现上面的两点。而由于当前的网络硬件肯定会出现延迟丢包等问题,所以分区容忍性是我们必须需要实现的。所以我们只能在一致性和可用性之间进行权衡,没有NoSQL系统能同时保证这三点,而NoSQL则选了可用性。

NoSQL数据库主要做的是简单的键值查询,因此NoSQL系统通常注重性能和扩展性,而非事务机制(事务就是强一致性的体现)

1.2.2 一致性理论

通过上面介绍我们知道,对分布式系统来说,CAP 理论告诉我们。为了下一步讨论分布式事务特性,先简单介绍下数据一致性的基础理论。

  • 强一致:当更新操作完成之后,任何多个后续进程或者线程的访问都会返回最新的更新过的值。这种是对用户最友好的,就是用户上一次写什么,下一次就保证能读到什么。根据 CAP 理论,这种实现需要牺牲可用性。

  • 弱一致性:系统并不保证续进程或者线程的访问都会返回最新的更新过的值。系统在数据写入成功之后,不承诺立即可以读到最新写入的值,也不会具体的承诺多久之后可以读到。

  • 最终一致性:弱一致性的特定形式。系统保证在没有后续更新的前提下,系统最终返回上一次更新操作的值。在没有故障发生的前提下,不一致窗口的时间主要受通信延迟,系统负载和复制副本的个数影响。DNS 是一个典型的最终一致性系统。

1.2.3 分布式事务特性—最终一致性

在互联网大型分布式平台场景中,为了保障系统的可用性,他们一般会把强一致性的需求转换成最终一致性的需求。所以,对于大部分分布式事务场景,我们仅需要保证最终一致性即可

二、分布式事务解决方案

2.1 本地消息(事务)表

这种实现方式的思路,其实是源于ebay经典的BASE (basically available, soft state, eventually consistent)方案。其基本的设计思想是将远程分布式事务拆分成一系列的本地事务。

举个例子。假设系统中有以下两个表

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;

if 上述事务成功
dequeue message
DELETE FROM message_applied WHERE msg_id = message.id;
end
end

我们来仔细分析一下上面代码:

  1. 消息队列与transaction使用同一实例,因此第一个事务不涉及分布式操作;
  2. message_applied与user表在同一个实例中,也能保证一致性;
  3. 第二个事务结束后,dequeue message之前系统可能出故障,出故障后系统会重新从消息队列中取出这一消息,但通过message_applied表可以检查出来这一消息已经被应用过,跳过这一消息实现正确的行为;
  4. 最后将已经成功应用,且已经从消息队列中删除的消息从message_applied表中删除,可以将message_applied表保证在很小的状态(不清除也是可以的,不影响系统正确性)。由于消息队列与message_applied在不同实例上,dequeue message之后,将对应message_applied记录删除之前可能出故障。一但这时出现故障,message_applied表中会留下一些垃圾内容,但不影响系统正确性,另外这些垃圾内容也是可以正确清理的。

2.2 两阶段和三阶段提交协议

为了解决分布式一致性问题,前人在性能和数据一致性的反反复复权衡过程中总结了许多典型的协议和算法。其中比较著名的有二阶提交协议(Two Phase Commitment Protocol)、三阶提交协议(Three Phase Commitment Protocol)和Paxos算法。

分布式事务最常用的解决方案就是二阶段提交(Two-phaseCommit,2PC)。在分布式系统中,每个节点虽然可以知晓自己的操作时成功或者失败,却无法知道其他节点的操作的成功或失败。当一个事务跨越多个节点时,为了保持事务的ACID特性,需要引入一个作为协调者的组件来统一掌控所有参与者节点的操作结果并最终指示这些节点是否要把操作结果进行真正的提交。因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作

二阶段提交算法的成立基于以下假设:

  • 该分布式系统中,存在一个节点作为协调者(Coordinator),其他节点作为参与者(Cohorts)。且节点之间可以进行网络通信。
  • 所有节点都采用预写式日志,且日志被写入后即被保持在可靠的存储设备上,即使节点损坏不会导致日志数据的消失。
  • 所有节点不会永久性损坏,即使损坏后仍然可以恢复。

所谓的两个阶段是指

1. 准备阶段(投票阶段) prepare
2. 提交阶段(执行阶段)commit

准备阶段

事务协调者给每个参与者发送Prepare消息,每个参与者要么直接返回失败,要么在本地执行事务,写本地的redo和undo日志,但不提交,到达一种“万事俱备,只欠东风”的状态。

可以进一步将准备阶段分为以下三个步骤:

  1. 协调者节点向所有参与者节点询问是否可以执行提交操作(vote),并开始等待各参与者节点的响应。
  2. 参与者节点执行询问发起为止的所有事务操作,并将Undo信息和Redo信息写入日志。(注意:若成功这里其实每个参与者已经执行了事务操作)
  3. 各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个”同意”消息;如果参与者节点的事务操作实际执行失败,则它返回一个”中止”消息。

提交阶段

如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源)

接下来分两种情况分别讨论提交阶段的过程。当协调者节点从所有参与者节点获得的相应消息都为同意”时:

  1. 协调者节点向所有参与者节点发出”正式提交(commit)”的请求。
  2. 参与者节点正式完成操作,并释放在整个事务期间内占用的资源。
  3. 参与者节点向协调者节点发送”完成”消息。
  4. 协调者节点受到所有参与者节点反馈的”完成”消息后,完成事务。

如果任一参与者节点在第一阶段返回的响应消息为”中止”,或者 协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:

  1. 协调者节点向所有参与者节点发出”回滚操作(rollback)”的请求。
  2. 参与者节点利用之前写入的Undo信息执行回滚,并释放在整个事务期间内占用的资源。
  3. 参与者节点向协调者节点发送”回滚完成”消息。
  4. 协调者节点受到所有参与者节点反馈的”回滚完成”消息后,取消事务。

不管最后结果如何,第二阶段都会结束当前事务。

二阶段提交看起来确实能够提供原子性的操作,但是不幸的事,二阶段提交还是有几个缺点的:

  1. 同步阻塞问题。执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。
  2. 单点故障。由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)
  3. 数据不一致。在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据部一致性的现象。
  4. 二阶段无法解决的问题:协调者再发出commit消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。

由于二阶段提交存在着诸如同步阻塞、单点问题、脑裂等缺陷,所以,研究者们在二阶段提交的基础上做了改进,提出了三阶段提交。关于三阶段提交算法网上有很多资料,这里就不多做介绍了。

2PC与3PC的区别

相对于2PC,3PC主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。而不会一直持有事务资源并处于阻塞状态。但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。

了解了2PC和3PC之后,我们可以发现,无论是二阶段提交还是三阶段提交都无法彻底解决分布式的一致性问题。Google Chubby的作者Mike Burrows说过, there is only one consensus protocol, and that’s Paxos” – all other approaches are just broken versions of Paxos. 意即世上只有一种一致性算法,那就是Paxos,所有其他一致性算法都是Paxos算法的不完整版。

2.3 采用消息中间件

解决分布式事务问题还有一种方案,也是现在大型互联网平台普遍采用的方案,就是利用消息中间件,在下面一节详细介绍这个方案。

三、 采用消息中间件(MQ)

3.1 消息中间件介绍

消息中间件也可称作消息系统(MQ),它本质上是一个暂存转发消息的一个中间件。在分布式应用当中,我们可以把一个业务操作转换成一个消息,比如支付宝转账余额宝操作,支付宝系统执行减掉账户金额操作之后向消息系统发一个消息,余额宝系统订阅这条消息然后进行增加账户金额操作。

3.1.1 两大类消息中间件

尽管存在各种各样的消息系统,每个消息系统都有各自的消息路由方式,但总体上有两种类型的消息系统:queuetopic,它们也各自关联着一种特定的消息处理模型:点对点(point-to-point/queue)发布/订阅(publish/subscribe/topic)

在点对点模式中,每个消息只有一个发送者和一个接收者。如下图所示: 在点对点模型中, 消息broker会把消息放入一个queue。当一个接收者请求下一个消息时,消息会被从queue中取出并传递给接收者。因为消息从queue中取出便会被移除,所以这保证了一个消息只能有一个接收者。
关于分布式事务

在发布/订阅模式中,消息是被发送到topic中的。就像queue一样,很多接收者可以监听同一个topic,但是与queue每个消息只传递给一个接收者不同,订阅了同一个topic的所有接收者都会收到消息的拷贝。从发布/订阅的名字中我们也可看出,发布者发布一条消息,所有订阅者都能收到,这就是发布订阅模式最大的特性。
如下图所示:
关于分布式事务

关于消息系统更多介绍可以参考:Spring整合JMS(消息中间件)

3.1.2 消息的可靠性

上面讲到了我们可以利用消息中间件化解分布式事务领域内的问题。但是在分布式业务之间引入消息中间件还存在一个问题,就是如何保证业务系统与消息系统之间消息传递的可靠性。在分布式业务场景中,可靠性永远是最重要的。如果采用消息中间件,保证业务之间消息的发送与接收的可靠性是非常重要的问题。

当采用消息中间件时,消息的可靠性体现在两个方面:

1. 消息的发送者端 (生产者):发送者端完成操作后一定能将消息成功发送到消息系统
2. 消息的接收者端(消费者):消费者端仅且能够从消息系统成功消费一次消息。

下面我们先介绍一下发送者端的消息可靠性。

3.2 发送者端保证消息的可靠性

3.2.1 利用本地事务

与2.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;

通过本地事务一定能保证扣完款后消息能保存下来。当上述事务提交成功后,我们再通过消息中间件实时扫描这张消息表,把消息表中的数据转移到消息中间件,若转移消息成功则删除消息表中的数据,若转移失败继续重试。

3.2.2 非事务性的消息中间件

通常情况下,在使用非事务消息支持的MQ产品时,我们很难将业务操作与对MQ的操作放在一个本地事务域中管理。通俗点描述,以“支付宝转账”为例,我们很难保证在支付宝扣款完成之后对MQ投递消息的操作就一定能成功。

先从消息生产者这端来分析,请看伪代码:
关于分布式事务

根据上述代码及注释,我们来分析下可能的情况:

1. 操作数据库成功,向MQ中投递消息也成功,皆大欢喜
2. 操作数据库失败,不会向MQ中投递消息了
3. 操作数据库成功,但是向MQ中投递消息时失败,向外抛出了异常,刚刚执行的更新数据库的操作将被回滚

所以这种方式基本上能保证发送者发送消息的可靠性。

3.2.3 支持事务的消息中间件

除了上面介绍的通过异常捕获和回滚的方式外,还有没有其他的思路呢?

阿里巴巴的RocketMQ中间件就支持一种事务消息机制,能够确保本地操作和发送消息达到本地事务一样的效果。

- 第一阶段,RocketMQ在执行本地事务之前,会先发送一个Prepared消息,并且会持有这个消息的地址
- 第二阶段,执行本地事物操作
- 第三阶段,确认消息发送,通过第一阶段拿到的地址去访问消息,并修改状态,如果本地事务成功,则修改状态为已提交,否则修改状态为已回滚

整个过程如下图所示:
关于分布式事务

但是如果第三阶段的确认消息发送失败了怎么办?RocketMQ会定期扫描消息集群中的事物消息,如果发现了prepare状态的消息(既不是提交也不是回滚的中间状态),它会向消息发送者确认本地事务是否已执行成功,如果成功是回滚还是继续发送确认消息呢。RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。

3.3 接收者端保证消息的可靠性

接收者端消息的可靠性要简单一些,可以从以下三方面来看。

3.3.1 保证消费者不重复消费消息

什么情况下会产生重复消费的情况呢?比如消费者接收到消息并完成了本地事务(如减库存操作),此时还要返回消息系统一个通知,告诉消息系统把这条消息删除掉。然后不巧恰恰在此时网络出现了问题,返回给消息系统删除消息的通知丢失,则消费者端会再次消费这条消息,导致了重复消费。

那么该怎么处理这种情况呢?

1.  消费端处理消息的**业务逻辑保持幂等性**
2. **保存消费者消费的状态**即保证每条消息都有唯一编号,并且保证消息处理成功后一定能写入到一张去重日志表

关于第1条幂等性,只要业务操作保持幂等性,不管来多少条重复消息,最后处理的结果都一样。这个很明显应该在消费端实现,不属于消息系统要实现的功能。

关于第2条,原理就是利用一张日志表来记录已经处理成功的消息的ID,如果新到的消息ID已经在日志表中,那么就不再处理这条消息。当然这个可以消息系统实现,也可以业务端实现。正常情况下出现重复消息的概率不一定大,且由消息系统实现的话,肯定会对消息系统的吞吐量和高可用有影响,所以,一般消费状态的保存都是在消费者端进行保存。

RocketMQ、Kafka都不保证消息不重复,如果你的业务需要保证严格的不重复消息,那么就需要在我们的业务端保存消费状态,进行去重。

3.3.2 解决消费者消费超时

再回到转账的例子,如果Bob的账户的余额已经减少,且消息已经发送成功,Smith端开始消费这条消息,这个时候就会出现消费失败和消费超时两个问题?解决超时问题的思路就是一直重试,直到消费端消费消息成功,整个过程中有可能会出现消息重复的问题,按照前面的思路解决即可。
关于分布式事务

3.3.3 解决消费失败:报警系统+人工处理

上面基本上可以解决超时问题, 但是如果消费失败怎么办?比如系统自身有bug或者程序逻辑有问题,那么重试1W次那也是无济于事的。 大家可以考虑一下,如果按照事务的流程,如果事务中的某个步骤操作失败了的话,就要回滚之前的所有操作。如果消息系统要实现这个回滚流程的话,系统复杂度将大大提升,且很容易出现Bug,估计出现Bug的概率会比消费失败的概率大很多。而且一般通过消息系统的处理流程都是一个异步操作,也就是说,但当用户下单时我们不会等到整个流程完成之后才返回给用户结果,而是直接返回给用户下单成功的结果,后端再慢慢处理。如果我们进行回滚操作的话,那么就会出现用户明明下单成功了过段时间一看又失败了这种情况,这是不允许的。

所以针对消费失败这种情况,最好的办法就是通过报警系统及时发现失败情况然后再人工处理。其实为了交易系统更可靠,我们一般会在类似交易这种高级别的服务代码中,加入详细日志记录的,一旦系统内部引发类似致命异常要及时通过短信(钉钉、邮件)通知给业务放。同时,应该设计一个报警系统在后台实时扫描和分析此类日志,检查出这种特殊的情况,通过短信(钉钉、邮件)及时通知相关人员。

参考文章:

http://www.jianshu.com/p/453c6e7ff81c
http://weibo.com/ttarticle/p/show?id=2309403965965003062676
http://www.cnblogs.com/LBSer/p/4715395.html