乐字节秒杀系统解决超卖问题和重复下单问题的一些分析

我最近在学做秒杀系统,选择了B站乐字节推出的一套课程,整套课程质量不错,老师带着一步一步敲代码。但是在解决库存超卖问题和重复下单问题的时候,老师讲得有点草率了,而这部分又是相当有含金量的。因此,我自己写了一些分析,如果有错误还请大家指正。(注:写本文时我刚学完第43节课页面优化总结,如果后续还有变动我再更新)

课程地址:https://www.bilibili.com/video/BV1ZM4y1P7ni

controller层的处理方式

@RequestMapping(value = "/doSeckill", method = RequestMethod.POST)
@ResponseBody // 返回类型是 ResponseBean,所以必须要加上这个注解
public ResponseBean doSeckill(User user, Long goodsId) {
    if (null == user) {
        return ResponseBean.error(ResponseBeanEnum.USER_NOT_EXISTS);
    }
    
    // 初步判断是否重复抢购,在service层还要再判断一次
    GoodsVO goods = goodsService.findGoodsVOByGoodsId(goodsId);
    if (goods.getSeckillStock() <= 0) {
        // model.addAttribute("errorMessage", ResponseBeanEnum.EMPTY_STOCK.getMessage());
        return ResponseBean.error(ResponseBeanEnum.EMPTY_STOCK);
    }
/*    SeckillOrder seckillOrder = seckillOrderService.getOne(
            new QueryWrapper()
                    .eq("user_id", user.getId())
                    .eq("goods_id", goodsId)
    );*/
    SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("seckill_order:" + user.getId() + ":" + goods.getId());
    if (seckillOrder != null) {
        return ResponseBean.error(ResponseBeanEnum.DUPLICATE_ORDER);
    }

    // 抢购下单
    Order order = orderService.seckill(user, goods);
    return ResponseBean.success(order);
}

GoodsVO类继承自Goods类(通用商品),Goods类对应MySQL中的t_goods表(通用商品表)。MySQL中还有一张表叫t_seckill_goods(秒杀商品表),秒杀商品不仅属于通用商品,还有属于自己的一些属性(比如秒杀价格、起讫时间)。GoodsVO就是在通用商品的基础上,多了秒杀价格、秒杀库存(与通用商品的库存不是一回事)、开始时间和结束时间这四个属性。

通过 findGoodsVOByGoodsId 从MySQL中找到秒杀商品后,先判断它的秒杀库存是不是≤0(有没有售罄),这里可以拦截掉大部分的无效下单请求。

但是,不是全部!因为这时候秒杀库存还没被扣掉(这一步在service层完成),所以可能会有很多实际上无效的下单请求会被放行,后续还要继续处理。

然后就是初步判断有没有重复下单了(每个用户限一件商品),先看我注释掉的旧写法:

SeckillOrder seckillOrder = seckillOrderService.getOne(
        new QueryWrapper<SeckillOrder>()
                .eq("user_id", user.getId())
                .eq("goods_id", goodsId)
    );

之前说过,系统中有通用商品,还有在基于此的秒杀商品,订单也是如此,既有通用商品订单,也有秒杀商品订单。在service层下单时,既要创建通用商品订单,也要创建秒杀商品订单。这里是从MySQL中查询有没有该用户的秒杀订单,如果有的话,就不允许继续下单了。老师在这里改进了做法,就是后续创建秒杀商品订单后将其存进Redis中,所以这里不再需要从MySQL中查找:

SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("seckill_order:" + user.getId() + ":" + goods.getId());
if (seckillOrder != null) {
    return ResponseBean.error(ResponseBeanEnum.DUPLICATE_ORDER);
}

service层的处理方式

