如何设计账户扣款

111 阅读7分钟

前言

一转眼距离之前离职已经八九个月了,最近又在重新准备面试,想看一看有没有合适的机会,继续打工。

刚刚面试了一个做跨境支付的公司,二面中面试官根据它们公司的业务场景,提问了这样的一个系统设计问题。

Q:如果一个客户先充值了一笔钱,然后这笔钱再支付给多个收款方,在这样的场景下TPS量比较高的情况,如何能够保证数据的正确性以及高的处理效率。

太久没有面试,当时的回答思绪有点不清晰,感觉不是很好,今天就来回忆、复盘这个功能应该怎么设计比较好。

1. 梳理业务流程

根据当时面试官讲述的场景,先来梳理一下业务主体的代收、代发流程

充值逻辑(代收)如下:

Drawing 2025-07-16 15.05.23.excalidraw.png

代发逻辑如下:

image.png

若是向多个账户转账则「2、3、4、5」步骤重复执行多次

在场景中还提到了几个点

  1. 调用银行的回调有时会耗时比较长,有时甚至需要30s
  2. 每个客户的交易频率不同,有的非常频繁,有的非常少
  3. 同一个账户的多个代发、代收的操作可能是交叉进行的

2. 复杂度分析

根据上述的业务流程,梳理这个系统的复杂度在哪里

1.数据正确性

同一个账户的的充值与付款,存在并发的「交叉操作」,要保障付款时不能超额支付、充值时保障余额正确。所以对于某个账户的一系列操作,要保障流水处理的顺序性,余额数据的准确性。

2.高效

交易频繁的账户占整体用户的小部分,不能因为这些账户影响到其他账户交易操作的用户体验。

这两点中,数据的正确性远高于系统的性能,可以什么都不做,也不能犯错。

至于银行的回调时间长,个人感觉就以回调的时间点作为实际的充值成功点即可。

3. 系统设计

3.1 账户数据存储

账户余额数据涉及了钱,所以一定要使用能够有ACID的存储,常用的关系型数据库mysql就可以。

那么需要在mysql中建立账户「余额表」,「流水表」两个表。

余额表体现用户的账户余额状态,流水表用于记录每笔操作的明细,也可用于与银行方进行对账使用。

3.2 如何保障并发操作

乐观锁

如果使用cas的方式,在账户中加入一个版本号信息,更新的时候携带版本号进行更新,如果版本号已经变更则进行重试N次。

这种方案,在并发操作冲突非常多的时候,会将读写压力放大很多倍,甚至有可能将数据库资源耗尽。

个人感觉适用于操作量大,但是同一账户并发冲突情况较低的业务场景。

悲观锁

如果使用数据库自身的悲观锁(FOR UPDATE),将数据行进行锁定,在并发的进行充值、扣款的时候,会产生大量的行锁竞争等待,压力都集中在了数据库中。

所以使用时需要保证事务执行操作精简,尽量减少耗时,个人感觉适用于事务冲突情况较高,但是

数据拆分

说到这里,其实想到了很久之前看过的电影票库存方案,为了解决电影票的售卖并发问题,将每个座位都生成了一行数据,这样在进行抢购电影票的时候,竞争的就不是电影场次库存的那一行数据,而是将竞争分散到了各个座位的行数据上,以此来分散压力。

那么账户余额数据,是不是可以也进行这样的拆分呢?

例如,常规客户的金额不超过1w,就依然单行存储。当客户的账户金额达到了某个阈值(例如100w),就拆分为十个子账户进行存储,每个子账户存储部分余额。

充值时,随机选择一个子账户进行充值,扣款时,随机选择一个账户进行扣款。

队列顺序操作

如果可以将对账户的操作都加入队列,然后顺序执行的话,数据竞争的压力就会小很多。

可以从两方面来做:

  1. 发起请求后,后端将该请求发送于「全局消息队列」当中,以账户id进行hash分配对应分区,消费者从分区中拉取任务进行顺序消费处理,成功再变更偏移量。通过与客户端长链接,或者客户端轮询的方式,将处理状态展示给客户。

其中消息队列要做好数据的副本冗余,以及数据的持久化,保证消息不能丢。

  1. 可以在客户端再实现一个当前的「局部请求队列」,假如客户要给10个人转账汇款,客户端将这十个操作串行化,一笔一笔的请求服务端操作,弹窗整体展示每笔的进度,以减轻服务端瞬间处理的并发压力。

3.3 加入缓存

上一小节,都是操作数据库的时候的解决并发的相关设计,个人感觉还可以前置加一层redis,来避免一部分流量直接打到数据库上。

例如,账户余额在redis也存储一份,要进行账户余额操作时,先对比一下redis中的余额是否可以操作。例如账户余额100,并发操作两个要扣80,第二个操作判断余额不足,直接就在redis中过滤掉,不用再进行数据的查询了。

不过引入redis也会带来两个存储的数据一致性问题,不论是延时双删、操作后通过消息队列同步数据,还是订阅binlog日志进行同步,在「数据库变更」到「缓存变更」两个操作之间,终究会有一个时间差会存在数据不一致的情况。

例如,先操作数据库,再变更缓存,可能会出现如下情况:

  1. 数据库已经充值成功,但是缓存的余额还没涨,导致扣款的请求被拒绝
  2. 数据库已经扣完钱,但是缓存余额没减,导致后续的扣款请求依然打到了数据库操作上

第一种情况,同步的延迟正常不会太久,可以通过重试3次这种方式来解决。

由于加入缓存是希望减少数据库的压力,所以第二种情况拦截住了大部分无效请求,少部分流入到数据库中,是可以接受的。

先变更缓存,再操作数据库,可能会出现如下情况:

  1. 缓存中扣减成功,但是数据库操作失败

这个情况就需要有补偿机制,来将redis扣减步骤进行回滚补偿,实现上相对来说会复杂一点。

个人感觉还是先操作数据库,然后缓存更新可以使用类似于Canal的工具去进行数据的同步,这样的方式去拦截部分流量,从场景上来说相对合适。

3.4 数据对账

跨平台的数据同步一般都需要有对账模块,我之前在公司做的是租房业务,每天从客户那同步过来的房源,也需要实时+离线任务进行数据对账。

而账户余额方面的对账,个人猜测可以拉取实际支付方的流水,再对比本地账单的流水进行对比,来保障账户余额的正确性。

总结

以上就是复盘能够想出来的一些方案了,还需要通过压测等手段明确系统容量的阈值,再结合实际的业务情况进行选择。

如果有相关经验的同学,希望能指点一二,看看思路哪里考虑的全面,感谢大家。