一文带你吃透分布式事务

1. 什么是分布式事务?

介绍这个之前,先来了解一下这几个问题

  1. 什么是事务?

  2. 什么是本地事务?

  3. 什么是分布式?

  4. 什么是分布式事务?

1.1. 什么是事务?

完成某件事情,可能有多个参与者需要执行多个步骤,最终多个步骤要么全部成功,要么全部失败。

举个例子:微信上 A 给 B 转账 100 元,A 账户减少 100,B 账户增加 100,这就是一个事务,这个操作中要么都成功,要么都失败。

事务的场景有很多,参与者也是多种多样

  1. 用户注册成功发送邮件,包含 2 个操作:db 中插入用户信息,给用户发送邮件,主要的 2 个参与者:db、邮件服务器

  2. 使用支付宝充值话费,包含 2 个操作:支付宝账户资金减少,手机余额增加,主要的 2 个参与者:支付宝账户、手机号服务商账户

事务的参与者是多种多样的,不过本文我们主要以 db 中的事务来做说明。

1.2. 什么是本地事务?

本地事务,通俗点理解:即事务中所有操作发生在同一个数据库中的情况。

比如 A 给 B 转账,A 和 B 的账户位于同一个数据库中。

通常我们用的都是关系型数据库,比如:MySQL、Oracle、SQL Server,这些数据库默认情况,这些 db 已经实现了事务的功能,即在一个 db 中执行一个事务操作,db 本身就可以确保这个事务的正确性,而不需要我们自己去考虑如何确保事务的正确性。

1.3. 数据库事务的 4 大特性

1.3.1. 一致性(Consistency)

事务操作之后的结果和期望的结果是一致的,A 给 B 转账 100,事务结束之后,看到 A 的账户应该减少 100,B 的账户应该增加 100,不会出现其他情况

1.3.2. 原子性(Atomicity)

事务的整个过程如原子操作一样,最终要么全部成功,或者全部失败,这个原子性是从最终结果来看的,从最终结果来看这个过程是不可分割的。

1.3.3. 隔离性(Isolation)

一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。

1.3.4. 持久性(Durability)

一个事务一旦提交,他对数据库中数据的改变就应该是永久性的。当事务提交之后,数据会持久化到硬盘,修改是永久性的。

1.4. 什么是分布式?

完成某件事情有多个参与者,多个参与者分布在不同的机器中,这些机器之间通过网络或者其他方式进行通讯。

比如使用工行卡给支付宝充值,工行卡的账户位于工商银行的 db 中,而支付宝账户位于支付宝的 db 中,2 个 db 位于不同的地方。

1.5. 什么是分布式事务?

分布式、事务这 2 个概念大家都理解了,那么分布式事务很容易理解了:事务的多个参与者分布在不同的地方。

单个 db 中我们很容易确保事务的正确性,但是当事务的参与者位于多个 db 中的时候,如何确保事务的正确性呢?

比如:A 给 B 转账,A 位于 DB1 中,B 位于 DB2 中

step1.通过网络,给DB1发送指令:给A账户减少100
step2.通过网络,给DB2发送指令:给B账户增加100

step1 成功之后,执行 step2 的时,网络出现故障,导致 step2 执行失败,最终:A 减少了 100,B 却没有增加 100,最终的结果和期望的结果不一致,导致了事务的失败。

在介绍分布式事务的解决方案之前,我们需要先了解另外 2 个概念:CAP 和 Base 理论,这 2 个理论为分布式事务的解决提供了依据。

2. CAP 理论

2.1. 理解 CAP 概念

CAP 是 Consistency、Availability、Partition tolerance 三个词语的缩写,分别表示一致性、可用性、分区容忍性。

下边我们分别来解释:

为了方便对 CAP 理论的理解,我们结合电商系统中的一些业务场景来理解 CAP。

如下图,是商品信息管理的执行流程:

 

整体执行流程如下:

1、商品服务请求主数据库写入商品信息(添加商品、修改商品、删除商品)

2、主数据库向商品服务响应写入成功。

3、商品服务请求从数据库读取商品信息。

2.1.1. C - Consistency

一致性是指写操作后的读操作可以读取到最新的数据状态,当数据分布在多个节点上,从任意结点读取到的数据都是最新的状态。

上图中,商品信息的读写要满足一致性就是要实现如下目标:

1、商品服务写入主数据库成功,则向从数据库查询新数据也成功。

2、商品服务写入主数据库失败,则向从数据库查询新数据也失败。

如何实现一致性?

1、写入主数据库后要将数据同步到从数据库。

2、写入主数据库后,在向从数据库同步期间要将从数据库锁定,待同步完成后再释放锁,以免在新数据写入从库的过程中,客户端向从数据库查询到旧的数据。

分布式系统一致性的特点:

1、由于存在数据同步的过程,写操作的响应会有一定的延迟。

2、为了保证数据一致性会对资源暂时锁定,待数据同步完成释放锁定资源。

3、如果请求数据同步失败的结点则会返回错误信息,一定不会返回旧数据。

2.1.2. A - Availability

可用性是指任何事务操作都可以得到响应结果,且不会出现响应超时或响应错误。

上图中,商品信息读取满足可用性就是要实现如下目标:

1、从数据库接收到数据查询的请求则立即能够响应数据查询结果。

2、从数据库不允许出现响应超时或响应错误。

如何实现可用性?

1、写入主数据库后要将数据同步到从数据库。

2、由于要保证从数据库的可用性,不可将从数据库中的资源进行锁定。

3、即使数据还没有同步过来,从数据库也要返回要查询的数据,哪怕是旧数据,如果连旧数据也没有则可以按照约定返回一个默认信息,但不能返回错误或响应超时。

