分布式事务

为什么使用分布式事物?

  1. Service 产生多个节点,一个小的服务单元本事多节点部署 。
  2. Resource 产生多个节点:数据库等不在一个地方,不同的服务如果保证一致
  3. 保证数据的可用性,一致性,完整性。(转账的例子)

分布式事务指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。
本质上来说,分布式事务就是为了保证不同数据库的数据一致性。

概念介绍

CAP理论

分布式事物CAP理论:

  • Consistency(一致性), 数据一致更新,所有数据变动都是同步的
  • Availability(可用性), 好的响应性能
  • Partition tolerance(分区容错性) 可靠性,部分节点出现问题,其他节点仍然可用。

    定理:任何分布式系统只可同时满足二点,没法三者兼顾。
    忠告:架构师不要将精力浪费在如何设计能满足三者的完美分布式系统,而是应该进行取舍。

分布式事物保持最终一致性。

对于 CP 来说,放弃可用性,追求一致性和分区容错性,我们的 ZooKeeper 其实就是追求的强一致。
对于 AP 来说,放弃一致性(这里说的一致性是强一致性),追求分区容错性和可用性,这是很多分布式系统设计时的选择,后面的 BASE 也是根据 AP 来扩展。

基于分布式领域BASE理论,它是在CAP理论(一致性、可用性、分区容忍性)的基础之上的延伸。

BASE理论

BASE 是 Basically Available(基本可用)、Soft state(软状态)和 Eventually consistent (最终一致性)三个短语的缩写,是对 CAP 中 AP 的一个扩展。
同时 CAP 中选择两个,比如你选择了 CP,并不是叫你放弃 A。因为 P 出现的概率实在是太小了,大部分的时间你仍然需要保证 CA。

基本可用:分布式系统出现故障的时候,允许损失一部分可用性,保证核心功能可用。

柔性状态:允许系统存在中间状态,这个中间状态又不会影响系统整体可用性。

最终一致性:数据经过重试等机制处理后,最终数据能达到一致。

ACID是传统数据库常用的设计思想,它追求的是强一致性。BASE是大型分布式系统场景下的设计思想,通过牺牲强一致性获得高可用性。

实现方式

2PC(全局事务型)

数据库支持的 XA Transactions 事物机制,Mysql5.5以上是否支持。推荐mariadb

1
SHOW VARIABLES LIKE '%innodb_support_xa%';

资源管理器(resource manager):用来管理系统资源,是通向事务资源的途径。数据库就是一种资源管理器。资源管理还应该具有管理事务提交或回滚的能力。

事务管理器(transaction manager):事务管理器是分布式事务的核心管理者。事务管理器与每个资源管理器(resource
manager)进行通信,协调并完成事务的处理。事务的各个分支由唯一命名进行标识。

XA 是一个两阶段提交协议:

  1. 预处理阶段:事务协调器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提交
  2. 提交阶段:事务协调器要求每个数据库提交数据。

image

MYSQL对XA事物支持过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
XA {START|BEGIN} xid [JOIN|RESUME]   //开启XA事务,如果使用的是XA START而不是XA

BEGIN,那么不支持[JOIN|RESUME],xid是一个唯一值,表示事务分支标识符

XA END xid [SUSPEND [FOR MIGRATE]] //结束一个XA事务,不支持[SUSPEND [FOR MIGRATE]]

XA PREPARE xid 准备提交

XA COMMIT xid [ONE PHASE] //提交,如果使用了ONE PHASE,则表示使用一阶段提交。两阶段提交协议中,如果只有一个RM参与,那么可以优化为一阶段提交

XA ROLLBACK xid //回滚

XA RECOVER [CONVERT XID] //列出所有处于PREPARE阶段的XA事务

缺点:

1、同步阻塞问题。执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。

2、单点故障。由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)

3、数据不一致。在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据部一致性的现象。

4、二阶段无法解决的问题:协调者再发出commit消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。

XA 协议比较简单,成本较低,但是其单点问题,以及不能支持高并发(由于同步阻塞)依然是其最大的弱点。

DEMO:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
$pdo1 = DB::connection()->getPdo();
$pdo2 = DB::connection('jianzhi_vip')->getPdo();

$xid = uniqid();
$xid2 = uniqid();
$this->info('开启xa事物');

$pdo1->exec("XA START '$xid'");
$pdo2->exec("XA START '$xid2'");

