先看看秒杀接口的源代码:

@RequestMapping(value="/{path}/do_miaosha",method=RequestMethod.POST)
	@ResponseBody
	public Result<Integer> doMiaosha(Model model,MiaoshaUser user,
			@RequestParam(value="goodsId",defaultValue="0") long goodsId,
			@PathVariable("path")String path) {
		model.addAttribute("user", user);
        
		//0.判断用户是否非空、秒杀路径是否正确
        
        // 1. 查看内存标记,看是否已结束
        boolean over = localOverMap.get(goodsId);
        if (over) {
            return Result.error(CodeMsg.MIAO_SHA_OVER);
        }
        
		//2.预减少redis的库存
		long stock=redisService.decr(GoodsKey.getMiaoshaGoodsStock,""+goodsId);
		//3.判断减少数量1之后的stock,减少到0一下,则代表之后的请求都失败,直接返回
		if(stock<0) {
             //进行内存标记
             localOverMap.put(goodsId, true);
			return Result.error(CodeMsg.MIAOSHA_OVER_ERROR);
		}
		//4.判断这个秒杀订单形成没有,判断是否已经秒杀到了,避免一个账户秒杀多个商品
		MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdAndCoodsId(user.getId(), goodsId);
		if (order != null) {// 查询到了已经有秒杀订单,代表重复下单
			return Result.error(CodeMsg.REPEATE_MIAOSHA);
		}
		//5.正常请求,入队,发送一个秒杀message到队列里面去,入队之后客户端应该进行轮询。
		MiaoshaMessage mms=new MiaoshaMessage();
		mms.setUser(user);
		mms.setGoodsId(goodsId);
		mQSender.sendMiaoshaMessage(mms);
		//返回0代表排队中
		return Result.success(0);
	}

其中的第三步:

if(stock < 0) {
    //进行内存标记
    localOverMap.put(goodsId, true);
	return Result.error(CodeMsg.MIAOSHA_OVER_ERROR);
}

乍一看没问题,但其实是需要进行redis的库存补充的,代码为redisService.incr(GoodsKey.getMiaoshaGoodsStock, "" + goodsId);

为什么需要补充库存?

原因有几点:

  1. 不补充redis的库存,就会无法保证数据库和redis的最终一致性。这里说的是最终一致性,而不是说强一致性。很简单的道理,redis的数量会是负数,而数据库的最多就是 0
  2. 即使不考虑最终一致性,那如果有订单重复、或者消费失效时,这些地方对redis库存进行了释放了(操作还是incr,不过"释放"好理解)。但此处不做回补处理,那显然stock还是负数,这样就造成:数据库还有库存,但redis已经没有库存了,而这显然是不合理的。

综上,此处是需要进行库存的补充的。

当然,更优的做法,是将第2、3步组合起来:
先判断redis库存是否 > 0 ,是才将库存减一。
但需要注意用Lua脚本实现,才能保证原子性。直接在代码用if判断是不行了。


其他需要补充的库存的地方

(原项目完全没有进行处理)

  1. 生产者:
  • 上述第四步的判断重复秒杀

其实更好的做法是 4 、2 互换,先判断是否重复秒杀,再进行预减库存。

这样就不用补充库存,就减少了对redis的访问了。笔者采用的就是这种

  • 重发消息超过限制
  1. 消费者:
  • 判断已经秒杀到
  • 下单时,发现重复订单,违反唯一约束
  • 消费者可以判定是消费失败时

这里说法比较"模糊",因为如何判定消费失败,是与业务相关的

以项目为准进行说明,就是最后的兜底 + 重试超限后,根据标记,决定是否补充库存

道理也很简单,try外定义一个boolean ,初始为false ,在秒杀接口后,设置为true

异常来源:

  • 秒杀接口之上的代码,补偿库存
  • 秒杀接口内部,由事务保证,进行回滚。下单失败,补偿库存
  • 秒杀借口之下(当前项目没有)。此时下单已成功。而消费者并没有开启事务,下单操作不会进行回滚。因此不能补充库存。
    这种情况虽然异常,但仍应视为消费成功