分布式系统可用性的特点:

1、 所有请求都有响应,且不会出现响应超时或响应错误。

2.1.3. P - Partition tolerance

通常分布式系统的各个结点部署在不同的子网,这就是网络分区,不可避免的会出现由于网络问题而导致结点之间通信失败,此时仍可对外提供服务,这叫分区容忍性。

上图中,商品信息读写满足分区容忍性就是要实现如下目标:

1、主数据库向从数据库同步数据失败不影响读写操作。

2、其一个结点挂掉不影响另一个结点对外提供服务。

如何实现分区容忍性?

1、尽量使用异步取代同步操作,例如使用异步方式将数据从主数据库同步到从数据,这样结点之间能有效的实现松耦合。

2、添加从数据库结点,其中一个从结点挂掉其它从结点提供服务。

分布式分区容忍性的特点:

1、分区容忍性分是布式系统具备的基本能力

2.2. CAP 组合方式

1、上边商品管理的例子是否同时具备 CAP 呢?

在所有分布式事务场景中不会同时具备 CAP 三个特性,因为在具备了 P 的前提下 C 和 A 是不能共存的。

比如:

下图满足了 P 即表示实现分区容忍:

图片

本图分区容忍的含义是:

1)主数据库通过网络向从数据同步数据,可以认为主从数据库部署在不同的分区,通过网络进行交互。

2)当主数据库和从数据库之间的网络出现问题不影响主数据库和从数据库对外提供服务。

3)其一个结点挂掉不影响另一个结点对外提供服务。

如果要实现 C 则必须保证数据一致性,在数据同步的时候为防止向从数据库查询不一致的数据则需要将从数据库数据锁定,待同步完成后解锁,如果同步失败从数据库要返回错误信息或超时信息。

如果要实现 A 则必须保证数据可用性,不管任何时候都可以向从数据查询数据,则不会响应超时或返回错误信息。

通过分析发现在满足 P 的前提下 C 和 A 存在矛盾性,如下:

网络分区的情况下,主库的数据无法同步给从库,为了确保外面看到数据是一致的,此时从库不能让外部访问,只能让主库对外提供服务,从库失去了可用性。

网络分区的情况下,主库的数据无法同步给从库,此时 2 个库数据是不一致的,如果此允许 2 个库都可以对外提供服务(可用性),那么访问到的数据是不一致的。

所以 CAP 无法同时满足。

2、CAP 有哪些组合方式呢?

所以在生产中对分布式事务处理时要根据需求来确定满足 CAP 的哪两个方面。

1)AP:

放弃一致性,追求分区容忍性和可用性。这是很多分布式系统设计时的选择。

例如:

上边的商品管理,完全可以实现 AP,前提是只要用户可以接受所查询的到数据在一定时间内不是最新的即可。

通常实现 AP 都会保证最终一致性,后面讲的 BASE 理论就是根据 AP 来扩展的,一些业务场景 比如:订单退款,今日退款成功,明日账户到账,只要用户可以接受在一定时间内到账即可。

2)CP:

放弃可用性,追求一致性和分区容错性,我们的 zookeeper 其实就是追求的强一致。

3)CA:

放弃分区容忍性,即不进行分区,不考虑由于网络不通或结点挂掉的问题,则可以实现一致性和可用性。

那么系统将不是一个标准的分布式系统,我们最常用的关系型数据就满足了 CA。

上边的商品管理,如果要实现 CA 则架构如下:

图片

主数据库和从数据库中间不再进行数据同步,数据库可以响应每次的查询请求,通过事务隔离级别实现每个查询请求都可以返回最新的数据。

2.3. 总结

通过上面我们已经学习了 CAP 理论的相关知识,CAP 是一个已经被证实的理论:一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)这三项中的两项。它可以作为我们进行架构设计、技术选型的考量标准。对于多数大型互联网应用的场景,结点众多、部署分散,而且现在的集群规模越来越大,所以节点故障、网络故障是常态,而且要保证服务可用性达到 N 个 9(99.99..%),并要达到良好的响应性能来提高用户体验,因此一般都会做出如下选择:保证 P 和 A,舍弃 C 强一致,保证最终一致性

3. Base 理论

3.1. 理解强一致性和最终一致性

CAP 理论告诉我们一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)这三项中的两项,其中 AP 在实际应用中较多,AP 即舍弃一致性,保证可用性和分区容忍性,但是在实际生产中很多场景都要实现一致性,比如前边我们举的例子主数据库向从数据库同步数据,即使不要一致性,但是最终也要将数据同步成功来保证数据一致,这种一致性和 CAP 中的一致性不同,CAP 中的一致性要求在任何时间查询每个结点数据都必须一致,它强调的是强一致性,但是最终一致性是允许可以在一段时间内每个结点的数据不一致,但是经过一段时间每个结点的数据必须一致,它强调的是最终数据的一致性。

3.2.1. Base 理论介绍

BASE 是 Basically Available(基本可用)、Soft state(软状态)和 Eventually consistent (最终一致性)三个短语的缩写。BASE 理论是对 CAP 中 AP 的一个扩展,通过牺牲强一致性来获得可用性,当出现故障允许部分不可用但要保证核心功能可用,允许数据在一段时间内是不一致的,但最终达到一致状态。满足 BASE 理论的事务,我们称之为“柔性事务”。

3.2.2. 基本可用

分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。如,电商网站交易付款出现问题了,商品依然可以正常浏览。

3.2.3. 软状态

