背景
通常情况下,我们的秒杀活动,抽奖活动因为访问人数大,并发量高,为了提高系统的吞吐量会用redis来做并发控制,比如通过redis的自减原子操作来做库存控制,如果库存小于0则表明库存已用完,直接自增回去即可。在活动期间正常时不允许去修改库存的如果去修改,可能会导致超卖的严重后果。
原因分析
伪代码如下
//1、从redis获取活动信息
ActivityInfo info = RedisUtil.getActivityInfo(xxx);
//2、在活动期间才允许进来
if(checkTime(info)){
//3、从redis获取库存
Long num = RedisUtil.getStock(key);
//4、如果num为空,表明redis中的库存未初始化
if(num==null){
//5、从数据库中获取库存
num = MySQL.getStock(xxxx);
//6、用setNx指令设置库存,若返回0则表明别的线程已经先执行了
Long flag = RedisUtil.setNx(key,num);
if(flag==0){
num = RedisUtil.getStock(key);
}
}
//5、如果库存大于0则进行自减
if(num>0){
Long newNum = RedisUtil.decr(key);
if(newNum<0){
//6、库存超过限制,回滚库存
RedisUtil.incr(key);
}else{
//7、用户可以获得该商品或者奖品,执行数据库语句
MySQL.update(num=num-1);
}
}
}
上面的过程先不考虑数据库操作异常,需要捕获异常然后redis回滚的情况,还有很多可优化的空间,具体优化方案可以参考我的另一篇笔记:https://www.suibibk.com/topic/830553874812108800
正常来说,上面的逻辑可以抗住比较高的并发,满足一般的互联网抽奖秒杀场景逻辑,如果并发量还是比较大那就需要在JVM也做缓存以及做限量,这里暂且不提,现在就上面的源码来分析问题:如果在活动进行中,修改了库存,导致redis中的库存key被清空了,为啥会发生超卖现象。
假设redis中的库存为1,现在又四个线程同时并发进来:
- 线程A:第5步自减后,redis的库存变为了0,然后执行第7步用户中奖
此时线程B也进来:
- 线程B: 第5步自减后,redis的库存变为了-1,然后准备执行第6步,打算把redis中的库存自增回去变为1.
同时,在线程B第6步还未开始执行,线程C进来了。
- 线程C:线程C执行到第4步发现redis中的key被清空了,重新从数据库中初始化进去,此时线程A已经把最后一个库存用掉了,redis会变为0,然后线程C执行到第5步发现不大于0则直接返回了。
这时线程B才开始执行第6步进行自增,本应该自增后会变为0的,但是redis中的值重新被初始化为0了,自增后变为了1。那么当线程D进来后,发现库存又有了。
根据上面的逻辑,可以知道,库存会超过限制,那怎么做呢?
解决方案
方案1:在活动期间内不允许修改库存,清空redis中的key
活动期间清空redis中的key不仅仅会导致上面的库存超卖逻辑,而且容易导致Redis缓存击穿。
注:我们管理端也是这样操作的,但是我们的活动信息时放在redis中,管理端修改后后台是不准实时去清理redis,而是定时轮询,每个几分钟清理一次,这就导致了,我在修改库存,其实活动还在跑。
所以,一定要确保活动被暂停了才行,至于缓存击穿的问题可以借用分布式锁来让一个线程去数据库加载数据即可。
方案2:在跟新数据库库存的时候用如下语句
如果是多个库存扣减:
update table
set num = case
when num>#{itemNum} then
num-#{itemNum}
else
num
end
where
id=#{id}
如果是单个库存扣减:
update table set num =num-1 where id=#{id} and num>0
然后如果执行成功,就表明是由库存的,用户可以获得奖品,如果执行不成功就表明之前的redis拦截失败了。判断失败了,要把redis进行回滚(这一步就相当于把误操作的redis值用程序代码逻辑来纠正)。
当然我还是推荐方案1,方案2实现起来比较麻烦并且事后纠正的方法还会存在纠正到一半redis又被清空了的情况。