问题原始描述:两用户查询某商品库存都是1,导致卖出2个商品,产生了超卖问题。
超卖导致的原因:
不同用户检查库存够用,然后并发下订单,减库存,由于检查库存和减少库存这两个操作不保证原子性,所以可能会出现本线程检查库存够用到实际减少库存操作之间,其他线程抢先扣除库存导致本线程扣除库存后库存出现负数,引发超卖。
幂等性:指的是一个操作应该满足f(x) = f(f(x))的特性,即为调用一次与调用多次效果一样。
解决超卖问题可以使用redis进行预扣库存操作,由于redis的increment操作具有原子性,即扣除库存与数量查询是原子操作,解决了 判断库存与减库存 两个操作不具有原子性的问题(超卖问题的诞生就是因为这两个操作不具有原子性)。
/** * redis原子操作预检库存操作 * 操作的前提是库存表已经存入redis中,(可在添加秒杀商品接口中执行) * 此操作执行了校验库存及扣除库存的操作 * @return 预扣库存是否成功 */ private boolean stockTicket(String goodId){ //库存数量键 String key = "sec:product:stock:"+goodId; //校验是否存在此商品 Object value = redisTemplate.opsForValue().get(key); if (null==value){ //商品不存在 log.info("商品不存在"); return false; } //检查库存 Integer stock = (Integer) value; if (stock<=0){ //库存不足 log.info("预检时库存不足"); return false; } //此处库存充足 //但是不能直接进行减库存操作 ,高并发访问情况下,此时可能有其他线程已经将redis的key修改了 //redis 预检库存操作 Long increment = redisTemplate.opsForValue().increment(key, -1); //这里的 increment是原子操作的产物,所以一定是线程安全的 if (increment>=0){ //秒杀成功 //todo: 这里再执行数据同步,可以使用mq的操作异步同步数据 log.info("秒杀成功"); //这里暂时直接插入 //下面这个操作错误的原因是执行此操作并不是按顺序执行,有可能最后一次修改并不是increment=0的那条线程, //有可能是increment=任何数的一条线程去做的最后一次修改,导致数据库中最后数据不为0 // seckillGoodsService.update( // new UpdateWrapper<SeckillGoods>().eq("goods_id",goodId).set("stock_count",increment)); //正确操作还是应该是库中自减 seckillGoodsService.update( new UpdateWrapper<SeckillGoods>().eq("goods_id",goodId). setSql("stock_count=stock_count-1")); //这里的自减是原子操作,线程安全 return true; }else { //秒杀失败,在此前第一次查看库存与第二次原子减库存之间有线程修改库存导致库存不足了 //为了保证数据的线程安全性,需要回退数据,将之前减去的redis中库存回退 redisTemplate.opsForValue().increment(key,1); return false; } }
流程:
重复下单问题的出现可能有两种情况引起
进行订单的预检:
出于性能考虑,若将订单预检在数据库中进行会有数据库大量的并发查找压力
所以将订单的预检功能上移到redis。
/** * 使用redis预存订单,请求刚进入时就查看预存订单表,若表中有数据则证明重复下单 * * @param userId 用户id * @param goodsId 商品id * @param Time 过期时间 ms(一般为秒杀结束后过期) * @return */ private boolean PreOrder(Integer userId,Integer goodsId,Long Time){ String key = "sec:product:order:"+userId+":"+goodsId; redisTemplate.opsForValue().set(key,1,Time, TimeUnit.SECONDS); //todo: 这里再执行真正的入单操作,可以使用mq进行异步入单 return true; }
/** * 校验订单 * 校验redis中是否预存了订单信息,若有则表示重复下单 * @param userId 用户id * @param goodsId 商品id * @return 是否重复下单,true表示未重复下单 */ @Override public boolean checkOrder(Integer userId,Integer goodsId){ String key = "sec:product:order:"+userId+":"+goodsId; Object o = redisTemplate.opsForValue().get(key); //为空则证明第一次下单 Assert.isTrue(null==o,ResponseEnum.REPEAT_ORDER); return true; }
流程
同样使用redis可以拦截快速二次请求
/** * 使用redis拦截同一用户快速二次请求 * @param userId 请求用户 * @return 是否放行 */ private boolean healthRequest(Integer userId){ String key = "sec:req:"+userId; long count = redisTemplate.opsForValue().increment(key, 1); if (count == 1) { //设置有效期2秒 redisTemplate.expire(key, 2, TimeUnit.SECONDS); } if (count > 1) { //重复提交订单 //抛出异常 Assert.isTrue(false,ResponseEnum.REPEAT_req); } return true; }
原理:
在redis中设置一个快速过期的以用户id作为key的请求次数缓存,用户请求进来后,若缓存中有数据则证明在过期时间内用户二次请求了系统,这时可以将其拦截。
@ApiOperation(value = "秒杀接口" ) @PostMapping("/doSeckill") public R doSeckill(HttpServletRequest request, @ApiParam(value = "商品vo", required = true) @RequestBody SeckillGoodDetailVO goods) { //解析用户id String token = request.getHeader("token"); // 获取令牌(前端将token放入请求头中) Integer userId = JwtUtils.getUserId(token); //解析出token中的用户id //校验是否重复下单 seckillOrderService.checkOrder(userId,goods.getId()); //执行操作 Order order = seckillOrderService.doSeckill2(userId, goods); return R.ok().message("下单成功").data("order",order); }
@Override public Order doSeckill2(Integer userId, SeckillGoodDetailVO goods) { //校验及扣库存 boolean b = stockTicket(goods.getId().toString()); //断言秒杀成功 Assert.isTrue(b, ResponseEnum.FAIL_SECKILL); //计算预入单过期时间 Long second = goods.getEndDate().toEpochSecond(ZoneOffset.of("+8"))- LocalDateTime.now().toEpochSecond(ZoneOffset.of("+8")); //redis预入订单 PreOrder(userId,goods.getId(),second); //真正订单入库操作,可以使用mq执行 Order order = order(userId, goods); return order; }
完整代码