由于不要求强一致性,所以 BASE 允许系统中存在中间状态(也叫软状态),这个状态不影响系统可用性,如订单的"支付中"、“数据同步中”等状态,待数据最终一致后状态改为“成功”状态。

3.2.4. 最终一致

最终一致是指经过一段时间后,所有节点数据都将会达到一致。如订单的"支付中"状态,最终会变为“支付成功”或者"支付失败",使订单状态与实际交易结果达成一致,但需要一定时间的延迟、等待

4. 分布式事务常见 5 种解决方案

  1. 方案 1:2PC(二阶段提交)

  2. 方案 2:3PC(三阶段提交)

  3. 方案 3:TCC

  4. 方案 4:可靠消息

  5. 方案 5:最大努力通知型

下面依次来介绍这 5 种方案。

5. 方案 1:2PC(二阶段提交)

5.1. 什么是 2PC?

2PC 即两阶段提交,是将整个事务流程分为两个阶段,准备阶段(Prepare phase)、提交阶段(commit phase),2 是指两个阶段,P 是指准备阶段,C 是指提交阶段。

2PC 中主要的 2 个角色:

  1. 事务协调者

  2. 事务参与者

5.1.1. 准备阶段

事务协调者给每个事务参与者发送 prepare 消息,每个参在本地执行本地事务但是不要提交事务(此时事务操作的资源可能被锁定),然后给协调者返回 yes 或者 no 的消息。

5.1.2. 提交阶段

准备阶段中所有参与者返回 yes,此时事务协调者会给每个事务参与者发送 commit 消息,参与者接收到 commit 消息之后,会对本地事务执行提交操作。

若准备阶段中有参与者返回 no,或者参与者响应超时(比如网络原因,导致事务协调者和事务参与者之间通讯故障),此时事务协调者会给每个事务参与者发送 rollback 消息,参与者接收到 rollback 消息之后,会对本地事务执行回滚操作。

图片

5.1.3. 2pc 中的一些规则

  1. 阶段 2 commit 的条件:阶段 1 中所有的参与者返回 yes

  2. 阶段 2 rollback 的条件,2 种情况:阶段 1 中任意参与者返回 no 时,或者阶段 1 中任意参与者响应超时

  3. 当参与者 prepare 可以成功,那么给参与者发送 commit 也一定可以成功,发送 rollback 一定可以回滚

  4. 2PC 中事务协调者这边有超时机制,即在阶段 1 中,协调者给参与者发送消息,一直没有回应,导致超时,此时,直接执行第二阶段 rollback;而协调者这边并没有超时机器,比如所有参与者阶段 1 执行完毕了,然后协调者挂了,此时参与者只能一直等了,干等。

5.1.4. 2PC 存在的问题

  1. 当阶段一都执行完毕之后,参与者本地事务执行了但是还未提交,此时参与者本地事务中的资源处于锁定状态的,若此时协调者挂了,会导致参与者本地事务锁住的资源无法释放,而直接影响到其他业务的执行。

    比如参与者 1 中去对商品 1 减库存,商品 1 的库存记录会被上锁,若此时其他业务也需要修改这条记录,直接会被阻塞,导致无法执行。

  2. 2PC 有性能问题:比如事务中有 10 个参与者,参与者 1 在阶段 1 中会锁定本地资源,然后等待其他 9 个参与者执行完毕阶段一,然后参与者 1 收到事务协调器发送的 commit 或者 rollback 之后,才会释放资源,参与者 1 需要等待 9 个参与者,导致锁定资源的时间太长,会影响系统的并发量。

  3. 协调者有单点故障:当阶段 1 执行完毕之后,协调者挂了,此时参与者懵了,只能一直等待,这个可以通过协调者高可用来解决,后面讲到的 3pc 中解决了这个问题。

  4. 事务不一致的问题:阶段 2 中部分参与者收到了 commit 信息,此时协调者挂了或者网络问题,导致其他协调者无法收到 commit 请求,这个过程中,多个协调者中数据是不一致的,解决方式:协调者、参与者要高可用,协调者支持 2PC 重试,2PC 中的 2 个阶段需要支持幂等。

5.2. XA 事务

XA(eXtended Architecture)是指由 X/Open 组织提出的分布式交易处理的规范。XA 是一个分布式事务协议,由 Tuxedo 提出,所以分布式事务也称为 XA 事务。

XA 协议主要定义了事务管理器 TM(Transaction Manager,协调者)和资源管理器 RM(Resource Manager,参与者)之间的接口。

其中,资源管理器往往由数据库实现,如 Oracle、DB2、MySQL,这些商业数据库都实现了 XA 接口,而事务管理器作为全局的调度者,负责各个本地资源的提交和回滚。

XA 事务是基于两阶段提交(Two-phaseCommit,2PC)协议实现的,可以保证数据的一致性,许多分布式关系型数据管理系统都采用此协议来完成分布式。阶段一为准备阶段,即所有的参与者准备执行事务并锁住需要的资源。当参与者 Ready 时,向 TM 汇报自己已经准备好。阶段二为提交阶段。当 TM 确认所有参与者都 Ready 后,向所有参与者发送 COMMIT 命令。

说的简单点:XA 就是 2PC 在数据中的一种实现。

mysql 大家都用过,普通事务过程:

start transaction; //打开事务
执行事务操作
commit|rollback; // 提交或者回滚事务

上面事务操作中,若当前连接未发送 commit 或者 rollback 操作,此时连接断掉或者 mysql 重启了,上面的事务会被自动回滚。

mysql 中 xa 的语法:

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事务

如:

