先看看秒杀接口的源代码:
@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);
为什么需要补充库存?
原因有几点:
- 不补充redis的库存,就会无法保证数据库和redis的最终一致性。这里说的是最终一致性,而不是说强一致性。很简单的道理,redis的数量会是负数,而数据库的最多就是 0
- 即使不考虑最终一致性,那如果有订单重复、或者消费失效时,这些地方对redis库存进行了释放了(操作还是incr,不过"释放"好理解)。但此处不做回补处理,那显然stock还是负数,这样就造成:数据库还有库存,但redis已经没有库存了,而这显然是不合理的。
综上,此处是需要进行库存的补充的。
当然,更优的做法,是将第2、3步组合起来:
先判断redis库存是否 > 0 ,是才将库存减一。
但需要注意用Lua脚本实现,才能保证原子性。直接在代码用if
判断是不行了。
其他需要补充的库存的地方
(原项目完全没有进行处理)
- 生产者:
- 上述第四步的判断重复秒杀
其实更好的做法是 4 、2 互换,先判断是否重复秒杀,再进行预减库存。
这样就不用补充库存,就减少了对redis的访问了。笔者采用的就是这种
- 重发消息超过限制
- 消费者:
- 判断已经秒杀到
- 下单时,发现重复订单,违反唯一约束
- 消费者可以判定是消费失败时
这里说法比较"模糊",因为如何判定消费失败,是与业务相关的
以项目为准进行说明,就是最后的兜底 + 重试超限后,根据标记,决定是否补充库存
道理也很简单,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){
补充库存
}
}
}
}
拓展思考:
- 进行redis的库存补充这个操作,能不能替换成更新redis库存为数据库库存,这样不是更能保证一致吗?
- 首先需要查询数据库,这就多增加了压力了
- 其次,与直观感觉相反,这样恰恰是会违反一致性的。因为引入了MQ,redis的库存一直都是 <= 数据库的库存,如果直接更新为数据库库存,就会造成"假情报"。
注意这里其实是不会影响正确性的,因为秒杀sql
中,用了>0
。无论如何都不会发生超卖。但是自然的,这些无效的消息也是浪费资源的。所以这样处理是不对的。
- 关于消费者代码中的,
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库存和数据库库存,现在已经可以保证最终一致性了。
但这并不等同于项目的正确性。