账户余额并发扣款如何保证数据一致性

简介
  • 很多业务系统中都离不开到余额系统,余额系统可以为客户提供一个虚拟账户,用户可以充值,然后使用账户中的余额购买平台内的商品,如果平台做的比较大后,也可以支持用户账户间的交易,如转账、收款等业务等等。

本文将系统性的梳理一下余额系统的整体设计,内容有

  • 常见账户并发扣款设计对比
  • 余额系统核心业务梳理
  • 余额系统数据表结构设计
  • 账户并发扣款业务流程梳理

原文:地址


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、扣款流程说明
  • 前面聊到过如何使用版本号实现乐观锁机制来避免账户并发扣款导致的账户余额不一致问题。这里我们在其基础之上加上了一个自旋锁机制,尽可能保证业务的成功执行,当然这里的重试次数和休眠时间需要根据业务执行的实际情况做相应的调整。
  • 其他订单相关业务操作可以通过监听订单状态变化通知队列,让下单扣款流程变得高效!
  • 3
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
好的,我可以为您提供一个Java实现,示例代码如下: 1. 采用分布式锁实现: ```java public void deductMoney(String userId, BigDecimal amount) { String lockKey = "deduct_money_" + userId; RLock lock = redisson.getLock(lockKey); try { // 尝试获取锁,等待时间为10秒,锁的过期时间为5秒 if (lock.tryLock(10, 5, TimeUnit.SECONDS)) { BigDecimal balance = getBalance(userId); if (balance.compareTo(amount) >= 0) { updateBalance(userId, balance.subtract(amount)); // 扣款成功,记录日志 log.info("User {} deduct money {} success", userId, amount); } else { // 余额不足,扣款失败,记录日志 log.error("User {} deduct money {} failed, insufficient balance", userId, amount); } } else { // 获取锁失败,记录日志 log.error("User {} deduct money {} failed, get lock failed", userId, amount); } } catch (InterruptedException e) { // 异常处理,记录日志 log.error("User {} deduct money {} failed, unexpected error occurred: {}", userId, amount, e.getMessage()); } finally { lock.unlock(); } } ``` 2. 使用乐观锁实现: ```java public void deductMoney(String userId, BigDecimal amount) { BigDecimal balance = getBalance(userId); if (balance.compareTo(amount) >= 0) { int rows = updateBalanceWithVersion(userId, balance.subtract(amount), balance.getVersion()); if (rows > 0) { // 扣款成功,记录日志 log.info("User {} deduct money {} success", userId, amount); } else { // 更新失败,记录日志 log.error("User {} deduct money {} failed, update failed", userId, amount); } } else { // 余额不足,扣款失败,记录日志 log.error("User {} deduct money {} failed, insufficient balance", userId, amount); } } ``` 3. 采用事务控制实现: ```java @Transactional(rollbackFor = Exception.class) public void deductMoney(String userId, BigDecimal amount) { BigDecimal balance = getBalance(userId); if (balance.compareTo(amount) >= 0) { updateBalance(userId, balance.subtract(amount)); // 扣款成功,记录日志 log.info("User {} deduct money {} success", userId, amount); } else { // 余额不足,扣款失败,记录日志 log.error("User {} deduct money {} failed, insufficient balance", userId, amount); throw new RuntimeException("Insufficient balance"); } } ``` 4. 采用消息队列实现: ```java public void deductMoney(String userId, BigDecimal amount) { String message = userId + ":" + amount.toString(); rabbitTemplate.convertAndSend("deduct_money_queue", message); } @RabbitListener(queues = "deduct_money_queue") public void processMessage(String message) { String[] arr = message.split(":"); String userId = arr[0]; BigDecimal amount = new BigDecimal(arr[1]); BigDecimal balance = getBalance(userId); if (balance.compareTo(amount) >= 0) { updateBalance(userId, balance.subtract(amount)); // 扣款成功,记录日志 log.info("User {} deduct money {} success", userId, amount); } else { // 余额不足,扣款失败,记录日志 log.error("User {} deduct money {} failed, insufficient balance", userId, amount); } } ``` 以上是四种常用的解决并发场景下扣款数据一致性问题的Java实现方式,您可以根据自己的实际情况选择适合自己的方式。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值