xa start 'xa-1';
执行事务操作;
xa prepare 'xa-1'; //阶段1,此时事务操作的资源被锁住,事务未提交
xa commit | rollback;//阶段2

xa 事务和普通事务有点区别,上面这个 xa 事务有个标识xa-1,当xa-1prepare 之后,如果此时连接断掉或者 mysql 重启了,这个事务还是处于prepare阶段,mysql 重启或者调用者重新连接 mysql 之后,可以拿着这个事务标识xa-1继续发送xa commit |rollback来结束这个事务。

大家可以在 mysql 中创建几个 db,然后通过上面的 xa 脚本试试两阶段提交,感受一下过程。

5.3. XA 中事务协调器设计要点

XA 中,事务参与者,比如常见的一些 db,已经实现了 2PC 的功能,但是协调器需要自己来开发,协调器的一些设计要点:

  1. 生成全局唯一 XA 事务 id 记录,并且记录下来

  2. 事务协调器需要有重试的功能,对于中间阶段操作异常的,通过不断的重试让事务最终能够完成

  3. 协调器会有重试操作,所以需确保 2pc 中每个阶段都是幂等的

5.4. 2PC 解决方案

  1. Seata:Seata 是由阿里中间件团队发起的开源项目 Fescar,后更名为 Seata,它是一个是开源的分布式事务框架,这个框架支持 2PC。

  2. atomikos+jta:jta 是 java 中对分布式事务制定的接口规范,atomikos 是 jta 的一种实现,内部是依靠 XA 的方式来实现的,如果事务参与者都自测 XA 事务,可以通过这种方式来解决,比如参与者是:mysql、oracle、sqlserver,可以使用采用这种方式;不过性能方面是值得大家考虑的一个问题。

  3. 开发者自己实现 :大家对 2pc 过程了解之后,可以自己开发一个,可以去挑战一下。

6. 方案 2:3PC(三阶段提交)

6.1. 回顾 2PC

举个例子,A 邀请 B、C 一起打王者荣耀,2PC 过程如下:

A 是协调者,B、C 是参与者。

6.1.1. 阶段 1(prepare 阶段)

(1)、step1-1:A 微信 B

step1-1-1:A->B:有空么,我们约C一起王者荣耀
step1-1-2:B->A:有空
step1-1-3:A->B:那你现在就打开电脑,登录王者荣耀,你等着,我去通知C,然后开个房间
step1-1-4:B->A:已登录

(2)、step1-2:A 微信 C

step1-2-1:A->C:有空么,我约了B一起王者荣耀
step1-2-2:C->A:有空
step1-2-3:A->C:那你现在就打开电脑,登录王者荣耀,你等着,我去开个房间
step1-2-4:C->A:已登录

6.1.2. 阶段 2(commit 阶段)

此时 B、C 都已经登录王者容易了,然后 A 登录王者荣耀开了个房间

(1)、step2-1:A 微信 B

step2-1-1:A->B:房间号是xxx,你可以进来了
step2-1-2:B->A:我的,我进来了

(2)、step2-2:A 微信 C

step2-2-1:A->C:房间号是xxx,你可以进来了
step2-2-2:C->A:我的,我进来了

然后 3 个人开始爽歪歪了。

6.1.3. 2PC 一些异常情况

(1)、情况 1:step1-2-4 超时,导致 A 无法收到 C 已登录的消息

此时 A 不知道 C 是什么情况,但是 2PC 中协调者这边有超时机制,如果协调者给参与者发送信息,长时间得不到回应时,将作为失败处理,此时 A 会给 B 和 C 发送 rollback 消息,让 B 和 C 都进行回滚,即取消游戏。

(2)、情况 2:step1-1 之后,协调者 A 挂了

此时 B 已经打开电脑在那等着了,却始终不见 A、C 的踪影,相当苦恼,也不知道还要等多久,苦逼!

(3)、情况 3:阶段 1 之后,协调者 A 挂了

此时 B、C 登录账号了,也等了十几分钟了,就是不见 A 的踪影,也只能干等着,什么事情也做不了。

(4)、情况 4:step2-2-1 出现问题,C 网络故障

此时 C 收不到 A 发送过来的消息,结果是导致 A 和 B 都已经进入房间了,就缺 C 了,游戏无法正常开始,导致最终的结果和期望的结果无法一致(期望 3 个人一起玩游戏,实际上房间里只有 2 个人)

6.1.4. 总的来说,2PC 主要有 2 个问题

参与者干等的问题

参与者只能按照协调者的指令办事,当收不到协调者的指令的时候,参与者只能坐等,在 db 中的效果,操作的数据会被一直锁着,导致其他操作者被阻塞。

数据不一致的问题

commit 阶段,协调者或者参与者挂掉,都可能导致最终数据不一致的问题。

6.2. 3PC

3PC 主要解决了 2PC 中 commit 阶段参与者干等的问题,2PC 中 commit 阶段,若协调者挂了,参与者不知道如何走了。2PC 中只有协调者这边有超时机制,而 3PC 中,协调者和参与者这边引入了超时机制,commit 阶段,若参与超过一定的时间收不到 commit 命令,参与者会自动提交,从而解决了 2PC 中资源长时间被锁的问题。

3PC 相对于 2PC,多了一个阶段,相当于把 2PC 的准备阶段再次一分为二,这样三阶段提交就有CanCommitPreCommitDoCommit三个阶段。

6.2.1. 阶段 1:CanCommit 阶段

之前 2PC 的一阶段是本地事务执行结束后,最后不 Commit,等其它服务都执行结束并返回 Yes,由协调者发出 commit 才真正执行 commit,而这里的 CanCommit 指的是 尝试获取数据库锁 如果可以,就返回 Yes。

