20 张图搞懂「分布式事务」 | 🏆 技术专题第五期征文

每一个时代,都不会亏待会学习的人。算法

你们好,我是 yes。数据库

今天我想和你们一块儿盘一盘分布式事务,会介绍常见的分布式事务实现方案和其优缺点以及适用的场景,并会带出它们的一些变体实现。markdown

还会捎带一下分布式数据库对 2PC 的改进模型,看看分布式数据库是如何作的。网络

而后再分析一波分布式事务框架 Seata 的具体实现,看看分布式事务到底是如何落地的,毕竟协议要落地才是有用的。架构

首先咱们来提一下事务和分布式事务是什么。框架

事务

事务的 ACID 想必你们都熟知,这实际上是严格意义上的定义,指的是事务的实现必须具有原子性、一致性、隔离性和持久性。运维

不过严格意义上的事务很难达到,像咱们熟知的数据库就有各类隔离级别,隔离级别越高性能越低,因此每每咱们都会从中找到属于本身的平衡,不会遵循严格意义上的事务异步

而且在咱们平日的谈论中,所谓的事务每每简单的指代一系列的操做所有执行成功,或者所有失败,不会出现一些成功一些失败的情形。分布式

清晰了平日咱们对事务的定义以后,再来看看什么是分布式事务。微服务

分布式事务

因为互联网的快速发展,以往的单体架构顶不住这么多的需求,这么复杂的业务,这么大的流量。

单体架构的优点在于前期快速搭建、快速上线,而且方法和模块之间都是内部调用,没有网络的开销更加的高效。

从某方面来讲部署也方便,毕竟就一个包,扔上去。

不过随着企业的发展,业务的复杂度愈来愈高,内部耦合极其严重,致使牵一发而动全身,开发不易,测试不易。

而且没法根据热点服务进行动态的伸缩,好比商品服务访问量特别大,若是是单体架构的话咱们只能把整个应用复制多份集群部署,浪费资源。

所以拆分势在必行,微服务架构就这么来了。

拆分以后服务之间的边界就清晰了,每一个服务都能独立地运行,独立地部署,因此能以服务级别弹性伸缩了。

服务之间的本地调用变成了远程调用,链路更长了,一次调用的耗时更长了,可是整体的吞吐量更大了。

不过拆分以后还会引入其余复杂度,好比服务链路的监控、总体的监控、容错措施、弹性伸缩等等运维监控的问题,还有像分布式事务、分布式锁跟业务息息相关的问题等。

每每解决了一个痛点又会引入别的痛点,因此架构的演进都是权衡的结果,就看大家的系统更能忍受哪一种痛点了。

而今天咱们谈及的就是分布式事务这个痛点。

分布式事务是由多个本地事务组成的,分布式事务跨越了多设备,之间又经历的复杂的网络,可想而知想要实现严格的事务道路阻且长。

单机版事务都不会严格遵照事务的严格实现,更别说分布式事务了,因此在现实状况下咱们只能实现残缺版的事务。

在明确了事务和分布式事务以后,咱们就先来看看常见的分布式事务方案:2PC、3PC、TCC、本地消息、事务消息。

2PC

2PC,Two-phase commit protocol,即两阶段提交协议。 它引入了一个事务协调者角色,来管理各个参与者(就是各数据库资源)。

总体分为两个阶段,分别是准备阶段和提交/回滚阶段。

咱们先来看看第一个阶段,即准备阶段。

由事务协调者给每一个参与者发送准备命令,每一个参与者收到命令以后会执行相关事务操做,你能够认为除了事务的提交啥都作了。

而后每一个参与者会返回响应告知协调者本身是否准备成功。

协调者收到每一个参与者的响应以后就进入第二阶段,根据收集的响应,若是有一个参与者响应准备失败那么就向全部参与者发送回滚命令,反之发送提交命令。

这个协议其实很符合正常的思惟,就像咱们大学上课点名的时候,其实老师就是协调者的角色,咱们都是参与者。

老师一个一个的点名,咱们一个一个的喊到,最后老师收到全部同窗的到以后就开始了今天的讲课。

