本文提纲如下
0. 前言最近,有同学刚好收到国内某电商大厂的面试邀请,如约去面,过三关斩六将,一路杀到终面,没想到倒在了分布式事务上。这些面试题全都是结合双十一的各种奇怪场景设计的,他没有经历过,因此就gg了,懊悔不已。因此,决定肝一篇关于分布式事务的干货,来复盘总结一下。 从 CPU 到内存、到磁盘、到操作系统、到网络,计算机系统处处存在不可靠因素。工程师和科学家努力使用各种软硬件方法对抗这种不可靠因素,保证数据和指令被正确地处理。在网络领域有 TCP 可靠传输协议、在存储领域有 Raid5 和 Raid6 算法、在数据库领域有 基于 ARIES 算法理论实现的事务机制…… 这篇文章先介绍单机数据库事务的 ACID 特性,然后指出分布式场景下操作多数据源面临的困境,引出分布式系统中常用的分布式事务解决方案,这些解决方案可以保证业务代码在操作多个数据源的时候,能够像操作单个数据源一样,具备 ACID 特性。 文章在最后给出业界较为成熟的分布式事务框架——Seata 的 AT 模式全局事务的实现。 1. 单数据源事务 & 多数据源事务如果一个应用程序在一次业务流中通过连接驱动和数据源接口只连接并查询(这里的查询是广义的,包括增删查改等)一个特定的数据库,该应用程序就可以利用数据库提供的事务机制(如果数据库支持事务的话)保证对库中记录所进行的操作的可靠性,这里的可靠性有四种语义:
笔者在这里不再对这四种语义进行解释,了解单数据源事务及其 ACID 特性是读者阅读这篇文章的前提。单个数据库实现自身的事务特性是一个复杂又微妙的过程,例如 MySQL 的 InnoDB 引擎通过 Undo Log + Redo Log + ARIES 算法来实现。 这是一个很宏大的话题,不在本文的描述范围,读者有兴趣的话可自行研究。 单数据源事务也可以叫做单机事务,或者本地事务。 在分布式场景下,一个系统由多个子系统构成,每个子系统有独立的数据源。多个子系统之间通过互相调用来组合出更复杂的业务。 在时下流行的微服务系统架构中,每一个子系统被称作一个微服务,同样每个微服务都维护自己的数据库,以保持独立性。 例如,一个电商系统可能由购物微服务、库存微服务、订单微服务等组成。购物微服务通过调用库存微服务和订单微服务来整合出购物业务。用户请求购物微服务商完成下单时,购物微服务一方面调用库存微服务扣减相应商品的库存数量,另一方面调用订单微服务插入订单记录(为了后文描述分布式事务解决方案的方便,这里给出的是一个最简单的电商系统微服务划分和最简单的购物业务流程,后续的支付、物流等业务不在考虑范围内)。电商系统模型如下图所示: 在用户购物的业务场景中,shopping-service 的业务涉及两个数据库:库存库(repo_db)和订单库(repo_db),也就是 g 购物业务是调用多数据源来组合而成的。作为一个面向消费者的系统,电商系统要保证购物业务的高度可靠性,这里的可靠性同样有 ACID 四种语义。 但是一个数据库的本地事务机制仅仅对落到自己身上的查询操作(这里的查询是广义的,包括增删改查等)起作用,无法干涉对其他数据库的查询操作。所以,数据库自身提供的本地事务机制无法确保业务对多数据源全局操作的可靠性。 基于此,针对多数据源操作提出的分布式事务机制就出现了。 分布式事务也可以叫做全局事务。 2. 常见分布式事务解决方案2.1. 分布式事务模型描述分布式事务,常常会使用以下几个名词:
在分布式事务模型中,一个 TM 管理多个 RM,即一个服务程序访问多个数据源;TM 是一个全局事务管理器,协调多方本地事务的进度,使其共同提交或回滚,最终达成一种全局的 ACID 特性。 2.2. 二将军问题和幂等性二将军问题是网络领域的一个经典问题,用于表达计算机网络中互联协议设计的微妙性和复杂性。 这里给出一个二将军问题的简化版本:
只有当送信士兵成功往返后,总指挥才能确认这场战争的胜利(上方图)。现在问题来了,派遣出去送信的士兵没有回来,则左侧蓝军中的总指挥能不能决定按命令中约定的时间发起进攻? 答案是不确定,派遣出去送信的士兵没有回来,他可能遇到两种状况:
类似的问题在计算机网络中普遍存在,例如发送者给接受者发送一个 HTTP 请求,或者 MySQL 客户端向 MySQL 服务器发送一条插入语句,然后超时了没有得到响应。请问服务器是写入成功了还是失败了?答案是不确定,有以下几种情况:
无论哪种场景,在客户端看来都是一样的结果:它发出的请求没有得到响应。为了确保服务端成功写入数据,客户端只能重发请求,直至接收到服务端的响应。 类似的问题问题被称为网络二将军问题。 网络二将军问题的存在使得消息的发送者往往要重复发送消息,直到收到接收者的确认才认为发送成功,但这往往又会导致消息的重复发送。 例如电商系统中订单模块调用支付模块扣款的时候,如果网络故障导致二将军问题出现,扣款请求重复发送,产生的重复扣款结果显然是不能被接受的。因此要保证一次事务中的扣款请求无论被发送多少次,接收方有且只执行一次扣款动作,这种保证机制叫做接收方的幂等性。 2.3. 两阶段提交(2PC) & 三阶段提交(3PC)方案2PC 是一种实现分布式事务的简单模型,这两个阶段是:
2PC 的流程如下图所示: 从上图可以看出,要实现 2PC,所有的参与者都要实现三个接口:
可以将这三个接口简单地(但不严谨地)理解成 XA 协议。XA 协议是 X/Open 提出的分布式事务处理标准。MySQL、Oracle、DB2 这些主流数据库都实现了 XA 协议,因此都能被用于实现 2PC 事务模型。 2PC 简明易懂,但存在如下的问题:
2PC 之后又出现了 3PC,把两阶段过程变成了三阶段过程,分别是:1.询问阶段、2.准备阶段、3.提交或回滚阶段,这里不再详述。 3PC 利用超时机制解决了 2PC 的同步阻塞问题,避免资源被永久锁定,进一步加强了整个事务过程的可靠性。但是 3PC 同样无法应对类似的宕机问题,只不过出现多数据源中数据不一致问题的概率更小。 2PC 除了性能和可靠性上存在问题,它的适用场景也很局限,它要求参与者实现了 XA 协议,例如使用实现了 XA 协议的数据库作为参与者可以完成 2PC 过程。但是在多个系统服务利用 api 接口相互调用的时候,就不遵守 XA 协议了,这时候 2PC 就不适用了。所以 2PC 在分布式应用场景中很少使用。 所以前文提到的电商场景无法使用 2PC,因为 shopping-service 通过 RPC 接口或者 Rest 接口调用 repo-service 和 order-service 间接访问 repo_db 和 order_db。除非 shopping-service 直接配置 repo_db 和 order_db 作为自己的数据库。 2.4. TCC 方案描述 TCC 方案使用的电商微服务模型如下图所示,在这个模型中,shopping-service 是事务协调者,repo-service 和 order-service 是事务参与者。 上文提到,2PC 要求参与者实现了 XA 协议,通常用来解决多个数据库之间的事务问题,比较局限。在多个系统服务利用 api 接口相互调用的时候,就不遵守 XA 协议了,这时候 2PC 就不适用了。现代企业多采用分布式的微服务,因此更多的是要解决多个微服务之间的分布式事务问题。 TCC 就是一种解决多个微服务之间的分布式事务问题的方案。TCC 是 Try、Confirm、Cancel 三个词的缩写,其本质是一个应用层面上的 2PC,同样分为两个阶段:
TCC 的流程如下图所示: 这里有个关键问题,既然 TCC 是一种服务层面上的 2PC。它是如何解决 2PC 无法应对宕机问题的缺陷的呢? 答案是不断重试。 由于 try 操作锁住了全局事务涉及的所有资源,保证了业务操作的所有前置条件得到满足,因此无论是 confirm 阶段失败还是 cancel 阶段失败都能通过不断重试直至 confirm 或 cancel 成功(所谓成功就是所有的服务都对 confirm 或者 cancel 返回了 ACK)。 这里还有个关键问题,在不断重试 confirm 和 cancel 的过程中(考虑到网络二将军问题的存在)有可能重复进行了 confirm 或 cancel,因此还要再保证 confirm 和 cancel 操作具有幂等性,也就是整个全局事务中,每个参与者只进行一次 confirm 或者 cancel。实现 confirm 和 cancel 操作的幂等性,有很多解决方案,例如每个参与者可以维护一个去重表(可以利用数据库表实现也可以使用内存型 KV 组件实现),记录每个全局事务(以全局事务标记 XID 区分)是否进行过 confirm 或 cancel 操作,若已经进行过,则不再重复执行。 TCC 由支付宝团队提出,被广泛应用于金融系统中。我们用银行账户余额购买基金时,会注意到银行账户中用于购买基金的那部分余额首先会被冻结,由此我们可以猜想,这个过程大概就是 TCC 的第一阶段。 2.5. 事务状态表方案另外有一种类似 TCC 的事务解决方案,借助事务状态表来实现。假设要在一个分布式事务中实现调用 repo-service 扣减库存、调用 order-service 生成订单两个过程。在这种方案中,协调者 shopping-service 维护一张如下的事务状态表:
初始状态为 1,每成功调用一个服务则更新一次状态,最后所有的服务调用成功,状态更新到 3。 有了这张表,就可以启动一个后台任务,扫描这张表中事务的状态,如果一个分布式事务一直(设置一个事务周期阈值)未到状态 3,说明这条事务没有成功执行,于是可以重新调用 repo-service 扣减库存、调用 order-service 生成订单。直至所有的调用成功,事务状态到 3。 如果多次重试仍未使得状态到 3,可以将事务状态置为 error,通过人工介入进行干预。 由于存在服务的调用重试,因此每个服务的接口要根据全局的分布式事务 ID 做幂等,原理同 2.4 节的幂等性实现。 2.6. 基于消息中间件的最终一致性事务方案无论是 2PC & 3PC 还是 TCC、事务状态表,基本都遵守 XA 协议的思想。即这些方案本质上都是事务协调者协调各个事务参与者的本地事务的进度,使所有本地事务共同提交或回滚,最终达成一种全局的 ACID 特性。在协调的过程中,协调者需要收集各个本地事务的当前状态,并根据这些状态发出下一阶段的操作指令。 但是这些全局事务方案由于操作繁琐、时间跨度大,或者在全局事务期间会排他地锁住相关资源,使得整个分布式系统的全局事务的并发度不会太高。这很难满足电商等高并发场景对事务吞吐量的要求,因此互联网服务提供商探索出了很多与 XA 协议背道而驰的分布式事务解决方案。其中利用消息中间件实现的最终一致性全局事务就是一个经典方案。 为了表现出这种方案的精髓,我将使用如下的电商系统微服务结构来进行描述: 在这个模型中,用户不再是请求整合后的 shopping-service 进行下单,而是直接请求 order-service 下单,order-service 一方面添加订单记录,另一方面会调用 repo-service 扣减库存。 这种基于消息中间件的最终一致性事务方案常常被误解成如下的实现方式: 这种实现方式的流程是:
![]() 这种做法看似很可靠。但没有考虑到网络二将军问题的存在,有如下的缺陷:
以上是被误解的实现方式,下面给出正确的实现方式,如下所示: ![]() 上图所示的方案,利用消息中间件如 rabbitMQ 来实现分布式下单及库存扣减过程的最终一致性。对这幅图做以下说明: 1)order-service 中, 在 t_order 表添加订单记录 && 这两个过程要在一个事务中完成,保证过程的原子性。同样,repo-service 中,
这四个过程也要在一个事务中完成,保证过程的原子性。 2)order-service 中有一个后台程序,源源不断地把消息表中的消息传送给消息中间件,成功后则删除消息表中对应的消息。如果失败了,也会不断尝试重传。由于存在网络 2 将军问题,即当 order-service 发送给消息中间件的消息网络超时时,这时候消息中间件可能收到了消息但响应 ACK 失败,也可能没收到,order-service 会再次发送该消息,直至消息中间件响应 ACK 成功,这样可能发生消息的重复发送,不过没关系,只要保证消息不丢失,不乱序就行,后面 repo-service 会做去重处理。 3)消息中间件向 repo-service 推送 repo_deduction_msg,repo-service 成功处理完成后会向中间件响应 ACK,消息中间件收到这个 ACK 才认为 repo-service 成功处理了这条消息,否则会重复推送该消息。但是有这样的情形:repo-service 成功处理了消息,向中间件发送的 ACK 在网络传输中由于网络故障丢失了,导致中间件没有收到 ACK 重新推送了该消息。这也要靠 repo-service 的消息去重特性来避免消息重复消费。 4)在 2)和 3)中提到了两种导致 repo-service 重复收到消息的原因,一是生产者重复生产,二是中间件重传。为了实现业务的幂等性,repo-service 中维护了一张判重表,这张表中记录了被成功处理的消息的 id。repo-service 每次接收到新的消息都先判断消息是否被成功处理过,若是的话不再重复处理。 通过这种设计,实现了消息在发送方不丢失,消息在接收方不被重复消费,联合起来就是消息不漏不重,严格实现了 order-service 和 repo-service 的两个数据库中数据的最终一致性。 基于消息中间件的最终一致性全局事务方案是互联网公司在高并发场景中探索出的一种创新型应用模式,利用 MQ 实现微服务之间的异步调用、解耦合和流量削峰,支持全局事务的高并发,并保证分布式数据记录的最终一致性。 ![]() 3. Seata in AT mode 的实现第 2 章给出了实现实现分布式事务的集中常见的理论模型。本章给出业界开源分布式事务框架 Seata 的实现。 Seata 为用户提供了 AT、TCC、SAGA 和 XA 事务模式。其中 AT 模式是 Seata 主推的事务模式,因此本章分析 Seata in AT mode 的实现。使用 AT 有一个前提,那就是微服务使用的数据库必须是支持事务的关系型数据库。 3.1. Seata in AT mode 工作流程概述Seata 的 AT 模式建立在关系型数据库的本地事务特性的基础之上,通过数据源代理类拦截并解析数据库执行的 SQL,记录自定义的回滚日志,如需回滚,则重放这些自定义的回滚日志即可。AT 模式虽然是根据 XA 事务模型(2PC)演进而来的,但是 AT 打破了 XA 协议的阻塞性制约,在一致性和性能上取得了平衡。 AT 模式是基于 XA 事务模型演进而来的,它的整体机制也是一个改进版本的两阶段提交协议。AT 模式的两个基本阶段是:
本章描述 Seata in AT mode 的工作原理使用的电商微服务模型如下图所示: ![]() 在上图中,协调者 shopping-service 先调用参与者 repo-service 扣减库存,后调用参与者 order-service 生成订单。这个业务流使用 Seata in XA mode 后的全局事务流程如下图所示: ![]() 上图描述的全局事务执行流程为:
其中 1)2)3)4)5)属于第一阶段,6)7)属于第二阶段。 3.2. Seata in AT mode 工作流程详述在上面的电商业务场景中,购物服务调用库存服务扣减库存,调用订单服务创建订单,显然这两个调用过程要放在一个事务里面。即: start global_trx 在库存服务的数据库中,存在如下的库存表 t_repo:
在订单服务的数据库中,存在如下的订单表 t_order:
现在,id 为 40002 的用户要购买一只商品代码为 20002 的鼠标,整个分布式事务的内容为: 1)在库存服务的库存表中将记录
修改为
2)在订单服务的订单表中添加一条记录
以上操作,在 AT 模式的第一阶段的流程图如下: ![]() 从 AT 模式第一阶段的流程来看,分支的本地事务在第一阶段提交完成之后,就会释放掉本地事务锁定的本地记录。这是 AT 模式和 XA 最大的不同点,在 XA 事务的两阶段提交中,被锁定的记录直到第二阶段结束才会被释放。所以 AT 模式减少了锁记录的时间,从而提高了分布式事务的处理效率。 AT 模式之所以能够实现第一阶段完成就释放被锁定的记录,是因为 Seata 在每个服务的数据库中维护了一张 undo_log 表,其中记录了对 t_order / t_repo 进行操作前后记录的镜像数据,即便第二阶段发生异常,只需回放每个服务的 undo_log 中的相应记录即可实现全局回滚。 undo_log 的表结构:
第一阶段结束之后,Seata 会接收到所有分支事务的提交状态,然后决定是提交全局事务还是回滚全局事务。 1)若所有分支事务本地提交均成功,则 Seata 决定全局提交。 Seata 将分支提交的消息发送给各个分支事务,各个分支事务收到分支提交消息后,会将消息放入一个缓冲队列,然后直接向 Seata 返回提交成功。之后,每个本地事务会慢慢处理分支提交消息,处理的方式为:删除相应分支事务的 undo_log 记录。之所以只需删除分支事务的 undo_log 记录,而不需要再做其他提交操作,是因为提交操作已经在第一阶段完成了(这也是 AT 和 XA 不同的地方)。这个过程如下图所示: ![]() 分支事务之所以能够直接返回成功给 Seata,是因为真正关键的提交操作在第一阶段已经完成了,清除 undo_log 日志只是收尾工作,即便清除失败了,也对整个分布式事务不产生实质影响。 2)若任一分支事务本地提交失败,则 Seata 决定全局回滚,将分支事务回滚消息发送给各个分支事务,由于在第一阶段各个服务的数据库上记录了 undo_log 记录,分支事务回滚操作只需根据 undo_log 记录进行补偿即可。全局事务的回滚流程如下图所示: ![]() 这里对图中的 2、3 步做进一步的说明:
![]() 事务具有 ACID 特性,全局事务解决方案也在尽量实现这四个特性。以上关于 Seata in AT mode 的描述很显然体现出了 AT 的原子性、一致性和持久性。下面着重描述一下 AT 如何保证多个全局事务的隔离性的。 在 AT 中,当多个全局事务操作同一张表时,通过全局锁来保证事务的隔离性。下面描述一下全局锁在读隔离和写隔离两个场景中的作用原理: 1)写隔离(若有全局事务在改/写/删记录,另一个全局事务对同一记录进行的改/写/删要被隔离起来,即写写互斥):写隔离是为了在多个全局事务对同一张表的同一个字段进行更新操作时,避免一个全局事务在没有被提交成功之前所涉及的数据被其他全局事务修改。写隔离的基本原理是:在第一阶段本地事务(开启本地事务的时候,本地事务会对涉及到的记录加本地锁)提交之前,确保拿到全局锁。如果拿不到全局锁,就不能提交本地事务,并且不断尝试获取全局锁,直至超出重试次数,放弃获取全局锁,回滚本地事务,释放本地事务对记录加的本地锁。 假设有两个全局事务 gtrx_1 和 gtrx_2 在并发操作库存服务,意图扣减如下记录的库存数量:
AT 实现写隔离过程的时序图如下: ![]() 图中,1、2、3、4 属于第一阶段,5 属于第二阶段。 在上图中 gtrx_1 和 gtrx_2 均成功提交,如果 gtrx_1 在第二阶段执行回滚操作,那么 gtrx_1 需要重新发起本地事务获取本地锁,然后根据 undo_log 对这个 id=10002 的记录进行补偿式回滚。此时 gtrx_2 仍在等待全局锁,且持有这个 id=10002 的记录的本地锁,因此 gtrx_1 会回滚失败(gtrx_1 回滚需要同时持有全局锁和对 id=10002 的记录加的本地锁),回滚失败的 gtrx_1 会一直重试回滚。直到旁边的 gtrx_2 获取全局锁的尝试次数超过阈值,gtrx_2 会放弃获取全局锁,发起本地回滚,本地回滚结束后,自然会释放掉对这个 id=10002 的记录加的本地锁。此时,gtrx_1 终于可以成功对这个 id=10002 的记录加上了本地锁,同时拿到了本地锁和全局锁的 gtrx_1 就可以成功回滚了。整个过程,全局锁始终在 gtrx_1 手中,并不会发生脏写的问题。整个过程的流程图如下所示: ![]() 2)读隔离(若有全局事务在改/写/删记录,另一个全局事务对同一记录的读取要被隔离起来,即读写互斥):在数据库本地事务的隔离级别为读已提交、可重复读、串行化时(读未提交不起什么隔离作用,一般不使用),Seata AT 全局事务模型产生的隔离级别是读未提交,也就是说一个全局事务会看到另一个全局事务未全局提交的数据,产生脏读,从前文的第一阶段和第二阶段的流程图中也可以看出这一点。这在最终一致性的分布式事务模型中是可以接受的。 如果要求 AT 模型一定要实现读已提交的事务隔离级别,可以利用 Seata 的 SelectForUpdateExecutor 执行器对 SELECT FOR UPDATE 语句进行代理。SELECT FOR UPDATE 语句在执行时会申请全局锁,如果全局锁已经被其他全局事务占有,则回滚 SELECT FOR UPDATE 语句的执行,释放本地锁,并且重试 SELECT FOR UPDATE 语句。在这个过程中,查询请求会被阻塞,直到拿到全局锁(也就是要读取的记录被其他全局事务提交),读到已被全局事务提交的数据才返回。这个过程如下图所示: ![]() ![]() 4. 结束语XA 协议是 X/Open 提出的分布式事务处理标准。文中提到的 2PC、3PC、TCC、本地事务表、Seata in AT mode,无论哪一种,本质都是事务协调者协调各个事务参与者的本地事务的进度,使使所有本地事务共同提交或回滚,最终达成一种全局的 ACID 特性。在协调的过程中,协调者需要收集各个本地事务的当前状态,并根据这些状态发出下一阶段的操作指令。这个思想就是 XA 协议的要义,我们可以说这些事务模型遵守或大致遵守了 XA 协议。 基于消息中间件的最终一致性事务方案是互联网公司在高并发场景中探索出的一种创新型应用模式,利用 MQ 实现微服务之间的异步调用、解耦合和流量削峰,保证分布式数据记录的最终一致性。它显然不遵守 XA 协议。 对于某项技术,可能存在业界标准或协议,但实践者针对具体应用场景的需求或者出于简便的考虑,给出与标准不完全相符的实现,甚至完全不相符的实现,这在工程领域是一种常见的现象。TCC 方案如此、基于消息中间件的最终一致性事务方案如此、Seata in AT mode 模式也如此。而新的标准往往就在这些创新中产生。 你难道真的没有发现 2.6 节(基于消息中间件的最终一致性事务方案)给出的正确方案中存在的业务漏洞吗?请各位重新看下这张图,仔细品一品两个微服务的调用方向,把你的想法留在评论区吧 :-) ![]() |
|