The source code for this blog is available on GitHub.

Bowen's Blog.

支付系统: 转账

Bowen
Bowen
payment

在支付系统中,transfer 和 remittance 都可以翻译为“转账”,但具体使用取决于上下文和语境:

转账 vs 汇款

Transfer

  • 用于一般的资金转移,比如账户之间的转账,常用于本地或内部的支付系统。
  • 示例:Bank Transfer(银行转账)、Account-to-Account Transfer(账户间转账)。
  • 适用场景:强调资金在账户间流动的动作或过程。

Remittance

  • 更常用于跨境汇款或国际资金转移,通常指汇款行为。
  • 示例:International Remittance(国际汇款)、Overseas Remittance(海外汇款)。
  • 适用场景:涉及跨境交易,特别是在资金从一个国家发送到另一个国家的情况下。

如果是一般性的本地或内部账户间转账,建议使用 transfer。 如果是特指跨境汇款或带有明确支付目的的汇款,建议使用 remittance。

仅从逻辑上看,几乎所有的资金流动,都可以视为一种转账。

  • 充值 Topup:可以看作是用户从银行转账到用户钱包。
  • 支付 Payment:可以看作是用户从钱包转账给商家,商家可以当作一类特殊的用户。
  • 退款 Refund:可以看作是商家账户转账给用户。
  • 取款 Withdraw:可以看作是用户从账户中提现,从用户钱包转账到银行。
  • 红包 Angbao:可以看作是发红包的人给收红包的人转账。

本文会从技术角度,讨论一下在 transfer 业务中遇到的有意思的问题。

存储设计

用户余额的存储

假设,用户A给用户B转100块钱,那么我们需要从A的账户里扣掉100,同时加100块到B的账户里。 就是这么简单的一个需求,但是后端实现起来却不简单。

首先,我们需要一个地方存储用户的余额信息。通常会设计一个钱包表来记录每个用户的余额信息。

以下是一个简单的表结构:我们先考虑最简单的情况。

CREATE TABLE `wallet_tab_v1` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) NOT NULL,
  `balance` bigint(20) NOT NULL DEFAULT '0',
  `create_time` datetime NOT NULL,
  `update_time` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

钱包只有一个balance字段,用来存储用户的余额,在真实业务场景,往往不够用。

比如支付宝有个场景,银行转进去的钱,可以免费提取;但是转账收到的钱,提现需要收手续费。

这个时候,就需要在钱包表里,增加一个字段,来区分不同来源的钱。 可以新增一个 wallet_type 字段,来区分不同的钱包类型。 此时,一个user_id可以有多个子钱包,user_id和wallet_type的组合作为uk。