而和点名有所不一样的是,老师发现某几个学生不在仍是能继续上课,而咱们的事务可不容许这样

事务协调者在第一阶段未收到个别参与者的响应,则等待必定时间就会认为事务失败,会发送回滚命令,因此在 2PC 中事务协调者有超时机制。

咱们再来分析一下 2PC 的优缺点。

2PC 的优势是能利用数据库自身的功能进行本地事务的提交和回滚,也就是说提交和回滚实际操做不须要咱们实现,不侵入业务逻辑由数据库完成,在以后讲解 TCC 以后相信你们对这点会有所体会。

2PC 主要有三大缺点:同步阻塞、单点故障和数据不一致问题。

同步阻塞

能够看到在第一阶段执行了准备命令后,咱们每一个本地资源都处于锁定状态,由于除了事务的提交以外啥都作了。

因此这时候若是本地的其余请求要访问同一个资源,好比要修改商品表 id 等于 100 的那条数据,那么此时是被阻塞住的,必须等待前面事务的完结,收到提交/回滚命令执行完释放资源后,这个请求才能得以继续。

因此假设这个分布式事务涉及到不少参与者,而后有些参与者处理又特别复杂,特别慢,那么那些处理快的节点也得等着,因此说效率有点低。

单点故障

能够看到这个单点就是协调者,若是协调者挂了整个事务就执行不下去了

若是协调者在发送准备命令前挂了还行,毕竟每一个资源都还未执行命令,那么资源是没被锁定的。

可怕的是在发送完准备命令以后挂了,这时候每一个本地资源都执行完处于锁定状态了,都杵着了,这就很僵硬了,若是是某个热点资源都阻塞了,这估计就要GG了。

数据不一致问题

由于协调者和参与者之间的交流是通过网络的,而网络有时候就会抽风的或者发生局部网络异常。

那么就有可能致使某些参与者没法收到协调者的请求,而某些收到了。好比是提交请求,而后那些收到命令的参与者就提交事务了,此时就产生了数据不一致的问题。

小结一下 2PC

至此咱们来先小结一些 2PC ,它是一个同步阻塞的强一致性两阶段提交协议,分别是准备阶段和提交/回滚阶段。

2PC 的优点在于对业务没有侵入,能够利用数据库自身机制来进行事务的提交和回滚。

它的缺点:是一个同步阻塞协议,会致使高延迟和性能的降低,而且存在协调者单点故障问题,极端状况下会有数据不一致的问题。

固然这只是协议,具体的落地仍是能够变通了,好比协调者单点问题,我就搞个主历来实现协调者,对吧。

分布式数据库的 2PC 改进模型

可能有些人对分布式数据库不熟悉,没有关系,咱们主要学的是思想,看看人家的思路。

我简单的讲下 Percolator 模型,它是基于分布式存储系统 BigTable 创建的模型,BigTable 是啥也不清楚的同窗没有关系影响不大。

仍是拿转帐的例子来讲,我如今有 200 块钱,你如今有 100 块钱,为了突出重点我也不按正常的结构来画这个表。

而后我要转 100 块给你。

此时事务管理器发起了准备请求,而后我帐上的钱就少了,你帐上的钱就多了,并且事务管理器还记录下此次操做的日志

此时的数据仍是私有版本,别的事务是读不到的,简单的理解 Lock 上有值就仍是私有的。

能够看到个人记录 Lock 标记的是 PK,你的记录标记的是指向个人记录指针,这个 PK 是随机选择的。

而后事务管理器会向被选择做为 PK 的那条记录发起提交指令。

此时就会把个人记录的锁给抹去了,这等于个人记录再也不是私有版本了,别的事务就都能访问了。

那你的记录上还有锁啊?不用更新吗?

嘿嘿不须要及时更新,由于访问你的这条记录的时候会去根据指针找个人那个记录,发现记录已经提交了因此你的记录就能够被访问了。

有人说这效率不就差了,每次都要去找一次,别急。

后台会有个线程来扫描,而后更新把锁记录给去了。

这不就稳了嘛。

相比于 2PC 的改进

