支付系统的TCC

Page content

TCC

TCC 分布式事务模型直接作用于服务层。不与具体的服务框架耦合,与底层 RPC 协议无关,与底层存储介质无关,可以灵活选择业务资源的锁定粒度,减少资源锁持有时间,可扩展性好,可以说是为独立部署的 SOA 服务而设计的。

1. TCC 模型优势

对于 TCC 分布式事务模型,笔者认为其在业务场景应用上,有两方面的意义。

1.1 跨服务的分布式事务

服务的拆分,也可以认为是资源的横向扩展,只不过方向不同而已。

横向扩展可能沿着两个方向发展:

  • 功能扩展,根据功能对数据进行分组,并将不同的功能组分布在多个不同的数据库上,这实际上就是 SOA 架构下的服务化。
  • 数据分片,在功能组内部将数据拆分到多个数据库上,为横向扩展增加一个新的维度。

横向扩展的两种方法可以同时进行运用:用户信息(Users)、产品信息(Products)与交易信息(Trans)三个不同功能组可以存储在不同的数据库中。另外,每个功能组内根据其业务量可以再拆分到多个数据库中,各功能组可以相互独立地进行扩展。

因此,TCC 的其中一个作用就是在按照功能横向扩展资源时,保证多资源访问的事务属性。

1.2 两阶段拆分

TCC 另一个作用就是把两阶段拆分成了两个独立的阶段,通过资源业务锁定的方式进行关联。 资源业务锁定方式的好处在于,既不会阻塞其他事务在第一阶段对于相同资源的继续使用,也不会影响本事务第二阶段的正确执行。

TCC 模型进一步减少了资源锁的持有时间。 同时,从理论上来说,只要业务允许,事务的第二阶段什么时候执行都可以,反正资源已经业务锁定,不会有其他事务动用该事务锁定的资源。

这对业务有什么好处呢?拿支付宝的担保交易场景来说,简化情况下,只需要涉及两个服务,交易服务和账务服务。交易作为主业务服务,账务作为从业务服务,提供 Try、Commit、Cancel 接口:

  • Try 接口扣除用户可用资金,转移到预冻结资金。预冻结资金就是业务锁定方案,每个事务第二阶段只能使用本事务的预冻结资金,在第一阶段执行结束后,其他并发事务也可以继续处理用户的可用资金。
  • Commit 接口扣除预冻结资金,增加中间账户可用资金(担保交易不能立即把钱打给商户,需要有一个中间账户来暂存)。

假设只有一个中间账户的情况下,每次调用支付服务的 Commit 接口,都会锁定中间账户,中间账户存在热点性能问题。 但是,在担保交易场景中,七天以后才需要将资金从中间账户划拨给商户,中间账户并不需要对外展示。 因此,在执行完支付服务的第一阶段后,就可以认为本次交易的支付环节已经完成,并向用户和商户返回支付成功的结果,并不需要马上执行支付服务二阶段的 Commit 接口,等到低锋期时,再慢慢消化,异步地执行。

可能部分读者认为担保交易比较特殊,其实直付交易(直接把钱打到商户账户的交易模式,Commit接口扣除预冻结资金以后,不是转移到中间账务,而是直接转移到商户账户)也可以这样使用,只要提前告知商户,高峰期交易资金不是实时到账,但保证在一定时间之内结算完成,商户应该也是可以理解的。

这就是 TCC 分布式事务模型的二阶段异步化功能,从业务服务的第一阶段执行成功,主业务服务就可以提交完成,然后再由框架异步的执行各从业务服务的第二阶段。

2. 通用型 TCC 解决方案

通用型 TCC 解决方案就是最典型的 TCC 分布式事务模型实现,所有从业务服务都需要参与到主业务服务的决策当中。

由于从业务服务是同步调用,其结果会影响到主业务服务的决策,因此通用型 TCC 分布式事务解决方案适用于执行时间确定且较短的业务,比如互联网金融企业最核心的三个服务:交易、支付、账务:

当用户发起一笔交易时,首先访问交易服务,创建交易订单;然后交易服务调用支付服务为该交易创建支付订单,执行收款动作,最后支付服务调用账务服务记录账户流水和记账。

为了保证三个服务一起完成一笔交易,要么同时成功,要么同时失败,可以使用通用型 TCC 解决方案,将这三个服务放在一个分布式事务中,交易作为主业务服务,支付作为从业务服务,账务作为支付服务的嵌套从业务服务,由 TCC 模型保证事务的原子性。

支付服务的 Try 接口创建支付订单,开启嵌套分布式事务,并调用账务服务的 Try 接口; 账务服务在 Try 接口中冻结买家资金。一阶段调用完成后,交易完成,提交本地事务,由 TCC 框架完成分布式事务各从业务服务二阶段的调用。

支付服务二阶段先调用账务服务的 Confirm 接口,扣除买家冻结资金;增加卖家可用资金。调用成功后,支付服务修改支付订单为完成状态,完成支付。

当支付和账务服务二阶段都调用完成后,整个分布式事务结束。

3. TCC接口设计

TCC 异常

  • 空回滚:什么是空回滚?空回滚就是对于一个分布式事务,在没有调用 TCC 资源 Try 方法的情况下,调用了二阶段的 Cancel 方法,Cancel 方法需要识别出这是一个空回滚,然后直接返回成功。

  • 幂等:幂等就是对于同一个分布式事务的同一个分支事务,重复去调用该分支事务的第二阶段接口,因此,要求 TCC 的二阶段 Confirm 和 Cancel 接口保证幂等,不会重复使用或者释放资源。如果幂等控制没有做好,很有可能导致资损等严重问题。

  • 防悬挂:按照惯例,咱们来先讲讲什么是悬挂。悬挂就是对于一个分布式事务,其二阶段 Cancel 接口比 Try 接口先执行。因为允许空回滚的原因,Cancel 接口认为 Try 接口没执行,空回滚直接返回成功。

设计

在分析完空回滚、幂等、悬挂等异常 Case 的成因以及解决方案以后,下面我们就综合起来考虑,一个 TCC 接口如何完整的解决这三个问题。

首先是 Try 方法。结合前面讲到空回滚和悬挂异常,Try 方法主要需要考虑两个问题,一个是 Try 方法需要能够告诉二阶段接口,已经预留业务资源成功。第二个是需要检查第二阶段是否已经执行完成,如果已完成,则不再执行。因此,Try 方法的逻辑可以如图所示:

先插入事务控制表记录,如果插入成功,说明第二阶段还没有执行,可以继续执行第一阶段。 如果插入失败,则说明第二阶段已经执行或正在执行,则抛出异常,终止即可。

接下来是 Confirm 方法。因为 Confirm 方法不允许空回滚,也就是说,Confirm 方法一定要在 Try 方法之后执行。 因此,Confirm 方法只需要关注重复提交的问题。 可以先锁定事务记录,

  • 如果事务记录为空,则说明是一个空提交,不允许,终止执行。
  • 如果事务记录不为空,则继续检查状态是否为初始化,如果是,则说明一阶段正确执行,那二阶段正常执行即可。
  • 如果状态是已提交,则认为是重复提交,直接返回成功即可;
  • 如果状态是已回滚,也是一个异常,一个已回滚的事务,不能重新提交,需要能够拦截到这种异常情况,并报警。

