出现场景

在并发的场景下,比如商城售卖商品中,一件商品的销售数量>库存数量的问题,称为超卖问题。主要原因是在并发场景下,请求几乎同时到达,对库存资源进行竞争,由于没有适当的并发控制策略导致的错误。
例如简单的下单操作,通常我们会按照如下写法

public ServerResponse createOrder(Integer userId, Integer shippingId){
    // 执行查询sql select amount form store where postID = 12345;
    // 判断是否大于0然后执行更新操作 update store set amount = amount - quantity where postID = 12345;
}

由于如上的写法在应用层没有任何并发控制,如果 postID 为12345的商品库存为1件,此时有两个请求到达,先后执行了查询sql,则通过MySQL读取库存时,会加共享锁,因此都能获取到商品库存为1件,然后又分别执行更新操作,MySQL会将两个更新操作串行化执行,依次成功减库存,因此库存数量变成-1。

以下是几种比较主流的解决方案

解决方案

数据库设置字段为无符号型

数据库设置字段为无符号型
当并发超卖时为负数直接报异常
通过捕获异常提示已经售空。

悲观锁

悲观锁主要用于保护数据的完整性。当多个事务并发执行时,某个事务对数据应用加锁,则其他事务只能等该事务执行完了,才能进行对该数据进行修改操作。

update goods set num = num - 1 WHERE id = 1001 and num > 0

假设现在商品只剩下一件了,此时数据库中 num = 1;
但有 100 个线程同时读取到了这个 num = 1,所以 100 个线程都开始减库存了。
但你最终会发觉,其实只有一个线程减库存成功,其他 99 个线程全部失败。update操作会自动加排它锁
需要注意的是,FOR UPDATE 生效需要同时满足两个条件时才生效:

  • 数据库的引擎为 innoDB
  • 操作位于事务块中(BEGIN/COMMIT)

悲观锁采用的是「先获取锁再访问」的策略,来保障数据的安全。但是加锁策略,依赖数据库实现,会增加数据库的负担,对于并发很高的场景并不会使用悲观锁,会导致其他事务都会发生阻塞,造成大量的事务发生积压拖垮整个系统。

乐观锁

select version from goods WHERE id= 1001

update goods set num = num - 1, version = version + 1 WHERE id= 1001 AND num > 0 AND version = @version(上面查到的version);

这种方式采用了版本号的方式,其实也就是 CAS 的原理。

假设此时 version = 100, num = 1; 100 个线程进入到了这里,同时他们 select 出来版本号都是 version = 100。
然后直接 update 的时候,只有其中一个先 update 了,同时更新了版本号。
那么其他 99 个在更新的时候,会发觉 version 并不等于上次 select 的 version,就说明 version 被其他线程修改过了。那么我就放弃这次 update。

  • 使用乐观锁需修改数据库的事务隔离级别:
    使用乐观锁的时候,如果一个事务修改了库存并提交了事务,那其他的事务应该可以读取到修改后的数据值,所以不能使用可重复读的隔离级别,应该修改为读取已提交(Read committed)

缺点:虽然防止了超卖,但是会导致很多线程更新库存失败了,所以在我们这种设计下,1000个人真要是同时发起购买,可能只有100个幸运儿能够买到东西。

通过redis队列解决

将秒杀的商品id作为键,库存作为redis中的list,提前加入redis缓存中,多少件商品就入队列多少个1,高并发请求到达时依次在队列中排序获取库存,能够获得库存则继续执行下单逻辑,否则库存不足抢不到。但是这种方式下,每个请求只能购买一件商品。

// 假设有1000个商品,商品id为goods_123
Jedis jedis = new Jedis("127.0.0.1", 6379);
for(int i = 0; i < 1000; i++){
	jedis.rpush("goods_123", 1);
}

抢购下单逻辑如下

Jedis jedis = new Jedis("127.0.0.1", 6379);
int result = 0;
if(result = jedis.lpop("goods_123") > 0){
	// 有库存
}else{
	// 库存不足
}

优点:实现简单,不需要在单独加锁(无论是悲观锁还是乐观锁)。
缺点:队列的长度是有限的,必须控制好,不然请求会越积越多。当库存非常大时, 会占用非常多的内存,每个请求只能购买一件商品。

