分布式事务系列(1) — 基础理论篇

1,360 阅读25分钟

事务

本地事务

我们通常对事务的理解就是:事务是指由一组操作组成的一个工作单元,这组操作要么同时成功,要么同时失败,没有中间状态。

这种事务通常说的就是 本地事务,单体应用一般会将所有数据存储在一个数据库中,然后借助关系数据库来完成事务的控制。

比如下面有一个极简化版的商城系统,在这个单体系统中,所有数据都是存储在一个数据库中的,所有的业务逻辑也都是在一个应用中的。用户下单的业务会包含很多步骤,这些步骤要么都成功,要么都要回滚,只要其中一步失败,所有操作都会回滚。

单体应用.jpg

对应到Java代码中,代码看起来就像下面这个样子:

@Transactional(rollbackFor = Exception.class)
public Boolean submitOrder(OrderDTO orderDTO) {
    // 创建订单
    Order order = orderService.createOrder(genOrderInfo(orderDTO));
    // 向商家转账
    financeService.transfer(orderDTO.getUserAccountId(), orderDTO.getBusinessAccountId(), orderDTO.getTotalAmount());
    // 创建销售出库单(内部还会再创建物流单)
    wmsService.createSaleDeliveryOrder(genSaleDeliveryOrder(order));
    // 给用户增加积分
    Double updatedPoint = orderDTO.getTotalAmount() * 0.1;
    promotionService.incrementIntegral(orderDTO.getUserAccountId(), updatedPoint);
    return true;
}

在 submitOrder 方法中,通过调用各个 Service 组件来完成提交订单的业务功能。然后在方法上加上 spring 的事务注解 @Transactional 来做事务控制。

虽然我们只是简单的加了一个 @Transactional 注解就能控制事务了,但其实它就是 AOP 的思想,通过拦截要调用的方法,在方法调用前开启事务,方法执行完就提交事务,方法抛出异常就回滚事务。

而它的底层其实就是在调用数据库的API,比如MySQL中可以通过如下语句来控制事务:

-- 开启事务
START TRANSACTION;

-- 提交事务
COMMIT;

-- 回滚事务
ROLLBACK;

也就是说本地事务或者说单体应用中,其实是借助关系数据库来完成事务控制的。

分布式事务

单体应用的问题很明显,所有的业务逻辑都在一个服务中,耦合严重、不利于维护、服务重,不好扩容等等,所以现在一般都是微服务应用。

微服务化之后,我们的系统就是分布式的了,分布式系统会把一个应用系统拆分为可独立部署的多个服务。微服务虽然解决了单体应用的一些问题,但也会引入另外一些问题,其中之一就是分布式事务问题。

微服务之间可能会相互协作来完成业务操作,相当于把之前的一组不可分割的操作单元分散到了不同的服务,然后服务之间通过远程通信来互相调用。服务内部依然可以通过本地事务来做事务控制,但服务之间的操作则无法再依赖本地事务来控制了。

比如之前的商城系统微服务化后,拆分了商城服务、订单中心、支付中心、仓储中心、物流中心、促销中心几个微服务,然后每个服务都会对应一个独立的数据库。

分布式应用 (1).jpg

之前在 submitOrder 方法中的本地 Service 组件调用也都会变成远程RPC调用,@Transactional 注解只能控制本地连接的数据库的事务,其它服务的事务则无法控制。比如用户下单之后,购物中心调用订单服务创建订单,然后调用支付服务进行支付转账,支付转账成功之后,调用仓储中心发货,如果此时仓储服务接收到请求,但由于网络问题未响应购物中心,那购物中心认为发货到底成功没有呢?如果失败了,支付已经成功了,钱怎么回滚?创建好的订单怎么回滚?

所以这种场景下,就需要分布式事务技术来保证操作的原子性了,调用链中的所有操作要么都成功,要么都回滚。

需要注意的是,分布式事务并不一定是在微服务中才会出现,也不是访问了不同的数据库。除了上面不同服务对应不同数据库的场景,下面两种场景也会出现分布式事务。比如商城系统并不会拆分微服务,但是拆分了数据库,然后系统中使用不同的数据源来连接各个数据库,这也是分布式事务。如果商城系统拆分了微服务,但是数据库并未拆分,服务间的调用同样会出现分布式事务。