首先 Percolator 在提交阶段不须要和全部的参与者交互,主须要和一个参与者打交道,因此这个提交是原子的!解决了数据不一致问题

而后事务管理器会记录操做日志,这样当事务管理器挂了以后选举的新事务管理器就能够经过日志来得知当前的状况从而继续工做,解决了单点故障问题

而且 Percolator 还会有后台线程,会扫描事务情况,在事务管理器宕机以后会回滚各个参与者上的事务。

能够看到相对于 2PC 仍是作了不少改进的,也是巧妙的。

其实分布式数据库还有别的事务模型,不过我也不太熟悉,就很少哔哔了,有兴趣的同窗能够自行了解。

仍是挺能拓宽思想的。

XA 规范

让咱们再回来 2PC,既然说到 2PC 了那么也简单的提一下 XA 规范,XA 规范是基于两阶段提交的,它实现了两阶段提交协议。

在说 XA 规范以前又得先提一下 DTP 模型,即 Distributed Transaction Processing,这模型规范了分布式事务的模型设计。

而 XA 规范又约束了 DTP 模型中的事务管理器(TM) 和资源管理器(RM)之间的交互,简单的说就是大家两之间要按照必定的格式规范来交流!

咱们先来看下 XA 约束下的 DTP 模型。

  • AP 应用程序,就是咱们的应用,事务的发起者。
  • RM 资源管理器,简单的认为就是数据库,具有事务提交和回滚能力,对应咱们上面的 2PC 就是参与者。
  • TM 事务管理器,就是协调者了,和每一个 RM 通讯。

简单的说就是 AP 经过 TM 来定义事务操做,TM 和 RM 之间会经过 XA 规范进行通讯,执行两阶段提交,而 AP 的资源是从 RM 拿的。

从模型上看有三个角色,而实际实现能够由一个角色实现两个功能,好比 AP 来实现 TM 的功能,TM 不必抽出来单独部署。

MySQL XA

知晓了 DTP 以后,咱们就来看看 XA 在 MySQL 中是如何操做的,不过只有 InnoDB 支持。

简单的说就是要先定义一个全局惟一的 XID,而后告知每一个事务分支要进行的操做。

能够看到图中执行了两个操做,分别是更名字和插入日志,等于先注册下要作的事情,经过 XA START XID 和 XA END XID 来包裹要执行的 SQL。

而后须要发送准备命令,来执行第一阶段,也就是除了事务的提交啥都干了的阶段。

而后根据准备的状况来选择执行提交事务命令仍是回滚事务命令。

基本上就是这么个流程,不过 MySQL XA 的性能不高这点是须要注意的。

能够看到虽然说 2PC 有缺点,可是仍是有基于 2PC 的落地实现的,而 3PC 的引出是为了解决 2PC 的一些缺点,可是它总体下来开销更大,也解决不了网络分区的问题,我也没有找到 3PC 的落地实现。

不过我仍是稍微提一下,知晓一下就行,纯理论。

3PC

3PC 的引入是为了解决 2PC 同步阻塞和减小数据不一致的状况。

3PC 也就是多了一个阶段,一个询问的阶段,分别是准备、预提交和提交这三个阶段。

准备阶段单纯就是协调者去访问参与者,相似于你还好吗?能接请求不。

预提交其实就是 2PC 的准备阶段,除了事务的提交啥都干了。

提交阶段和 2PC 的提交一致。

3PC 多了一个阶段其实就是在执行事务以前来确认参与者是否正常,防止个别参与者不正常的状况下,其余参与者都执行了事务,锁定资源。

出发点是好的,可是绝大部分状况下确定是正常的,因此每次都多了一个交互阶段就很不划算。

而后 3PC 在参与者处也引入了超时机制,这样在协调者挂了的状况下,若是已经到了提交阶段了,参与者等半天没收到协调者的状况的话就会自动提交事务。

不过万一协调者发的是回滚命令呢?你看这就出错了,数据不一致了。

还有维基百科上说 2PC 参与者准备阶段以后,若是协调者挂了,参与者是没法得知总体的状况的,由于大局是协调者掌控的,因此参与者相互之间的情况它们不清楚。

