分布式事务

[TOC]

分布式事务

指事务的操作位于不同的节点上,需要保证事务的 ACID 特性。
例如在下单场景下,库存和订单如果不在同一个节点上,就涉及分布式事务。

两阶段提交(2PC)

两阶段提交(Two-phase Commit,2PC),通过引入协调者(Coordinator)来协调参与者的行为,并最终决定这
些参与者是否要真正执行事务。

  1. 运行过程
    1.1 准备阶段
    协调者询问参与者事务是否执行成功,参与者发回事务执行结果。

    image-20201114200146983

1.2 提交阶段
如果事务在每个参与者上都执行成功,事务协调者发送通知让参与者提交事务;否则,协调者发送通知让参与者回滚
事务。
需要注意的是,在准备阶段,参与者执行了事务,但是还未提交。只有在提交阶段接收到协调者发来的通知后,才进
行提交或者回滚。

image-20201114200203810

存在的问题
2.1 同步阻塞

可以看到在第一阶段执行了准备命令后,我们每个本地资源都处于锁定状态,因为除了事务的提交之外啥都做了。

所以这时候如果本地的其他请求要访问同一个资源,比如要修改商品表 id 等于 100 的那条数据,那么此时是被阻塞住的,必须等待前面事务的完结2.2 单点问题
协调者在 2PC 中起到非常大的作用,发生故障将会造成很大影响。特别是在阶段二发生故障,所有参与者会一直等
待,无法完成其它操作。

2.3 数据不一致
在阶段二,如果协调者只发送了部分 Commit 消息,此时网络发生异常,那么只有部分参与者接收到 Commit 消
息,也就是说只有部分参与者提交了事务,使得系统数据不一致。

1
(如果任一参与者(Cohort)节点在第一阶段返回的响应消息为"No",或者协调者(Coordinator)节点在第一阶段的询问超时之前无法获取所有参与者(Cohort)节点的响应消息时:)

3PC

三阶段提交(Three-phase commit),是二阶段提交(2PC)的改进版本。3PC 的引入是为了解决 2PC 同步阻塞和减少数据不一致的情况。

与两阶段提交不同的是,三阶段提交有两个改动点。

  1. 引入超时机制。同时在协调者和参与者中都引入超时机制。
  2. 在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。也就是说,除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommit、PreCommit、DoCommit三个阶段。

在这里插入图片描述

CanCommit:协调者向所有参与者发送CanCommit命令,询问是否可以执行事务提交操作。如果全部响应YES则进入下一个阶段。

PreCommit协调者向所有参与者发送PreCommit命令,询问是否可以进行事务的预提交操作,参与者接收到PreCommit请求后,如参与者成功的执行了事务操作,则返回Yes响应,进入最终commit阶段。一旦参与者中有向协调者发送了No响应,协调者向所有参与者发送abort请求,参与者接受abort命令执行事务的中断。

DoCommit: 在前两个阶段中所有参与者的响应反馈均是YES后,协调者向参与者发送DoCommit命令正式提交事务,如协调者没有接收到参与者发送的ACK响应,会向所有参与者发送abort请求命令,执行事务的中断。

优缺点

优点:

同时在协调者和参与者中都引入超时机制。 改善同步阻塞 改善单点故障

img

TCC 方案

TCC 就是一种业务层面或者是应用层的两阶段提交

TCC 分为指代 Try、Confirm、Cancel

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

Try

  • 做资源预留(比如冻结库存,而不是直接减库存)。

Confirm

  • 确认提交,在Try阶段所有事务参与者执行成功之后开始执行Confirm,通常情况下,TCC默认Confirm是不会出错的,认为只要Try成功,则Confirm一定成功,若Confirm真的出错了,需要采用重试机制或者人工干预。

Cancel

  • 执行回滚,在Try阶段有事务参与者执行失败则开始执行Cancel,通常情况下,TCC默认Cancel是不会出错的,认为只要Try成功,则Cancel一定成功,若Cancel真的出错了,需要采用重试机制或者人工干预。

基于消息中间件的最终一致性事务方案

本地消息表与业务数据表处于同一个数据库中,这样就能利用本地事务来保证在对这两个表的操作满足事务特性,并
且使用了消息队列来保证最终一致性。

  1. 在分布式事务操作的一方完成写业务数据的操作之后向本地消息表发送一个消息,本地事务能保证这个消息一
    定会被写入本地消息表中。

  2. 之后将本地消息表中的消息转发到消息队列中,如果转发成功则将消息从本地消息表中删除,否则继续重新转
    发。

  3. 在分布式事务操作的另一方从消息队列中读取一个消息,并执行消息中的操作。

    image-20201114202427598

Seata

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

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。

所以说这点在编码的时候还是得注意下的。