分布式事务的实现方式

Sun, Sep 12, 2021 阅读时间 2 分钟

分布式基础理论

CAP

CAP 理论可以表述为,一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition Tolerance)这三项中的两项

  • C(Consistency):一致性,所有节点同时看到相同的数据,即每次写入,都是要保证所有节点都写入成功。
  • A(Availability):可用性,任何时候,读写都是成功的,即服务总是可用的,比如稳定性可以做到三个9,四个9等,这就是对可用性的描述。
  • P(Partition Tolerance):分区容错性,当部分节点故障或者网络错误时,系统仍然可用

CAP理论用反证法很容易证明,如果CAP都满足,有分区容错P,就一定不能满足一致性C。

对于分布式系统来说,P(分区容错性)是一定要的,CAP理论的实际应用模型,就是CP(强调一致)或者AP(强调可用)。AC的话,没有分区容错就是退化到单机了,不是分布式系统。

  • CP:放弃可用性,追求强一致性。对金融等安全性要求高的行业一般使用CP,在面对网络分区时,系统可能会是不可用的。典型例子:Zookeeper。
  • AP:放弃强一致性,追求可用性。这是互联网行业更常见的选择,为了保证可用,在某时刻,多个节点的数据可能不一致。典型例子:Eureka,只剩一个节点也能提供服务。

Base

Base 是三个短语的简写,即基本可用(Basically Available)、软状态(Soft State)和最终一致性(Eventually Consistent)

Base 理论的核心思想是最终一致性,即使无法做到强一致性(Strong Consistency),但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual Consistency)。

  • 基本可用

    追求任何时候读写都成功,而是系统能基本运行,它是允许损失部分可用性的,可能是响应时间变长,或者服务被降级,比如当QPS过高时的限流,一部分流量不可用,另一部分可用,整体是基本可用的,通过一些类似的手段保持系统稳定。

  • 软状态

    和事务的原子性做对比,软状态允许系统的数据存在中间状态,即允许多个节点中的数据存在一定延迟。

  • 最终一致

    数据不能一直是中间状态,必须要在经过一段时间后达成一致,这个时间取决于网络,负载,存储选型,复制方案等等因素。

一致性的模型

强一致性

当更新操作完成之后,任何多个后续进程的访问都会返回最新的更新过的值,这种是对用户最友好的,就是用户上一次写什么,下一次就保证能读到什么。根据 CAP 理论,这种实现需要牺牲可用性。

弱一致性

写入成功后,不要求立刻读到最新值,可以有一段时间的不一致。

最终一致性

最终一致是弱一致的特例,强调所有数据副本,经过一段时间的同步后,都能达到一个一致的状态。

最终一致性模型根据其提供的不同保证可以划分为更多的模型,包括因果一致性和会话一致性等:

  • 因果一致性

    有因果的操作,顺序可以得到保证,比如你写的评论和别人对你的回复,写评论的操作必须要在回复之前。没有因果关系的数据,则可以允许操作顺序不一致。

  • 会话一致性

    会话一致,就是一个会话中,可以做到读己之所写,比如MySQL的MVCC,同一个事务中写的数据,这个事务中是可以读到的,不需要等事务提交。

Base理论就是CAP理论的实际应用,由于CAP的制约,通过放弃强一致,放弃绝对的可用,允许基本可用(允许被降级、被限流等),允许最终一致,相当于保证P的情况下,割舍了一部分A和C。Base理论面向的是高可用、可扩展的分布式系统,在实际开发中更加常见。绝对的CP模型,则适合传统金融等业务。

什么是分布式事务

分布式事务关注的是分布式场景下如何处理事务,是指事务的参与者、支持事务操作的服务器、存储等资源分别位于分布式系统的不同节点之上。简单来说,分布式事务就是一个业务操作,是由多个细分操作完成的,而这些细分操作又分布在不同的服务器上;分布式事务,就是这些操作要么全部成功执行,要么全部不执行。

在实际开发中,分布式事务产生的原因主要来源于存储和服务的拆分

  • 存储层拆分

    典型的就是数据库的分库分表,一般来说,当单表容量打到千万级别,就要考虑拆分,那么拆分后,要实现跨库跨表的更新,就产生了分布式事务。

  • 服务层拆分

    一个大系统的各个业务拆分成一个一个的微服务,那么多个业务操作之间,就也可能需要分布式事务。

分布式事务的解决方案

分布式事务的解决方案,典型的有两阶段和三阶段提交协议、 TCC 分段提交,和基于消息补偿的最终一致性设计。