@Transactional // 事务注解
@Override
public Order seckill(User user, GoodsVO goods) {
    // 秒杀商品表减库存
    SeckillGoods seckillGoods = seckillGoodsService.getOne(new QueryWrapper<SeckillGoods>()
            .eq("goods_id", goods.getId())
    );
    seckillGoods.setSeckillStock(seckillGoods.getSeckillStock() - 1);
    // seckillGoodsService.updateById(seckillGoods);

    // 这种写法可以保证 seckill_stock 非负,但无法保证订单数不超额
/*        boolean seckillGoodsResult = seckillGoodsService.update(
            new UpdateWrapper()
                    .set("seckill_stock", seckillGoods.getSeckillStock())
                    .eq("goods_id", goods.getId())
                    .gt("seckill_stock", 0)
    );*/

    // 使用 SQL 语句直接减库存,这种做法是正确的
    boolean seckillGoodsResult = seckillGoodsService.update(
            new UpdateWrapper<SeckillGoods>()
                    .setSql("seckill_stock = seckill_stock - 1")
                    .eq("goods_id", goods.getId())
                    .gt("seckill_stock", 0)
    );

    // 根据更新结果判断是否要创建订单
    if (!seckillGoodsResult) {
        return null;
    }

    // 生成订单
    Order order = new Order();
    // order.setId(); // 自动生成的,不用管
    order.setUserId(user.getId());
    order.setGoodsId(goods.getId());
    order.setDeliveryAddressId(0L);
    order.setGoodsName(goods.getGoodsName());
    order.setGoodsCount(1);
    order.setGoodsPrice(seckillGoods.getSeckillPrice());
    order.setOrderChannel(1);
    order.setStatus(0); // 未支付
    order.setCreateTime(new Date());
    // order.setPayTime(); // 未支付
    orderMapper.insert(order);

    // 生成秒杀订单
    SeckillOrder seckillOrder = new SeckillOrder();
    // seckillOrder.setId(); // 自动生成的,不用管
    seckillOrder.setUserId(user.getId());
    seckillOrder.setOrderId(order.getId()); // 插入后会自动返回主键
    seckillOrder.setGoodsId(goods.getId());
    seckillOrderService.save(seckillOrder);
    redisTemplate.opsForValue().set("seckill_order:" + user.getId() + ":" + goods.getId(), seckillOrder);

    // 返回订单
    return order;
}

先从MySQL中找到秒杀商品的信息:

SeckillGoods seckillGoods = seckillGoodsService.getOne(new QueryWrapper<SeckillGoods>()
        .eq("goods_id", goods.getId())
);

接下来防止超卖的操作老师讲得不太清楚,我尽可能解释一下,首先是被注释掉的错误写法:

seckillGoods.setSeckillStock(seckillGoods.getSeckillStock() - 1);
seckillGoodsService.updateById(seckillGoods);

这里是在service层扣除库存然后更新数据库,而问题是在这一层将库存-1,再更新MySQL中的记录,这样做正确吗?

实际上是不正确的,因为前面的拦截并不完美,会有售罄后的下单请求被放进来,如果在此直接操作MySQL中的库存,显然是会出错的,库存甚至可以变成负数。老师又给出了一种写法:判断库存是否为正,确认是正数再扣除,这样做正确吗?

boolean seckillGoodsResult = seckillGoodsService.update(
        new UpdateWrapper<SeckillGoods>()
                .set("seckill_stock", seckillGoods.getSeckillStock())
                .eq("goods_id", goods.getId())
                .gt("seckill_stock", 0)
);

还是不正确的,因为可能有先来的请求扣了库存,但还没来得及下单,线程就变成就绪态了,后到的请求反而完成了全部流程。比如说库存是10,请求A已经扣成9了,但是后面来了一大堆请求把库存扣成了0,然后又轮到请求A继续执行,结果库存又变成了9,然后又有一大堆请求会被放进来。

超卖的核心问题在于判断超卖和扣库存不能一气呵成:这个两个操作必须是原子的,而这种写法不能满足,所以出现了错误。

所以,老师给出了如下的写法:

// 使用 SQL 语句直接减库存,这种做法是原子的
boolean seckillGoodsResult = seckillGoodsService.update(
        new UpdateWrapper<SeckillGoods>()
                .setSql("seckill_stock = seckill_stock - 1")
                .eq("goods_id", goods.getId())
                .gt("seckill_stock", 0)
);

