✅账户里面只有十块钱,同时发来两笔订单一共大于十块钱,怎么保证不超花?
典型回答
这是一个典型的超卖问题。
所谓"超卖"指的就是商品卖多了,一般我们在商品扣减库存的时候,都会先判断库存够不够,如果够在进行扣减,不够则直接返回下单失败。而在这个问题中,其实就是避免余额的超卖。
方案一、分布式锁
这个问题的解决方式最简单的也是最容易出错的方式,那就是先查一把余额,判断下够不够,如果够就扣减,不够就不扣减,伪代码如下:
function 扣减余额(账户, 扣减金额):
# 1. 查询账户余额
余额 = 查询账户余额(账户)
# 2. 判断余额是否足够
if 余额 >= 扣减金额:
# 余额足够,进行扣减
余额 = 余额 - 扣减金额
更新账户余额(账户, 余额)
else:
# 余额不足,不进行扣减但是这么做最大的问题就是一旦请求是同时来的,那么就会出现查询的时候金额都是够的,然后更新的时候就更新成负数了。
所以,我们要解决这个问题,那就是让所有的余额扣减的请求排队执行!就是不要发生并发也能解决。
那么最简单的方案,就在查询用户余额之前,针对账户先加一把锁。只有抢到锁的线程才能进行余额的查询和扣减。这样就可以避免这个问题了。
function 扣减余额(账户, 扣减金额):
# 1. 尝试给账户加锁
if 加锁(账户) == 成功:
try:
# 2. 查询账户余额
余额 = 查询账户余额(账户)
# 3. 判断余额是否足够
if 余额 >= 扣减金额:
# 余额足够,进行扣减
余额 = 余额 - 扣减金额
更新账户余额(账户, 余额)
else:
# 余额不足,不进行扣减
finally:
# 4. 无论成功与否,解锁账户
解锁(账户)
else:
# 加锁失败,输出提示方案二、数据库乐观锁
但是这样做有一个缺点,那就是加锁会降低并发度,如果并发量特别大的时候,会导致大量的线程阻塞。
那既然是排队,我们除了依靠分布锁提前加锁来排队,我们也可以依靠数据库自身的update的更新时候的互斥锁来排队的。
那么,我们就只需要在更新余额的SQL中增加一个以下的逻辑:
update account
set balance = balance - #{amount}
where account_id='123' and balance >= #{amount} 以上SQL,只有当剩余金额(balance)大于本次扣减金额(amount)的时候,SQL才能执行成功,否则SQL更新结果就是0条,无法更新成功。
这样做的好处就是锁的粒度很小,他只有在更新的这一瞬间有一个互斥锁,锁的时长大大小于提前加一个分布式锁。
而且这么做的并发度很高,因为锁的粒度小,那么多个线程一起来的时候,就可以快速的排队执行。只要当前剩余金额足够,就能扣减成功,而且避免了金额扣减为负数。
这其实是一种乐观锁的思想,只有在最后更新的时候依靠update自带的互斥锁进行排队,并且根据更新的结果的行数判断是否成功。只不过他的where条件并不是version,而是balance >= #{amount} 罢了。
方案三、Redis扣减
上面这个方案,还是有缺点的,那就是他受限于MySQL的单行更新的热点瓶颈,MySQL的热点更新是一个非常大的问题,如果靠MySQL抗他一定是有一个物理极限的,超过这个QPS就扛不住了。所以就需要用其他方式解决。
那么最常见的方案,就是基于Redis来做防超卖,因为Redis本身是单线程的,所以他的命令的执行天然就是排队的。而且利用他的lua脚本还能让多个命令以一个原子命令的方式执行,可以在一个lua脚本中完成余额的查询、判断以及扣减的一系列组合动作。也能保证只有在余额足够的时候才扣减。
如:
local key = KEYS[1] -- 账户的键名
local amount = tonumber(ARGV[1]) -- 扣减的金额
-- 获取账户当前的余额
local balance = tonumber(redis.call('get', key))
-- 如果余额足够,则扣减并返回新的余额
if balance >= amount then
redis.call('decrby', key, amount)
return redis.call('get', key)
else
return "INSUFFICIENT BALANCE"
end当然,这个方案的引入肯定是可以抗更高的并发的,因为redis很快,但是他引入之后,还会带来一个一致性的问题,那就是Redis中的余额和数据库中的余额的一致性问题。
一般解决方案都是通过MQ的重试+旁路+对账来解决的。这个就不展开说了,因为这部分方案特别特别复杂,纯讲理论很难吸收,大家感兴趣可以看我的项目实战课,这里面有代码和视频的讲解~