支付系统: TCC

Page content

CAP 理论

在分布式系统领域,有一个理论,对于分布式系统的设计影响非常大,那就是 CAP 理论,即对于一个分布式系统而言,它是无法同时满足 Consistency(强一致性)、Availability(可用性) 和 Partition tolerance(分区容忍性) 这三个条件的,最多只能满足其中两个。

但在实际中,由于网络环境是不可信的,所以分区容忍性几乎是必不可选的,设计者基本就是在一致性和可用性之间做选择。

当然大部分情况下,大家都会选择牺牲一部分的一致性来保证可用性(可用性较差的系统非常影响用户体验的,但是对另一些场景,比如支付场景,强一致性是必须要满足)。 但是分布式系统又无法彻底放弃一致性(Consistency),如果真的放弃一致性,那么就说明这个系统中的数据根本不可信,数据也就没有意义,那么这个系统也就没有任何价值可言。

CAP 理论三个特性的详细含义如下:

  • 一致性(Consistency):每次读取要么是最新的数据,要么是一个错误;
  • 可用性(Availability):client 在任何时刻的读写操作都能在限定的延迟内完成的,即每次请求都能获得一个响应(非错误),但不保证是最新的数据;
  • 分区容忍性(Partition tolerance):在大规模分布式系统中,网络分区现象,即分区间的机器无法进行网络通信的情况是必然会发生的,系统应该能保证在这种情况下可以正常工作。

两阶段提交协议(2PC)

要解决的问题

二阶段提交协议(Two-phase Commit,即2PC)是常用的分布式事务解决方案,它可以保证在分布式事务中,要么所有参与进程都提交事务,要么都取消事务,即实现 ACID 的原子性(A)。

在数据一致性中,它的含义是:要么所有副本(备份数据)同时修改某个数值,要么都不更改,以此来保证数据的强一致性。

2PC 要解决的问题可以简单总结为:在分布式系统中,每个节点虽然可以知道自己的操作是成功还是失败,却是无法知道其他节点的操作状态。 当一个事务需要跨越多个节点时,为了保持事务的 ACID 特性,需要引入一个作为协调者的组件来统一掌控所有节点(参与者)的操作结果并最终指示这些节点是否要把操作结果进行真正的提交(比如将更新后的数据写入磁盘等等)。

因此,二阶段提交的算法思路可以概括为:参与者将操作结果通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。

2PC 分为两个过程

  • 表决阶段:此时 Coordinator(协调者)向所有的参与者发送一个 vote request,参与者在收到这请求后,如果准备好了就会向 Coordinator 发送一个 VOTE_COMMIT 消息作为回应,告知 Coordinator 自己已经做好了准备,否则会返回一个 VOTE_ABORT 消息;
  • 提交阶段:Coordinator 收到所有参与者的表决信息,如果所有参与者一致认为可以提交事务,那么 Coordinator 就会发送 GLOBAL_COMMIT 消息,否则发送 GLOBAL_ABORT 消息;对于参与者而言,如果收到 GLOBAL_COMMIT 消息,就会提交本地事务,否则就会取消本地事务。

2PC 优缺点

简单总结一下 2PC 的优缺点:

  • 优点:原理简洁清晰、实现方便;
  • 缺点:同步阻塞、单点问题、某些情况可能导致数据不一致。

关于这几个缺点,在实际应用中,都是对2PC 做了相应的改造:

  • 同步阻塞:2PC 有几个过程(比如 Coordinator 等待所有参与者表决的过程中)都是同步阻塞的,在实际的应用中,这可能会导致长阻塞问题,这个问题是通过超时判断机制来解决的,但并不能完全解决同步阻塞问题;
  • Coordinator 单点问题:实际生产应用中,Coordinator 都会有相应的备选节点; 数据不一致:如果在第二阶段,Coordinator 和参与者都出现挂掉的情况下,是有可能导致数据不一致的。

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. 同库模式 数据表设计

数据表设计

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

我们可以基于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',
  `ext_biz` bigint(20) unsigned NOT NULL,
  `ext_order_no` varchar(256) NOT NULL,
  `extra_data` JSON,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_transaction_id` (`transaction_id`),
  UNIQUE KEY `uk_ext_index` (`ext_order_no`, `ext_biz`,`transaction_type`),
  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,
  `extra_data` JSON,
  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,
  `version` bigint(20) unsigned NOT NULL,
  PRIMARY KEY (`wallet_id`),
  UNIQUE 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

  • tried
  • comfirmed
  • canceled

交易类型 - tranaction type

  • Topup 充值
  • Payment 支付
  • Transfer 转账
  • Withdrawal 取款
  • Request 收款

支付订单示例

用户的资金流:

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

中间帐户的资金流:

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

6. TCC示例

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