而 3PC 通过了第一阶段的确认,即便协调者挂了参与者也知道本身所处预提交阶段是由于已经获得准备阶段全部参与者的承认了。

简单的说就像加了个围栏,使得各参与者的状态得以统一。

小结 2PC 和 3PC

从上面已经知晓了 2PC 是一个强一致性的同步阻塞协议,性能已是比较差的了。

而 3PC 的出发点是为了解决 2PC 的缺点,可是多了一个阶段就多了一次通信的开销,并且是绝大部分状况下无用的通信。

虽然说引入参与者超时来解决协调者挂了的阻塞问题,可是数据仍是会不一致。

能够看到 3PC 的引入并没什么实际突破,并且性能更差了,因此实际只有 2PC 的落地实现。

再提一下,2PC 仍是 3PC 都是协议,能够认为是一种指导思想,和真正的落地仍是有差异的。

TCC

不知道你们注意到没,无论是 2PC 仍是 3PC 都是依赖于数据库的事务提交和回滚。

而有时候一些业务它不只仅涉及到数据库,多是发送一条短信,也多是上传一张图片。

因此说事务的提交和回滚就得提高到业务层面而不是数据库层面了,而 TCC 就是一种业务层面或者是应用层的两阶段提交

TCC 分为指代 Try、Confirm、Cancel ,也就是业务层面须要写对应的三个方法,主要用于跨数据库、跨服务的业务操做的数据一致性问题。

TCC 分为两个阶段,第一阶段是资源检查预留阶段即 Try,第二阶段是提交或回滚,若是是提交的话就是执行真正的业务操做,若是是回滚则是执行预留资源的取消,恢复初始状态。

好比有一个扣款服务,我须要写 Try 方法,用来冻结扣款资金,还须要一个 Confirm 方法来执行真正的扣款,最后还须要提供 Cancel 来进行冻结操做的回滚,对应的一个事务的全部服务都须要提供这三个方法。

能够看到原本就一个方法,如今须要膨胀成三个方法,因此说 TCC 对业务有很大的侵入,像若是没有冻结的那个字段,还须要改表结构。

咱们来看下流程。

虽然说对业务有侵入,可是 TCC 没有资源的阻塞,每个方法都是直接提交事务的,若是出错是经过业务层面的 Cancel 来进行补偿,因此也称补偿性事务方法。

这里有人说那要是全部人 Try 都成功了,都执行 Comfirm 了,可是个别 Confirm 失败了怎么办?

这时候只能是不停地重试调失败了的 Confirm 直到成功为止,若是真的不行只能记录下来,到时候人工介入了。

TCC 的注意点

这几个点很关键,在实现的时候必定得注意了。

幂等问题,由于网络调用没法保证请求必定能到达,因此都会有重调机制,所以对于 Try、Confirm、Cancel 三个方法都须要幂等实现,避免重复执行产生错误。

空回滚问题,指的是 Try 方法因为网络问题没收到超时了,此时事务管理器就会发出 Cancel 命令,那么须要支持 Cancel 在未执行 Try 的状况下能正常的 Cancel。

悬挂问题,这个问题也是指 Try 方法因为网络阻塞超时触发了事务管理器发出了 Cancel 命令,可是执行了 Cancel 命令以后 Try 请求到了,你说气不气

这都 Cancel 了你来个 Try,对于事务管理器来讲这时候事务已是结束了的,这冻结操做就被“悬挂”了,因此空回滚以后还得记录一下,防止 Try 的再调用。

TCC 变体

上面咱们说的是通用型的 TCC,它须要改造之前的实现,可是有一种状况是没法改造的,就是你调用的是别的公司的接口

没有 Try 的 TCC

好比坐飞机须要换乘,换乘的又是不一样的航空公司,好比从 A 飞到 B,再从 B 飞到 C,只有 A - B 和 B - C 都买到票了才有意义。

这时候的选择就没得 Try 了,直接调用航空公司的买票操做,当两个航空公司都买成功了那就直接成功了,若是某个公司买失败了,那就须要调用取消订票接口。