首先我们看一下基于消息补偿的最终一致性设计:

基于消息补偿的最终一致性设计

顾名思义,就是通过异步补偿的方式,保证最终一致性。主要有本地消息表,和第三方可靠消息表等。我们以本地消息表为例进行说明:

这是一种业务耦合的设计,消息生产者需要额外建立一个事务消息表,并记录消息发送状态,消息消费方需要处理这个消息,并完成自己的业务逻辑,另外会有一个异步线程来定期扫描未完成的消息,确保最终一致性。

上图是一个创建订单减库存的例子。订单系统收到下单请求,创建一个订单插入数据库,并且同时存储该订单对应的消息数据,比如购买商品的ID和数量,消息和订单表是同一个数据库,他们的写入可以放在一个本地事务中,需要保证同时成功或失败。

然后订单系统会把扣库存的消息发到消息中间件,库存系统收到消息中间件的消息,就扣库存,库存扣成功了,就把执行成功或失败的结果发给订单服务。

订单服务收到扣库存成功,就把消息表中这个消息置为已完成。订单服务还需要有一个异步任务,定期扫描消息表,发现有失败的,就重试(努力送达,Best Effort)。

如果业务允许,这种方案还可以做成不要求最终一致性的柔性事务:不强制要求异步处理的消息一定完成,尽力完成,比如多重试几次就得了,而不是无限制去重试,允许数据还是有可能会最终不一致。这种情况下如果出现了事务执行失败,需要人工介入去解决。

还要注意的是,这种实现方式,订单系统的订单是一定会创建的,它没有回滚。就是说,会把第一个子事务作为分布式事务执行成功的标志,然后尽力去完成第二个子事务。

利用Canal

Canal是阿里开源的中间件,可以订阅MySQL的binlog,流入其他的数据引擎。在这里我们其实可以直接用Canal将事务消息表的数据流入到消息队列中,通过这种方式解耦订单服务和消息队列的耦合,也是个不错的方案。

保证幂等的方式

由于网络等复杂原因,可能由于努力送达的时候,出现多次重试或者消息重复投递导致另一个子事务执行了多次。为了防止这种情况下可能对业务产生的影响,任何一个子事务都要保证幂等性。并且在后续介绍的分布式事务实现方式里,幂等性也都是十分重要的。

  • 天然就幂等的操作不需要额外处理

  • 可以利用数据库去重

    在消费者事务(上面例子中的库存扣减事务)中,也添加一个事务消息表,记录某个事务是否处理过,如果处理过的话,重复的消息投递就不再处理,以此来保证幂等。

    为了对每个分布式事务进行唯一的标记,需要给每个事务添加一个全局唯一的事务ID,这样消费者事务才可以识别重复事务。

两阶段提交(2PC)

事务协调者

一台服务器在执行本地事务时,无法知道其他服务器的执行情况的,所以所有的一致性算法中,都需要一个leader进程进行多个节点之间的协调者,大多数分布式事务的实现方案里,都会有这样一个角色。

二阶段提交的实现

二阶段提交协议中,首先会存在一个协调者,其他是事务的参与者,所有节点之都可以网络通信。所有节点都采用预写式日志,日志写入后被保存在可靠的存储设备上,即使节点损坏也不会导致日志的丢失。并且所有节点不会永久性损坏,损坏后也可以恢复。

二阶段提交的流程:

二阶段提交分成了两部分:Commit-Request 提交请求阶段,和 Commit 提交阶段

  • Commit-request阶段

    在提交请求阶段,协调者通知所有事务参与者,准备提交各自的事务。然后事务的参与者将告知协调者自己是否可以提交,同意(事务参与者本地事务执行成功)或者取消(本地事务执行故障)。在这个阶段,事务参与者并没有进行commit操作。

  • Commit提交阶段

    协调者收到所有事务参与者的投票后进行决策,提交还是取消这个事务,要提交必须所有事务参与者都同意提交,事务参与者收到协调者发来的消息后将执行对应的操作,即本地Commit或者Rollback。

二阶段提交的问题

  1. 资源被同步阻塞

    在执行过程中,所有参与节点都是事务独占状态,当参与者占有公共资源时,那么其他服务访问事务参与者的公共资源就会被阻塞。

  2. 协调者可能出现单点故障

    在事务提交阶段,如果有的事务参与者没收到提交的信号,则就会造成有的事务提交了,而另一些事务没提交。

MySQL的redo log的两阶段提交

