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