分布式事务场景.jpg

基础理论

ACID

在单体应用中,一个业务操作可能会对数据库进行多次操作,数据的一致性则由数据库的事务来保证,这依赖的就是关系型数据库的 ACID 理论,也就是事务的基本特性。

  • A(Atomic)原子性:原子性指一个数据库事务中的所有操作是不可分割的单元,只有事务中所有的数据库操作都执行成功,才算整个事务成功,才能提交事务。事务中任何一个SQL语句执行失败,已经执行成功的SQL语句也必须撤销,数据库状态应该退回到执行事务前的状态。

  • C(Consistency)一致性:一致性指事务将数据库从一种状态转变为下一种一致的状态。在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏,比如唯一性约束、外键约束等。

  • I(Isolation)隔离性:事务的隔离性要求并发中的事务是相互隔离的,不同的事务操作相同的数据时,每个事务应独立互不干扰。对应到数据库就有读未提交、读已提交、可重复读、串行化4种事务隔离级别,如果没有事务隔离的话,就可能会有更新丢失、脏读、不可重复读、幻读的问题。

  • D(Durability)持久性:持久性要求事务一旦提交,其结果就是永久性的。即使发生宕机等故障,数据库也能将数据恢复。

关于MySQL数据库对事务 ACID 支持的细节原理可参考我的专栏:【MySQL系列专栏

CAP 理论

分布式系统领域有个重要的 CAP 理论,CAP 理论是分布式事务处理的理论基础,了解了CAP理论有助于我们研究分布式事务的处理方案。

该理论提到了分布式系统的 CAP 三个特性:

C(Consistency)数据一致性

分布式系统的最大难点,就是各个节点的状态如何同步。数据在存在多副本的情况下,可能由于网络、机器故障、软件系统等问题导致数据写入部分副本成功,部分副本失败,进而造成副本之间数据不一致,读取出来的数据可能就是脏数据。

数据一致性又分为强一致性弱一致性最终一致性,平常说的一致性通常指强一致性。强一致性就是 ACID 理论所强调的,数据更新会立刻同步到其它节点;弱一致性在数据更新后就无法知道其它节点是否都同步成功;最终一致性则是数据更新后,可能一段时间不一致,但最后过一段时间后一定是一致的。

在分布式系统中,如果能够做到针对一个数据项的更新操作执行成功后,所有副本都能同步更新,保证用户读到的都是最新的值,那么这样的系统就被认为具有强一致性严格一致性

Redis 主从异步同步时,主节点数据同步到从节点时是异步的,那么主节点写入一条数据后,此时客户端从 redis 从节点查数据可能就查不到,但过一会就查到了,这就是最终一致性

A(Availability)可用性

可用性是指系统提供的服务必须一直处于可用的状态,在任何时候客户端对集群进行读写操作时,请求能够正常响应,并在一定的延时内完成。

P(Partition Tolerance)分区容错性

分区容错性要求分布式系统在遇到任何网络分区故障的时候,集群仍然能够对外提供服务,除非是整个网络环境都发生了故障。

网络分区也就是俗称的“脑裂”,是指在分布式系统中,不同的节点分布在不同的子网络中,由于一些特殊的原因导致这些子网络之间出现网络不连通的状况,但各个子网络的内部网络是正常的,从而导致整个系统的网络环境被切分成了若干个孤立的区域。


CAP 理论主要告诉我们,一个分布式系统不可能同时满足一致性、可用性和分区容错性,三者之间不可能有交集,最多只能同时满足其中的两项。

分布式系统中,网络条件相对不可控,分区容错无法避免,因此可以认为 CAP 的 P 总是成立,剩下的 C 和 A 无法同时做到,所以只能在 AP 和 CP 之间进行选择。

image.png

【CP】

满足 CP 比较经典的就是一些分布式存储,比如 zookeeper、mongodb、hbase 等等,它们都是保证数据一致性。当出现数据无法同步时,此时就会牺牲掉 A,客户端可能就会收到异常,但不会让你请求到不一致的数据。