也就是在第一阶段直接就执行完整个业务操做了,因此要重点关注回滚操做,若是回滚失败得有提醒,要人工介入等。

这其实就是 TCC 的思想。

异步 TCC

这 TCC 还能异步?其实也是一种折中,好比某些服务很难改造,而且它又不会影响主业务决策,也就是它不那么重要,不须要及时的执行。

这时候能够引入可靠消息服务,经过消息服务来替代个别服务来进行 Try、Confirm、Cancel 。

Try 的时候只是写入消息,消息还不能被消费,Confirm 就是真正发消息的操做,Cancel 就是取消消息的发送。

这可靠消息服务其实就相似于等下要提到的事务消息,这个方案等于糅合了事务消息和 TCC。

TCC 小结

能够看到 TCC 是经过业务代码来实现事务的提交和回滚,对业务的侵入较大,它是业务层面的两阶段提交,

它的性能比 2PC 要高,由于不会有资源的阻塞,而且适用范围也大于 2PC,在实现上要注意上面提到的几个注意点。

它是业界比较经常使用的分布式事务实现方式,并且从变体也能够得知,仍是得看业务变通的,不是说你要用 TCC 必定就得死板的让全部的服务都改形成那三个方法。

本地消息表

本地消息就是利用了本地事务,会在数据库中存放一直本地事务消息表,在进行本地事务操做中加入了本地消息的插入,即将业务的执行和将消息放入消息表中的操做放在同一个事务中提交

这样本地事务执行成功的话,消息确定也插入成功,而后再调用其余服务,若是调用成功就修改这条本地消息的状态。

若是失败也没关系,会有一个后台线程扫描,发现这些状态的消息,会一直调用相应的服务,通常会设置重试的次数,若是一直不行则特殊记录,待人工介入处理。

能够看到仍是很简单的,也是一种最大努力通知思想。

事务消息

这个其实我写过一篇文章,专门讲事务消息,从源码层面剖析了 RocketMQ 、Kafka 的事务消息实现,以及二者之间的区别。

在这里我再也不详细阐述,由于以前的文章写的很详细了,大概四五千字吧。我就附上连接了:事务消息

Seata 的实现

首先什么是 Seata ,摘抄官网的一段话。

Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。

能够看到提供了不少模式,咱们先来看看 AT 模式。

AT模式

AT 模式就是两阶段提交,前面咱们提到了两阶段提交有同步阻塞的问题,效率过低了,那 Seata 是怎么解决的呢?

AT 的一阶段直接就把事务提交了,直接释放了本地锁,这么草率直接提交的嘛?固然不是,这里和本地消息表有点相似,就是利用本地事务,执行真正的事务操做中还会插入回滚日志,而后在一个事务中提交。

这回滚日志怎么来的

经过框架代理 JDBC 的一些类,在执行 SQL 的时候解析 SQL 获得执行前的数据镜像,而后执行 SQL ,再获得执行后的数据镜像,而后把这些数据组装成回滚日志。

再伴随的这个本地事务的提交把回滚日志也插入到数据库的 UNDO_LOG 表中(因此数据库须要有一张UNDO_LOG 表)。

这波操做下来在一阶段就能够没有后顾之忧的提交事务了。

而后一阶段若是成功,那么二阶段能够异步的删除那些回滚日志,若是一阶段失败那么能够经过回滚日志来反向补偿恢复。

这时候有细心的同窗想到了,万一中间有人改了这条数据怎么办?你这镜像就不对了啊?

因此说还有个全局锁的概念,在事务提交前须要拿到全局锁(能够理解为对这条数据的锁),而后才能顺利提交本地事务。

若是一直拿不到那就须要回滚本地事务了。

官网的示例很好,我就不本身编了,如下部份内容摘抄自 Seata 官网的示例

此时有两个事务,分别是 tx一、和 tx2,分别对 a 表的 m 字段进行更新操做,m 的初始值 1000。

tx1 先开始,开启本地事务,拿到本地锁,更新操做 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。

tx2 后开始,开启本地事务,拿到本地锁,更新操做 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 须要重试等待全局锁 。

