前言
一转眼距离之前离职已经八九个月了,最近又在重新准备面试,想看一看有没有合适的机会,继续打工。
刚刚面试了一个做跨境支付的公司,二面中面试官根据它们公司的业务场景,提问了这样的一个系统设计问题。
Q:如果一个客户先充值了一笔钱,然后这笔钱再支付给多个收款方,在这样的场景下TPS量比较高的情况,如何能够保证数据的正确性以及高的处理效率。
太久没有面试,当时的回答思绪有点不清晰,感觉不是很好,今天就来回忆、复盘这个功能应该怎么设计比较好。
1. 梳理业务流程
根据当时面试官讲述的场景,先来梳理一下业务主体的代收、代发流程
充值逻辑(代收)如下:
代发逻辑如下:
若是向多个账户转账则「2、3、4、5」步骤重复执行多次
在场景中还提到了几个点
- 调用银行的回调有时会耗时比较长,有时甚至需要30s
- 每个客户的交易频率不同,有的非常频繁,有的非常少
- 同一个账户的多个代发、代收的操作可能是交叉进行的
2. 复杂度分析
根据上述的业务流程,梳理这个系统的复杂度在哪里
1.数据正确性
同一个账户的的充值与付款,存在并发的「交叉操作」,要保障付款时不能超额支付、充值时保障余额正确。所以对于某个账户的一系列操作,要保障流水处理的顺序性,余额数据的准确性。
2.高效
交易频繁的账户占整体用户的小部分,不能因为这些账户影响到其他账户交易操作的用户体验。
这两点中,数据的正确性远高于系统的性能,可以什么都不做,也不能犯错。
至于银行的回调时间长,个人感觉就以回调的时间点作为实际的充值成功点即可。
3. 系统设计
3.1 账户数据存储
账户余额数据涉及了钱,所以一定要使用能够有ACID的存储,常用的关系型数据库mysql就可以。
那么需要在mysql中建立账户「余额表」,「流水表」两个表。
余额表体现用户的账户余额状态,流水表用于记录每笔操作的明细,也可用于与银行方进行对账使用。
3.2 如何保障并发操作
乐观锁
如果使用cas的方式,在账户中加入一个版本号信息,更新的时候携带版本号进行更新,如果版本号已经变更则进行重试N次。
这种方案,在并发操作冲突非常多的时候,会将读写压力放大很多倍,甚至有可能将数据库资源耗尽。
个人感觉适用于操作量大,但是同一账户并发冲突情况较低的业务场景。
悲观锁
如果使用数据库自身的悲观锁(FOR UPDATE),将数据行进行锁定,在并发的进行充值、扣款的时候,会产生大量的行锁竞争等待,压力都集中在了数据库中。
所以使用时需要保证事务执行操作精简,尽量减少耗时,个人感觉适用于事务冲突情况较高,但是
数据拆分
说到这里,其实想到了很久之前看过的电影票库存方案,为了解决电影票的售卖并发问题,将每个座位都生成了一行数据,这样在进行抢购电影票的时候,竞争的就不是电影场次库存的那一行数据,而是将竞争分散到了各个座位的行数据上,以此来分散压力。
那么账户余额数据,是不是可以也进行这样的拆分呢?
例如,常规客户的金额不超过1w,就依然单行存储。当客户的账户金额达到了某个阈值(例如100w),就拆分为十个子账户进行存储,每个子账户存储部分余额。
充值时,随机选择一个子账户进行充值,扣款时,随机选择一个账户进行扣款。
队列顺序操作
如果可以将对账户的操作都加入队列,然后顺序执行的话,数据竞争的压力就会小很多。
可以从两方面来做:
- 发起请求后,后端将该请求发送于「全局消息队列」当中,以账户id进行hash分配对应分区,消费者从分区中拉取任务进行顺序消费处理,成功再变更偏移量。通过与客户端长链接,或者客户端轮询的方式,将处理状态展示给客户。
其中消息队列要做好数据的副本冗余,以及数据的持久化,保证消息不能丢。
- 可以在客户端再实现一个当前的「局部请求队列」,假如客户要给10个人转账汇款,客户端将这十个操作串行化,一笔一笔的请求服务端操作,弹窗整体展示每笔的进度,以减轻服务端瞬间处理的并发压力。
3.3 加入缓存
上一小节,都是操作数据库的时候的解决并发的相关设计,个人感觉还可以前置加一层redis,来避免一部分流量直接打到数据库上。
例如,账户余额在redis也存储一份,要进行账户余额操作时,先对比一下redis中的余额是否可以操作。例如账户余额100,并发操作两个要扣80,第二个操作判断余额不足,直接就在redis中过滤掉,不用再进行数据的查询了。
不过引入redis也会带来两个存储的数据一致性问题,不论是延时双删、操作后通过消息队列同步数据,还是订阅binlog日志进行同步,在「数据库变更」到「缓存变更」两个操作之间,终究会有一个时间差会存在数据不一致的情况。
例如,先操作数据库,再变更缓存,可能会出现如下情况:
- 数据库已经充值成功,但是缓存的余额还没涨,导致扣款的请求被拒绝
- 数据库已经扣完钱,但是缓存余额没减,导致后续的扣款请求依然打到了数据库操作上
第一种情况,同步的延迟正常不会太久,可以通过重试3次这种方式来解决。
由于加入缓存是希望减少数据库的压力,所以第二种情况拦截住了大部分无效请求,少部分流入到数据库中,是可以接受的。
先变更缓存,再操作数据库,可能会出现如下情况:
- 缓存中扣减成功,但是数据库操作失败
这个情况就需要有补偿机制,来将redis扣减步骤进行回滚补偿,实现上相对来说会复杂一点。
个人感觉还是先操作数据库,然后缓存更新可以使用类似于Canal的工具去进行数据的同步,这样的方式去拦截部分流量,从场景上来说相对合适。
3.4 数据对账
跨平台的数据同步一般都需要有对账模块,我之前在公司做的是租房业务,每天从客户那同步过来的房源,也需要实时+离线任务进行数据对账。
而账户余额方面的对账,个人猜测可以拉取实际支付方的流水,再对比本地账单的流水进行对比,来保障账户余额的正确性。
总结
以上就是复盘能够想出来的一些方案了,还需要通过压测等手段明确系统容量的阈值,再结合实际的业务情况进行选择。
如果有相关经验的同学,希望能指点一二,看看思路哪里考虑的全面,感谢大家。