这阶段主要分为 2 步

事务询问:协调者向参与者发送 CanCommit 请求。询问是否可以执行事务提交操作。然后开始等待参与的响应。

响应反馈:参与者接到 CanCommit 请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回 Yes 响应,并进入预备状态。否则反馈 No,然后事务就结束了,此时参与者并没有执行任务任何操作。

6.2.2. 阶段 2:PreCommit 阶段

在阶段一中,如果所有的参与者都返回 Yes 的话,那么就会进入 PreCommit 阶段进行事务预提交。这里的PreCommit 阶段 跟上面的第一阶段是差不多的,只不过这里 协调者和参与者都引入了超时机制(2PC 中只有协调者可以超时,参与者没有超时机制)。

6.2.3. 阶段 3:DoCommit 阶段

这里跟 2pc 的阶段二是差不多的。

6.3. 王者荣耀 3PC 过程

6.3.1. 正常的过程

(1)、阶段 1(CanCommit 阶段)

step1-1:A 微信 B
step1-1-1:A->B:有空么,我们约C一起王者荣耀
step1-1-2:B->A:有空
step1-2:A 微信 C
step1-1-1:A->B:有空么,我们约B一起王者荣耀
step1-1-2:B->A:有空

(2)、阶段 2(PreCommit 阶段)

step2-1:A 微信 B
step2-1-1:A->B:你现在就打开电脑,登录王者荣耀,等我消息,如果10分钟没消息,你就自己开个房间玩吧(参与者超时机制)。
step2-1-2:B->A:已登录
step2-2:A 微信 C
step2-2-1:A->C:那你现在就打开电脑,登录王者荣耀,等我消息,如果10分钟没消息,你就自己开个房间玩吧(参与者超时机制)。
step2-2-2:C->A:已登录

(3)、阶段 3(DoCommit 阶段)

此时 B、C 都已经登录王者容易了,然后 A 登录王者荣耀开了个房间

step3-1:A 微信 B
step3-1-1:A->B:房间号是xxx,你可以进来了
step3-1-2:B->A:我的,我进来了
step3-2:A 微信 C
step3-2-1:A->C:房间号是xxx,你可以进来了
step3-2-2:C->A:我的,我进来了

然后 3 个人开始爽歪歪了。

6.3.2. 异常的几种情况

(1)、阶段 1 异常

此时并没有进行事务操作,所以这个阶段出问题了,可以直接结束事务。

(2)、阶段 2,参与者挂了

参与者挂了没关系,协调者直接通知其他参与者回滚。

(3)、阶段 2,协调者挂了

协调者挂了,由于参与者引入了超时机制,所以参与者并不会无限期等待,等待一定的时间之后,会自动提交本地事务。

虽然这个超时机制解决了无限等待的问题,却并没有解决一致性的问题,比如上面 3PC 中step2-1:A微信B之后,协调者挂了,此时 A 已经登录了,但是 C 未收到 A 要求登录的消息,超时 10 分钟之后,A 自己去开了一个游戏玩起来了,结果和期望的结果不一致了。

6.4. 3PC 存在的问题

虽然解决了 2PC 中参与者长时间阻塞的问题(资源长时间无法释放的问题),但是并没有解决一致性的问题。

有没有办法解决这些问题?

有,TCC,接下来,我们来看 TCC。

7. 方案 3:TCC

7.1. 什么是 TCC?

分布式事务中的几个角色

  • TM:事务管理器,可以理解为分布式事务的发起者

  • 分支事务:事务中的多个参与者,可以理解为一个个独立的事务。

TCC 是 Try、Confirm、Cancel 三个词语的缩写,TCC 要求每个分支事务实现三个操作:预处理 Try、确认 Confirm、撤销 Cancel。

Try 操作做业务检查及资源预留,Confirm 做业务确认操作,Cancel 实现一个与 Try 相反的操作即回滚操作。

TM 首先发起所有的分支事务的 try 操作,任何一个分支事务的 try 操作执行失败,TM 将会发起所有分支事务的 Cancel 操作,若 try 操作全部成功,TM 将会发起所有分支事务的 Confirm 操作,其中 Confirm/Cancel 操作若执行失败,TM 会进行重试。

7.1.1. 正常流程

try 阶段:依次调用参与者的 try 方法,都返回成功

confirm 阶段:依次调用参与者的 confirm 方法,都返回成功

事务完成。

图片

7.1.2. 异常流程

try 阶段:依次调用参与者的 try 方法,前面 2 个参与者 try 方法返回 yes,而参与者 3 返回 no

cancel 阶段:对已经成功的参与者执行 cancel 操作,注意了:cancel 阶段参与者调用的顺序和 try 阶段参与者的顺序相反,即先调用参与者 2 的 cancel,然后调用参与者 1 的 cancel

图片

7.2. TCC 场景案例

7.2.1. 案例 1:跨库转账

举例,场景为 A 转账 100 元给 B,A 和 B 账户在不同的服务。

账户A
try:
 try幂等校验
 检查余额是否够100元
 A账户扣减100元
confirm:
 空
cancel:
 cancel幂等校验
 A账户增加可用余额100元

账户B
try:
 空
confirm:
 confirm幂等校验
 B账户增加100元
cancel:
 空

7.2.2. 案例 2:提现到支付宝

举例,大家玩过抖音,有些朋友抖音上面有收益,可以将收益提现到支付宝,假如提现 100 到支付宝

抖音(账户表:余额、冻结金额)
try:
 try幂等校验
 检查余额是否够100元
 抖音账户表余额-100,冻结金额+100