分布式锁+分段缓存

借鉴ConcurrenthashMap分段锁的机制,把100个商品,分在3个段上,key为分段名字,value为库存数量。用户下单时对用户id进行%3计算,看落在哪个redis的key上,就去取哪个。

如key1=product-01,value1=33;key2=product-02,value2=33;key3=product-03,value3=33;
其实会有几个问题:

  • 用户想买34件的时候,要去两个片查
  • 一个片上卖完了为0,又要去另外一个片查
  • 取余方式计算每一片数量,除不尽时,让最后一片补,如100/3=33.33。

缺点:方案复杂,有遗留问题
优点:比单纯的分布式锁,性能要好

(推荐方案)Redis原子操作(Redis incr)+乐观锁

先查询redis中是否有库存信息,如果没有就去数据库查,这样就可以减少访问数据库的次数。
获取到后把数值填入redis,以商品id为key,数量为value。
还需要设置redis对应这个key的超时时间,以防所有商品库存数据都在redis中。

  1. 比较下单数量的大小,如果够就做后续逻辑。
  2. 执行redis客户端的increment,参数为负数,则做减法。

有的人会不做第一步查询直接减,其实这样不太好,因为当库存为1时,很多做减3,或者减30情况,其实都是不够,这样就白减。

扣减数据库的库存,这个时候就不需要再select查询,直接乐观锁update,把库存字段值减1 。
做完扣库存就在订单系统做下单。

样例场景:

假设两个用户在第一步查询得到库存等于10,A用户走到第二步扣10件,同时一秒内B用户走到第二部扣3件。
因为redis单线程处理,若A用户线程先执行redis语句,那么现在库存等于0,B就只能失败,就不会去更新数据库了。

 public void order(OrderReq req) {
        String key = "product:" + req.getProductId();
        // 第一步:先检查 库存是否充足
        Integer num = (Integer) redisTemplate.get(key);
          if (num == null){
          // 去查数据库的数据
          // 并且把数据库的库存set进redis,注意使用NX参数表示只有当没有redis中没有这个key的时候才set库存数量到redis
          //注意要设置序列化方式为StringRedisSerializer,不然不能把value做加减操作
          // 同时设置超时时间,因为不能让redis存着所有商品的库存数,以免占用内存。
           if (count >=0) {
            //设置有效期十分钟
            redisTemplate.expire(key, 60*10+随机数防止雪崩, TimeUnit.SECONDS);
        }
          // 减少经常访问数据库,因为磁盘比内存访问速度要慢
        }
        if (num < req.getNum()) {
            logger.info("库存不足");
        }
        // 第二步:减少库存
        long value = redisTemplate.increment(key, -req.getNum().longValue());
        // 库存充足
        if (value >= 0) {
            logger.info("成功购买");
            // update 数据库中商品库存和订单系统下单,订单的状态为待支付
            // 也可以使用最终一致性的方式,更新库存成功后,发送mq,等待订单创建生成回调。
            boolean res= updateProduct(req);
              if (res)
                createOrder(req);
        } else {
            // 减了后小小于0 ,如两个人同时买这个商品,导致A人第一步时看到还有10个库存,但是B人买9个先处理完逻辑,
            // 导致B人的线程10-9=1, A人的线程1-10=-9,则现在需要增加刚刚减去的库存,让别人可以买1个
            redisTemplate.increment(key, req.getNum().longValue());
            logger.info("恢复redis库存");
        }
    }

updateProduct方法中执行的sql如下:

update Product set count = count - 购买数量 where id = 商品id and count - 购买数量 >= 0 and version = 查到的version;

虽然redis已经防止了超卖,但是数据库层面,也要使用乐观锁防止超卖,以防redis崩溃时无法使用或者不需要redis处理时,因为不一定全部商品都用redis。

LUA脚本保持库存原子性

扣减redis的库存时,最好用lua脚本处理,因为如果剩余1个时,用户买100个,这个时候其实会先把key increase -100就会变负99。

所以用lua脚本先查询数量剩余多少,是否够减100后,再去减100。替换“库存不足”那个判断到incre的那几行代码。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