分布式事务是个复杂的问题, 针对不通的业务场景, 实现的方式也各式各样, 今天主要从下面两个方面:
- 为什么? 为什么需要分布式事务;
- 怎么做? 分布式事务如何实现;
来展开, 希望这场分享下来, 大家能对分布式事务有个基础的了解;
1. 为什么需要分布式事务?
分布式事务是伴随分布式产生的, 我们通过架构的演进来看下为什么需要分布式事务;
假设我们是一家互金公司, 刚开始的时候, 都处于一台服务器上: 有个场景是用户下单购买了基金, 这个时候需要扣账户余额, 并给用户加资产;
当所有业务都处于一个DB的Connection的时候, 可以使用单机事务来保证, 落订单成功了, 必然会给用户加上资产的;
但是随着公司的发展, 单个应用已经撑不起现有业务了, 而且随着业务的扩展, 需要进入为微服务阶段了:
这只是一个场景: 将原有的系统做了拆分, 有了三个微服务: 订单, 账户和资产, 服务间通过RPC进行信息交互; 这三个微服务共用同一个DB实例;
还是刚才用户购买基金, 但是可以看到服务拆分虽然降低了系统间的耦合, 但是也导致了原本的单机事务不可用了(这里的不可用指的是跨服务之间的写入操作, 单系统内仍然可以使用单机事务), 所以我现在其实是无法保证落订单成功必定会加资产的;
和单机事务出现的原因一样, 正因为出现了三态问题, 我们就需要引入一种机制, 来帮业务系统处理掉中间态;
2. 分布式事务如何实现
2.1 分布式常见的问题
2.1.1 部分失效问题
当一项工作需要多个节点参与时, 其中的一些节点可能会失败, 甚至你不会收到明确的结果;
2.1.2 不可靠的网络
我们的应用基本都是互联网应用, 系统间的交互都是基于网络的, 虽然平时在说网络调用时都说的是同步阻塞的, 但是我们所处的网络大部分都是异步分组网络(asynchronous packet networks); 也就是说一个节点给另一个节点发送数据时, 网络不能保证数据包什么时候到达, 甚至不能保证一定到达; 可以参考一下场景:
- 请求都没发出去(被拔网线);
- 请求阻塞了, 但是随后会发出去;
- 请求的远程节点可能挂了(断电或者什么原因);
- 远程节点收到了, 但是还没处理(比如在GC), 但是一段时间后会处理;
- 远程节点响应了, 但是在网络中丢了(比如交换机配置错误, 响应到另一台机器上去了);
- 远程节点响应了, 但是响应被阻塞了, 但是恢复后会发出;
再回过来看, 如果我们请求方没有收到响应, 是因为
- 请求没发出?
- 远程节点不可用?
- 远程节点挂起了?
- 还是远程节点响应了, 我们没收到?
其实我们是不知道远程阶段的状态的, 只是知道我们没有收到响应;
还有个词需要专门提一下, 网络分区; 简单来说就是有A, B, C三个节点, AB能正常通信, 但是AB无法与C通信, 那么就说AB与C形成了网络分区;
2.1.3 不可靠的时钟
2.1.4 拜占庭将军问题
2.2 CAP
在理论计算机科学中,CAP定理(CAP theorem),又被称作布鲁尔定理(Brewer's theorem),它指出对于一个分布式计算系统来说,不可能同时满足以下三点:[1][2]
- 一致性(Consistency) (等同于所有节点访问同一份最新的数据副本)
- 可用性(Availability)(每次请求都能获取到非错的响应——但是不保证获取的数据为最新数据)
- 分区容错性(Partition tolerance)(以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择[3]。)
虽然CAP看似给了三个选项, 但这里面的P其实是个必须项, 因为前面也说了, 网络是不可靠, 那么就是从C 和 A做取舍, 最终结果是CP 和 AP;
其实我觉得CAP考虑的还是比较片面的, 主要原因是P只是考虑网络异常中的网络分区, 延时节点或者死亡节点压根就没提;
2.2.1 CAP的一致性
这里指的就是线性一致性, 这个线性一致性怎么理解呢? 读写并发的时候, 只有一个读已经读取了新数据, 该读取之后的所有读取都应该读取的是新数据;
这里的线性一致性有前提 就是按时间轴, 从左至右不会不会出现回退的情况, 怎么理解:
注: 此模型不假设隔离性;
看最后一个读取 read x = 2是非法的, 因为db1已经做了CAS操作, 且最新的X值已经被db2读取到了, 那么db3就不应该还能读取到2了;
所以线性一致性通过记录所有请求和响应的时间, 再校验他们能否排列成一个时间有序的数组;
线性一致性 和 可序列化(串行化)
串行化: 指的是事务并发的时候, 一个事务必定在前一个事务完成以后才会执行; 这里事务执行的先后顺序可以和事务实际顺序不同;
线性一致性: 线性一致性保证的是读取和写入的新鲜度保证, 但是线性一致性不是事务, 所以他不会防止写入偏差;
2.3 如何实现分布式事务
2.3.1 回滚型事务: 两阶段提交(2PC)
单机事务的原子性是由数据的落盘顺序决定的: 在数据最终落盘之前, 事务都有可能被中止的;
但是分布式事务涉及到了多个节点, 那么仅向这些节点发送并独立提交事务是不可行的, 因为这样很容易违反原子性: 一些节点上提交成功了, 但是另一些节点上提交失败了; 为了解决这种情况, 必须有一个机制来协调这些节点的事务提交;
必须要注意一点是:事务的提交是不可撤销的, 原因是如果事务的提交是可撤销的, 那么该事务之后的所有写入都需要被撤销, 这个是不现实的;
接下来看下2PC是怎么保证原子提交的;
TC: 事务协调器: 主要作用就是来协调各个分支的提交与回滚的; 分布式事务的原子提交逻辑主要在TC上面;
参与者: 各个系统上的数据库;
主要流程:
- 当应用想要启动一个分布式事务时,它向协调者请求一个事务ID。此事务ID是全局唯一的。
- 应用在每个参与者上启动单节点事务,并在单节点事务上捎带上这个全局事务ID。所有的读写都是在这些单节点事务中各自完成的。如果在这个阶段出现任何问题(例如,节点崩溃或请求超时),则协调者或任何参与者都可以中止。
- 当应用准备提交时,协调者向所有参与者发送一个准备请求,并打上全局事务ID的标记。如果任意一个请求失败或超时,则协调者向所有参与者发送针对该事务ID的中止请求。
- 参与者收到准备请求时,需要确保在任意情况下都的确可以提交事务。这包括将所有事务数据写入磁盘(出现故障,电源故障,或硬盘空间不足都不能是稍后拒绝提交的理由)以及检查是否存在任何冲突或违反约束。通过向协调者回答“是”,节点承诺,只要请求,这个事务一定可以不出差错地提交。换句话说,参与者放弃了中止事务的权利,但没有实际提交。
- 当协调者收到所有准备请求的答复时,会就提交或中止事务作出明确的决定(只有在所有参与者投赞成票的情况下才会提交)。协调者必须把这个决定写到磁盘上的事务日志中,如果它随后就崩溃,恢复后也能知道自己所做的决定。这被称为提交点(commit point)。
- 一旦协调者的决定落盘,提交或放弃请求会发送给所有参与者。如果这个请求失败或超时,协调者必须永远保持重试,直到成功为止。没有回头路:如果已经做出决定,不管需要多少次重试它都必须被执行。如果参与者在此期间崩溃,事务将在其恢复后提交——由于参与者投了赞成,因此恢复后它不能拒绝提交。
上面是只有TC的情况, 可以发现TC的业务其实非常重的, 那么在生产中一般会抽象出一个TM(事务管理器), TM主要是用来定义事务边界和事务编排的, 一般事务的发起者就是TM; 下面看一下具有TM的2PC是怎么做的:
2PC根据CAP来看的话, 是属于CP的, 也就是为了一致性牺牲了可用性;
2PC的问题
- 2PC要求所有参与者在全局事务提交之前, 各个分支事务的资源都必须锁住; 那么如果一阶段完成到全局事务提交之前, 各个参与者锁住的资源其实不可用的; 这个时候如果其中某个参与延迟特别高, 就会导致全局事务变成长事务, 其他参与者的资源长时间处于不可用的状态;
- 2PC的协调者如果不做高可用的话, 协调者挂了, 所有参与者都只能被动的等待协调者重启, 在这段时间内, 锁住的资源同样得不到释放;
2.3.2 补偿型事务
2.3.2.1 TCC
在回滚型事务不理想的情况下, 我们的想法是想让一阶段结束, 就让参与者释放锁; 然而因为事务是不可撤销的, 那么如果参与者出现了异常怎么处理; 我们就需要引入补偿, 二阶段的时候再起一个事务, 对一阶段的操作补偿;
补偿型事务中人气最高的是TCC, TCC是try, confirm, cancel的简写; 下面我们来看看TCC是怎么实现分布式事务的:
如果只是看图的话, 和回滚型的2PC基本没啥区别的, 但是TCC和2PC最大的区别是锁的粒度, TCC把二阶段的锁控制交给了业务层来做处理, 分布式事务随着一阶段的提交, 也就释放了锁; 因此使用TCC需要对业务做改造, 下面基于基金的购买来说明下如何做业务改造;
首先看account的改造:
假设原本的表结构是:
account_no | user_id | amount |
---|---|---|
账户 | 用户ID | 可用金额 |
那么需要在该表中引入一个中间态的金额: 冻结金额
account_no | user_id | amount | frozen_amount |
---|---|---|---|
账户 | 用户ID | 可用金额 | 冻结金额 |
同样position中也需要引入两个中间态: 一个是购买中的金额, 另一个是赎回中的金额;
position_id | user_id | capital | buying_capital | taking_capital |
---|---|---|---|---|
资产ID | 用户ID | 持有中的资产 | 购买中的资产 | 赎回中的资产 |
表结构改造完成以后, 结合图片来看下TCC是具体怎么执行的;
a. TM会开启全局事务, 获取全局事务ID;
b. TM调用各个分支事务的分支事务的try方法:
order_try: insert order status = 0(初始化状态, 表示处理中);
account_try: update account set amount = amount -100, frozen_amount = frozen_amount + 100;
position_try: update position set buying_capital = buying_capital + 100;
c. TM 等待各个分支事务的ACK;
d. 如果各个分支事务try的ACK都是OK, 那么就需要发送commit指令给TC做全局事务的提交;
e. TC调用分支事务的confirm方法:
order_confirm: update order set status = 1(成功) where id = 1;
account_confirm: udpate account set frozen_amount = frozen_amount - 100;
position_confirm: update position set capital = capital + 100, buying_capital = buying_capital - 100;
f. 如果其中有个分支事务的ACK是失败或者超时了, 那么就需要调用cancel做补偿:
g. TC调用分支事务的cancel做补偿:
order_cancel: update order set status = -1(失败) where id = 1;
account_cancel: update account set amount = amount + 100, frozen_amount = frozen_amount + 100;
position_cancel: update position set buying_amount = buying_amount - 100;
h. 如果在二阶段(confirm | cancel)TC没有接收到分支事务二阶段的ACK, 是需要再次调用confirm | cancel方法的, 所以TCC中的confirm | cancel方法一定要是幂等的;
TCC存在的问题
虽然TCC通过将一个全局事务拆分为两个小事务, 一阶段 和 二阶段的事务, 灵活性和可用性得到了保证; 但是刚才的讲解中也可以看到, TCC对业务的侵入性很高:
- 需要提供三个接口, try, confirm, cancel;
- 需要对现有业务做改造, 需要处理一阶段造成的中间态数据;
- 如果是不可控的第三方业务, TCC是无法做的(不可能去推动第三方做TCC改造);
那么又没有即不需要做业务改造, 又能避免2PC的性能问题的呢? SAGA可能是一个解决方案;
2.3.2.2 SAGA
SAGA的解决方案其实和TCC挺相似的, 也是将长事务拆分; 但是SAGA的一阶段不是预留资源, 而是和2PC一样直接修改并提交; 如果在某个分支事务执行出错了, 需要按顺序回滚之前的所有分支事务, 按顺序怎么理解呢? 还是刚才的例子:
order -> account -> position; 结果在position加资产的时候出异常了, 那么SAGA会进行回滚, 顺序是:
account -> order; 也就是回滚的顺序是倒序;
虽然SAGA相对TCC省去了业务改造, 并且也省去了prepare阶段的资源占有; 但是这种直接操作的结果是对其他业务线程可见的, 就会导致充值场景下, 一阶段给用户加了资金, 但是二阶段需要回滚, 但是用户已经消费了, 导致回滚失败的情况;
2.3.3 通知型事务
通知型事务的产生是为了解决2PC的性能问题, TCC的业务侵入性, 将事务看成消息, 由消费者来保证最终一致性;
通知型事务又可以分为可靠通知和最大努力通知;
2.3.3.1 最大努力通知
最大努力通知有个前提就是服务存在上下游关系, 上游(provider)成功了, 通知的消费者必须保证消费成功, 如果消费失败会重试机制; 如果还是重试失败, 上游必须提供个接口给下游查询事务成功后数据;
这个场景比较常用的是跨系统间的信息交互, 比如支付网关和业务系统; 支付网关支付成功后会通知业务系统, 业务系统消费失败达到次数限制后会反查支付网管的数据;
2.3.3.2 可靠通知
可靠通知可以看成是支持回滚的最大努力通知, 在达到重试次数后, 允许下游执行事务回滚; 这样就没有了消费者必须成功的约束了;
我们回看分布式事务下的ACID保证,原子性(Atomicity)和持久性(Durability)与传统事务无异,但一致性(Consistency)与隔离性(Isolation)上除了2PC完全满足外, 其他的补偿型与通知型事务都有或多或少的缺失,它们都强调最终一致性,即允许在一段可接受的时间内各节点数据不一致,由于它们多半是将大事务分解成一个个本地小事务,所以在一段时间也存在隔离性问题;