MySQL采用两阶段协议来完成分布式事务的处理,即XA规范。为了保证binlog和redo log的一致性。binlog被当做事务协调者,以binlog的写入成功作为事务提交成功的标志。如果在第一或者第二步失败,则整个事务回滚,如果是第三步失败,MySQL重启后会检查XID是否已经提交,若没有提交,则会再执行一次提交。这部分内容,可以查看Redo log的两阶段提交这篇文章。

两阶段提交结合消息队列的实现

由于两节点提交需要增加一个事务协调者的角色,所以稍显复杂。如果不追求强一致,可以用消息队列异步完成。如下图:

二阶段提交虽然有一些问题,但是性能很好,实现简单,所以应用很多,XA规范就是基于二阶段提交理论。

三阶段提交(3PC)

为了解决二阶段协议中的同步阻塞等问题,三阶段提交在协调者和参与者中都添加了超时机制,二阶段变成了三步:询问、锁资源、提交。

三阶段提交的实现

三阶段提交分为 CanCommit、PreCommit、DoCommit 三个阶段:

  • CanCommit阶段

    类似二阶段提交的准备提交阶段,协调者向所有参与者发送Can-Commit请求,参与者如果可以提交就返回yes,否则就返回no。

  • PreCommit阶段

    协调者根据所有参与者的响应决定执行何种操作:

    1. 如果都是yes,则发送预提交请求:协调者向参与者发送PreCommit请求,并进入Prepared阶段参与者收到 PreCommit 请求后,会执行事务操作。如果参与者成功执行了事务,则回复 ACK 给协调者。

    2. 如果有no或者等待某个参与者时执行超时了,则协调者会向所有参与者发送 abort 中断请求。参与者收到abort后,则执行事务的中断。

  • DoCommit阶段

    该阶段也分为两种情况:

    1. 协调者收到了所有参与者的 ACK 完成预提交,协调者会向所有参与者发送执行请求。参与者收到执行请求后,执行正式的事务提交,参与者完成事务提交后,向协调者返回 ACK 。协调者收到所有的ACK,完成事务。
    2. 中断事务的情况,协调者没有收到参与者发送的 ACK,可能是因为参与者没有发送 ACK,也可能是网络超时了,这时协调者会执行中断事务的操作。对于参与者来说,**参与者如果没有收到协调者的 DoCommit 通知,超时后会自动执行 Commit **。

三阶段提交相比二阶段提交的改进

  1. 引入了超时机制:2PC中,只有协调者有超时机制,如果一定时间没收到参与者的消息则默认失败,3PC在协调者和参与者中都添加了超时机制。
  2. 添加了预提交阶段:PreCommit是一个缓冲,保证了在最后提交阶段之前各个节点的状态是一致的。

三阶段提交的问题

  • 如果在DoCommit阶段,参与者在收到了PreCommit之后,网络断了,那么在超时后,参与者依然会完成提交,而协调者超时后会进行事务中断,这就造成了这个节点的事务提交了, 而其他节点的事务却中断回滚了,这就造成了脑裂问题。
  • 除了脑裂,三阶段还存在交互量巨大从而造成系统消息负载过大的问题。

三阶段提交很少应用在实际的分布式事务设计中,还是二阶段提交更常用。

TCC事务模型

TCC(Try-Confirm-Cancel),基于业务层面的事务定义,锁粒度完全由业务自己控制,目的是解决复杂业务中,跨表跨库等大颗粒的资源锁定问题,TCC把事务运行过程分成Try、Confirm/Cancel两个阶段,每个阶段的逻辑由业务代码控制,避免了长事务,以获得更高的性能。

TCC的各个阶段

如上图,每个服务都提供了三个接口,Try、Confirm、Cancel,try接口是由业务应用(想要发起一个分布式事务的应用)调用的,Confirm和Cancel是由事务协调者调用的。

  • Try 阶段:

    调用 Try 接口,尝试执行业务,完成所有业务检查,预留业务资源。

  • Confirm/Cancel阶段

    • Confirm 操作,对业务系统做确认提交,确认执行业务操作,使用try阶段预留的业务资源
    • Cancel 操作:在业务执行错误,需要回滚的状态下执行业务取消,释放预留资源

Try阶段如果失败了,根据失败的类型,业务应用可以决定要继续 Confirm 还是 Cancel,如果是 Confirm 或者Cancel 失败了,TCC中会添加事务日志,并进行重试,所以服务的 Confirm 和 Cancel 接口需要是幂等的,如果重试依然失败,则需要人工进行处理。

TCC的优点

  1. TCC是将二阶段提交上升到服务层面来做,避免传统的二阶段提交中长事务带来的低性能。
  2. TCC对业务进行拆解,让应用自己定义数据库操作的粒度,降低锁冲突,提高业务吞吐

