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 |