高并发下的高频账号余额加减方案探索

问题描述

打造一个服务,管理用户余额(信用点、积分、代币等),实现主要功能:

  • 加减款:即对余额进行增减
  • 冻结流程:减用户余额 加用户的冻结余额,同时生成一条冻结记录
  • 付款流程:减付款用户的冻结余额,加收款商户(可能为多个)的余额
  • 余额流水:余额的每笔变动都会产生相应流水明细

同时 还有以下特征:

  • 高并发
  • 多数用户均为高频账号
  • 强一致性

代码层面:事务与锁

问题中存在的若干规则

  • 冻结与减款(减用户余额)时,校验减后余额
  • 付款(减用户冻结余额)时,校验减后冻结余额
  • 冻结时,更新余额、更新冻结余额、生成冻结记录
  • 付款时,各账号加减余额、冻结余额、解冻、生成付款记录

锁的优化

总体来看,需要加锁的地方有:1)更新余额 2)解冻(更改冻结状态)

主要拿更新余额来说,最直观的流程:

  • 加锁查询余额
  • 检查余额
  • 更新余额


1
2
3
SELECT * FROM `Accounts` WHERE `Id`=@Id FOR UPDATE;
/* 检查余额 */
Update `Accounts` SET `Balance`=@Balance;

由于“多数用户均为高频账号”,在高频校验更新余额时势必会产生性能问题,而在某些特殊场景下,甚至会产生死锁的问题

那么这里的主要思路就是减少降低锁的使用频率:

a) 只在减款校验时加锁

余额的更新中,只有减款时需要校验当前实时余额,而加款则不需要

减款时流程不变,加款时直接对余额进行加操作

1
Update `Accounts` SET `Balance`=@Balance+@Amount;

b) 先减款,再校验

加款操作去掉了锁,减款是否也能去掉呢?
我们在减款中加锁,是为了避免在减款操作时余额被并发更改,出现校验时账号有充足的余额,但是减款时余额却变成了负数
如果我们按照之前的流程 加锁-查询-更新,的确是需要锁住这一行记录,但是如果先减款,再判断余额是否小于0,就可以避免锁的需求

1
2
Update `Accounts` SET `Balance`=@Balance-@Amount RETURNING `Balance`;
/* 判断Balance是否小于0,如果小于0,则回滚事务 */

这样在减款操作时也避免了显式锁行

解冻校验也可以用类似方法,比如将冻结记录的校验与更新,使用WHERE语句合成一条SQL语句,来避免锁的使用

尝试摆脱数据库事务

上一步里去除了更新余额时的显式行锁,但是对于高频账号来说,数据库事务自带的锁/隔离机制仍然会是其并发性能的一大阻碍
但是在需要多个写(更新、插入)操作同时成功同时回滚的场景下,数据库的强一致性事务似乎又是不可或缺的

那么可以换一种思路:
只要保证所有操作最终一定会成功,那么是否就可以去除对数据库事务的依赖了呢?

看上面几个流程
首先单纯的加减款肯定是可以不依赖数据库事务的,那么就是冻结、付款等需要多次写操作的场景
比如冻结场景,要么 1.减款成功,生成冻结 2.减款失败,不生成冻结
减款失败的情况不需要担心,但是如果减款成功的情况下,需要保证一定有对应的一条冻结记录插入

如何保证?
可以在生成冻结失败时,重试此操作,直到最终生成成功为止
但是我们可能不止需要重试冻结失败的操作,在程序异常中止然后重启后,有些情况下我们无从得知上次异常中止的流程中,是否已经进行了减款操作,失去了数据库事务两阶段提交(2PC)支持,我们只能重试整个冻结流程,即1.减款成功,生成冻结

这里就有很严重一个问题,此时减款操作的重试是不安全的,每次减款,都是更新账号上的余额字段,这就需要一个幂等机制,来让减款可以安全地重试

EventSourcing

如果了解EventSourcing的话,接下来的事情就顺理成章了

还记得需求中关于流水明细的部分吗?

参考EventSourcing的实现,可以把每次的余额更新操作,都转换成相关的流水明细的插入操作,而插入操作是很容易实现幂等的,同时,大多数情况下,插入记录的性能要比更新记录的性能要好

实现非常简单,即在进行加减款操作时,不更新数据库中的余额字段,而是向数据库中插入一条变动记录,如账号XXX余额减10
如何进行减款前的校验呢?我们可以预先把某账号的流水记录预先读取出来,然后将此账号的余额按照流水记录走过一遍,就在内存中得到了当前的余额,同时在插入变动记录时,同时更新内存中的余额值,当然,要保证内存中的余额变动和流水中一致

分布式?

前面说过,要保证内存中的余额和数据库中的流水记录一致,如果是单实例的应用,很简单,只需要创造一个单例的账号对象,并保证其余额不会被并发更新就好了,但是如果是分布式的应用怎么办?

如何在分布式系统中避免并发冲突?和许多分布式EventSourcing框架一样,此时,Actor是唯一解决方案。Actor模型提供了针对每个Actor(账号)的单线程执行约束,也就是说,每个账号作为Actor存在于集群中时,其代码执行是不会有并发冲突的

看起来很完美?

实际上不是,无论是EventSourcing还是Actor模型,都不是常规的编程思想,其实现无疑会比较复杂,并且在分布式环境中,对其不够熟悉的话,很容易踩入各种各样的并发陷阱 - 当然这些陷阱在常规分布式应用中也是普遍存在的,但是在这里更容易令人疏忽大意

并且,Actor的单线程执行约束,本身也是并发性能的一个阻碍

高频账号问题

在代码层面提高高频账号或者单点账号的单操作性能,从而提高其并发性能,但是在这条路上想走得更远是十分困难的。
或许可以以一个更大的视角来尝试解决

高频账号问题,本质上其实相当类似秒杀/减库存问题
所以很大程度上,可以借用秒杀/减库存问题的解决方案

拆分高频账号

一个思路是将高频账号拆分为多个子账号(资金池),加减款时随机找一个子账号扣款
但是和秒杀/减库存不同,在资金的加减上,拆分子账号会引入许多问题:

  1. 如何调度平衡各个子账号之间的资金?
  2. 流水无法记录变动前后的总余额
  3. 扣款时如果一个子账号的余额不够,需要扣多个子账号怎么办?

想到这里,除非整个业务体系能改造,我已经基本放弃此方案了

批量提交与异步

批量提交与异步,是提高单点吞吐量的绝佳法宝,比如很多数据库都有通过批量commit事务来提高吞吐量

回到问题本身,资金的变动分为a)加款 b)减款

对于加款,它属于必定会成功的操作,可以直接把它丢进一个可靠的队列里去执行
出队时,缓冲若干个加款命令,合并成一个批量提交

而减款是有可能会失败的(余额不足),我们需要一个手段把减款的结果通知给请求方

如果请求是同步的(如HTTP请求),我们只能挂起相应的HTTP请求,等到扣款有结果了再唤醒,返回响应,但是这种在分布式系统中实现起来会非常麻烦
相反,如果能够将接口改造成为异步的话,实现起来就比较简单了

  1. 接到扣款请求
  2. 将请求添加进队列,并直接返回响应,表示已收到请求
  3. 请求方主动查询请求结果,或处理方回调通知结果

这种实现并不怎么合适,因为有时上游调用方需要根据调用的结果来决定下一步的流程,比如冻结成功后才能发起付款,如果失败则需要告知用户/管理员等
所以也许使用一个异步的事件系统来控制整个业务流程会比较合适
但是这就是后话了