用乐观锁解决库存超卖问题

1,614 阅读1分钟

实现优惠券秒杀下单

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第13天,点击查看活动详情

我们设计了优惠券表,和限时优惠券表,以及订单表

优惠券表中带有优惠券的基本信息如id,商铺关联id,优惠券描述,优惠券价格,优惠券的状态(已使用,未使用,已注销等)

限时限量优惠券表,相当于优惠券表的扩展,自然会有优惠券表的id,以及库存,活动开启和关闭时间等

订单表里则包括,优惠券id,用户id,支付方式,支付状态,支付时间等信息。

抢购下单

对于用户点击抢购优惠券的按钮,会向后端的VoucherOrder服务里发送添加秒杀优惠券的订单请求

@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
​
​
    @Resource
    private IVoucherOrderService voucherOrderService;
​
    @PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {
        return voucherOrderService.seckillVoucher(voucherId);
    }
}

业务逻辑是这样的:

  1. 查看优惠券是否在活动时间内

  2. 判断库存是否充足

  3. 库存充足则更新库存

  4. 填写订单信息,存入订单表

    tip:加上了事务回滚,因为涉及更改两次数据库

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
​
    @Resource
    private ISeckillVoucherService seckillVoucherService;
​
​
   @Resource
   private RedisIdWorker redisIdWorker;
​
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Result seckillVoucher(Long voucherId) {
        if(voucherId == null || voucherId<0){
            return Result.fail("id 不合法");
        }
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
​
        //判断活动是否开始
        LocalDateTime beginTime = seckillVoucher.getBeginTime();
        LocalDateTime endTime = seckillVoucher.getEndTime();
        if(LocalDateTime.now().isAfter(endTime) || LocalDateTime.now().isBefore(beginTime)){
            return Result.fail("活动尚未开始");
        }
        //判断库存是否充足
        Integer stock = seckillVoucher.getStock();
        if(stock <= 0){
            return Result.fail("库存不够");
        }
​
        boolean isSuccess = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .update();
        if(!isSuccess){
            return Result.fail("订单库存不足");
        }
        VoucherOrder voucherOrder = new VoucherOrder();
        long id = redisIdWorker.nextId("order");
        voucherOrder.setId(id);
        voucherOrder.setVoucherId(voucherId);
        UserDTO user = UserHolder.getUser();
        voucherOrder.setUserId(user.getId());
        save(voucherOrder);
        return Result.ok(id);
    }
}

这个逻辑理应是很完美的。

但是它没有考虑到一个问题:并发。我们再用Jmeter进行压力测试的时候,发现,100的库存被卖了109次!

这在秒杀场景中是很容易出现场景!这是不允许出现的!

超卖问题

那么超卖问题又是怎么发生的呢?

原来,在高并发的情况下,当库存数量为1的时候,第一个线程查到了数目为1,准备执行扣减逻辑。可是这时候冒出来了第二个线程,它也查到了数目为1,因为此时第一个线程并没有完成数目的扣减!所以第二个线程也会去走扣减逻辑,因此,库存就出现了负数的情况。

这也就是并发安全问题!

image-20221025141214622

乐观锁解决超卖

既然我们要用乐观锁解决超卖,那就不得不去提一下他的设计理念了。

我们都知道,乐观锁的好兄弟是悲观锁。

悲观锁又是啥呢?这哥们儿很悲观,认为线程安全问题一定会发生,所以在操作更改数据的时候一定会加锁。确保线程的串行执行。例如sychronized,Lock都是悲观锁。但是他的效率是很低的。

乐观锁又是啥呢?这哥们儿比较乐观,它认为,线程安全问题不一定会发生,只有在更改数据的时候,会去判断有没有其它线程对数据进行了修改。

  • 如果没有修改,则认为自己是安全的,自己才更新数据
  • 如果已经被其它线程修改,说明已经发生了安全问题,此时可以重试或异常

所以,我们会发现,实现乐观锁的关键在于,如何在更改数据的时候判断,之前查询到的数据,别人有没有来做过修改?

版本号法

image-20221025142328971

我们给一个秒杀商品填上一个版本号字段。

线程一试图减少库存。它会先去查询库存和版本号,并记录下来,在修改的时候,判断库存是否大于0

如果否,报错;如果是,就执行sql语句

set
    stock = stock -1
    version = version +1
where   
    id = 10
    and version = 1

这样一来,是不是如果在修改的途中,数据被修改了,version变成2了,那么这条sql语句就不会执行了?

这样就保证了,在更改数据的时候知道,之前查询到的数据,别人没有来做过修改!

image-20221025142937393

CAS法

我们实现乐观锁打算给它添加了一个字段——version用来比较前后数据是否有被修改过

基于这个思想,我们可以给这个实现基于一些改进

我们去掉version字段。

image-20221025143219911

sql语句改成

set
    stock = stock -1
where   
    id = 10
    and stock = 1

这样是不是也能确保前后数据,没有被更改!

CAS法:compare and set ,先比较后设值!

image-20221025143405545

代码实现

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
​
    @Resource
    private ISeckillVoucherService seckillVoucherService;
​
​
   @Resource
   private RedisIdWorker redisIdWorker;
​
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Result seckillVoucher(Long voucherId) {
        if(voucherId == null || voucherId<0){
            return Result.fail("id 不合法");
        }
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
​
        //判断活动是否开始
        LocalDateTime beginTime = seckillVoucher.getBeginTime();
        LocalDateTime endTime = seckillVoucher.getEndTime();
        if(LocalDateTime.now().isAfter(endTime) || LocalDateTime.now().isBefore(beginTime)){
            return Result.fail("活动尚未开始");
        }
        //判断库存是否充足
        Integer stock = seckillVoucher.getStock();
        if(stock <= 0){
            return Result.fail("库存不够");
        }
​
        boolean isSuccess = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)
                .update();
        if(!isSuccess){
            return Result.fail("订单库存不足");
        }
        VoucherOrder voucherOrder = new VoucherOrder();
        long id = redisIdWorker.nextId("order");
        voucherOrder.setId(id);
        voucherOrder.setVoucherId(voucherId);
        UserDTO user = UserHolder.getUser();
        voucherOrder.setUserId(user.getId());
        save(voucherOrder);
        return Result.ok(id);
    }
}
        boolean isSuccess = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)
                .update();

有人会好奇了,你之前写的不是stock = getStock()嘛

现在怎么变成了stock>0了?

如果按照第一种情况那么写,其实失败率是很高的,毕竟我们的库存数量不一定都是1,其他时候,库存树木都是很大的。因此只要满足>0就行啦!