最后是 Cancel 方法。因为 Cancel 方法允许空回滚,并且要在先执行的情况下,让 Try 方法感知到 Cancel 已经执行,所以和 Confirm 方法略有不同。 首先依然是锁定事务记录。

  • 如果事务记录为空,则认为 Try 方法还没执行,即是空回滚。空回滚的情况下,应该先插入一条事务记录,确保后续的 Try 方法不会再执行。
  • 如果插入成功,则说明 Try 方法还没有执行,空回滚继续执行。
  • 如果插入失败,则认为 Try 方法正再执行,等待 TC 的重试即可。
  • 如果一开始读取事务记录不为空,则说明 Try 方法已经执行完毕,再检查状态是否为初始化,如果是,则还没有执行过其他二阶段方法,正常执行 Cancel 逻辑。如果状态为已回滚,则说明这是重复调用,允许幂等,直接返回成功即可。
  • 如果状态为已提交,则同样是一个异常,一个已提交的事务,不能再次回滚。

通过这一部分的讲解,大家应该对 TCC 模型下最常见的三类异常 Case,空回滚、幂等、悬挂的成因有所了解,也从实际例子中知道了怎么解决这三类异常,在解决了这三类异常的情况下,我们的 TCC 接口设计就是比较完备的了。

4. TCC优化

同库模式

第一个优化方案是改为同库模式。 同库模式简单来说,就是分支事务记录与业务数据在相同的库中。

什么意思呢?之前提到,在注册分支事务记录的时候,框架的调用方切面会先向 TC 注册一个分支事务记录,注册成功后,才会继续往下执行 RPC 调用。 TC 在收到分支事务记录注册请求后,会往自己的数据库里插入一条分支事务记录,从而保证事务数据的持久化存储。 那同库模式就是调用方切面不再向 TC 注册了,而是直接往业务的数据库里插入一条事务记录。

先给大家简单讲讲同库模式的恢复逻辑。 一个分布式事务的提交或回滚还是由发起方通知 TC,但是由于分支事务记录保存在业务数据库,而不是 TC 端。 因此,TC 不知道有哪些分支事务记录,在收到提交或回滚的通知后,仅仅是记录一下该分布式事务的状态。

那分支事务记录怎么真正执行第二阶段呢? 需要在各个参与者内部启动一个异步任务,定期捞取业务数据库中未结束的分支事务记录,然后向 TC 检查整个分布式事务的状态。 TC 在收到这个请求后,会根据之前保存的分布式事务的状态,告诉参与者是提交还是回滚,从而完成分支事务记录。

TCC异步化

什么是异步化。TCC 模型的一个作用就是把两阶段拆分成了两个独立的阶段,通过资源业务锁定的方式进行关联。资源业务锁定方式的好处在于,既不会阻塞其他事务在第一阶段对于相同资源的继续使用,也不会影响本事务第二阶段的正确执行。

从理论上来说,只要业务允许,事务的第二阶段什么时候执行都可以,反正资源已经业务锁定,不会有其他事务动用该事务锁定的资源。

假设只有一个中间账户的情况下,每次调用支付服务的 Commit 接口,都会锁定中间账户,中间账户存在热点性能问题。

例如,在担保交易场景中,七天以后才需要将资金从中间账户划拨给商户,中间账户并不需要对外展示。 因此,在执行完支付服务的第一阶段后,就可以认为本次交易的支付环节已经完成,并向用户和商户返回支付成功的结果,并不需要马上执行支付服务二阶段的 Commit 接口,等到低锋期时,再慢慢消化,异步地执行。

5. TCC数据表设计 - 同库模式

数据表设计

同库模式简单来说,就是分支事务记录与业务数据在相同的库中。

我们可以基于user_id % 1000分表,同一个用户的 wallet, money_movement, transaction表可以落到同一个DB。 这样对于单个用户,可以充分利用MySQL事务来保证数据更新的一致性。

