当前位置:   article > 正文

商品超买超卖问题分析及实战_c#商品减库存代码

c#商品减库存代码

项目场景:

商品超买超卖是高并发下非常典型的问题,也是面试中秒杀场景常常会问到的问题。
常见的问题有:
1、怎么设计一个秒杀系统?
2、商品超买、超卖问题产生的原因?
3、怎么防止商品出现超买|超卖问题?
4、乐观锁和悲观锁的适用场景是什么?
5、提高事务的隔离级别能解决超买|超卖问题吗?

今天和大家一起探究下商品超买、超卖的原因及其解决方案。


原因分析:

商品下单扣减库存的流程如下:
1、根据商品ID查询商品库存信息
2、判断商品库存是否大于购买数量
3、库存充足则进行下单减库存操作

模拟代码如下:

@Transactional(rollbackFor = Exception.class)
public void secKill(Integer goodsId, Integer num) throws InterruptedException {
        //1、查询商品库存
        GoodsStock  goodsStock  = goodsStockMapper.getStock(goodsId);
        //2、判断库存是否充足
        if(goodsStock.getNum() >= num){
            //3、使用减法计算出剩余库存
            int stockNum = goodsStock.getNum() - num;
            goodsStock.setNum(stockNum);
            //4、更新商品剩余库存的值
            int result = goodsStockMapper.updateByPrimaryKeySelective(goodsStock);
            if(result<0){
                 log.error("库存不足");
                 throw new RuntimeException("秒杀失败");
            }else{
                log.info("秒杀成功");
            }
        }else{
            log.error("库存不足");
            throw new RuntimeException("秒杀失败");
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

采用jMeter压测发现,这样的代码不但会出现超买超卖的问题,还会导致商品的剩余库存出现覆盖更新的情况。
流程分析:
1、在高并发的情况下,会有很多请求同时查询到商品的库存信息,进入到步骤1。
2、并通过了库存是否充足的判断,计算出剩余库存。
3、通过updateByPrimaryKeySelective方法直接将计算出的剩余库存的值写入到数据库。
假设A商品当前剩余库存是10,有10个线程同时进入到步骤1下单购买10个A商品,刚好都通过了步骤2的库存是否充足的判定,经过步骤3计算出剩余库存为0,然后执行更新操作将剩余库存的值写入到数据库。
最后我们发现,明明A商品只有10件,但是我们确卖出了100件。这就是商品的超买、超卖问题。

下面是模拟2个并发事务购买10个A商品的请求过程:
A商品库存只有10个,最后却卖出了20件。
在这里插入图片描述

原因说明:

1、添加事务控制并不能保证减库存方法secKill()执行的原子性,代码仍然会并发执行。
2、在并发场景下,先查询库存,再用java代码判定库存是否充足是不正确。
3、在并发场景下,如果先通过java代码计算出剩余库存,再把剩余库存的值更新到数据库中会导致出现覆盖更新的情况。

解决方案:

1、最简单暴力的办法,既然减库存的方法secKill()不支持并发,那么可以将整个方法块做异步处理。如果不考虑分布式,可以直接用synchronized关键字,如果需要支持分布式,可以采用redis加分布式锁

2、采用悲观锁,给数据库记录加锁。

3、采用乐观锁,更新记录时通过比对版本号判定是否执行更新库存。

4、直接在sql中执行减法来更新库存,并在where调价中判定库存是否充足

update table set stock = stock - 10  where goods_id=1 and stock - 10 >=0 ;
  • 1

代码实战:

1、商品库存表

CREATE TABLE `goods_stock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `goods_id` varchar(255) DEFAULT NULL COMMENT '商品id',
  `num` int(11) DEFAULT NULL COMMENT '库存数量',
  `version` int(11) unsigned DEFAULT NULL COMMENT '版本号',
  PRIMARY KEY (`id`),
  UNIQUE KEY `ui_goods_id` (`goods_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;

INSERT INTO `seckill`.`goods_stock`(`id`, `goods_id`, `num`, `version`) VALUES (1, '1', 10000, NULL);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

2、控制层代码 SecKillController

@RestController
@Slf4j
public class SecKillController {

    @Autowired
    private SecKillService secKillService;

    @GetMapping(value = "/secKill/{goodsId}/{num}")
    public void secKill(@PathVariable Integer goodsId, @PathVariable Integer num) throws InterruptedException{
        secKillService.secKill(goodsId,num);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

1、synchronized方式

只需要在方法头上添加synchronized关键字,不用修改任何代码

    @Transactional(rollbackFor = Exception.class)
    public synchronized void  secKill(Integer goodsId, Integer num) throws InterruptedException {
        //1、查询商品库存
        GoodsStock  goodsStock  = goodsStockMapper.getStock(goodsId);
        //2、判断库存是否充足
        if(goodsStock.getNum() >= num){
            //3、减库存
            int stockNum = goodsStock.getNum() - num;
            goodsStock.setNum(stockNum);
            int result = goodsStockMapper.updateByPrimaryKeySelective(goodsStock);
            if(result < 1){
                 log.error("库存不足");
                 throw new RuntimeException("秒杀失败");
            }else{
                log.info("秒杀成功");
            }
        }else{
            log.error("库存不足");
            throw new RuntimeException("秒杀失败");
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

采用JMeter压测:
线程组:采用500个线程,请求20次。
在这里插入图片描述
http请求:采用get请求,http://localhost:8080/secKill/1/1,每次购买一个商品
在这里插入图片描述
执行日志:商品库存依次递减1,说明现在扣减库存的方法secKill()确实是异步执行。
在这里插入图片描述
优点:简单,只需要在方法头部加上一个synchronized关键字,不用修改其他代码。
缺点:不支持分布式,并发能力较弱。

2、redis分布式锁

    public void  secKill(Integer goodsId, Integer num)  {
        RLock rlock = redissonClient.getLock("goods_sku");
        try{
             //加分布式锁
             rlock.lock();
             //1、查询商品库存
        GoodsStock  goodsStock  = goodsStockMapper.getStock(goodsId);
        //2、判断库存是否充足
        if(goodsStock.getNum() >= num){
            //3、减库存
            int stockNum = goodsStock.getNum() - num;
            goodsStock.setNum(stockNum);
            int result = goodsStockMapper.updateByPrimaryKeySelective(goodsStock);
            if(result < 1){
                 log.error("库存不足");
                 throw new RuntimeException("秒杀失败");
            }else{
                log.info("秒杀成功");
            }
        }else{
            log.error("库存不足");
            throw new RuntimeException("秒杀失败");
        }
        }catch(Exception e){
            log.error("秒杀失败");
        }finally{
             rlock.unlock();
        }
        
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

优点:支持分布式,能保证方法异步执行,避免超买超卖问题。
缺点:引入了redis中间件。

3、悲观锁

    @Transactional(rollbackFor = Exception.class)
    public  void  secKill(Integer goodsId, Integer num) throws InterruptedException {
        //1、查询商品库存
        GoodsStock  goodsStock  = goodsStockMapper.getStockForUpdate(goodsId);
        //2、判断库存是否充足
        if(goodsStock.getNum() >= num){
            //3、减库存
            int result = goodsStockMapper.secKill(goodsId,num);
            if(result < 1){
                 log.error("库存不足");
                 throw new RuntimeException("秒杀失败");
            }else{
                log.info("秒杀成功");
            }
        }else{
            log.error("库存不足");
            throw new RuntimeException("秒杀失败");
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  <select id="getStockForUpdate" resultType="com.laowan.seckill.modle.GoodsStock">
    select
    <include refid="Base_Column_List" />
    from goods_stock
    where goods_id = #{goodsId} for update
  </select>

  <select id="getStock" resultType="com.laowan.seckill.modle.GoodsStock">
    select
    <include refid="Base_Column_List" />
    from goods_stock
    where goods_id = #{goodsId}
  </select>
  
 <update id="secKill">
    update goods_stock
    set  num = num - #{num}
    where goods_id = #{goodsId}
  </update>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

核心是在执行goodsStockMapper.getStockForUpdate(goodsId)方法时,通过在查询语句后面添加for update,会去尝试给查询的记录添加写锁(排他锁)。
这样当第一个事务A进来获取写锁后,后面的事务就不能获取该记录上的写锁,会一直等待事务A执行完毕释放写锁后,再竞争获取写锁,才能执行扣减库存的操作。

select * from goods_stock where goods_id = #{goodsId} for update
  • 1

优点:支持分布式,充分利用了数据库的排他锁机制,保证扣减库存的操作串行执行。
缺点:并发效率相比synchronized、redis分布式锁会低很多,并且容易导致mysql死锁的问题。

悲观锁方式本质上是利用数据库的排他锁的特性,让事务内代码异步串行执行,从而避免了超买超卖问题。

上面介绍的几种方式,本质上都是通过加锁的方式,使得扣减库存的操作异步串行执行。
那么有没有不用加锁的方式呢?

4、乐观锁

    @Transactional(rollbackFor = Exception.class)
    public  void  secKill(Integer goodsId, Integer num) throws InterruptedException {
        //1、查询商品库存
        GoodsStock  goodsStock  = goodsStockMapper.getStock(goodsId);
        //2、判断库存是否充足
        if(goodsStock.getNum() >= num){
            //3、减库存
            goodsStock.setNum(num);
            int result = goodsStockMapper.secKillForVersion(goodsStock);
            if(result < 1){
                 log.error("库存不足");
                 throw new RuntimeException("秒杀失败");
            }else{
                log.info("秒杀成功");
            }
        }else{
            log.error("库存不足");
            throw new RuntimeException("秒杀失败");
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
<update id="secKillForVersion">
    update goods_stock
       set  num = num - #{num},version=version+1
    where goods_id = #{goodsId} and version=#{version}
</update>
  • 1
  • 2
  • 3
  • 4
  • 5

验证:
数据准备:

INSERT INTO `seckill`.`goods_stock`(`id`, `goods_id`, `num`, `version`) VALUES (1, '1', 1000, 0);
  • 1

在这里插入图片描述

JMeter压测:
在这里插入图片描述
执行结果:
在这里插入图片描述
乐观锁的版本号增长了779,商品的库存也正好扣减了779,说明成功控制了扣减库存的并发。
通过增加了版本号的控制,在扣减库存的时候在where条件进行版本号的比对。
实现查询的是哪一条记录,那么就要求更新的是哪一条记录,在查询到更新的过程中版本号不能变动,否则更新失败。

改进:
增加乐观锁的循环次数,提高请求成功的概率。

public  void  secKill(Integer goodsId, Integer num) throws InterruptedException {
           int retryCount = 0;
           int result = 0;
            //1、查询商品库存
            GoodsStock  goodsStock  = goodsStockMapper.getStock(goodsId);
            //2、判断库存是否充足
            if(goodsStock.getNum() < num){
                log.error("库存不足");
                throw new RuntimeException("秒杀失败");
            }
            //最多重试3次
            while(retryCount < 3 && result == 0){
                result = this.reduceStock(goodsId,num);
                retryCount++;
            }
            if(result > 0){
                log.info("秒杀成功");
            }else{
                log.error("库存不足");
                throw new RuntimeException("秒杀失败");
            }
    }

    /**
     * 减库存
     * 
     * 由于默认的事务隔离级别是可重复读,会导致在同一个事务中查询3次goodsStockMapper.getStock()
     * 得到的数据始终是相同的,所以需要提取reduceStock方法。每次循环都启动新的事务尝试扣减库存操作。
     */
    @Transactional(rollbackFor = Exception.class)
    public  int  reduceStock(Integer goodsId, Integer num){
        int result = 0;
        //1、查询商品库存
        GoodsStock  goodsStock  = goodsStockMapper.getStock(goodsId);
        //2、判断库存是否充足
        if(goodsStock.getNum() >= num){
            //3、减库存
            goodsStock.setNum(num);
            result = goodsStockMapper.secKillForVersion(goodsStock);
        }
        return result;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42

注意⚠️
不能在同一个事务里面,重试扣减库存的操作。

最后压测发现,扣减库存请求的成功率提高了很多。但请求过程中还是会出现大量版本冲突的问题。

乐观锁机制类似java中的cas机制,在查询数据的时候不加锁,只有更新数据的时候才比对数据是否已经发生过改变,没有改变则执行更新操作,已经改变了则进行重试。

乐观锁机制在大并发场景下,会出现大量的版本冲突导致重试的情况,而这种重试无疑会增大数据库和程序的压力。 显然乐观锁方式并不适合高并发的场景

5、where条件

where条件的方式主要是将库存是否充足的判定放在了更新库存的where条件中,以此保证不会出现超买超卖的情况。

    @Transactional(rollbackFor = Exception.class)
    public  void  secKill(Integer goodsId, Integer num) throws InterruptedException {
        //1、查询商品库存
        GoodsStock  goodsStock  = goodsStockMapper.getStock(goodsId);
        //2、判断库存是否充足
        if(goodsStock.getNum() >= num){
            //3、减库存
            int result = goodsStockMapper.secKillForWhere(goodsId,num);
            if(result < 1){
                log.error("库存不足");
                throw new RuntimeException("秒杀失败");
            }else{
                log.info("秒杀成功");
            }
        }else{
            log.error("库存不足");
            throw new RuntimeException("秒杀失败");
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

在更新语句中扣减库存,并在where调价中判定库存是否充足。

  <update id="secKillForWhere">
    update goods_stock
        set  num = num - #{num}
    where goods_id = #{goodsId} and num - #{num} >=0
  </update>
  • 1
  • 2
  • 3
  • 4
  • 5

JMeter压测:

INSERT INTO `seckill`.`goods_stock`(`id`, `goods_id`, `num`, `version`) VALUES (1, '1', 500, 0);
  • 1

在这里插入图片描述

线程数设置:
也可以加大线程数,看是否会出现库存数被扣减成负数的情况。
在这里插入图片描述

压测结果:
库存成功被扣完,且没有出现异常,执行效率也非常高。
在这里插入图片描述

6、unsigned 非负字段限制

本质上来说,这种方式和where条件方式类似。
where条件是通过限制扣减库存的查询条件来限制超卖
unsigned 非负字段限制是如果扣减库存出现负值后,在保存的时候会报错来防止出现超卖。

库存字段num增加非负字段限制unsigned,保证只能保存非负整数。

CREATE TABLE `goods_stock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `goods_id` varchar(255) DEFAULT NULL COMMENT '商品id',
  `num` int(11) unsigned DEFAULT NULL COMMENT '库存数量',
  `version` int(11) unsigned DEFAULT NULL COMMENT '版本号',
  PRIMARY KEY (`id`),
  UNIQUE KEY `ui_goods_id` (`goods_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
@Transactional(rollbackFor = Exception.class)
public  void  secKill(Integer goodsId, Integer num) throws InterruptedException {
        //1、查询商品库存
        GoodsStock  goodsStock  = goodsStockMapper.getStock(goodsId);
        //2、判断库存是否充足
        if(goodsStock.getNum() >= num){
            //3、减库存
            int result = goodsStockMapper.secKill(goodsId,num);
            if(result < 1){
                log.error("库存不足");
                throw new RuntimeException("秒杀失败");
            }else{
                log.info("秒杀成功");
            }
        }else{
            log.error("库存不足");
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

压测过程中出现如下异常提示:

Data truncation: BIGINT UNSIGNED value is out of range in '(`seckill`.`goods_stock`.`num` - 1)'
  • 1

说明库存不足时,不能继续扣减库存。

小结:
unsigned 非负字段限制方式算是一种兜底策略,从底层数据库底层数据规则的层面限制出现超买超卖的情况。 并且简单有效,能和其他方式共同使用,防止超卖超卖情况的出现。

为什么不通过事务隔离级别控制事务代码的并发

简单来说,事务的隔离级别并不能控制事务代码的并发。

事务是什么?
Transactions are atomic units of work that can be committed or rolled back. When a transaction makes multiple changes to the database, either all the changes succeed when the transaction is committed, or all the changes are undone when the transaction is rolled back.

事务是由一组SQL语句组成的原子操作单元,其对数据的变更,要么全都执行成功(Committed),要么全都不执行(Rollback)。
在这里插入图片描述
InnoDB实现的数据库事务具有常说的ACID属性,即原子性(atomicity),一致性(consistency)、隔离性(isolation)和持久性(durability)。

  • 原子性:事务被视为不可分割的最小单元,所有操作要么全部执行成功,要么失败回滚(即还原到事务开始前的状态,就像这个事务从来没有执行过一样)
  • 一致性:在成功提交或失败回滚之后以及正在进行的事务期间,数据库始终保持一致的状态。如果正在多个表之间更新相关数据,那么查询将看到所有旧值或所有新值,而不会一部分是新值,一部分是旧值
  • 隔离性:事务处理过程中的中间状态应该对外部不可见,换句话说,事务在进行过程中是隔离的,事务之间不能互相干扰,不能访问到彼此未提交的数据。这种隔离可通过锁机制实现。有经验的用户可以根据实际的业务场景,通过调整事务隔离级别,以提高并发能力
  • 持久性:一旦事务提交,其所做的修改将会永远保存到数据库中。即使系统发生故障,事务执行的结果也不能丢失。

这里我们要注意:
事务的原子性指的是在事务范围内执行的insert、update、delete要么全部成功,要么全部失败。而并不能保证添加了@Transactional注解声明事务的方法的执行具有原子性,也不能保证在事务A执行一系列insert、update、delete操作的过程中,事务B不能执行insert、update、delete操作。

场景一:
在默认的事务隔离级别下,可重复读(Repeatable Read)由于采用了MVCC机制,读不加锁,所以都可以查询到商品的库存信息,进行扣减库存的操作,这里都可以并发执行。
直到运行到update的操作,由于需要先获取到数据的排他锁,才能执行更新操作,才变为串行执行。
所以,这里如果采用在事务代码中,用java代码的方式先求出剩余库存,再将值更新到数据库,就会出现覆盖更新的情况。
在这里插入图片描述

场景二: 串行化(Serializable)
如果我们将事务的隔离级别提升为串行化(Serializable),执行的情况呢?
在这里插入图片描述
可以发现,尽管将事务的隔离级别提升为串行化(Serializable),也只是影响了在事务中执行sql时的加锁方式,并不能保证事务范围内的代码异步执行
小结:
事务隔离级别串行化(Serializable)并不能保证事务访问内的逻辑在并发场景下的串行化执行,大家不要被其命名所误导。
串行化(Serializable)隔离级别只能保证,先查到的,先更新

总结:

本文主要对商品超买超卖问题进行了深度分析,并提供了相关解决方案和实战代码,通过JMeter压测对其正确性进行了验证。
1、介绍了高并发场景下商品超买超卖问题出现的原因。
2、通过代码实战介绍了6种方式解决超买超买问题:

  1. synchronized方式
  2. redis分布式锁
  3. 悲观锁
  4. 乐观锁
  5. where条件
  6. unsigned 非负限制

其中前3种是通过加锁的方式保证扣减库存代码串行执行,后面3种是无锁方式。

3、如果事务代码中涉及到复杂计算,需要先通过java代码计算,然后再将计算结果更新到数据库,这种情况推荐采用synchronized方式、redis分布式锁、悲观锁这些加锁的方式,保证整个事务范围内的代码串行执行,防止出现覆盖更新的情况。
如果是像扣减库存这样简单的计算操作,可以在update中轻松实现的情况,推荐采用where条件、unsigned 非负限制方式,并发的效率更高。

4、强烈建议unsigned 非负限制方式能和其他方式一起使用,保证数据安全性。
5、乐观锁为什么不适合在高并发场景下使用
6、介绍了为什么不能通过提高事务的隔离级别来解决超买超卖的问题.

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/在线问答5/article/detail/891508
推荐阅读
相关标签
  

闽ICP备14008679号