☰
乐字节秒杀系统解决超卖问题和重复下单问题的一些分析
2026/10/5 6:43:02 网站建设 项目流程

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

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

controller层的处理方式

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

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

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

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

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

SeckillOrderseckillOrder=seckillOrderService.getOne(newQueryWrapper<SeckillOrder>().eq("user_id",user.getId()).eq("goods_id",goodsId));

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

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

service层的处理方式

@Transactional// 事务注解@OverridepublicOrderseckill(Useruser,GoodsVOgoods){// 秒杀商品表减库存SeckillGoodsseckillGoods=seckillGoodsService.getOne(newQueryWrapper<SeckillGoods>().eq("goods_id",goods.getId()));seckillGoods.setSeckillStock(seckillGoods.getSeckillStock()-1);// seckillGoodsService.updateById(seckillGoods);// 这种写法可以保证 seckill_stock 非负,但无法保证订单数不超额/* boolean seckillGoodsResult = seckillGoodsService.update( new UpdateWrapper<SeckillGoods>() .set("seckill_stock", seckillGoods.getSeckillStock()) .eq("goods_id", goods.getId()) .gt("seckill_stock", 0) );*/// 使用 SQL 语句直接减库存,这种做法是正确的booleanseckillGoodsResult=seckillGoodsService.update(newUpdateWrapper<SeckillGoods>().setSql("seckill_stock = seckill_stock - 1").eq("goods_id",goods.getId()).gt("seckill_stock",0));// 根据更新结果判断是否要创建订单if(!seckillGoodsResult){returnnull;}// 生成订单Orderorder=newOrder();// 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(newDate());// order.setPayTime(); // 未支付orderMapper.insert(order);// 生成秒杀订单SeckillOrderseckillOrder=newSeckillOrder();// 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);// 返回订单returnorder;}

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

SeckillGoodsseckillGoods=seckillGoodsService.getOne(newQueryWrapper<SeckillGoods>().eq("goods_id",goods.getId()));

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

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

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

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

booleanseckillGoodsResult=seckillGoodsService.update(newUpdateWrapper<SeckillGoods>().set("seckill_stock",seckillGoods.getSeckillStock()).eq("goods_id",goods.getId()).gt("seckill_stock",0));

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

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

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

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

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

2026年8月31日:解决当年留下来的这个问题,UPDATE操作本质上是修改数据,为了保证数据一致性,InnoDB 会对满足WHERE条件的索引记录加上 X 锁(写锁),阻止其他事务对该行进行任何修改或读取(除非使用SELECT ... FOR SHARE或允许非锁定读)。所以,并不会存在我上面提到的问题!

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

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更底层的东西了,等我以后学懂了再来看这个问题。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询