我最近在学做秒杀系统,选择了B站乐字节推出的一套课程,整套课程质量不错,老师带着一步一步敲代码。但是在解决库存超卖问题和重复下单问题的时候,老师讲得有点草率了,而这部分又是相当有含金量的。因此,我自己写了一些分析,如果有错误还请大家指正。(注:写本文时我刚学完第43节课页面优化总结,如果后续还有变动我再更新)
课程地址:https://www.bilibili.com/video/BV1ZM4y1P7ni
@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);
}
@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的说法:
所以说,这样的写法还是有问题的?这已经涉及MySQL更底层的东西了,等我以后学懂了再来看这个问题。
如果我的分析哪里有错误,还请大家指正。特别是关于超卖的并发安全性问题,还有我最后提出的那个疑问,希望有朋友可以点拨一下。