try {
$this->info('准备xa事物');
$ret1 = $pdo1->exec("UPDATE jz_abtest_record SET test_result=test_result+1 WHERE id=1");

$ret2 = $pdo2->exec("UPDATE test SET age=7 WHERE id=1");

//阶段1:$dbtest1提交准备就绪
$pdo1->exec("XA END '$xid'");
$pdo1->exec("XA PREPARE '$xid'");
//阶段1:$dbtest2提交准备就绪
$pdo2->exec("XA END '$xid2'");
$pdo2->exec("XA PREPARE '$xid2'");

//异常报告写在prepare之后
if (!$ret1 || !$ret2) {
throw new Exception("错误异常操作", 1);
}

$this->info('提交xa事物');
//阶段2:提交两个库
$pdo1->exec("XA COMMIT '$xid'");
$pdo2->exec("XA COMMIT '$xid2'");

} catch (Exception $e) {

$this->info('回滚');
//阶段2:回滚
$pdo1->exec("XA ROLLBACK '$xid'");
$pdo2->exec("XA ROLLBACK '$xid2'");
print_r($e->getMessage());
}

TCC(事务补偿型)

TCC

TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作

它分为三个阶段:

  • Try 阶段主要是对业务系统做检测及资源预留
  • Confirm 阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。
  • Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。

举个例子,假入 Bob 要向 Smith 转账,思路大概是:
我们有一个本地方法,里面依次调用
1、首先在 Try 阶段,要先调用远程接口把 Smith 和 Bob 的钱给冻结起来。
2、在 Confirm 阶段,执行远程调用的转账的操作,转账成功进行解冻。
3、如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。

优点: 跟2PC比起来,实现以及流程相对简单了一些,但数据的一致性比2PC也要差一些,增加可用性

缺点:

  1. 业务逻辑的每个分支都需要实现try、confirm、cancel三个操作,应用侵入性较强,改造成本高
  2. 实现难度较大。需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。为了满足一致性的要求,confirm和cancel接口必须实现幂等

TCC两阶段提交与XA两阶段提交的区别是:

  1. XA是资源层面的分布式事务,强一致性,在两阶段提交的整个过程中,一直会持有资源的锁。

XA事务中的两阶段提交内部过程是对开发者屏蔽的,回顾我们之前讲解JTA规范时,通过UserTransaction的commit方法来提交全局事务,这只是一次方法调用,其内部会委派给TransactionManager进行真正的两阶段提交,因此开发者从代码层面是感知不到这个过程的。而事务管理器在两阶段提交过程中,从prepare到commit/rollback过程中,资源实际上一直都是被加锁的。如果有其他人需要更新这两条记录,那么就必须等待锁释放。

2.TCC是业务层面的分布式事务,最终一致性,不会一直持有资源的锁。

TCC中的两阶段提交并没有对开发者完全屏蔽,也就是说从代码层面,开发者是可以感受到两阶段提交的存在。如上述航班预定案例:在第一阶段,航空公司需要提供try接口(机票资源预留)。在第二阶段,航空公司提需要提供confirm/cancel接口(确认购买机票/取消预留)。开发者明显的感知到了两阶段提交过程的存在。try、confirm/cancel在执行过程中,一般都会开启各自的本地事务,来保证方法内部业务逻辑的ACID特性。其中:try过程的本地事务,是保证资源预留的业务逻辑的正确性,confirm/cancel执行的本地事务逻辑确认/取消预留资源,以保证最终一致性.

异步确保最终一致型(柔性事务)

它的核心思想是将需要分布式处理的任务通过消息或者日志的方式来异步执行,消息或日志可以存到本地文件、数据库或消息队列,再通过业务规则进行失败重试,它要求各服务的接口是幂等的。

  1. 记录日志+补偿
    记录事务的开始和结束状态。事务根据日志记录找回事务的当前执行状态,并根据状态决定重试异常步骤,也就是正向补偿,或者回滚上一次执行步骤,也就是反向补偿。
  2. 消息
    多次重试,也就是发送多次消息,由于要多次重发,所以程序必须是幂等(同一操作反复执行多次结果不变)
  3. 无锁设计
    放弃锁是一个解决问题的思路。比如通过乐观锁,大多数是基于版本号来实现。

本地消息表(异步确保型)

其核心思想是将分布式事务拆分成本地事务进行处理:
消息表

  1. 消息生产方,需要额外建一个消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。

  2. 消息消费方,需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。

  3. 生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。

这种方案遵循BASE理论,采用的是最终一致性,笔者认为是这几种方案里面比较适合实际业务场景的,即不会出现像2PC那样复杂的实现(当调用链很长的时候,2PC的可用性是非常低的),也不会像TCC那样可能出现确认或者回滚不了的情况。

举个经典的跨行转账的例子来描述。
第一步伪代码如下,扣款100,通过本地事务保证了凭证消息插入到消息表中:

1
2
3
4
begin transaction:
  update User set account = account - 100 where userId = 'A'
  insert into message(msgId, userId, amount, status) values('123','A', 100, 1)
commit transaction

