首页 >  java代码 >  库存问题,如何保证不超卖?

库存问题,如何保证不超卖?

时间:2024-01-11

这是一个经典问题,在高并发企业级项目中会经常碰到,而且在面试中也是时常会被问到。这个问题的底层含义其实保证能够合乎逻辑的对数据库进行更新,这个逻辑其实就是保证一个值不被更新成负值。

一、问题原因

看如下代码:

public String buyGoods(Long goodsId, Integer goodsNum) {
    //1、查询商品库存
    Goods goods = goodsMapper.selectById(goodsId);
    //2、如果当前库存为0,提示商品已经卖光了
    if (goods.getGoodsInventory() <= 0) {
        return "商品已经卖光了!";
    }
    //3、如果当前购买数量大于库存,提示库存不足
    if (goodsNum > goods.getGoodsInventory()) {
        return "库存不足!";
    }
    //4、更新库存
    goods.setGoodsInventory(goods.getGoodsInventory() - goodsNum);
    goodsMapper.updateById(goods);
    return "购买成功!";
}

仔细一看没啥毛病,但一旦多线程访问,问题就会出现在第二步,因为同时访问的线程都会得到库存仍然存在的结果,都能够执行第三步,但是第三步如果这些线程去更新,极有可能会更新成负值。

在秒杀系统、售票系统、仓库库存系统等业务场景等等,是无论如何不能出现库存为负值的情况的。

二、数据库保险方案

在库存操作中,一般只会涉及到uodate操作,而在数据库(这里主要指mysql)中,可以利用其自身的特性来帮助解决这个问题。

使用where语句和unsigned 非负字段。

关于数据库是如何执行update语句,可以查看:update语句的执行过程

update t_goods set goods_inventory = goods_inventory - #{goodsNum},
     version         = version + 1 where id = #{goodsId}and version = #{version}

 
关于unsiged的详细介绍,可以查看:mysql unsigned介绍

UNSIGNED属性就是将数字类型无符号化,与C、C++这些程序语言中的含义相同。

例如,TINYINT的类型范围是【-128,127】,TINYINY UNSIGNED的范围类型就是0~255。

三、锁方案

数据库行锁:

SELECT * FROM t_goods WHERE id = #{goodsId} for update

这里直接使用悲观锁(乐观锁就不介绍了,非业务字段实在有些鸡肋),确保库存操作为同步操作。会损耗一定的性能。

redis分布式锁:

import org.redisson.Redisson;

import org.redisson.api.RedissonClient;

public String buyRedisLock(Long goodsId, Integer goodsNum) {

    RLock lock = redissonClient.getLock("goods_buy");

    try {
        lock.lock();

        Goods goods = goodsMapper.selectById(goodsId);

        if (goods.getGoodsInventory() <= 0) {
                return "商品已经卖光了!";
        }
        if (goodsNum > goods.getGoodsInventory()) {
                return "库存不足!";
        }
        goods.setGoodsInventory(goods.getGoodsInventory() - goodsNum);

        goodsMapper.updateById(goods);

        return "购买成功!";

    } catch (Exception e) {

        //没加上锁就表示已经有线程在操作了,本质上还是同步操作

        log.error("秒杀失败");

    } finally {

        lock.unlock();
    }
    return "购买失败";
}

四、缓存方案

redis虽然是以缓存数据库著称,但其有很多原子操作。在并发较高的场景下,如秒杀,可以将库存预先缓存到redis中,不仅能减少数据库面临的压力,还能进行库存预扣:

    @Resource

   private StringRedisTemplate stringRedisTemplate;

   /**
     * 扣库存操作,秒杀的处理方案
     * @param orderCode
     * @param skuCode
     * @param num
     * @return
     */
    public boolean buyGoods(String orderCode,String skuCode, Integer num) {

        String key = "shop-product-stock" + skuCode;

        Object value = stringRedisTemplate.opsForValue().get(key);
        if (value == null) {
            //前提 提前将商品库存放入缓存 ,如果缓存不存在,视为没有该商品
            return false;
        }

        //先检查 库存是否充足
        Integer stock = (Integer) value;
        if (stock < num) {
            LogUtil.info("库存不足");
            return false;
        }

       //不可在这里直接操作数据库减库存,否则导致数据不安全,因为此时可能有其他线程已经将redis的key修改了

       //redis 减少库存,然后才能操作数据库,increment属于原子操作,关于其用法可以参考:redis in

        Long newStock = stringRedisTemplate.opsForValue().increment(key, -num.longValue());
        //库存充足
        if (newStock >= 0) {
            LogUtil.info("成功抢购");
            //TODO 真正扣库存操作 可用MQ 进行 redis 和 mysql 的数据同步,减少响应时间
        } else {
            //库存不足,需要增加刚刚减去的库存
            stringRedisTemplate.opsForValue().increment(key, num.longValue());
            LogUtil.info("库存不足,并发");
            return false;
        }
        return true;
    }

总结:在生产环境中,几类方案各有利弊,根据具体场景去使用。如果用户量小,并发度低,切不是分布式系统,比如小型库存管理系统,使用数据库保险方案足以解决问题。反之,则需要使用到行锁货分布式锁,当然无论是使用数据库机制还是锁机制,都会有一定的性能损耗,这是无可避免的。

当然还有更多优秀的方案,需要结合具体环境去使用。