这样可以确保判断和扣库存一气呵成,就可以解决超卖的问题了。 (我后续又问了AI这样能不能确保不超卖,AI说就并发安全性而言,这并不能完全保证在高并发环境下不会出现超卖的情况:虽然这个更新操作本身是原子的,但多个并发的事务可能同时读取到相同的库存值,并都尝试执行更新。如果库存量恰好是1,两个事务都可能读取到1并都通过gt("seckill_stock", 0) 的检查,然后都尝试更新库存。这会导致两个事务都成功,实际上商品已经被超卖了。但在实际压测的时候,好像没有出现超卖的情况。我的理解是,这种写法虽然确保了操作的原子性,但是不能保证并发的安全性。关于这个问题,我再去研究一下,就先搁置在这里吧)

但是,这样能解决重复下单的问题吗?

redisTemplate.opsForValue().set("seckill_order:" + user.getId() + ":" + goods.getId(), seckillOrder);

把秒杀订单存入Redis后,controller层就能查到了,照理说重复下单的请求在controller层直接就被拦截掉了呀。但是,操作MySQL和Redis的操作也不是一气呵成的,在MySQL里新建秒杀订单完成以后,可能还没来得及将其存入Redis中,结果线程又变成就绪态了。这时候秒杀订单在MySQL里,却不在Redis里,然后用户又发起了一个下单请求,恰好此时库存还够,结果重复的下单请求被放进来了。所以,老师才会在MySQL中的秒杀商品表使用user_id字段和goods_id字段建立索引,这样一旦出现的秒杀商品订单,就会被MySQL拦截掉。

但是,这样还不够,因为创建秒杀商品订单的时候,通用商品订单已经被创建了!如果不做些什么,就会出现存在普通商品订单,但没有秒杀商品订单的情况,而且库存也是照扣不误。所以,这个方法必须使用事务注解@Transactional,MySQL的任何操作一旦失败直接回滚。

但我还是有疑问:如果库存为3,用户甲下单请求A将库存扣成了2,这时候线程变成就绪态,然后用户乙的下单请求B完成了整个的下单流程,库存变成了1。巧的是,这时候用户甲又发起了一个下单请求C,而且抢在A之前完成了,所以库存就是0了。下单请求A在创建订单的时候,被索引拦截,这时候库存回滚成2还是1呢?

AI的说法:

  1. 初始状态:库存是3。
  2. 请求A:首先开始,并成功将库存从3扣减到2(但由于某种原因,它尚未完成整个下单流程)。
  3. 请求B:在请求A等待时,请求B开始并执行,成功将库存从2扣减到1,并完成整个下单流程。
  4. 请求C:在请求A和B之后,请求C开始并执行,成功将库存从1扣减到0。
  5. 请求A的后续操作:如果请求A在请求B和C之后恢复并执行其剩余的下单流程,由于此时库存已经是0,请求A将尝试将库存从0扣减到-1(这通常会被数据库拒绝,因为库存不能为负)。但是,由于请求A的扣减操作是在它自己的事务中进行的,并且该事务尚未提交,因此它不会影响到已经提交的请求B和C所做的修改
  6. 事务回滚:
    • 如果请求A的事务因为某种原因(如违反了数据库的完整性约束)而回滚,那么只有请求A对库存的修改(即从未提交的尝试将库存从2扣减到1)会被撤销。但是,由于请求B和C的事务已经成功提交,所以它们的修改(即将库存从3扣减到1,再从1扣减到0)仍然有效。
    • 因此,即使请求A的事务回滚,库存也不会恢复到3,而是保持在请求C提交后的状态,即0或更少(如果请求C之后还有其他请求进一步扣减库存)。
  7. 结果:最终库存可能是0或更少,取决于在请求C之后是否还有其他请求对库存进行了扣减。

所以说,这样的写法还是有问题的?这已经涉及MySQL更底层的东西了,等我以后学懂了再来看这个问题。

如果我的分析哪里有错误,还请大家指正。特别是关于超卖的并发安全性问题,还有我最后提出的那个疑问,希望有朋友可以点拨一下。

你可能感兴趣的:(redis,spring,boot,mysql,后端)