confirm:
 confirm幂等校验
 抖音账户冻结金额-100
cancel:
 cancel幂等校验
 抖音账户表余额+100,冻结金额-100

账户B
try:
 空
confirm:
 confirm幂等校验
 调用支付宝打款接口,打款100元(对于商户同一笔订单支付宝接口是支持幂等的)
cancel:
 空

7.3. TCC 常见框架

框架名称 github 地址 star 数量
tcc-transaction https://github.com/changmingxie/tcc-transaction 4750
hmily https://github.com/Dromara/hmily 2900
ByteTCC https://github.com/liuyangming/ByteTCC 2450
EasyTransaction https://github.com/QNJR-GROUP/EasyTransaction 2100

7.4. 自研 TCC 框架设计思路

7.4.1. 涉及到的角色(事务发起者、事务参与者、TCC 服务)

(1)、事务发起者(TM)

  • 发起分布式事务:调用 tcc 服务注册一个分布式事务订单

  • 调用分支:依次调用每个分支

  • 上报结果:最终将事务所有分支的执行结果汇报给 TCC 服务

  • 提供补偿接口:给 TCC 服务使用,tcc 服务会调用这个补偿接口对进行补偿操作

(2)、事务参与者

  • 提供 3 个方法:try、confirm、cancel

  • 确保 3 个方法的幂等性

  • 3 个方法返回的结果状态码只有 3 种(成功、失败、处理中),处理中相当于状态未知,对于状态未知的,会在补偿的过程中进行重试

(3)、TCC 服务

  • 是一个独立的服务

  • 提供分布式事务订单注册接口:给事务发起者使用【事务发起者调用 tcc 服务生成一个分布式事务订单(订单状态:0:处理中,1:处理成功,2:处理失败),获取一个分布式订单 id:TID】

  • 提供分布式事务结果上报接口:给事务发起者使用【事务发起者在事务的执行过程中将事务的执行结果汇报给 TCC 服务】

  • 提供事务补偿操作:启动一个 job 轮询 tcc 订单中状态为 1 的订单,继续调用事务发起者进行补偿,最终经过多次补偿,这个订单最终的状态应该为 1(成功)或者 2(失败);否则人工介入进行处理

7.4.2. 时序图

](img/5.jpg)

7.4.3. 自研 TCC 框架技术要点

(1)、框架应该考虑的地方

开发者应该只用关注分支中 3 个方法的代码,其他的应该全部交由框架去完成。

(2)、tcc 服务中的事务订单表设计

  • id:订单 id

  • bus_order_id:业务方订单 id

  • bus_order_type:业务类型 (bus_order_id & bus_order_type 需唯一)

  • request_data:业务请求数据,json 格式存储,包含了玩转的业务方请求数据

  • status:状态,0:处理中,100:处理成功,200:处理失败,初始状态为 0,最终必须为 100 或者 200

(3)、关于分支中 3 个方法幂等的设计

以 java 中的 spring 为例,可以通过拦截器来实现,拦截器对分支的 3 个方法进行拦截,拦截器中实现幂等性的操作。

可以用一张表来实现【分支方法执行记录表:tid、分支、方法(try、confirm、cancel)、状态(0:处理中;100:成功;200:失败)、request_json(请求参数)、response_json(响应参数)】

关于请求参数:这个用来记录整个方法请求的完整参数,内部包含了业务参数,可以采用 json 格式存储。

响应参数:分支方法的执行结果,以 json 格式存储。

拦截器中,通过分支 & 方法 这 2 个条件去查询分支方法执行记录表,如果查询的记录状态为 100 或者 200,那么直接将 response_json 返回。

(4)、try 阶段同步、其他阶段异步

如果 try 阶段全部成功,那么 confirm 阶段最终应该一定是成功的,try 阶段如果有失败的,那么需要执行 cancel,最终所有的 cancel 应该也是一定可以成功的;所以 try 阶段完成之后,其实已经知道最终的结果了,所以 try 阶段完成之后,后面的 confirm 或者 cancel 可以采用异步的方式去执行;提升系统整体的性能。

(5)、异步上报事务执行结果

发起方将所有分支每个步骤的执行结果及最终事务的执行结果上报给 tcc 服务,由 tcc 服务落库,方便运营人员查看事务执行结果以及排错。

(6)、关于补偿

tcc 服务中添加一个补偿 job,定时轮询 tcc 分布式订单表,将状态为处理中的记录撸出来,订单表 request_data 包含了请求参数,使用 request_data 去调用事务发起者提供的补偿接口进行补偿操作,直到订单的状态为最终状态(成功或者失败)。

补偿采用衰减的形式,对应同一笔订单采用时间间隔衰减的方式补偿,每次间隔时间:10s、20s、40s、80s、160s、320s。。。

(7)、人工干预

tcc 分布式订单如果长期处于处理中,经过了很多次的补偿,也未能到达最终状态,此时可能业务有问题,需要人工进行补偿,对于这对订单记录需要有监控系统进行报警,提醒开发者进行干预处理。

7.5. 小结

如果拿 TCC 事务的处理流程与 2PC 两阶段提交做比较,2PC 通常都是在跨库的 DB 层面,而 TCC 则在应用层面的处理,是 2PC 在应用层面的一种实现,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据操作的粒度,使得降低锁冲突、提高吞吐量成为可能。而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现 try、confirm、cancel 三个操作,代码量比较大。

8. 方案 4:可靠消息

8.1. 什么是可靠消息最终一致性?

可靠消息最终一致性方案是指当事务发起方执行完成本地事务后并发出一条消息,事务参与方(消息消费者)一定能够接收消息并处理事务成功,此方案强调的是只要消息发给事务参与方最终事务要达到一致。