能够看到 tx2 的修改被阻塞了,以后重试拿到全局锁以后就能提交而后释放本地锁。

若是 tx1 的二阶段全局回滚,则 tx1 须要从新获取该数据的本地锁,进行反向补偿的更新操做,实现分支的回滚。

此时,若是 tx2 仍在等待该数据的全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的全局锁等锁超时,放弃全局锁并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。

由于整个过程全局锁在 tx1 结束前一直是被 tx1 持有的,因此不会发生脏写的问题

而后 AT 模式默认全局是读未提交的隔离级别,若是应用在特定场景下,必须要求全局的读已提交 ,能够经过 SELECT FOR UPDATE 语句的代理。

固然前提是你本地事务隔离级别是读已提交及以上。

AT 模式小结

能够看到经过代理来无侵入的获得数据的先后镜像,组装成回滚日志伴随本地事务一块儿提交,解决了两阶段的同步阻塞问题。

而且利用全局锁来实现写隔离。

为了整体性能的考虑,默认是读未提交隔离级别,只代理了 SELECT FOR UPDATE 来进行读已提交的隔离。

这其实就是两阶段提交的变体实现

TCC 模式

没什么花头,就是我们上面分析的须要搞三个方法, 而后把自定义的分支事务归入到全局事务的管理中

我贴一张官网的图应该挺清晰了。

Saga 模式

这个 Saga 是 Seata 提供的长事务解决方案,适用于业务流程多且长的状况下,这种状况若是要实现通常的 TCC 啥的可能得嵌套多个事务了。

而且有些系统没法提供 TCC 这三种接口,好比老项目或者别人公司的,因此就搞了个 Saga 模式,这个 Saga 是在 1987 年 Hector & Kenneth 发表的论⽂中提出的。

那 Saga 如何作呢?来看下这个图。

假设有 N 个操做,直接从 T1 开始就是直接执行提交事务,而后再执行 T2,能够看到就是无锁的直接提交,到 T3 发现执行失败了,而后就进入 Compenstaing 阶段,开始一个一个倒回补偿了。

思想就是一开始蒙着头干,别怂,出了问题我们再一个一个改回去呗。

能够看到这种状况是不保证事务的隔离性的,而且 Saga 也有 TCC 的同样的注意点,须要空补偿,防悬挂和幂等。

并且极端状况下会由于数据被改变了致使没法回滚的状况。好比第一步给我打了 2 万块钱,我给取出来花了,这时候你回滚,我帐上余额已经 0 了,你说怎么办嘛?难道给我还搞负的不成?

这种状况只能在业务流程上入手,我写代码其实一直是这样写的,就拿买皮肤的场景来讲,我都是先扣钱再给皮肤。

假设先给皮肤扣钱失败了不就白给了嘛?这钱你来补啊?你以为用户会来反馈说皮肤给了钱没扣嘛?

可能有小机灵鬼说我到时候把皮肤给改回去,嘿嘿这种事情确实发生过,啧啧,被骂的真惨。

因此正确的流程应该是先扣钱再给皮肤,钱到本身袋里先,皮肤没给成功用户天然而然会找过来,这时候再给他呗,虽然说可能你写出了个 BUG ,可是还好不是个白给的 BUG。

因此说这点在编码的时候仍是得注意下的。

最后

能够看到分布式事务仍是会有各类问题,通常分布式事务的实现仍是只能达到最终一致性。

极端状况下仍是得人工介入,因此作好日志记录很关键。

还有编码的业务流程,要往利于公司的方向写,就例如先拿到用户的钱,再给用户东西这个方向,切记。

在上分布式事务以前想一想,有没有必要,能不能改造一下避免分布式事务?

再极端一点,你的业务有没有必要上事务?

最后我的能力有限,若有纰漏请赶忙联系鞭挞我,若是以为文章不错还望点个在看支持一下哟。

巨人的肩膀

分布式协议与算法实战,韩健

分布式数据库30讲,王磊

seata.io


我是 yes,从一点点到亿点点,咱们下篇见

🏆 技术专题第五期 | 聊聊分布式的那些事......

相关文章
相关标签/搜索