分布式事务调研


由于组内最近需要对某个业务进行拆分,进行微服务部署,所以需要解决在分布式系统下事务一致性的问题。

先抛出一个常见的业务场景:

在一个电商系统中,有多个微服务:订单服务、库存服务、积分服务等,那么在一个订单被支付后,需要执行以下步骤:

  1. 订单服务:更改订单状态为已支付;
  2. 库存服务:对应商品扣减库存;
  3. 积分服务:用户增加相应积分。

那么在这种情况下,我们如何保证数据的一致性?

1. XA模式(2PC)

XA是一种需要数据库支持的分布式事务解决方案,主要原理如下图:

2. Seata-AT模式

Seata是阿里开源的一款分布式事务框架,支持AT模式、TCC模式、SAGA模式、XA模式,下面主要讲一些AT模式。

AT模式的详细说明可以参考官方文档。

在AT模式下,用户只需关系自己的业务SQL,用户的业务SQL作为一阶段,Seata框架会自动生成事务的二阶段提交和回滚操作。

和其他分布式事务框架相比,Seata有以下几个亮点:

  1. 应用层基于SQL解析实现了自动补偿,从而最大程度的降低业务侵入性;
  2. 将分布式事务中TC(事务协调者)独立部署,负责事务的注册、回滚;
  3. 通过全局锁实现了写隔离与读隔离。

但是Seata在实现上述功能时,同时也增加了很多开销,如:

一条Update的SQL,则需要全局事务xid获取(与TC通讯)、before image(解析SQL,查询一次数据库)、after image(查询一次数据库)、insert undo log(写一次数据库)、before commit(与TC通讯,判断锁冲突),这些操作都需要一次远程通讯RPC,而且是同步的。

另外undo log写入时blob字段的插入性能也是不高的。每条写SQL都会增加这么多开销,粗略估计会增加5倍响应时间。(参考文章:分布式事务选型的取舍)

官网中也有说明,读隔离在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted)

如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。

SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。

出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。

3. TCC

3.1 TCC的基本概念

TCC是Try-Confirm-Cancel的简称

  1. Try阶段
    • 完成所有业务检查(一致性),预留业务资源(隔离性)
  2. Confirm阶段
    • 确认执行业务操作,不做任何业务检查, 只使用Try阶段预留的业务资源
  3. Cancel阶段
    • 取消Try阶段预留的业务资源。

3.2 TCC的运行逻辑

TCC对业务入侵比较严重,代入上述业务场景,对每个接口我们都要实现三个逻辑Try-Confirm-Cancel。

  1. 订单服务
    1. Try阶段:修改订单状态为UPDATING(修改中);
    2. Confirm阶段:修改订单状态为DONE;
    3. Cancel阶段:回滚订单状态为WAITING_PAY(待支付).
  2. 库存服务,假设订单中商品数量为a
    1. Try阶段:可售库存数量减a,冻结库存加a;
    2. Confirm阶段:冻结库存减a;
    3. Cancel阶段:冻结库存减a,可售库存加a;
  3. 积分服务,假设用户此次需增加积分b分
    1. Try阶段:预增加积分加b;
    2. Confirm阶段:实际积分加b,预增加积分减b;
    3. Cancel阶段:预增加积分减b;

在我们选择一个TCC分布式事务框架之后,运行逻辑如下:

  1. 根据服务链路依次执行各服务的Try逻辑;
  2. 如果Try逻辑都执行正常,则TCC框架会推进执行Confrim逻辑,完成整个事务;
  3. 如果某个服务的Try逻辑异常,则TCC框架感知到后会推进执行Cancel逻辑,撤销之前的Try操作,完成事务回滚。

3.3 TCC需要注意的问题

  1. 空回滚
    • 在TCC没有调用Try请求,直接调用第二阶段的Cancel请求时,Cancel请求需要识别出这是一个空回滚,直接返回成功;
  2. 防悬挂控制
    • 在因网络拥堵导致超时的情况下,可能会出现二阶段Cancel请求比一阶段Try请求先执行;此时应该允许空回滚,但要拒绝在执行空回滚之后到来的一阶段Try请求;
  3. 幂等性
    • 无论是网络数据包重传,还是异常事务的补偿执行,都会导致TCC服务的Try、Confirm或者Cancel操作被重复执行;用户在实现TCC服务时,需要考虑幂等控制,即Try、Confirm、Cancel 执行一次和执行多次的业务结果是一样的;
  4. 业务数据可见性
    • 处于Try阶段之后,Confirm/Cancel阶段之前的预留数据,应该如何展示?需要业务自行定义。

4. SAGA

Seata中支持SAGA模式。

在Saga模式中,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者,一阶段正向服务和二阶段补偿服务都由业务开发实现。

适用场景:

  • 业务流程长、业务流程多
  • 参与者包含其它公司或遗留系统服务,无法提供 TCC 模式要求的三个接口

优势:

  • 一阶段提交本地事务,无锁,高性能
  • 事件驱动架构,参与者可异步执行,高吞吐
  • 补偿服务易于实现

缺点:

  • 不保证隔离性

5. 消息中间件保证数据最终一致性

这种方式是通过可靠的消息中间件来来实现数据的最终一致性:

  1. 订单服务更改订单状态为已支付,同时发送MQ消息给消息中间件;
  2. 库存服务消费这条MQ消息,进行扣减库存;
  3. 积分服务消费这条MQ消息,进行增加积分。

这个需要解决的问题是:如何保证订单服务更改订单状态和发送MQ消息是原子性的?

目前部分开源的MQ(如RocketMQ、DDMQ)实现了对事务消息的支持。

5.1 RocketMQ事务消息

RocketMQ采用了2PC的思想来实现了提交事务消息,同时增加一个补偿逻辑来处理二阶段超时或者失败的消息。详情见Github。

主要分为两个流程:正常事务消息的发送及提交、事务消息的补偿流程。

  1. 事务消息的发送及提交
    1. 发送消息(half消息);
    2. 服务端响应消息写入结果;
    3. 根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)。
    4. 根据本地事务状态执行Commit或者Rollback(Commit操作生成消息索引,消息对消费者可见)
  2. 补偿流程,用于解决消息Commit或者Rollback发生超时或者失败的情况
    1. 对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查”
    2. Producer收到回查消息,检查回查消息对应的本地事务的状态
    3. 根据本地事务状态,重新Commit或者Rollback

5.2 劣势

劣势:只保证了生产者和消息队列的一致性,如果我们需要保证生产者和消费者的一致性则无法解决;比如订单服务更改订单状态成功并成功发送MQ消息 --> 库存服务消费MQ消息进行扣减库存时发现库存不足,无法扣减。这时就无法保证订单和库存的数据一致性。

6. 各个模式的对比

分布式事务模式 侵入式 性能 读隔离 依赖项
XA模式 无脏读 数据库需支持XA模式
AT模式 一般 脏读 TC为单独部署的Server服务器
TCC模式 较高 中间态 事务日志存储
Saga模式 非常高 脏读 事务日志存储
事务消息 非常高 脏读 需要消息中间件支持