超卖问题及其解决方法

超卖问题介绍

在并发的场景下,比如商城售卖商品中,一件商品的销售数量>库存数量的问题,称为超卖问题。主要原因是在并发场景下,请求几乎同时到达,对库存资源进行竞争,由于没有适当的并发控制策略导致的错误。在高并发的场景下,如上问题更加严重。

简单的下单操作

如果没有并发的控制策略,则在下单时只需要考虑如下几步:查询库存数量、判断是否满足订单数量需求,提示库存不足或者减库存下单成功。通常我们会按照如下写法

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 "处理异常请重新操作";
  • 11
    点赞
  • 52
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值