ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

乐字节秒杀系统解决超卖问题和重复下单问题的一些分析 我最近在学做秒杀系统选择了B站乐字节推出的一套课程整套课程质量不错老师带着一步一步敲代码。但是在解决库存超卖问题和重复下单问题的时候老师讲得有点草率了而这部分又是相当有含金量的。因此我自己写了一些分析如果有错误还请大家指正。注写本文时我刚学完第43节课页面优化总结如果后续还有变动我再更新课程地址https://www.bilibili.com/video/BV1ZM4y1P7nicontroller层的处理方式RequestMapping(value/doSeckill,methodRequestMethod.POST)ResponseBody// 返回类型是 ResponseBean所以必须要加上这个注解publicResponseBeandoSeckill(Useruser,LonggoodsId){if(nulluser){returnResponseBean.error(ResponseBeanEnum.USER_NOT_EXISTS);}// 初步判断是否重复抢购在service层还要再判断一次GoodsVOgoodsgoodsService.findGoodsVOByGoodsId(goodsId);if(goods.getSeckillStock()0){// model.addAttribute(errorMessage, ResponseBeanEnum.EMPTY_STOCK.getMessage());returnResponseBean.error(ResponseBeanEnum.EMPTY_STOCK);}/* SeckillOrder seckillOrder seckillOrderService.getOne( new QueryWrapperSeckillOrder() .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);}// 抢购下单OrderorderorderService.seckill(user,goods);returnResponseBean.success(order);}GoodsVO类继承自Goods类通用商品Goods类对应MySQL中的t_goods表通用商品表。MySQL中还有一张表叫t_seckill_goods秒杀商品表秒杀商品不仅属于通用商品还有属于自己的一些属性比如秒杀价格、起讫时间。GoodsVO就是在通用商品的基础上多了秒杀价格、秒杀库存与通用商品的库存不是一回事、开始时间和结束时间这四个属性。通过 findGoodsVOByGoodsId 从MySQL中找到秒杀商品后先判断它的秒杀库存是不是≤0有没有售罄这里可以拦截掉大部分的无效下单请求。但是不是全部因为这时候秒杀库存还没被扣掉这一步在service层完成所以可能会有很多实际上无效的下单请求会被放行后续还要继续处理。然后就是初步判断有没有重复下单了每个用户限一件商品先看我注释掉的旧写法SeckillOrderseckillOrderseckillOrderService.getOne(newQueryWrapperSeckillOrder().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){// 秒杀商品表减库存SeckillGoodsseckillGoodsseckillGoodsService.getOne(newQueryWrapperSeckillGoods().eq(goods_id,goods.getId()));seckillGoods.setSeckillStock(seckillGoods.getSeckillStock()-1);// seckillGoodsService.updateById(seckillGoods);// 这种写法可以保证 seckill_stock 非负但无法保证订单数不超额/* boolean seckillGoodsResult seckillGoodsService.update( new UpdateWrapperSeckillGoods() .set(seckill_stock, seckillGoods.getSeckillStock()) .eq(goods_id, goods.getId()) .gt(seckill_stock, 0) );*/// 使用 SQL 语句直接减库存这种做法是正确的booleanseckillGoodsResultseckillGoodsService.update(newUpdateWrapperSeckillGoods().setSql(seckill_stock seckill_stock - 1).eq(goods_id,goods.getId()).gt(seckill_stock,0));// 根据更新结果判断是否要创建订单if(!seckillGoodsResult){returnnull;}// 生成订单OrderordernewOrder();// 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);// 生成秒杀订单SeckillOrderseckillOrdernewSeckillOrder();// 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中找到秒杀商品的信息SeckillGoodsseckillGoodsseckillGoodsService.getOne(newQueryWrapperSeckillGoods().eq(goods_id,goods.getId()));接下来防止超卖的操作老师讲得不太清楚我尽可能解释一下首先是被注释掉的错误写法seckillGoods.setSeckillStock(seckillGoods.getSeckillStock()-1);seckillGoodsService.updateById(seckillGoods);这里是在service层扣除库存然后更新数据库而问题是在这一层将库存-1再更新MySQL中的记录这样做正确吗实际上是不正确的因为前面的拦截并不完美会有售罄后的下单请求被放进来如果在此直接操作MySQL中的库存显然是会出错的库存甚至可以变成负数。老师又给出了一种写法判断库存是否为正确认是正数再扣除这样做正确吗booleanseckillGoodsResultseckillGoodsService.update(newUpdateWrapperSeckillGoods().set(seckill_stock,seckillGoods.getSeckillStock()).eq(goods_id,goods.getId()).gt(seckill_stock,0));还是不正确的因为可能有先来的请求扣了库存但还没来得及下单线程就变成就绪态了后到的请求反而完成了全部流程。比如说库存是10请求A已经扣成9了但是后面来了一大堆请求把库存扣成了0然后又轮到请求A继续执行结果库存又变成了9然后又有一大堆请求会被放进来。超卖的核心问题在于判断超卖和扣库存不能一气呵成这个两个操作必须是原子的而这种写法不能满足所以出现了错误。所以老师给出了如下的写法// 使用 SQL 语句直接减库存这种做法是原子的booleanseckillGoodsResultseckillGoodsService.update(newUpdateWrapperSeckillGoods().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拦截掉。但是这样还不够因为创建秒杀商品订单的时候通用商品订单已经被创建了如果不做些什么就会出现存在普通商品订单但没有秒杀商品订单的情况而且库存也是照扣不误。所以这个方法必须使用事务注解TransactionalMySQL的任何操作一旦失败直接回滚。但我还是有疑问如果库存为3用户甲下单请求A将库存扣成了2这时候线程变成就绪态然后用户乙的下单请求B完成了整个的下单流程库存变成了1。巧的是这时候用户甲又发起了一个下单请求C而且抢在A之前完成了所以库存就是0了。下单请求A在创建订单的时候被索引拦截这时候库存回滚成2还是1呢AI的说法初始状态库存是3。请求A首先开始并成功将库存从3扣减到2但由于某种原因它尚未完成整个下单流程。请求B在请求A等待时请求B开始并执行成功将库存从2扣减到1并完成整个下单流程。请求C在请求A和B之后请求C开始并执行成功将库存从1扣减到0。请求A的后续操作如果请求A在请求B和C之后恢复并执行其剩余的下单流程由于此时库存已经是0请求A将尝试将库存从0扣减到-1这通常会被数据库拒绝因为库存不能为负。但是由于请求A的扣减操作是在它自己的事务中进行的并且该事务尚未提交因此它不会影响到已经提交的请求B和C所做的修改事务回滚如果请求A的事务因为某种原因如违反了数据库的完整性约束而回滚那么只有请求A对库存的修改即从未提交的尝试将库存从2扣减到1会被撤销。但是由于请求B和C的事务已经成功提交所以它们的修改即将库存从3扣减到1再从1扣减到0仍然有效。因此即使请求A的事务回滚库存也不会恢复到3而是保持在请求C提交后的状态即0或更少如果请求C之后还有其他请求进一步扣减库存。结果最终库存可能是0或更少取决于在请求C之后是否还有其他请求对库存进行了扣减。所以说这样的写法还是有问题的这已经涉及MySQL更底层的东西了等我以后学懂了再来看这个问题。如果我的分析哪里有错误还请大家指正。特别是关于超卖的并发安全性问题还有我最后提出的那个疑问希望有朋友可以点拨一下。
返回列表