比如 ZooKeeper,它是满足 CP,即数据一致性。Zookeeper 在收到客户端写操作时,会立刻同步到其它节点,但 ZooKeeper 默认并不是严格的强一致,它在过半数节点同步成功之后就返回,就会认为已经同步成功,那么客户端就可能读到未同步的节点的数据,这就是弱一致性。如果需要强一致,可以在读取数据的时候先执行一下 sync() 操作,即与 leader 节点先同步下数据,这样才能保证强一致。在发生网络分区的时候,如果 follower 节点与 leader 节点无法连通,那么对这个 follower 节点的读写请求将会报错,通过报错来让客户端不会看到不一致的数据,这就保证了一致性,但这就无法满足 可用性了。

【AP】

大多数业务类系统都是满足 AP,比如 12306、电商网站等,它们主要是满足系统的高可用性,数据一般都是最终一致。比如我在 12306 APP上买票,购票前发现还有余票,但是下单时却告诉我无票了,但是再看还是有余票的,也就是它的车票库存扣减并没有及时同步。

再比如注册中心组件 Eureka,它是满足 AP,即高可用性。Eureka 采用 Peer to Peer 的对等复制模式,副本之间不分主从,任何副本都可以接收写操作,然后每个副本之间相互异步的进行数据更新同步。在出现网络分区的时候,Eureka 会继续提供服务注册及发现功能,但是 Eureka 副本之间的数据就无法同步,这就无法满足 一致性。Eureka 认为服务注册及发现中心保留可用及过期的数据总比丢失掉可用的数据好,这样应用实例的注册信息在集群的所有节点间就不是强一致的。

如果想详细了解 Eureka 的架构设计和集群设计,可参考我的专栏:【SpringCloud系列专栏

BASE 理论

BASE 是对 CAP 中一致性可用性权衡的结果,BASE 是希望 CAP 基本都可以实现,但不要求全部100%实现。其核心思想是每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到基本可用最终一致性

BASE 包含如下三要素:

BA(Basically Available)基本可用

基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性,比如响应时间可能会延迟,某些节点不可用的时候通过降级或限流来保证基本的可用性等。

S(Soft state)软状态

指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。那么各个节点之间的数据在一段时间内可能就会不一致,这个不一致的状态就是软状态。

E(Eventually consistent)最终一致性

最终一致性强调的是,虽然存在软状态,但系统中所有的数据副本最终还是能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。

BASE 理论面向的是大型高可用可扩展的分布式系统,和传统事务的 ACID 特性是相反的,它完全不同于 ACID 的强一致性模型,而是提出通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态。但同时,在实际的分布式场景中,不同业务单元和组件对数据一致性的要求是不同的,因此在具体的分布式系统架构设计过程中,ACID 特性与 BASE 理论往往又会结合在一起使用。

分布式事务方案

X/Open DTP 模型

如果一个业务操作涉及对多个数据源进行操作,那么使用原来单一数据库的本地事务来控制就会不能满足全局事务数据的一致性要求。为此 X/Open 组织定义了 DTP(Distributed Transaction Processing) 模型,来规范全局事务处理。

这个 DTP 模型中有如下几个角色:

  • AP(Application Program)应用程序:就是要使用分布式事务的应用程序。

  • RM(Resource Manager)资源管理器:通常就是指数据库,要访问的资源在数据库中,然后数据库会保障资源的 ACID 特性。

  • TM(Transaction Manager)事务管理器:就是在系统里嵌入的一个专门管理横跨多个数据库事务的一个组件,主要是给事务分配唯一标识,负责事务的启动、提交及回滚,保障全局事务的原子性。

  • CRM(Communication Resource Manager)通信资源管理器:一般是由消息中间件来作为这个组件,也可以没有。

然后还有个 XA 规范,XA 定义了 TM 与 RM 之间交互的接口规范,TM 用它来通知数据库事务的开始结束以及提交回滚等。XA 仅仅是个规范,具体的实现是数据库产商来提供的,比如 MySQL 提供了 XA 规范的接口函数和类库实现。