这里面有 2 个重点:

  1. 消息发送方本地事物执行成功之后,消息一定会投递成功

  2. 消息消费者最终也一定能够消费此消息,最终使分布式事务最终达成一致性

8.2. 业务场景:下单送积分

电商中有这样的一个场景:商品下单之后,需给用户送积分,订单表和积分表分别在不同的 db 中,涉及到分布式事务的问题。

我们通过可靠消息来解决这个问题:

  1. 商品下单成功之后送积分的操作,我们使用 mq 来实现

  2. 商品下单成功之后,投递一条消息到 mq,积分系统消费消息,给用户增加积分

我们主要讨论一下,商品下单及投递消息到 mq 的操作,如何实现?每种方式优缺点?

8.3. 投递消息过程:方式一

8.3.1. 过程

  • step1:开启本地事务

  • step2:生成购物订单

  • step3:投递消息到 mq

  • step4:提交本地事务

这种方式是将发送消息放在了事务提交之前。

8.3.2. 可能存在的问题

  • step3 发生异常:导致 step4 失败,商品下单失败,直接影响到商品下单业务

  • step4 发生异常,其他 step 成功:商品下单失败,消息投递成功,给用户增加了积分

8.4. 投递消息过程:方式二

下面我们换种方式,我们将发送消息放到事务之后进行。

8.4.1. 过程

  • step1:开启本地事务

  • step2:生成购物订单

  • step3:提交本地事务

  • step4:投递消息到 mq

8.4.2. 可能会出现的问题

step4 发生异常,其他 step 成功:导致商品下单成功,投递消息失败,用户未增加积分

上面两种是比较常见的做法,也是最容易出错的。

8.5. 投递消息过程:方式三

  • step1:开启本地事务

  • step2:生成购物订单

  • step3:本地库中插入一条需要发送消息的记录 t_msg_record

  • step3:提交本地事务

  • step5:新增一个定时器,轮询 t_msg_record,将待发送的记录投递到 mq 中

这种方式借助了数据库的事务,业务和消息记录作为了一个原子操作,业务成功之后,消息日志必定是存在的。解决了前两种方式遇到的问题。如果我们的业务系统比较单一,可以采用这种方式。

对于微服务化的情况,上面这种方式不是太好,每个服务都需要上面的操作;也不利于扩展。

8.6. 投递消息过程:方式四

增加一个消息服务消息库,负责消息的落库、将消息发送投递到 mq。

  • step1:开启本地事务

  • step2:生成购物订单

  • step3:当前事务库插入一条日志:生成一个唯一的业务 id(bus_id),将 bus_id 和订单关联起来保存到当前事务所在的库中

  • step4:调用消息服务:携带 bus_id,将消息先落地入库,此时消息的状态为待发送状态,返回消息 id(msg_id)

  • step5:提交本地事务

  • step6:如果上面都成功,调用消息服务,将消息投递到 mq 中;如果上面有失败的情况,则调用消息服务取消消息的发送

能想到上面这种方式,已经算是有很大进步了,我们继续分析一下可能存在的问题:

  1. 系统中增加了一个消息服务,商品下单操作依赖于该服务,业务对该服务依赖性比较高,当消息服务不可用时,整个业务将不可用。

  2. 若 step6 失败,消息将处于待发送状态,此时业务方需要提供一个回查接口(通过 bus_id 查询),验证业务是否执行成功;消息服务需新增一个定时任务,对于状态为待发送状态的消息做补偿处理,检查一下业务是否处理成功;从而确定消息是投递还是取消发送

  3. step4 依赖于消息服务,如果消息服务性能不佳,会导致当前业务的事务提交时间延长,容易产生死锁,并导致并发性能降低。我们通常是比较忌讳在事务中做远程调用处理的,远程调用的性能和时间往往不可控,会导致当前事务变为一个大事务,从而引发其他故障。

8.7. 投递消息过程:方式五

在以上方式中,我们继续改进,进而出现了更好的一种方式:

  • step1:生成一个全局唯一业务消息 id(bus_msg_id),调用消息服务,携带 bus_msg_id,将消息先落地入库,此时消息的状态为待发送状态,返回消息 id(msg_id)

  • step2:开启本地事务

  • step3:生成购物订单

  • step4:当前事务库插入一条日志(将 step3 中的业务和 bus_msg_id 关联起来)

  • step5:提交本地事务

  • step6:分 2 种情况:如果上面都成功,调用消息服务,将消息投递到 mq 中;如果上面有失败的情况,则调用消息服务取消消息的发送

若 step6 失败,消息将处于待发送状态,此时业务方需要提供一个回查接口(通过 bus_msg_id 查询),验证业务是否执行成功;

消息服务需新增一个定时任务,对于状态为待发送状态的消息做补偿处理,检查一下业务是否处理成功;从而确定消息是投递还是取消发送。

方式五和方式四对比,比较好的一个地方:将调用消息服务,消息落地操作,放在了事务之外进行,这点小的改进其实算是一个非常好的优化,减少了本地事务的执行时间,从而可以提升并发量,阿里有个消息中间件RocketMQ就支持方式 5 这种,大家可以去用用。

图片

8.8. 关于消息消费的一些问题

如何解决重复消费的问题?

消费者轮询从 mq server 中拉取消息,然后进行消费。

消息消费者消费消息的过程

  • step1:从 mq 中拉取消息

  • step2:执行本地业务,比如增加积分操作

  • step3:消费完毕之后,将消息从 mq 中删掉