第二步,通知对方银行账户上加100了。那问题来了,如何通知到对方呢?

通常采用两种方式:

  1. 采用时效性高的MQ,由对方订阅消息并监听,有消息时自动触发事件。
  2. 采用定时轮询扫描的方式,去检查消息表的数据。
    两种方式其实各有利弊,仅仅依靠MQ,可能会出现通知失败的问题。而过于频繁的定时轮询,效率也不是最佳的(90%是无用功)。所以,我们一般会把两种方式结合起来使用。

解决了通知的问题,又有新的问题了。万一这消息有重复被消费,往用户帐号上多加了钱,那岂不是后果很严重?其实我们可以消息消费方也通过一个“消费状态表”来记录消费状态。在执行“加款”操作之前,检测下该消息(提供标识)是否已经消费过,消费完成后,通过本地事务控制来更新这个“消费状态表”。这样子就避免重复消费的问题:

1
2
3
4
5
6
7
get msgId = '123';
check if mgsId is in message_applied(msgId);
if not applied:
begin transaction:
update User set account = account + 100 where userId = 'B'
insert into message_applied(msgId) values('123')
commit transaction

上诉的方式是一种非常经典的实现,基本避免了分布式事务,实现了“最终一致性”。但是,关系型数据库的吞吐量和性能方面存在瓶颈,频繁的读写消息会给数据库造成压力。所以,在真正的高并发场景下,该方案也会有瓶颈和限制的。

缺点:
消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理

MQ非事务类型

在本地消息表基础之上,优化一个独立的消息服务改进:

基本思路是将本地操作和发送消息放在一个事务中,保证本地操作和消息发送要么两者都成功或者都失败。下游应用向消息系统订阅该消息,收到消息后执行相应操作。

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

Q: 如何保证消息与业务操作一致,不丢失?

A: 主流的 MQ 产品都具有持久化消息的功能 + 重试机制。

Q: 如何避免消息被重复消费造成的问题?

  1. 保证消费者调用业务的服务接口的幂等性。
  2. 通过消费日志或者类似状态表来记录消费状态,便于判断(建议在业务上自行实现,而不依赖MQ产品提供该特性)。

MQ事务类型

举个例子,Bob向Smith转账,那我们到底是先发送消息,还是先执行扣款操作?

好像都可能会出问题。如果先发消息,扣款操作失败,那么Smith的账户里面会多出一笔钱。反过来,如果先执行扣款操作,后发送消息,那有可能扣款成功了但是消息没发出去,Smith收不到钱。除了上面介绍的通过异常捕获和回滚的方式外,还有没有其他的思路呢?

利用RocketMQ 的事务消息优化上面的接口,利用事物消息取代上面独立的消息服务,发送Prepared消息之后,消费端是不能直接消费的,需要本地事物完成之后,确认了之后才行。

image

  1. 第一阶段 Prepared 消息,会拿到消息的地址。
  2. 第二阶段执行本地事务。
  3. 第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。

消息接受者就能使用这个消息。如果确认消息失败,在 RocketMQ Broker中提供了定时扫描没有更新状态的消息。

如果有消息没有得到确认,会向消息发送者发送消息,来判断是否提交,在 RocketMQ 中是以 Listener 的形式给发送者,用来处理。

如果减了是回滚还是继续发送确认消息呢?RocketMQ 会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。

RocketMQ 事务设计结构(4.3以上)

事务消息作为一种异步确保型事务, 将两个事务分支通过 MQ 进行异步解耦,RocketMQ 2. 事务消息的设计流程同样借鉴了两阶段提交理论,整体交互流程如下图所示:
image

  1. 事务发起方首先发送 prepare 消息到 MQ,RocketMQ将消息状态标记为Prepared,注意此时这条消息消费者是无法消费到的
  2. 在发送 prepare 消息成功后执行本地事务。
  3. 根据本地事务执行结果返回 commit 或者是 rollback。
  4. 如果消息是 rollback,MQ 将删除该 prepare 消息不进行下发,如果是 commit 消息,MQ 将会把这个消息发送给 consumer 端,RocketMQ将消息状态标记为可消费,这个时候消费者,才能真正的保证消费到这条数据。
  5. 如果执行本地事务过程中,执行端挂掉,或者超时,MQ 将会不停的询问其同组的其他 producer 来获取状态。
  6. Consumer 端的消费成功机制有 MQ 保证。

    如果确认消息发送失败了怎么办?

    RocketMQ会定期扫描消息集群中的事务消息,如果发现了Prepared消息,它会向消息发送端(生产者)确认。RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。

    如果消费失败怎么办?

    阿里提供给我们的解决方法是:人工解决。

开源GTS全局事务中间件

image

参考

分享即是成长