0. 前言
从 CPU 到内存、到磁盘、到操作系统、到网络,计算机系统处处存在不可靠因素。工程师和科学家努力使用各种软硬件方法对抗这种不可靠因素,保证数据和指令被正确地处理。在网络领域有 TCP 可靠传输协议、在存储领域有 Raid5 和 Raid6 算法、在数据库领域有基于 ARIES 算法理论实现的事务机制……
这篇文章先介绍单机数据库事务的 ACID 特性,然后指出分布式场景下操作多数据源面临的困境,引出分布式系统中常用的分布式事务解决方案,这些解决方案可以保证业务代码在操作多个数据源的时候,能够像操作单个数据源一样,具备 ACID 特性。文章在最后给出业界较为成熟的分布式事务框架——Seata 的 AT 模式全局事务的实现。
1. 单数据源事务 & 多数据源事务
如果一个应用程序在一次业务流中通过连接驱动和数据源接口只连接并查询(这里的查询是广义的,包括增删查改等)一个特定的数据库,该应用程序就可以利用数据库提供的事务机制(如果数据库支持事务的话)保证对库中记录所进行的操作的可靠性,这里的可靠性有四种语义:
- 原子性,A
- 一致性,C
- 隔离性,I
- 持久性,D
笔者在这里不再对这四种语义进行解释,了解单数据源事务及其 ACID 特性是读者阅读这篇文章的前提。单个数据库实现自身的事务特性是一个复杂又微妙的过程,例如 MySQL 的 InnoDB 引擎通过 Undo Log + Redo Log + ARIES 算法来实现。这是一个很宏大的话题,不在本文的描述范围,读者有兴趣的话可自行研究。
单数据源事务也可以叫做单机事务,或者本地事务。
在分布式场景下,一个系统由多个子系统构成,每个子系统有独立的数据源。多个子系统之间通过互相调用来组合出更复杂的业务。在时下流行的微服务系统架构中,每一个子系统被称作一个微服务,同样每个微服务都维护自己的数据库,以保持独立性。
例如,一个电商系统可能由购物微服务、库存微服务、订单微服务等组成。购物微服务通过调用库存微服务和订单微服务来整合出购物业务。用户请求购物微服务商完成下单时,购物微服务一方面调用库存微服务扣减相应商品的库存数量,另一方面调用订单微服务插入订单记录(为了后文描述分布式事务解决方案的方便,这里给出的是一个最简单的电商系统微服务划分和最简单的购物业务流程,后续的支付、物流等业务不在考虑范围内)。电商系统模型如下图所示:
在用户购物的业务场景中,shopping-service 的业务涉及两个数据库:库存库(repo_db)和订单库(repo_db),也就是 g 购物业务是调用多数据源来组合而成的。作为一个面向消费者的系统,电商系统要保证购物业务的高度可靠性,这里的可靠性同样有 ACID 四种语义。
但是一个数据库的本地事务机制仅仅对落到自己身上的查询操作(这里的查询是广义的,包括增删改查等)起作用,无法干涉对其他数据库的查询操作。所以,数据库自身提供的本地事务机制无法确保业务对多数据源全局操作的可靠性。
基于此,针对多数据源操作提出的分布式事务机制就出现了。
分布式事务也可以叫做全局事务。
2. 常见分布式事务解决方案
2.1. 分布式事务模型
描述分布式事务,常常会使用以下几个名词:
- 事务参与者:例如每个数据库就是一个事务参与者
- 事务协调者:访问多个数据源的服务程序,例如 shopping-service 就是事务协调者
- 资源管理器(Resource Manager, RM):通常与事务参与者同义
- 事务管理器(Transaction Manager, TM):通常与事务协调者同义
在分布式事务模型中,一个 TM 管理多个 RM,即一个服务程序访问多个数据源;TM 是一个全局事务管理器,协调多方本地事务的进度,使其共同提交或回滚,最终达成一种全局的 ACID 特性。
2.2. 二将军问题和幂等性
二将军问题是网络领域的一个经典问题,用于表达计算机网络中互联协议设计的微妙性和复杂性。这里给出一个二将军问题的简化版本:
一支白军被围困在一个山谷中,山谷的左右两侧是蓝军。困在山谷中的白军人数多于山谷两侧的任意一支蓝军,而少于两支蓝军的之和。若一支蓝军对白军单独发起进攻,则必败无疑;但若两支蓝军同时发起进攻,则可取胜。两只蓝军的总指挥位于山谷左侧,他希望两支蓝军同时发起进攻,这样就要把命令传到山谷右侧的蓝军,以告知发起进攻的具体时间。假设他们只能派遣士兵穿越白军所在的山谷(唯一的通信信道)来传递消息,那么在穿越山谷时,士兵有可能被俘虏。
只有当送信士兵成功往返后,总指挥才能确认这场战争的胜利(上方图)。现在问题来了,派遣出去送信的士兵没有回来,则左侧蓝军中的总指挥能不能决定按命令中约定的时间发起进攻?
答案是不确定,派遣出去送信的士兵没有回来,他可能遇到两种状况:
1)命令还没送达就被俘虏了(中间图),这时候右侧蓝军根本不知道要何时进攻;
2)命令送达,但返回途中被俘虏了(下方图),这时候右侧蓝军知道要何时进攻,但左侧蓝军不知道右侧蓝军是否知晓进攻时间。
类似的问题在计算机网络中普遍存在,例如发送者给接受者发送一个 HTTP 请求,或者 MySQL 客户端向 MySQL 服务器发送一条插入语句,然后超时了没有得到响应。请问服务器是写入成功了还是失败了?答案是不确定,有以下几种情况:
1)可能请求由于网络故障根本没有送到服务器,因此写入失败;
2)可能服务器收到了,也写入成功了,但是向客户端发送响应前服务器宕机了;
3)可能服务器收到了,也写入成功了,也向客户端发送了响应,但是由于网络故障未送到客户端。
无论哪种场景,在客户端看来都是一样的结果:它发出的请求没有得到响应。为了确保服务端成功写入数据,客户端只能重发请求,直至接收到服务端的响应。
类似的问题问题被称为网络二将军问题。
网络二将军问题的存在使得消息的发送者往往要重复发送消息,直到收到接收者的确认才认为发送成功,但这往往又会导致消息的重复发送。例如电商系统中订单模块调用支付模块扣款的时候,如果网络故障导致二将军问题出现,扣款请求重复发送,产生的重复扣款结果显然是不能被接受的。因此要保证一次事务中的扣款请求无论被发送多少次,接收方有且只执行一次扣款动作,这种保证机制叫做接收方的幂等性。
2.3. 两阶段提交(2PC) & 三阶段提交(3PC)方案
2PC 是一种实现分布式事务的简单模型,这两个阶段是:
1)准备阶段:事务协调者向各个事务参与者发起询问请求:“我要执行全局事务了,这个事务涉及到的资源分布在你们这些数据源中,分别是……,你们准备好各自的资源(即各自执行本地事务到待提交阶段)”。各个参与者协调者回复 yes(表示已准备好,允许提交全局事务)或 no(表示本参与者无法拿到全局事务所需的本地资源,因为它被其他本地事务锁住了)或超时。
2)提交阶段:如果各个参与者回复的都是 yes,则协调者向所有参与者发起事务提交操作,然后所有参与者收到后各自执行本地事务提交操作并向协调者发送 ACK;如果任何一个参与者回复 no 或者超时,则协调者向所有参与者发起事务回滚操作,然后所有参与者收到后各自执行本地事务回滚操作并向协调者发送 ACK。
2PC 的流程如下图所示:
从上图可以看出,要实现 2PC,所有的参与者都要实现三个接口:
- Prepare():TM 调用该接口询问各个本地事务是否就绪
- Commit():TM 调用该接口要求各个本地事务提交
- Rollback():TM 调用该接口要求各个本地事务回滚
可以将这三个接口简单地(但不严谨地)理解成 XA 协议。XA 协议是 X/Open 提出的分布式事务处理标准。MySQL、Oracle、DB2 这些主流数据库都实现了 XA 协议,因此都能被用于实现 2PC 事务模型。
2PC 简明易懂,但存在如下的问题:
1)性能差,在准备阶段,要等待所有的参与者返回,才能进入阶段二,在这期间,各个参与者上面的相关资源被排他地锁住,参与者上面意图使用这些资源的本地事务只能等待。因为存在这种同步阻塞问题,所以影响了各个参与者的本地事务并发度;
2)准备阶段完成后,如果协调者宕机,所有的参与者都收不到提交或回滚指令,导致所有参与者“不知所措”;
3)在提交阶段,协调者向所有的参与者发送了提交指令,如果一个参与者未返回 ACK,那么协调者不知道这个参与者内部发生了什么(由于网络二将军问题的存在,这个参与者可能根本没收到提交指令,一直处于等待接收提交指令的状态;也可能收到了,并成功执行了本地提交,但返回的 ACK 由于网络故障未送到协调者上),也就无法决定下一步是否进行全体参与者的回滚。
2PC 之后又出现了 3PC,把两阶段过程变成了三阶段过程,分别是:询问阶段、准备阶段、提交或回滚阶段,这里不再详述。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,同样分为两个阶段:
1)阶段一:准备阶段。协调者调用所有的每个微服务提供的 try 接口,将整个全局事务涉及到的资源锁定住,若锁定成功 try 接口向协调者返回 yes。
2)阶段二:提交阶段。若所有的服务的 try 接口在阶段一都返回 yes,则进入提交阶段,协调者调用所有服务的 confirm 接口,各个服务进行事务提交。如果有任何一个服务的 try 接口在阶段一返回 no 或者超时,则协调者调用所有服务的 cancel 接口。
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 维护一张如下的事务状态表:
分布式事务 ID | 事务内容 | 事务状态 |
global_trx_id_1 | 操作 1:调用 repo-service 扣减库存 操作 2:调用 order-service 生成订单 |
状态 1:初始 状态 2:操作 1 成功 状态 3:操作 1、2 成功 |
初始状态为 1,每成功调用一个服务则更新一次状态,最后所有的服务调用成功,状态更新到 3。
有了这张表,就可以启动一个后台任务,扫描这张表中事务的状态,如果一个分布式事务一直(设置一个事务周期阈值)未到状态 3,说明这条事务没有成功执行,于是可以重新调用 repo-service 扣减库存、调用 order-service 生成订单。直至所有的调用成功,事务状态到 3。
如果多次重试仍未使得状态到 3,可以将事务状态置为 error,通过人工介入进行干预。
由于存在服务的调用重试,因此每个服务的接口要根据全局的分布式事务 ID 做幂等,原理同 2.4 节的幂等性实现。