CREATE TABLE `wallet_tab_v2` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) NOT NULL,
  `wallet_type` tinyint(3) NOT NULL DEFAULT '0',
  `balance` bigint(20) NOT NULL DEFAULT '0',
  `create_time` datetime NOT NULL,
  `update_time` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `user_id_wallet_type` (`user_id_wallet_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

wallet_type的设计,可以根据业务场景来定义,比如:

  • bank_card_deposit: 银行卡转入的钱,可以免费提现
  • transfer_deposit: 转账转入的钱,提现需要收手续费
  • refund_deposit: 退款转入的钱,提现需要收手续费
  • fund_deposit: 从基金提现转入的钱,提现需要收手续费

用户总的可用额度,需要把所有的deposit类型的钱包的余额相加得到。

转账订单的存储

为了支持查询和对账,我们需要记录每一笔转账的详细信息。因此可以设计一张转账订单表,记录每笔转账的交易号、来源用户和目标用户,以及状态等信息。 订单表的结构可以是这样的:

CREATE TABLE `order_tab_v1` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `order_id` varchar(64) NOT NULL,
  `from_user_id` bigint(20) NOT NULL,
  `to_user_id` bigint(20) NOT NULL,
  `amount` bigint(20) NOT NULL,
  `status` tinyint(4) NOT NULL DEFAULT '0',
  `create_time` datetime NOT NULL,
  `update_time` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

转账的幂等性

幂等性指的是,同一操作多次执行,其影响与只执行一次相同。在转账场景中,如果用户因网络原因重复点击“转账”按钮,就可能导致多次请求到达后端,进而重复扣款。

解决方案

可以通过引入唯一的交易号或nonce字段来实现幂等性:

  • 前端生成nonce字段,每次发起请求时携带。
  • 后端在创建订单时将nonce字段存储到数据库中。
  • 若后续请求中携带相同nonce,则直接返回已存在的订单数据,而不是创建新的订单。

修改后的订单表结构如下:把nonce作为一个uk,这样利用数据库uk的唯一性来保证幂等性。

CREATE TABLE `order_tab_v2` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `order_id` varchar(64) NOT NULL,
  `from_user_id` bigint(20) NOT NULL,
  `to_user_id` bigint(20) NOT NULL,
  `amount` bigint(20) NOT NULL,
  `status` tinyint(4) NOT NULL DEFAULT '0',
  `create_time` datetime NOT NULL,
  `update_time` datetime NOT NULL,
  `nonce` varchar(64) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `order_id` (`order_id`),
  UNIQUE KEY `nonce` (`nonce`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

设计订单的状态机

转账流程中,订单的状态需要通过状态机进行管理。以下是一个简单的状态机设计:

  • 0:未完成 - 初始状态,表示订单刚创建。
  • 1:处理中 - 转账正在处理中,例如扣款或确认阶段。
  • 2:失败 - 转账失败,例如余额不足。
  • 3:已完成 - 转账成功。

状态流转逻辑如下:

  • 创建订单时,状态初始化为0。
  • 转账过程中,状态变更为1。
  • 转账失败时,状态变更为2;成功时,状态变更为3。

转账的原子性

在实际进行转账的时候,在创建完订单之后,需要执行下面几步:

  • 查询A的余额,判断是否足够转账;
  • 扣除A的余额;
  • 查询B的余额;
  • 增加B的余额。
  • 更新转账订单。
select balance from wallet_tab where user_id = A for update;
update wallet_tab set balance = balance - 100 where user_id = A;

select balance from wallet_tab where user_id = B for update;
update wallet_tab set balance = balance + 100 where user_id = B;

update order_tab set status = 3 where order_id = 'order_id';

这样简单的几步,隐含了很多问题:

  • 如果并发执行给A扣钱,会不会把A的余额扣成负数?在之前的文章里,我们讨论过余额扣减的问题:支付系统: 余额更新
  • 如果A扣钱成功了,但是B加钱失败了怎么办呢?
  • 如果A扣钱成功了,B加钱成功了,但是订单状态更新失败了怎么办呢?

这就是一个典型的分布式事务场景,我们需要保证A和B的操作要么都成功,要么都失败。 利用MySQL的事务的原子性,可以帮助我们解决这个问题。

start transaction;
select balance from wallet_tab where user_id = A for update;
update wallet_tab set balance = balance - 100 where user_id = A;
select balance from wallet_tab where user_id = B for update;
update wallet_tab set balance = balance + 100 where user_id = B;
update order_tab set status = 3 where order_id = 'order_id';
commit;

并发转账产生死锁

看起来,利用数据库事务解决了原子性的问题。 但是这样处理方式,有可能产生死锁。 比如:A给B转账100块,B给A转账100块,同时发生,就会产生死锁。

事务1:A给B转账账执行的SQL

start transaction;

select balance from wallet_tab where user_id = A for update;
update wallet_tab set balance = balance - 100 where user_id = A;

select balance from wallet_tab where user_id = B for update;
update wallet_tab set balance = balance + 100 where user_id = B;

update order_tab set status = 3 where order_id = 'order_id';

commit;

事务2: B给A转账执行的SQL

start transaction;

select balance from wallet_tab where user_id = B for update;
update wallet_tab set balance = balance - 100 where user_id = B;

select balance from wallet_tab where user_id = A for update;
update wallet_tab set balance = balance + 100 where user_id = A;

update order_tab set status = 3 where order_id = 'order_id';

commit;

这2个事务,会满足的死锁的4个必要条件:

  • 互斥条件:资源不能被共享,即一次只能有一个事务访问。
  • 请求和保持条件:一个事务因请求资源而阻塞时,对已获得的资源保持不放。
  • 非抢占:一个事务获得的资源在未使用完之前,不能被其他事务强行剥夺。
  • 循环等待条件:若干事务之间形成一种头尾相接的循环等待资源关系。

这个case;有一个巧妙的解决方案,就是对用户user_id进行排序,比如:A的user_id < B的user_id,永远先更新user_id更小的,这样就可以避免死锁。

分库分表的挑战

在实际的业务场景中,用户的余额表和订单表,可能会非常大,单表的数据量可能会超过MySQL的单表限制。 同时,随着业务QPS的增加,单表的并发读写压力也会增大。 这个时候,我们就需要考虑分库分表,来提高系统的可扩展性。

如果我们把wallet_tab表按照user_id进行分库分表,那么在转账的时候,就需要跨库操作。

一个简洁的分库分表方案:

  • 分表:根据user_id%10值,来决定存储在哪个wallet_tab表中。根据from_user_id和to_user_id的值,来决定存储在哪个order_tab表中。
    • wallet_tab分成10个表,wallet_tab_0, wallet_tab_1, ..., wallet_tab_9
    • order_tab也分成10个表,order_tab_0, order_tab_1, ..., order_tab_9
  • 分库:0-4的表存在db_0中,5-9的表存在db_1中

此时,如果用户A的数据在db_0,用户B的数据在db_1中;跨库操作可能带来以下问题:

  • 对A给B转账的订单,如果放在db_0,只根据用户B的user_id,无法知道转账的订单在哪个表中,用户B全部的订单列表不好查询。
  • 转账的数据库操作,就不能在一个DB事务中完成了。

订单数据改造和拆分

对于第一个问题,我们可以把一笔转账订单记录成2笔订单。

  • 第一笔订单,记录A给B转账的金额,放在db_0中。
  • 另一笔订单,记录B收到A转账的金额,放在db_1中。

order表的结构对应调整一下:

  • 用user_id存储订单所属的用户。
  • 用linked_user_id字段来关联另一个用户。
  • 新增一个link_order_id字段,来关联2笔订单。
  • 新增一个order_type字段,来区分订单的类型。可以定义3种订单:
    • transfer_send: 转出
    • transfer_receive: 转入
    • transfer_return: 退款 - 转账失败,退款给用户
CREATE TABLE `order_tab_v3` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `order_id` varchar(64) NOT NULL,
  `order_type` tinyint(4) NOT NULL DEFAULT '0',
  `user_id` bigint(20) NOT NULL,
  `linked_user_id` bigint(20) NOT NULL, -- 转账的另一方
  `amount` bigint(20) NOT NULL,
  `status` tinyint(4) NOT NULL DEFAULT '0',
  `create_time` datetime NOT NULL,
  `update_time` datetime NOT NULL,
  `nonce` varchar(64) NOT NULL,
  `linked_order_id` varchar(64) NOT NULL, -- 关联订单
  PRIMARY KEY (`id`),
  UNIQUE KEY `order_id` (`order_id`),
  UNIQUE KEY `nonce` (`nonce`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

转账步骤拆分

分库分表后,并且分拆成2笔订单后,在实际进行转账的时候,需要执行下面几步:

第1步,创建A的转出订单order_0,放在db_0中;

第2步,db_0执行事务:更新A的余额和转出订单;

  • 查询A的余额;
  • 扣除A的余额;
  • 更新A的转出订单。

第3步,db_1执行事务:更新B的余额和转入订单;

  • 创建B的转入订单order_1,放在db_1中;-- 这里,可以直接创建一个status=3的订单。
  • 查询B的余额;
  • 增加B的余额。

第4步,如果第3步成功,我们需要更新order_0 link_order_id字段,来关联2笔订单。 第3步失败,可以在db_0创建一个order_type=transfer_return的订单,把钱退给A。

但这样分拆之后,从最开始的1次数据库更新操作,变成了4次数据库更新操作。

而且数据的更新,也不能在一个DB事务中完成了。 需要一些额外的逻辑,保证对A和B的数据更新要么都成功,要么都失败。

我们可以利用消息队列重试来解决这个问题。思路如下:

  • 如果B的转入订单没有实时处理成功,放到消息队列中,异步重试处理。
  • 如果一段时间后,比如1h,最终还是未重试成功,我们需要把转账的钱退给A。
    • 可以创建一个order_type=transfer_return的订单,把钱退给A。
    • A在查询订单的时候,可以看到这笔订单,可以看到转账失败,钱已经退回。

还有一个思路就是使用之前已经讨论过的支付TCC的模式:支付系统: TCC

发红包的挑战

发红包本质是一个1-N的转账,比P2P转账更复杂一些。

红包的扇出效应

我们可以定义2个新的的订单类型来记录。 假设,用户A发了一个100块的红包,给了10个人,每个人10块钱。

  • 在A账户下记录一笔transfer_angbao_send的订单,金额为100块。
  • 在10个领红包人的账户下,分别记录一笔transfer_angbao_receive的订单,金额为10块。

这种扇出效应,会导致对DB的压力特别大。想想,微信红包,春晚发红包的场景,该怎么处理呢?

红包预分配机制

红包可用是等额,或是随机的,可将红包的金额分配提前生成,并存储到缓存(如Redis)中。例如:

红包领取分配的金额,key存为angbao:order_id,value为金额的list, e.g. [10, 20, ..., 90]。 每次领取时,从Redis中弹出一个领取金额,然后插入transfer_angbao_receive的订单到DB。

  • 如果插入失败了怎么办呢?可以把这个金额重新放回到Redis List中,等待下次领取。

红包的过期处理

如果红包没被领取完,怎么办呢?可以定时清理过期的红包,创建一个order_type=transfer_return的订单,把没领取的金额退回给A。

小结

用户A给用户B转100块钱,表面上看是简单的扣钱和加钱。 这样一个简单的需求,背后却有很多技术细节和挑战,涉及存储设计、幂等性、原子性、死锁处理、分库分表等复杂问题。

在国内的用户,对微信支付和支付宝的便利都习以为常了。 移动支付普及的背后,是大量产品经理,软件工程师,SRE和测试人员,持续不断的付出和努力。

技术上的深入探讨与改进,是为了实现高性能、高可靠性,以及用户体验的不断优化。 路漫漫其修远兮,与诸君共勉。