两天,我把分布式事务搞完了
每个时代,都不会亏待会学习的人。
今天我想和大家一起盘一盘分布式事务,会介绍常见的分布式事务实现方案和其优缺点以及适用的场景,并会带出它们的一些变体实现。
还会捎带一下分布式数据库对 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 官网的示例:
此时有两个事务,分别是 tx1、和 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。
所以说这点在编码的时候还是得注意下的。
最后
可以看到分布式事务还是会有各种问题,一般分布式事务的实现还是只能达到最终一致性。
极端情况下还是得人工介入,所以做好日志记录很关键。
还有编码的业务流程,要往利于公司的方向写,就例如先拿到用户的钱,再给用户东西这个方向,切记。
在上分布式事务之前想想,有没有必要,能不能改造一下避免分布式事务?
再极端一点,你的业务有没有必要上事务?
最后个人能力有限,如有纰漏请赶紧联系鞭挞我,如果觉得文章不错还望点个在看支持一下哟。