超卖问题介绍
在并发的场景下,比如商城售卖商品中,一件商品的销售数量>库存数量的问题,称为超卖问题。主要原因是在并发场景下,请求几乎同时到达,对库存资源进行竞争,由于没有适当的并发控制策略导致的错误。在高并发的场景下,如上问题更加严重。
简单的下单操作
如果没有并发的控制策略,则在下单时只需要考虑如下几步:查询库存数量、判断是否满足订单数量需求,提示库存不足或者减库存下单成功。通常我们会按照如下写法
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。
能解决超卖问题但不推荐的方法
要解决上述超卖问题,有很多种方法,下面介绍几种高并发场景下性能比较差但思路简单的方式
- 应用层加互斥锁(注意分布式应用环境下不适用)
可以在应用层该下单方法上加互斥锁,比如通过synchronized关键字加互斥锁,使得该方法的调用变为串行调用执行,多个请求到达时能够串行执行,整createOrde中的查询库存和减库存成为一个“原子操作”,防止超卖。但是在高并发场景下,这使得大量请求到达时,不是并发执行,而是串行执行,排队等待互斥锁的请求很多,后面的请求响应时间很长,性能很差。
注意在分布式应用环境下,比如应用部署在两个机器上,那么此方式是无效得,因为多机之间没有约束。
public synchronized ServerResponse createOrder(Integer userId, Integer shippingId){
// 执行查询sql select amount form store where postID = 12345;
// 判断是否大于0然后执行更新操作 update store set amount = amount - quantity where postID = 12345;
}
- MySQL数据库层加互斥锁
首先给出一种错误的通过数据库事务解决超卖问题的方式
@Transactional
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;
}
如上的方式,虽然将查询和减库存放在了一个事务中(假设事务的隔离级别是可重复度),但是依然会出现超卖问题,例如多个查询sql请求到达数据库,此时如果是InnoDB存储引擎,多版本控制机制下多个事务的查询sql(快照读),在此时均可以读取到一样的库存数量,然后多个事务的更新操作会被MySQL通过互斥锁变成串行执行操作,且均可以执行减库存的更新操作,从而使得超卖问题出现。
后端应用系统是分层的,分析问题要一层一层的分析。从应用层上看,超卖问题的原因是查询和减库存是两个操作,而不是原子操作,如果将查询和减库存两个操作合二为一,便将互斥下沉到数据库层并解决了超卖问题。
public ServerResponse createOrder(Integer userId, Integer shippingId){
// 将查询和更新的sql合并为一条sql
// update store set amount = amount - quantity where amount >= quantity and postID = 12345;
}
如上,一条语句执行查询和减库存操作,使得多个请求到达数据库时,数据库直接将多个更新语句通过互斥锁串行化执行,保证不会出现超卖。但是将互斥操作下沉到数据库可行吗?应该是不可行的,大量的写请求到达数据库,然后串行执行,这样操作的性能差,对数据库的压力也大。在并发数量比较少的情况下,还可以接受,但是如果是高并发的场景,上述方法不可取。要正确解决高并发下的超卖问题,一般需要借助缓存,比如redis
通过Redis解决超卖问题的方法
- 通过redis事务解决
将库存数量放在redis中,查询库存和减库存均走redis而不操作mysql,从而减轻数据库的压力,但是这个方式和应用层互斥、mysql互斥没有本质区别,仅仅是将互斥操作放在了redis中而已。这样的写法还有一些问题,由于采用watch的方式,大量请求到达时,会有很多请求的watch监视的库存键被修改,导致后面的减库存事务失效而不执行,此处的逻辑时在这种情况下直接返回"Transaction error…",该请求就返回错误结果了,而没有重新请求等操作,可能会出现明明有库存而请求减库存失败,比如库存两件,两个请求均watch库存键,其中一个请求先执行事务减库存1件,此时还有库存1件,但是另外一个请求的事务发现watch库存键被修改,事务失效,减库存失败。因此,需要在应用层增加对此种失败请求的处理逻辑,比如对这种watch导致的减库存失败的请求,进行多次减库存的尝试。
@RequestMapping(value = "/reduce", method = RequestMethod.GET)
public String reduce() {
Jedis jedis = new Jedis("127.0.0.1", 6379);
List<Object> result ;
Transaction transaction = null;
try {
jedis.watch("product_sku");
int sku = Integer.parseInt(jedis.get("product_sku"));
if (sku > 0) {
transaction = jedis.multi();
transaction.set("product_sku", String.valueOf(sku - 1));
result = transaction.exec();
if (result == null || result.isEmpty()) {
System.out.println("Transaction error...");// 可能是watch-key被外部修改,或者是数据操作被驳回,事务不会执行
return "Transaction error...";
}
} else {
return "库存不足";
}
return "减少1件库存成功";
} catch (Exception e) {
log.error(e.getMessage());
transaction.discard();
return "fail";
}
}
- 通过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{
// 库存不足
}
-
通过redis分布式锁解决
如果采用简单的分布式锁解决,会导致性能很差;如何优化分布式锁呢?可以考虑分段加锁,参考优化秒杀中分布式锁 -
推荐的方式
-
通过Redis原子操作及MySQL锁实现
回顾我们通过MySQL锁实现防止超卖的方式
update store set amount = amount - numbers where product_id = 12345 and amount >= numbers;
这种方式的缺陷在于请求比较多的时候,对MySQL的压力很大,可以通过Redis来减轻MySQL数据库的压力。
处理逻辑如下:
下单操提供商品id、购买数量numbers
首先得到Redis中对应该商品的id,可以设置为"product_"+id,假设此处id为1,则key为"product_1";
然后去Redis查询该key对应的键值对是否存在,如果查到了,则直接获取到了目前redis中的商品库存数量,这个数量和MySQL中的数量是一致的;如果查不到则需要到MySQL中查出该id对应的商品库存数量,并通过Redis的setnx命令将该商品库存存入Redis中,key为"product_"+id,value为商品数量,如果setnx命令返回值为1则说明设置成功,如果返回值0则说明设置失败,设置失败则说明有其它线程先完成了设置,此时需要再查一遍Redis中此商品对应的库存;
然后判断该商品库存数量是否满足下单需要,如果不满足直接返回库存不足;
然后对Redis库存进行减操作,通过decrby key numbers命令减Redis库存,然后得到返回值,对返回值进行判断,如果返回值<0则说明库存不足,此时将减去的库存加上 incrby key numbers,然后返回库存不足;如果返回值>=0则说明库存足够,则执行MySQL减库存操作(update 语句),然后执行下单的后续操作。
// 请求到达时对redis中缓存的库存进行减操作,并判断是否超过库存数量
Jedis jedis = new Jedis("127.0.0.1", 6379);
// 假设请求购买10件,商品id为5
int id = 5;
int num = 10;
Integer numbers = (Integer)jedis.get("product_" + id);
if(numbers == null){
// redis中目前没有该商品,从mysql中查出
number = mysql.query("select amount from store where product_id = {id}");
// 将此商品放入redis中
int result = jedis.setnx("product_" + id, number);
if(result == 0){
// 说明已有别的线程成功执行了setnx操作
number = (Integer)jedis.get("product_" + id);
}
}
// 刷新过期时间为两个小时
jedis.expire("product_" + id, 60 * 60 * 2);
if(num > numbers){
return "库存不足";
}
int result = jedis.decrby("product_" + id, num);
if(result >= 0){
// Redis减库存成功 刷新mysql
mysql.query("update store set amount = amount - {num} where product_id = {id} and amount > {num}");
// 执行其它下单操作
return "下单成功";
}else{
// 库存不足,将刚才减的加回去
jedis.incrby("product_" + id, num);
return "库存不足";
}
return "处理异常请重新操作";