当 step2 成功,step3 失败之后,这个消息会再次从 mq 中拉取出来,会出现重复消费的问题,所以我们需要考虑消费的幂等性,同一条消息多次消费和一次消费产生的结果应该是一致的,关于幂等性是另外一个课题,下次会详说。

9. 方案 5:最大努力通知型

9.1. 支付宝充值案例

假如我们自己有一个电商系统,支持用户使用支付宝充值,流程如下:

图片

9.2. 用户支付流程(是一个同步的过程)

  1. 用户在浏览器发起充值请求->电商服务

  2. 电商服务生成充值订单,状态为 0:待支付(0:待支付、100:支付成功、200:支付失败)

  3. 电商服务携带订单信息请求支付宝,生成支付宝订单,组装支付宝支付请求地址(订单信息、支付成功之后展示给用户的页面 return_url、支付异步通知地址 notify_url),将组装的信息返回给用户

  4. 用户浏览器跳转至支付宝支付页面,确认支付

  5. 支付宝携带支付结果同步回调 return_url,return_url 将支付结果展示给用户

9.3. 支付宝将支付结果异步通知给商户

用户支付流程完毕之后,此时支付宝中支付订单已经支付完毕,但电商中的充值订单状态还是 0(待支付),此时支付宝会通过异步的方式将支付结果通知给 notify_url,通知的过程中可能由于网络问题,导致支付宝通知失败,此时支付宝会通过多次衰减式的重试,尽最大努力将结果通知给商户,这个过程就是最大努力通知型。

商户接收到支付宝通知之后,通过幂等性的方式对本地订单进行处理,然后告知支付宝,处理成功,之后支付宝将不再通知。

9.4. 什么是衰减式的通知?

比如支付宝最大会尝试通知 100 次,每次通知时间间隔会递增。比如第 1 次失败之后,隔 10s 进行第 2 次通知,第 2 次失败之后,隔 30s 进行第三次通知,间隔时间依次递增的方式进行通知。

9.5. 如果支付宝一直通知不成功怎么办?

商户可以主动去调用支付宝的查询接口,查询订单的支付状态。

9.6. 为什么需要进行异步通知?

用户支付过程中,不是有个 return_url 么?支付宝支付成功之后会携带支付结果同步调用这个地址,那么商户直接在这个 return_url 中去处理一下本地订单状态不就可以了么?这种做法可以,但是有可能用户的网络不好,调用 return_url 失败了,此时还得依靠异步通知 notify_url 的方式将支付结果告知商户。

9.7. 最大努力通知型用在什么场景?

分布式事务中,不能立即知道调用结果的,被调方业务处理耗时可能比较长,被调方业务处理完毕之后,可以采用最大努力通知的方式将结果通知给调用方。

9.8. 最大努力通知型要有补偿机制

被调方会尽最大努力将结果通知给调用方,极端情况下有失败的可能,此时被调方需提供查询接口。

调用方对于长时间不知道结果的业务,可以主动去被调方查询,然后进行处理。

9.9. 不需要通知,主动去查可以么?

可以,被调方会提供查询接口,调用方主动去查询的方式完全是可以知道结果的,不过采用通知的方式实时性更高的一些。

被调方成功之后,会立即通知调用方,但是调用方主动采用查询的方式,那么什么时候查询呢?这个度不好把握,所以两则结合更好。

10. 分布式事务对比分析

在学习各种分布式事务的解决方案后,我们了解到各种方案的优缺点:

2PC最大的诟病是一个阻塞协议。RM 在执行分支事务后需要等待 TM 的决定,此时服务会阻塞并锁定资源。由于其阻塞机制和最差时间复杂度高, 因此,这种设计不能适应随着事务涉及的服务数量增加而扩展的需要,很难用于并发较高以及子事务生命周期较长 (long-running transactions) 的分布式服务中。

如果拿TCC事务的处理流程与 2PC 两阶段提交做比较,2PC 通常都是在跨库的 DB 层面,而 TCC 则在应用层面的处理,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据操作的粒度,使得降低锁冲突、提高吞吐量成为可能。而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现 try、confirm、cancel 三个操作。此外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。典型的使用场景:满,登录送优惠券等。

可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景。引入消息机制后,同步的事务操作变为基于消息执行的异步操作, 避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。典型的使用场景:注册送积分,登录送优惠券等。

最大努力通知是分布式事务中要求最低的一种,适用于一些最终一致性时间敏感度低的业务;允许发起通知方处理业务失败,在接收通知方收到通知后积极进行失败处理,无论发起通知方如何处理结果都会不影响到接收通知方的后续处理;发起通知方需提供查询执行情况接口,用于接收通知方校对结果。典型的使用场景:银行通知、支付结果通知等。

2PC TCC 可靠消息 最大努力通知
一致性 强一致性 最终一致 最终一致 最终一致
吞吐量
实现复杂度

11. 总结

在条件允许的情况下,我们尽可能选择本地事务单数据源,因为它减少了网络交互带来的性能损耗,且避免了数据弱一致性带来的种种问题。若某系统频繁且不合理的使用分布式事务,应首先从整体设计角度观察服务的拆分是否合理,是否高内聚低耦合?是否粒度太小?分布式事务一直是业界难题,因为网络的不确定性,而且我们习惯于拿分布式事务与单机事务 ACID 做对比。

无论是数据库层的 XA、还是应用层 TCC、可靠消息、最大努力通知等方案,都没有完美解决分布式事务问题,它们不过是各自在性能、一致性、可用性等方面做取舍,寻求某些场景偏好下的权衡。

猜你喜欢

转载自blog.csdn.net/Javatutouhouduan/article/details/131895386