简介
- 很多业务系统中都离不开到余额系统,余额系统可以为客户提供一个虚拟账户,用户可以充值,然后使用账户中的余额购买平台内的商品,如果平台做的比较大后,也可以支持用户账户间的交易,如转账、收款等业务等等。
本文将系统性的梳理一下余额系统的整体设计,内容有
- 常见账户并发扣款设计对比
- 余额系统核心业务梳理
- 余额系统数据表结构设计
- 账户并发扣款业务流程梳理
原文:地址
1、常见账户并发扣款设计对比
1.1 通常的账户扣款流程
如下:
- 1、先从数据库中查询余额
select balance from account where id='xxx';
余额值:
balance = 100
- 2、进行逻辑处理,检查余额是否充足
- 订单总金额
totalAmount = 70
- 判断余额是否足够:
balance >= totalAmount
- 计算得到新的余额值:
balance = balance - totalAmount
- 3、进行扣款
update account set balance = 30 where id='xxx';
1.2 并发扣减会带来哪些问题
并发扣减指的是同一时刻多个线程同时发起扣款操作。如果这些线程同时查询到余额都大于订单总金额,那么他们各自都会执行扣款操作,等所有线程都操作成功后,最后的余额和实际所扣款会不一致,造成公司资金损失。
1.3 如何解决并发扣减带来的数据不一致问题呢
那么有什么方式可以避免扣款导致的数据不一致问题呢?
- 最容易想到的也许就是,我们不让他们并发访问不就行了,在扣减资金的时候大家排队,one by one 的去扣减,也就是大家所熟知的悲观锁方式
- 悲观锁避免了扣减时资金不一致的风险,但是需要全程锁定该操作的所有资源,会阻塞其他线程,会导致系统扣款业务吞吐率低;
- 为了提升系统的吞吐率,我们可以采用乐观锁方式,在真正需要扣款的时候才对资源进行上锁,有两种方式实现乐观锁
- 1、使用余额balance字段作为乐观锁版本标识,但是如果同时又充值和扣减操作,会出现ABA的问题
- 2、使用版本号字段作为乐观锁版本标识,每次修改记录时,版本号加一,如果修改时版本好小于当前版本好,则修改失败
利用CAS(Compare and Set)原则实现乐观锁
- 1、查询余额和版本号
select balance,version from account where id='xxx';
结果:
balance = 100
oldVersion = 3
- 2、进行逻辑处理,检查余额是否充足
- 订单总金额
totalAmount = 70
- 判断余额是否足够:
balance >= totalAmount
- 计算得到新的余额值:
balance = balance - totalAmount
- 记录版本号加一:
version = oldVersion + 1
- 3、进行扣款
update account set balance = 30,version = 4 where id='xxx' and version = 3;
- 解读:
只有在真正更新某用户的账户余额的时候检查该记录的当前版本号version和之前查询的版本号oldVersion值是否一致,如果版本号一致则修改成功,否则修改失败,让业务调用方进行重试。
这样就避免了使用账户余额字段balance作为乐观锁字段带来的ABA问题了。
2、余额系统核心业务梳理
余额系统主要业务有:
- 1、账户充值
- 2、购买商品下单
- 3、用户退款
- 4、账户转账
- 5、商户收款
- 6、账户提现
- 7、账单
- 8、查询历史交易记录
- 9、自动对账
本文主要以购买下单为例做讲解
3、余额系统数据表结构设计
3.1、余额系统数据表模型
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cQhAGquN-1584000499694)(http://www.danyuanblog.com/file-gateway//ueditor/jsp/upload/image/20200223/1582467791251002739.png)]
3.2、表结构说明
- 账户表
drop table if exists account; /*==============================================================*/ /* Table: account */ /*==============================================================*/ create table account ( account_id bigint not null comment '账户ID', account_name varchar(64) comment '账户名', account_type varchar(32) comment '账户类型,MEMBER 会员账户,PARTNER 商户账户', balance decimal comment '账户余额', freeze_amount decimal comment '冻结资金', status varchar(32), version int comment '版本号,用于乐观锁', deleted bit comment '逻辑删除标志', crt_time datetime comment '创建时间', chg_time datetime comment '最近改变时间', crt_user varchar(100) comment '创建记录的用户', chg_user varchar(100) comment '最近改变该记录的用户', primary key (account_id) ); alter table account comment '用户余额账户表';
- 账户明细表
drop table if exists account_item; /*==============================================================*/ /* Table: account_item */ /*==============================================================*/ create table account_item ( item_id varchar(32) not null comment '账户流水记录ID,32位UUID值', account_id bigint comment '账户ID', account_type varchar(32) comment '账户类型,MEMBER 会员账户,PARTNER 商户账户', balance decimal comment '账户扣减后的余额', operate_type varchar(32) comment '操作类型,RECHARGE 充值; BUY 购买下单; REFUND 退款; ACCOUNT_TRANSFER 账户转账;ACCOUNT_COLLECT 账户收款;CASH 账户提现', operate tinyint comment '对账户余额的操作:1 增加; 0 扣减', amount decimal comment '额度值', item_status varchar(32) comment '记录操作的状态: SUCCESS 成功; FAILED 失败', order_number varchar(64) comment '订单号', version int comment '版本号,用于乐观锁', deleted bit comment '逻辑删除标志', crt_time datetime comment '创建时间', chg_time datetime comment '最近改变时间', crt_user varchar(100) comment '创建记录的用户', chg_user varchar(100) comment '最近改变该记录的用户', primary key (item_id) ); alter table account_item comment '用户余额账户明细表';
- 账单表
drop table if exists account_bill; /*==============================================================*/ /* Table: account_bill */ /*==============================================================*/ create table account_bill ( bill_id varchar(32) not null comment '账户流水记录ID,32位UUID值', account_id bigint comment '账户ID', account_type varchar(32) comment '账户类型,MEMBER 会员账户,PARTNER 商户账户', begin_balance decimal comment '期初余额,当月记账周期开始时的余额值', balance decimal comment '当前记账周期结束时的余额值', bill_status varchar(32) comment '账单的状态:OUT 已出账; NOT_OUT 未出账(用户可以提前查看当期账单情况)', version int comment '版本号,用于乐观锁', deleted bit comment '逻辑删除标志', crt_time datetime comment '创建时间', chg_time datetime comment '最近改变时间', crt_user varchar(100) comment '创建记录的用户', chg_user varchar(100) comment '最近改变该记录的用户', primary key (bill_id) ); alter table account_bill comment '用户账单信息,按月为用户出账单';
4、账户并发扣款业务流程梳理
4.1、账户扣款流程如下
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SAz0schi-1584000499697)(http://www.danyuanblog.com/file-gateway//ueditor/jsp/upload/image/20200223/1582465230101079309.png)]
4.2、扣款流程说明
- 前面聊到过如何使用版本号实现乐观锁机制来避免账户并发扣款导致的账户余额不一致问题。这里我们在其基础之上加上了一个自旋锁机制,尽可能保证业务的成功执行,当然这里的重试次数和休眠时间需要根据业务执行的实际情况做相应的调整。
- 其他订单相关业务操作可以通过监听订单状态变化通知队列,让下单扣款流程变得高效!