不加锁的情况下,mysql和redis如何控制“超卖”

2019独角兽企业重金招聘Python工程师标准>>> hot3.png

什么是超卖?

商品超卖,简单理解就是仓库只有1000个商品,用户却成功下单1000个以上。这种超卖现象,不局限于电商的库存数,还包括其它场景,比如抢红包的预算,抽奖的奖品数等等。

用java来模拟并发下的库存超卖:

    //库存数(AtomicInteger原子操作)
    public static AtomicInteger stockNum = new AtomicInteger(1000);
    //订单数
    public static AtomicInteger orderNum = new AtomicInteger(0);

    //获取库存
    public static int getStockNum() {
        try {
            //模拟运行耗时
            Thread.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return stockNum.get();
    }

    //更新库存+i,i<0时表示减库存
    public static int updateStockNum(int i) {
        try {
            //模拟运行耗时
            Thread.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //返回当前库存数
        return stockNum.addAndGet(i);
    }

    //添加订单+1
    public static int insertOrder() {
        try {
            //模拟运行耗时
            Thread.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return orderNum.addAndGet(1);
    }

    public static void main(String[] args) throws InterruptedException {
        //简单模拟1100个人并发抢购
        for (int i = 0; i < 1100; i++) {
            new Thread(() -> {
                //查询库存
                int stockNum = getStockNum();
                if (stockNum <= 0) {
                    System.out.println("售罄!!!");
                } else {
                    //减库存
                    updateStockNum(-1);
                    //添加订单
                    insertOrder();
                }
            }).start();
        }
        Thread.sleep(5 * 1000);
        System.out.println("最终下单数:" + orderNum.get()+" , 库存数:"+stockNum.get());
    }

测试多次,最终订单数几乎都超过1000 , 库存出现负数,比如:

...
...
售罄!!!
售罄!!!
售罄!!!
最终下单数:1045 , 库存数:-45

 

问题出在哪?

情况a:多个线程几乎同时去获取库存,拿到的值一样,当库存充足时不会出现问题,但当线程数 > 库存数,比如5个线程拿到的库存数都为3,然后各自去减库存,最终就可能超卖2个。

情况b:线程获取库存的同时,其它线程正准备或正在修改库存。当库存充足时也不会出现问题,但其他线程将库存数减到了小于当前要去更新库存的线程数时,比如线程T1和T2获取到库存值为3,几乎同时2个其它线程将库存减到了1,然后T1和T2再去减库存的话,最终就可能超卖1个。

 

该怎么处理?

库存变量虽然使用了AtomicInteger,但它只保证“int值更新”的原子性(N个线程同时去+1,结果始终是+N),类似于redis的incr/decr操作,它们都不能直接保证外层“整个业务操作”的原子性。

加锁怎么样呢?比如java的本地synchronized或者其他分布式锁.

加锁当然是可以的, 但是在高并发场景下,“锁”或多或少是件很影响性能和体验的事,且锁粒度不好控制。

对于上面的代码,我们额外加了一些逻辑判断,即使不加锁, 似乎也可以解决问题,如下:

        不加锁的情况下,mysql和redis如何控制“超卖”_第1张图片

测试多次,都不会出现超卖:(多线程下请忽略打印顺序)

...
超卖,请回滚!!!
...
售罄!!!
最终下单数:1000 , 库存数:0

代码的核心控制逻辑就是,在更新库存之后,再去实时判断一下库存数,如果超卖了就及时回退。这个判断逻辑得益于updateStockNum() 可以实时返回库存数,而不用再去getStockNum() 查一下,只要去查,就会存在耗时,并发下就会存在不准确性。

实际开发中,库存变量这种业务数据不会像上面的demo那样一直存在本地内存中,我们会使用mysql或者结合redis等数据库。它们各自的更新操作,都可以返回一些实时的操作信息,比如mysql update 操作可以实时返回受影响的行数,借助这些特性和上面代码中的思路,我们可以很方便地控制超卖。

1 ) mysql

商品的库存信息,一般对应于表中的一行记录

DROP TABLE
IF EXISTS stock;

CREATE TABLE stock (
	id INT PRIMARY KEY,
	num INT NOT NULL
) ENGINE = INNODB;

INSERT INTO stock VALUES (1, 1000);

减库存语句:

UPDATE stock
SET num = num - 1
WHERE
	num >= 1 -- 保证库存为0时不执行update
AND id = 1;

上面的sql语句,num>=1 这个条件至关重要,保证了只有库存为正值时才执行update操作,我们就可以利用实时返回的 “受影响的行数” 来判断减库存是否成功。

Java实现:

    //减库存,返回mysql update受影响行数
    public static int minusStockNum() throws SQLException {
        PreparedStatement ps = null;//略
        int reply=ps.executeUpdate(" UPDATE stock SET num = num - 1 WHERE num >= 1 AND id = 1 ");
        return reply;
    }

    public static void main(String[] args) throws InterruptedException {
        //简单模拟1100个人并发抢单
        for(int i=0;i<1100;i++){
            new Thread(()->{
                int stockNum=getStockNum();
                if(stockNum<=0){
                    System.out.println("售罄!!!");
                }else{
                    //减库存,拿到mysql update 受影响行数
                    int reply=minusStockNum();
                    //判断是否为0
                    if(reply==0){
                        System.out.println("超卖 !!!");
                    }else{
                        //减库存成功,可以下单
                        insertOrder(); 
                    }
                }
            }).start();
        }
    }

如果实时返回的“受影响的行数” reply==0,则表示num>=1不成立,即商品已售罄。在并发下,可能reply==0 这个代码分支会进来多次,无外乎“浪费了”几次数据库请求,但不需要进行库存回退操作,因为当前更新没有最终执行。

2 ) redis

redis读写操作的原子性,效果类似于java.util.concurrent.atomic下面的原子类,前者是单线程模式,天生具有原子性,后者则利用了CAS算法。

基于redis的库存控制,下面列举2种方法:

2.1)使用redis的incrby