这几者的关系如下:

image.png

2PC

上面所说的 DTP 模型、XA 其实都只是定义了一套规范,2PC 则定义了实现分布式事务过程的一些细节。

2PC,Two-Phase Commit,即 两阶段提交协议。两阶段提交协议是将事务的提交过程分成了准备阶段提交阶段来处理,主要是 TM、RM 参与,TM 和 RM 通信的接口就是 XA 接口。

1、准备阶段

当我们的应用程序向 TM 请求开启一个全局事务,在准备阶段,TM 会向参与此次事务的各个 RM 发起预提交事务的请求。其它应用收到请求后,会在本地开启事务,执行SQL,它会记录相关的事务日志,但是不会提交事务。如果执行失败,不能提交事务,则回滚已经处理的操作,然后返回失败。如果执行成功,可以提交事务,则返回成功。

image.png

2、提交阶段

如果准备阶段所有 RM 都返回成功,TM 就再向各个 RM 发送提交事务请求,然后应用程序就会提交之前的本地事务。但是在准备阶段只要有一个 RM 返回失败,TM 就会发送回滚事务请求,然后应用程序回滚本地事务。最后,各个 RM 将执行结果反馈给 TM,然后 TM 释放占用的相关资源。

image.png

2PC 的缺点

两阶段提交协议主要用于实现强一致性,原理简单,实现方便,但是缺点也很明显。

  • 同步阻塞

两阶段提交协议存在的最明显也是最大的一个问题就是同步阻塞,这会极大地限制分布式系统的性能。在两阶段提交的执行过程中,所有参与该事务操作的逻辑都处于阻塞状态,而且在准备阶段占用的资源,一直到分布式事务完成才会释放,这个过程中如果其他人要访问这个资源,也会被阻塞住。

  • 单点故障

TM 是个单点,一旦 TM 出现问题,那么整个两阶段提交流程将无法运转,更为严重的是,如果 TM 是在提交阶段出现问题的话,那么其他参与者将会一直处于锁定事务资源的状态中,而无法继续完成事务操作。

  • 脑裂问题

在提交阶段 TM 向 RM 发送请求后,如果出现网络分区,也就是“脑裂”问题,那就会导致有些 RM 提交了事务,有些 RM 没提交,这就会导致整个分布式系统出现数据不一致的问题。

3PC

2PC 存在一些明显的缺点,因此就有了改进版的 3PC,即三阶段提交协议,3PC 由 CanCommit、PreCommit、DoCommit 三个阶段组成。

1、CanCommit 阶段

TM 首先发一个 CanCommit 消息给参与事务的 RM,这一步不会执行实际的SQL,其实就是看能否访问通 RM,包括 RM 自身的一些网络环境等,如果连某一个 RM 都访问不通,就没必要继续执行事务了。

image.png

2、PreCommit 阶段

如果 CanCommit 阶段所有 RM 都返回成功,那么就进入 PreCommit 阶段。TM 发送 PreCommit 消息给各个 RM,这就相当于 2PC 的准备阶段,这时 RM 会在本地开启事务执行 SQL,并记录日志,但是不会提交事务。

image.png

3、DoCommit 阶段

如果 PreCommit 阶段都返回成功,就发送 DoCommit 消息给各个 RM,RM 就会在本地提交事务,如果所有 RM 都返回成功,分布式事务就执行成功。如果 PreCommit 阶段有一个 RM 返回失败或者超时一直没有返回,TM 就会认为分布式事务失败,然后发送 abort 消息给各个 RM,让 RM 回滚事务。

image.png

3PC 相比于 2PC 的改进

  • 引入了 CanCommit 阶段,这一步首先确认所有 RM 的环境都是OK的。

  • 在 DoCommit 阶段,如果 RM 收到 PreComit 消息并返回成功了,但是等待超时没有收到 TM 发来的 DoCommit 或 abort 消息,RM 就会判定 TM 故障了,然后自己执行 DoCommit,把事务提交了。

