高并发下防止库存超卖的解决方案

最近在看秒杀相关的项目,针对防止库存超卖的问题,查阅了很多资料,其解决方案可以分为悲观锁、乐观锁、分布式锁、Redis原子操作、队列串行化等等,这里进行浅显的记录总结。

首先我们来看下库存超卖问题是怎样产生的:

1

2

3

4

5

6

//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语句,在查询商品表库存时将该条记录加锁,待下单减库存完成后,再释放锁。

1

2

3

4

5

6

7

8

9

10

//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

2

3

4

5

6

//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);

...

当然除此之外还有很多其他解决方案,也有很多可以优化的地方,继续学习吧~

你可能感兴趣的:(java)