实现逻辑和最上面的AtomicInteger几乎一样:

    //redis池
    private static JedisPool jedisPool = null;
    //订单数
    public static AtomicInteger orderNum = new AtomicInteger(0);

    //初始化库存池
    public static void initStockNum(int num) {
        Jedis jedis = jedisPool.getResource();
        try {
            jedis.set("stockNum", String.valueOf(num));
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            jedis.close();
        }
    }

    //获取库存
    public static long getStockNum() {
        Jedis jedis = jedisPool.getResource();
        long currentNum = 0;
        try {
            currentNum = Long.valueOf(jedis.get("stockNum"));
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            jedis.close();
        }
        return currentNum;
    }

    //更新库存+i,i<0时表示减库存
    public static long updateStockNum(int i) {
        Jedis jedis = jedisPool.getResource();
        long currentNum = 0;
        try {
            currentNum = jedis.incrBy("stockNum", i);
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            jedis.close();
        }
        return currentNum;
    }

    //添加订单
    public static int insertOrder() {
        try {
            //模拟运行耗时
            Thread.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return orderNum.addAndGet(1);
    }

    public static void main(String[] args) throws InterruptedException {
        //连接池
        jedisPool = new JedisPool(new GenericObjectPoolConfig(), "192.168.1.2", 6379, 2000, "password");
        //初始化库存
        initStockNum(1000);
        //简单模拟1100个人并发抢购
        for (int i = 0; i < 1100; i++) {
            new Thread(() -> {
                //查询库存
                long stockNum = getStockNum();
                if (stockNum <= 0) {
                    System.out.println("售罄!!!");
                } else {
                    /**
                     * 减库存操作,实时拿到现在的库存数,如果<0,则说明超卖了,需要回滚
                     */
                    long currentNum = updateStockNum(-1);
                    if (currentNum < 0) {
                        System.out.println("超卖,请回滚!!!");
                        //回退库存
                        updateStockNum(1);
                    } else {
                        //添加订单
                        insertOrder();
                    }
                }
            }).start();
        }
        Thread.sleep(5 * 1000);
        System.out.println("最终下单数:" + orderNum.get() + " , 库存数:" + getStockNum());
    }

 

2.2)使用redis的列表

比如库存10,则lpush10个值,然后依次lpop,当lpop返回空nil,说明没有库存了:

lpush stockList 1 1 1 1 1 1 1 1 1 1
lpop stockList 1  //第1个人返回1
lpop stockList 1  //第2个人返回1
...
lpop stockList 1  //第11个人返回nil
lpop stockList 1  //第12个人返回nil

Java实现:

    //redis池
    private static JedisPool jedisPool = null;
    //订单数
    public static AtomicInteger orderNum = new AtomicInteger(0);

    //初始化库存池
    public static void initStockNum(int num) {
        Jedis jedis = jedisPool.getResource();
        try {
            String[] flags = new String[num];
            for (int i = 0; i < num; i++) {
                flags[i] = String.valueOf(i);
            }
            jedis.del("stockList");
            jedis.lpush("stockList", flags);
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            jedis.close();
        }
    }

    //获取库存
    public static long getStockNum() {
        Jedis jedis = jedisPool.getResource();
        long currentNum = 0;
        try {
            currentNum = jedis.llen("stockList");
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            jedis.close();
        }
        return currentNum;
    }

    //库存加减1,返回受影响的个数
    public static int updateOneStockNum(int i) {
        Jedis jedis = jedisPool.getResource();
        int reply = 0;
        try {
            if (i == 1) {
                System.out.println("库存+1");
                jedis.lpush("stockList", "1");
                reply=1;
            } else if (i == -1) {
                System.out.println("库存-1");
                if(jedis.lpop("stockList")!=null){
                    reply=1;
                }
            }
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            jedis.close();
        }
        return reply;
    }

    //添加订单
    public static int insertOrder() {
        try {
            //模拟运行耗时
            Thread.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return orderNum.addAndGet(1);
    }

    public static void main(String[] args) throws InterruptedException {
        //连接池
        jedisPool = new JedisPool(new GenericObjectPoolConfig(), "192.168.1.2", 6379, 2000, "password");
        //初始化库存
        initStockNum(1000);
        //简单模拟1100个人并发抢购
        for (int i = 0; i < 1100; i++) {
            new Thread(() -> {
                //查询库存
                long stockNum = getStockNum();
                if (stockNum <= 0) {
                    System.out.println("售罄!!!");
                } else {
                    /**
                     * 减库存操作,拿到redis lpop受影响的个数,如果==0,则说明售罄,这个和mysql类似不需要回退
                     */
                    long reply= updateOneStockNum(-1);
                    if (reply == 0) {
                        System.out.println("售罄!!!");
                    } else {
                        //添加订单
                        insertOrder();
                    }
                }
            }).start();
        }
        Thread.sleep(5 * 1000);
        System.out.println("最终下单数:" + orderNum.get() + " , 库存数:" + getStockNum());
    }

比较2.1,好处是不存在回退库存的操作了,但如果库存数较大,比如在list放一万个1,会不会很臃肿?

 

 

转载于:https://my.oschina.net/wangxu3655/blog/1609919

你可能感兴趣的:(数据库,java,python)