3PC 主要是 RM 具备了超时机制,在 DoCommit 阶段等待超时就会自动提交事务。这个超时机制就是基于 CanCommit 引入的,RM 会认为,既然接收到了 PreCommit 消息,说明 CanCommit 阶段所有的 RM 一定是返回成功的,所以如果超时没有收到 DoCommit 消息,RM 就会认为其它 RM 的 PreCommit 都会成功,然后都可以自动执行事务提交。

有了这个超时机制,就不怕 TM 单点故障了,因为 RM 可以自己提交事务。然后资源阻塞的问题也会减轻,不像 2PC 中收不到 commit 消息会一直阻塞等待。但 3PC 还是会有问题,在 DoCommit 阶段,如果 TM 发送 abort 消息给一部分 RM 后挂了,这部分 RM 回滚事务,然后其余的 RM 等待超时自动提交了事务,这就会造成数据不一致的问题。

2PC 和 3PC 的技术实现都依赖于数据库的 XA 机制,XA 方案一般适用于单系统跨多个数据库的场景(多数据源),跨多个系统实现起来就比较复杂,所以微服务场景下一般不会使用 XA 方案。XA 常用的技术框架是 JTA + Atomikos

TCC

TCC 包含 Try、Confirm、Cancel 三个阶段:

  • Try:尝试执行,完成所有业务检查,预留必须的业务资源。

  • Confirm:如果所有分支的 Try 都成功了,则走到 Confirm 阶段。Confirm 真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源。

  • Cancel:如果所有分支的 Try 有一个失败了,则走到 Cancel 阶段。Cancel 释放 Try 阶段预留的业务资源。

TCC 主要有以下几个角色:

  • 主业务服务:发起事务的服务,TCC 事务的控制服务,负责整个分布式事务的编排和管理。

  • 从业务服务:就是被调用的服务,分布式事务参与者,需要提供 try、confirm、cancel 三个接口。

  • 业务活动管理器:管理分布式事务的状态,包括子事务的状态,负责在提交分布式事务的时候调用各个从业务服务的 confirm 或 cancel 接口。

用前面购物中心的例子来说明下TCC的流程:

    1. 首先主业务服务(购物中心)会开启本地事务,完成自己需要做的一些事情,但还没有提交事务。
    1. 主业务服务向业务活动管理器申请启动一个分布式事务活动,并向业务活动管理器注册各个从业务活动。
    1. 接着主业务服务调用各个从业务服务(订单中心、支付中心)的 try 接口,从业务服务在本地开启事务,完成业务检查、资源预留锁定等。比如订单中心会创建一个订单,但订单状态是待支付的;支付中心会从A账户中预留要扣减的资金,如果A账户余额不足,这一步就会返回失败。
    1. 如果所有从业务服务的 try 接口都调用成功的话,那么主业务服务就提交本地事务,然后通知业务活动管理器调用各个从业务服务的 confirm 接口。
    1. 如果某个从业务服务的 try 接口调用失败的话,那么主业务服务回滚本地事务,然后通知业务活动管理器调用各个从业务服务的 cancel 接口。
    1. 如果 confirm 过程中有失败,那么也会让业务活动管理器通知各个从业务服务 cancel 接口
    1. 最后分布式事务结束

TCC.jpg

TCC 事务机制相对于传统事务机制(2PC、XA),其特征在于它不依赖资源管理器(RM)对XA的支持,而是通过对业务逻辑的调度来实现分布式事务。如果要接入到一个 TCC 分布式事务中来,从业务服务必须改造自己的接口,原本的一个接口就变成了三个接口:Try - Confirm - Cancel。因此 TCC 对业务的侵入性比较大,与业务紧耦合,需要根据特定的场景和业务逻辑来设计相应的操作。

按照 TCC 的协议,Confirm 和 Cancel 如果返回失败,需引入重试机制或人工处理,直到最终成功。所以 TCC 三个操作都有可能被调用多次,需要保证 Try-Confirm-Cancel 三个接口的幂等性。

国内开源的 TCC 分布式事务框架有 tcc-transactionHmilyByteTCCEasyTransaction 等。