TCC的缺点

  1. 侵入性强,必须对微服务进行改造,每个业务逻辑,都要实现Try、Confirm、Cancel三个接口,而且Confirm和Cancel还要保证幂等。
  2. TCC的事务协调器要记录事务日志,写磁盘,也会带来性能消耗。

一个TCC的例子

上图是一个电商中的支付业务。用户在支付以后,需要进行更新订单状态、扣减账户余额、增加账户积分和扣减商品操作,而这些操作是一个分布式事务。

支付业务的子事务拆解:

  • 订单更新为支付完成状态
  • 扣减用户账户余额
  • 增加用户账户积分
  • 扣减当前商品的库存

根据事务拆解,我们要改造业务系统,给各个服务接口都提供Try、Confirm、Cancel接口:

  • Try:

    Try 操作都是锁定某个资源,设置一个预备的状态,冻结部分数据,比如订单服务给这个订单设置一个预备状态字段,通过这个字段冻结订单的操作,而不是直接修改订单为支付成功。

    库存服务也要冻结库存,可以在库存表增加一个字段标识冻结,也可以添加一个新的库存冻结表,专门存储冻结的库存数据,积分服务也一样,增加字段表示积分数量预增加。

  • Confirm

    Confirm 就是把try的锁定的资源提交,包括订单状态修改为支付成功,库存数据要减去,积分数据增加。

  • Cancel

    Cancel 操作要回滚,订单服务去掉预备状态,更改为待支付或者取消状态,库存服务删除冻结库存,添加到可销售中,积分就是把增加的积分减掉。

TCC和2PC的区别

2PC或者XA,是数据库存储资源层面的事务,实现的是强一致性,两阶段提交的整个阶段,一直会持有数据库的锁。

TCC 关注的是业务层的正确提交和回滚,try阶段不涉及加锁,只是冻结资源,是业务层的分布式事务,关注最终一致性,不会一直持有各个资源的锁。

TCC 的核心思想:就是针对每个业务操作,都要提供一个对应的准备、确认和补偿操作,同时把相关的处理,从数据库转移到业务中

TCC模型常见的三种异常

  1. 空回滚

    空回滚就是一个分布式事务,在没有调用Try时,调用了Cancel方法,这种情况下就需要识别出来。

    比如增加一张事务控制表,每次Try时都记录事务ID,当调用了Cancel方法时,先查表,没有这个事务,直接返回成功即可。

  2. 幂等

    这个就不说了,Confirm和Cancel都要保证幂等。

  3. 悬挂

    当Cancel调用发生在了Try之前时,因为我们解决了空回滚,这时,就会一直挂在Try这里,资源一直被锁定。

    Seata的解决方法是,也是每次回滚也记录一张表,调Try时,查这张表,如果发现这个事务ID已经Cancel过了,就可以认为这个事务已经完事了,返回即可。

SAGA

Saga事务模型和TCC一样都是一种补偿协议,每个操作,都要提供自己的正向操作和逆向回滚操作,和TCC的区别在于,SAGA执行事务时,每个子事务是依次执行的,一个子事务执行成功了,才会去执行下一个子事务,一旦一个子事务执行失败,则前面执行成功的子事务都要回滚。如下图:

SAGA的这种执行方式就导致了事务的执行周期较长,适合于那种层层审核的业务,比如金融系统。

Seata

Seata是阿里开源的分布式事务解决方案,它提供了AT、TCC、SAGA、XA四种事务模型。

Seata中有三个核心的角色

  • TC(Transaction Coordinator) :事务协调者,是Server端,要单独部署,维护全局和分支事务的状态,驱动全局事务提交或回滚;
  • TM(Transaction Manager) :是Client端,由业务服务集成,定义全局事务,发起分布式事务,和决定是提交还是回滚的某个业务服务。
  • RM(Resource Manager) :是Client端,由业务服务集成,管理分支事务处理的资源,具体处理子事务的。

如上图,Seata管理的一个分布式事务的整个过程大致如下

  • TM要求TC开始一项新的全局事务。TC生成代表全局事务的XID
  • XID会通过微服务的调用链传播到各个子事务所在的业务系统中。
  • RM将本地事务注册为这个XIDTC的全局事务的分支子事务。
  • TM要求TC提交或回退相应的XID全局事务。
  • TC驱动XID的相应全局事务下的所有分支事务以完成分支提交或回滚。

具体的事务执行过程,则是根据选择的事务模型不同,有所区别。