这里还需要注意:消费端发现消息已消费,或者库存不足时,是不需要而且不能进行redis库存的补充。

伪代码如下,( 去掉了可靠性传输的相关代码,方便强调库存操作)

@RabbitHandler
    @RabbitListener(queues = MQConfig.MIAOSHA_QUEUE)
    public void receive(String message, Channel channel, Message messages) throws Exception {
        boolean miaoshaSuccess = false;
        // try住整个代码,防止漏异常。如果catch需要的try中的变量,则提前声明就好了
        try {
            // 消息幂等性的处理
            String correlatonId = messages.getMessageProperties().getCorrelationId();
            //根据correlationId判断消息是否被消费过
            if (消息已经被消费过) {
                return;
            }
            if (库存不足) {
                return;
            }
            if (已经秒杀到) {// 重复下单
                补充库存
                return;
            }

            //进行原子操作:1.库存减1,2.下订单,3.写入秒杀订单--->是一个事务
            miaoshaService.miaosha(user, goodsvo);
            miaoshaSuccess = true;
        } catch (DuplicateKeyException e) {
            补充库存
        } catch (Exception e) {
            if(重试超出限制){
                if(miaoshaSuccess == true){
                    补充库存
                }
            }
        }
    }

拓展思考:

  1. 进行redis的库存补充这个操作,能不能替换成更新redis库存为数据库库存,这样不是更能保证一致吗?
  • 首先需要查询数据库,这就多增加了压力了
  • 其次,与直观感觉相反,这样恰恰是会违反一致性的。因为引入了MQ,redis的库存一直都是 <= 数据库的库存,如果直接更新为数据库库存,就会造成"假情报"。
    注意这里其实是不会影响正确性的,因为秒杀sql中,用了 >0。无论如何都不会发生超卖。但是自然的,这些无效的消息也是浪费资源的。所以这样处理是不对的。
  1. 关于消费者代码中的,if (库存不足) {return;} 这段代码的必要性的思考

因为得益于redis的单线程,我们其实可以保证进入MQ的消息数量就是商品的数量。

当然这里面有"水分",这些消息中可能有同一个用户的多条消息。但这难道不是意味着:MQ的消息消耗的商品,是 <= 数据库的库存。 如果是这样,那这个if应当是可以省略的。

而且经笔者测试,这个if也确实一次都没有触发。

其实此处还可以延伸到miaoshaService.miaosha(user, goodsvo) 的代码:

@Transactional //减库存 下订单 写入秒杀订单 的原子操作 public void miaosha(MiaoshaUser user, GoodsVo goods) { // 小优化:可以先下订单,再减库存。这样在订单重复的时候,可以不用回滚库存的表操作 boolean success = goodsService.reduceStockWithPessimisticLock(goods); if (success) { orderService.createOrder(user, goods); } else { setGoodsOver(goods.getId()); } }

这里的success 也是同理,看数据库库存还有没有来返回。所以其实setGoodsOver 方法是无效的。(已经过测试)

因此这里的success应该换成查询数据库库存是否为0。代码如下:

@Transactional public void miaosha(MiaoshaUser user, GoodsVo goods) { orderService.createOrder(user, goods); // 但其实success 必为true, setGoodsOver 永远不会被触发。 goodsService.reduceStockWithPessimisticLock(goods); int stock = goodsService.getGoodsVoByGoodsId(goods.getId()).getStockCount(); // 其实只会 == 0 ,但安全起见,还是<= if (stock <= 0) { setGoodsOver(goods.getId()); } }

当然了,这是基于redis单机的情况下说的,集群环境下我们是无法做到上述的保证的,因此这些代码还是需要的。

不知道笔者理解有没有错。


至此,库存的补偿方案完成了。
redis库存和数据库库存,现在已经可以保证最终一致性了。
这并不等同于项目的正确性