后面我会深入分析和研究 ByteTCC 分布式事务框架的源码和原理,理解 TCC 模式的实现。ByteTCC 基于Try/Confirm/Cancel 机制实现,可与 Spring 容器无缝集成,兼容 Spring 的声明式事务管理。支持 dubbo、springcloud ,可满足多数据源、跨应用、跨服务器等各种分布式事务场景的需求。

本地消息表

本地消息表的核心思想是将分布式事务拆分成本地事务进行处理,数据库中需要一张存放本地消息的表,然后通过 MQ 来通知下游服务。

下面来看看本地消息表又是如何处理的:

    1. 首先上游服务开启本地事务,完成业务操作,并将消息写入本地消息表中,这一步可以保证业务的执行和将消息放入消息表中的操作是都成功的。
    1. 接着向 MQ 投递消息。
    1. 下游服务收到消息后,开启本地事务,执行业务操作。
    1. 下游服务执行完成后,返回消息到 MQ 中。
    1. 上游服务收到消息后,处理后续业务,然后将本地消息表中的消息删掉,或者将消息状态改为完成,分布式事务结束。
    1. 还需要一个定时任务来定时从本地消息表读取未完成的消息,然后调用通知的接口,重复投递消息,所以下游服务的消费接口一定要保证幂等性。当然,消息重复投递次数有一定限制,当超出这个限制后,可以通过发邮件告警的形式,让人工介入。

本地消息表.jpg

不同于 TCC 提供了 Cancel 取消接口,本地消息表则不提供回退的补偿机制,而是通过 MQ 重复投递消息让下游服务重复消费实现的补偿机制。也就是说服务方只提供一个幂等接口,不成功就重试,直到成功为止,一直不成功则人工处理。所以本地消息表其实就是利用了系统的本地事务来实现的一种最终一致性的分布式事务方案。

本地消息表的优缺点也很明显,本地消息表无需依赖任何框架,不需要回退接口,实现逻辑简单。但是需要有 MQ 中间件以及定时重试任务,与业务强绑定,本地消息表与业务数据表在同一个库,占用业务系统资源。

可靠消息最终一致性

可靠消息最终一致性的方案适用于耗时比较长的操作,通过消息中间件做成异步调用,通过MQ消息中间件来保证消息的可靠性,实现最终一致性的一种方案。

可靠消息最终一致性方案引入了一个可靠性消息服务和MQ消息中间件,流程大致如下:

    1. 首先上游服务向可靠消息服务发送一条待确认消息。
    1. 可靠消息服务将这个待确认消息保存在本地数据库,此时不会将消息发给 MQ。
    1. 接着上游服务开启本地事务,执行业务操作。
    1. 上游服务如果执行业务失败,回滚本地事务,然后通知可靠消息服务删除之前的那条消息,事务结束。上游服务如果执行业务成功,就提交本地事务,然后向可靠消息服务发送一条确认消息。
    1. 可靠消息服务收到确认消息后,将消息状态改为已发送,并将消息投递到 MQ,这两个操作必须在一个事务中完成。
    1. 下游服务监听到消息后,在本地开启事务,执行业务操作。
    1. 业务执行完成后,对 MQ 进行 ack 操作,确认这个消息处理成功。
    1. 下游服务最后通知可靠消息服务处理完毕。
    1. 可靠消息服务收到消息后,将消息状态改为已完成

可靠消息最终一致性.jpg

可靠消息服务最终一致性方案相比于本地消息表方案来说,对业务服务的侵入性更小,使用一个独立的服务来维护消息,这个消息服务可以去做高可用,自身去保证消息投递成功,消息恢复等,不占用业务服务的资源,同时也是可复用的。同样需要注意的是,下游服务的业务接口需要保证幂等性,因为消息可能会重复投递。

最大努力通知

本地消息表方案、可靠消息最终一致性方案会保证最终一定执行成功,最大努力通知方案则不一定保证最终一定会成功,可能会失败。

最大努力通知只是说尽最大的努力达成事务的最终一致,但不一定最终成功,是一种柔性事物的思想。适用于不太核心的一些业务,例如短信通知。


本部分只是从整体上概括了常见的一些分布式事务方案,每个方案都会有一些更细节的问题有待讨论,这些就放到后面的文章中来深入分析。