// 事务控制表 也可作为流水表
CREATE TABLE `transaction_tab_0000` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) unsigned NOT NULL,
  `transaction_id` bigint(20) unsigned NOT NULL,
  `transaction_type` tinyint(3) NOT NULL DEFAULT '0',
  `status` tinyint(3) NOT NULL,
  `amount` bigint(20) NOT NULL,
  `create_time` bigint(20) NOT NULL DEFAULT '0',
  `update_time` bigint(20) NOT NULL DEFAULT '0',
  `caller_id` bigint(20) unsigned NOT NULL,
  `reference_id` varchar(256) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_transaction_id` (`transaction_id`),
  UNIQUE KEY `uk_ref_index` (`caller_id`,`transaction_type`,`reference_id`),
  KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=966 DEFAULT CHARSET=utf8mb4;

// 资金动账表
CREATE TABLE `money_movement_tab_0000` (
  `money_movement_id` bigint(20) unsigned NOT NULL,
  `user_id` bigint(20) NOT NULL,
  `transaction_id` bigint(20) unsigned NOT NULL,
  `wallet_id` bigint(20) unsigned NOT NULL,
  `wallet_type` int(10) unsigned NOT NULL,
  `balance_changed` bigint(20) NOT NULL,
  `balance_before` bigint(20) NOT NULL,
  `balance_after` bigint(20) NOT NULL,
  `create_time` bigint(20) unsigned NOT NULL,
  PRIMARY KEY (`money_movement_id`),
  KEY `idx_transaction_id` (`transaction_id`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

// 用户钱包表
CREATE TABLE `wallet_tab_0000` (
  `wallet_id` bigint(20) unsigned NOT NULL,
  `wallet_type` tinyint(3) unsigned NOT NULL,
  `user_id` bigint(20) unsigned NOT NULL,
  `balance` bigint(20) NOT NULL,
  `create_time` bigint(20) unsigned NOT NULL,
  `update_time` bigint(20) unsigned NOT NULL,
  PRIMARY KEY (`wallet_id`),
  KEY `uniq_user_id_wallet_type` (`user_id`, `wallet_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

枚举字段

wallet type:

  • deposit 用户余额
  • moneyout 流出的额度
  • settlement 系统结算的额度
  • moneyin 流入的额度

sum(deposit + moneyout + moneyin + settlement) = 0

tranaction status:

  • 1 tried
  • 2 comfirmed
  • 3 canceled

支付订单示例

用户的资金流:

  • Try: deposit -> moneyout
  • Confrim: moneyout -> settlement
  • Cancel: moneyout -> deposit

中间帐户的资金流:

  • Try: settlement -> moneyin
  • Confirm: moneyin -> deposit
  • Cancel: moneyin -> settlement

Try Step

User Wallet:
  user_id: 123
  deposit: 1000
  moneyout: 0
  moneyin: 0
  settlement: -1000

Start DB Atomic
  - select wallet for update where uid=123
  - insert transaction - txn_type: payment, status: tried
  - insert money movement
  - update wallet balance:
    deposit (-100): 扣除钱包余额 1000 - 100 = 900
    moneyout (+ 100): 冻结100块,相当于预留了资源 0 + 100 = 100
End DB Atomic

如果前面已经有一笔100的订单,还没confirm;这时又发起另外一笔200的订单;这里也Try可以继续处理:

    deposit (-200): 扣除钱包余额 900 - 200 = 700
    moneyout (+ 200): 冻结200块,相当于预留了资源 100 + 200 = 300

因为try预留了资源,confirm异步处理也是没有问题的。

Confirm Step

Start DB Atomic
  - select wallet for update where uid=123
  - update transaction from tried to confirmed - txn_type: payment
  - insert money movement
  - update wallet balance:
    moneyout (-100): 100 - 100 = 0
    settlement (+ 100): 把钱放到结算钱包 -1000 + 100 = -900
End DB Atomic

Cancel Step

Start DB Atomic
  - select wallet for update where uid=123
  - update transaction from tried to canceled
  - insert money movement
  - update wallet balance:
    moneyout (-100): 100 - 100 = 0
    deposit (+ 100): 恢复钱包余额 900 + 100 = 1000
End DB Atomic
本文由 络壳 原创或整理,转载请注明出处