账户余额的批量入账与扣账实现

1,问题:

在高并发系统中,存在热点账户现象,即一个账户有大量的入账和扣账请求,在这样的背景下,频繁的更新账户的余额会对数据库造成较大的压力。

2,解决思路:

update改为insert。创建待入账流水表和待扣账流水表。批量更新账户余额。

4,引出的新问题:

入账好说,扣账需要注意一点,就是在余额系统中,余额的值不能为负。

5,解决思路:

在插入待扣账流水之前要进行余额检查。即余额-代扣流水总额>0。

6,引出的新问题:

 

待扣账流水终究要更新到余额的,这时会有并发问题,描述如下:

  定时执行批量更新的线程 插入待扣流水线程  
T1 按照时间段获取一批待扣流水    
T2 更新余额完成(开启事务A)    
T3   查询余额(会获得已经更新的旧值)  
T4   查询待扣流水总额  
T5 删除待扣流水(事务A提交)    
T6   余额检查  

这会产生实际余额可扣账,但是检查失败的情况。但是,从业务安全的角度来说,余额没有变负,牺牲了用户体验。还会有一种问题:

  定时执行批量更新的线程 待扣账流水线程
T1 按照时间段获取一批待扣流水(100元)  
T2   查询余额(110元)
T3 更新余额(开启事务A)(110元-100元=10元)  
T4  删除待扣流水(事务A提交)  
T5    查询待扣流水总额(0元)
T6    余额检查(110元-0元-本次扣账100元>0,检查通过)

T4,T5还可能发生一部分删除,一部分未删除时,T5开始查询。都会造成余额实际为负的情况。于是我们采用另外一种方式:

  定时执行批量更新的线程 待扣账流水线程
T1 按照时间段获取一批待扣流水(100)  
T2   查询待扣流水总额(100)
T3 更新余额(开启事务A)(110-100)  
T4 删除待扣流水(事务A提交)  
T5   查询余额(10)
    余额检查(10-100-本次扣账<0)(即使查询余额发生在事务A未提交时,那么就会变成110-100-本次扣账。也是安全的)

待扣账流水线程的业务逻辑修改为先汇总待扣总额,然后查询余额,这样最坏的情况就是如表中所展示的情况。可能会造成扣款失败,但是对于业务来说是安全的,不会出现余额为负。

7,还有问题:

更新余额的线程和待扣流水的线程问题看似解决,但是有一个很严重的问题。

  待扣账流水线程A 待扣账流水线程B
T1 查询待扣流水总额(100) 查询待扣流水总额(100)
T2 查询余额(110)  
T3   查询余额(110)
T4 余额检查(110-100-本次10>=0)通过  
T5   余额检查(110-100-本次10>=0)通过
T6 插入待扣流水 插入待扣流水

好蠢啊,有没有。余额对我们来说是竞争资源,这个在扣账的情况下真的没法并发,不上锁是不行的!前面已经说了,目的就是为了解决update的行锁竞争问题!咋办呢?

引入一个新的组件吧,用redis。内存计算要比数据库行锁快的多,而且redis是线程安全的。

  待扣账流水线程A 待扣账流水线程B
T1
EXISTS 账户ID?
 
T2  return false
EXISTS 账户ID?
 
T3  
return false
T4 获取redisLock成功
获取redisLock失败
T5 get 余额 from  DB
50毫秒后重试 
T6 set 账户ID 余额  
T7  DECRBY 本次扣账金额>0  EXISTS 账户ID?
T8    return true
T9 插入待扣流水 DECRBY 本次扣账金额>0
T10   插入待扣流水
     

Redis Decrby 命令将 key 所储存的值减去指定的减量值。

如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 DECRBY 操作。

这里为啥用了redisLock呢?为了达成只有一个线程set redis的值。其他线程等待的效果。当然了,看系统的并发,如果你不害怕缓存击穿现场。直接setnx也是可以的呀。

现在不要忘记那个批量更新余额的线程。

 

 

插入待扣流水的线程伪代码实现一下:

这个方法就叫tryGet方法吧。

boolean exists = exists account_id ?

if(exists){
  BigDecimal balance = get value by account_id from redis;
  if(balance - 本次扣减>0){
    BigDecimal afterDec = DECRBY 本次扣账金额;
    if(afterDec<0){
      //被余额检查扣成了负数,你得把本次扣减的还回去。
      //十分重要,一定要还回去
      INCRESBY 本次扣减金额。
    }else{
      int count = insert;
      if(count<0){
        //还回去
        INCRESBY 本次扣减金额。
      }
    }

  }
   }else{   if(redisLock.tryLock()){
    try{
      BigDecimal balance = query from DB where account_id = ?;
      set Redis account_id balance;
    }finally{
      redisLock.unlock();
    }   }else{
    sleep 50ms;
    tryGet();   } }

 批量扣账的线程只负责把待扣流水更新入余额。

批量待入流水除了更新余额,还要更新redis的值。

使用redis会面临严重的问题,你无法保证调用Redis一定成功!!!redis也没有流水号什么的,不会像业务系统有幂等性!!!用来做余额检查有天然的缺陷啊!!!使用补偿也无法解决啊。大神有方案留言。

 

  批量更新余额线程 插入待扣流水线程
T1 按照时间段获取一批待扣流水
EXISTS 账户ID?
T2   return true
T3 更新余额(开启事务A)  
T4 删除待扣流水(事务A提交)  
T5 tryGet方法  
T6    
T7    
T8    
T9    
T10    
T11    
T12    
T13    
posted @ 2019-03-10 12:36  coolgame  阅读(1451)  评论(0编辑  收藏  举报