最近在看秒杀相关的项目,针对防止库存超卖的问题,查阅了很多资料,其解决方案可以分为悲观锁、乐观锁、分布式锁、Redis原子操作、队列串行化等等,这里进行浅显的记录总结。
首先我们来看下库存超卖问题是怎样产生的:
//1.查询出商品库存信息
select stock from t_goods where id=1;
//2.根据商品信息生成订单
insert into t_orders (id,goods_id) values (null,1);
//3.修改商品库存
update t_goods set stock=stock-1 where id=1;
在高并发场景下,如果同时有两个线程a和b,同时查询到商品库存为1,他们都认为存库充足,于是开始下单减库存。如果线程a先完成减库存操作,库存为0,接着线程b也是减库存,于是库存就变成了-1,商品被超卖了。
下面让我们来看看针对库存超卖问题的解决方案;
解决方案一:悲观锁
所谓悲观锁,即悲观的认为自己在操作数据库时,会大几率出现并发,于是在操作前会先进行加锁,操作完成后再释放锁。如果加锁失败说明该记录正在被修改,那么当前操作可以等待后尝试。
以我们常用的MySQL为例,行锁、表锁、排他锁等都是悲观锁,为避免冲突,会在操作时先加锁,其他线程必须等待它的完成。
这里我们通过使用select...for update语句,在查询商品表库存时将该条记录加锁,待下单减库存完成后,再释放锁。
//0.开始事务
begin;/begin work;/start transaction; (三者选一就可以)
//1.查询出商品信息
select stock from t_goods where id=1 for update;
//2.根据商品信息生成订单
insert into t_orders (id,goods_id) values (null,1);
//3.修改商品stock减一
update t_goods set stock=stock-1 where id=1;
//4.提交事务
commit;
这样可以解决并发时库存超卖的问题,然而高并发时,所有的操作都被串行化了,效率很低,将严重影响系统的吞吐量。而且使用悲观锁还有可能造成死锁问题。
解决方案二:乐观锁
现在我们尝试下使用乐观锁,所谓乐观锁,是相对于悲观锁而言的,它假设数据一般情况下不会发生并发,因此不会对数据进行加锁,操作完成提交时才对数据是否冲突进行检测,如果发现冲突则返回错误。
比较常见的实现方式是,在表中增加一个version字段,操作前先查询version信息,在数据提交时检查version字段是否被修改,如果没有被修改则进行提交,否则认为是过期数据。
//1.查询出商品信息
select stock, version from t_goods where id=1;
//2.根据商品信息生成订单
insert into t_orders (id,goods_id) values (null,1);
//3.修改商品库存
update t_goods set stock=stock-1, version = version+1 where id=1, version=version;
这样,在并发时,如果线程a尝试修改商品库存时,发现版本号已经被线程b修改了,线程a执行update语句条件不满足便不再执行了,库存也不会被超卖。
但是这种乐观锁的方式,在高并发时,只有一个线程能执行成功,会造成大量的失败,这给用户的体验显然是很不好的。
这里我们可以减小锁的颗粒度,最大程度提升系统的吞吐量,提高并发能力:
1
2
//修改商品库存时判断库存是否大于0
update t_goods set stock=stock-1 where id=1 and stock>0;
上面的update语句通过stock>0进行乐观锁的控制,在执行时,会在一次原子操作中查询stock的值,并扣减一。
解决方案三:分布式锁
除了在数据库层面加锁,我们还可以通过在内存中加锁,实现分布式锁。例如我们可以在Redis中设置一个锁,拿到锁的线程抢购成功,拿不到锁的抢购失败。
Redis的setnx方法可以实现锁机制,key不存在时创建,并设置value,返回值为1;key存在时直接返回0。线程调用setnx方法成功返回1认为加锁成功,其他线程要等到当前线程业务操作完成释放锁后,才能再次调用setnx加锁成功。
Long TIMEOUT_SECOUND = 120000L;
Jedis client = jedisPool.getResource();
//线程设置lock锁成功
while(client.setnx("lock",String.valueOf(System.currentTimeMillis())) == 1){
Long lockTime = Long.valueOf(client.get("lock"));
//持有锁超时后自动释放锁
if (lockTime!=null && System.currentTimeMillis() > lockTime+TIMEOUT_SECOUND){
client.del("lock");
}
Thread.sleep(10000);
}
......
......
client.del("lock");
解决方案四:Redis原子操作
虽然通过以上方按可以防止库存超卖,但是高并发情况下对数据库进行频繁操作,会造成严重的性能问题。因此我们必须在前端对请求进行限制。
我们可以在Redis中设置一个队列key为商品的id,队列的长度为商品库存量。每次请求到达时pop出一个元素,这样拿到元素的请求即认为秒杀成功,后续通过MQ发送消息异步完成数据库减库存操作。没有拿到元素的请求即认为秒杀失败。
由于Redis是工作线程是单线程的,而list的pop操作是原子性的,因此并发的请求都被串行化了,库存就不会超卖了。
//获取商品库存
String token = redisTemplate.opsForList().leftPop(goodsStock);
if(token == null){
log.info(">>>商品已售空");
return setResultError("亲,该秒杀已经售空,请下次再来!");
}
//异步发送MQ消息,执行数据库操作
sendSecondKillMsg(goodsId, userId);