secKill项目 --- 可靠性传输的实现 及 易错点总结

本文只讨论可靠性传输相关的问题,预减库存的问题在另一篇博客:


如何实现消息的可靠性传输,已在其他两篇博文中总结了,此处不赘述

RabbitMQ实现可靠性传输 理论篇

RabbitMQ实现可靠性传输 代码篇

这里记录一下易错的,需要注意的点:

  1. 消息的唯一ID是需要手动分配的,消息的持久化也是需要额外做的。

    两者都需要通过MessagePostProcessor完成

  2. 限制重发次数交由定时任务完成。

    重发需要注意的点:

    • 超出限制后,记得移除redis中的相关key,并入库记录

    • 重发时,消息的correlationId要注意设置为同一个,不应且不能更换。

      这是保证消息幂等性的大前提。

    除了交由定时任务完成,貌似有提供的接口,因为配置参数有 template.retry。但笔者不会用

而可靠性传输中,最难处理的,无疑就是消费端了。因为需要采用manual手动确认模式,而这里要注意的点很多

先看伪代码

@RabbitHandler
    @RabbitListener(queues = MQConfig.MIAOSHA_QUEUE)
    public void receive(String message, Channel channel, Message messages) throws Exception {
        // 消息幂等性的处理
        String correlatonId = messages.getMessageProperties().getCorrelationId();
        //根据correlationId判断消息是否被消费过
        if (消息已经被消费过) {
            return;
        }
		if(库存不足) {
			return;
		}
		if (已经秒杀到) {// 重复下单
			return;
		}
        
		//进行原子操作:1.库存减1,2.下订单,3.写入秒杀订单--->是一个事务
		miaoshaService.miaosha(user,goodsvo);	
    }

manual模式中,最关键的就是需要对消息进行回应。

因此,三个if在return前,以及最后的原子操作后,都需要进行统一操作:

  1. 对消息进行响应:channel.basicAck
  2. 标记已消费该消息(幂等性的保证,第一个if可以省略这一步)

这样就完成了最初的处理。

接下来要继续考虑:原子操作中,是否会出错?答案是肯定的,最显然就是DuplicateKeyException,因为我们在数据库层面也做了订单的唯一约束。

完全是可能出现这个情况的:比如同一用户的多个相同请求分别到了不同的消费者,在订单生成前,还没有缓存到redis。此时并不会被第三步的if处理

因此这里,我们是需要对原子操作这步进行 try{} catch{}的。

如果不进行处理,事务的确是会正常回滚。但问题是,此时是不会进行上述的同一操作的,而不进行回应,对MQ来说,该消费者就一直处于消费消息的状态,不会再分发消息,(不考虑prefetch),直到连接中断为止。即相当于该消费者无效了

如果此时有别的消费者,那还能继续消费消息(这种情况一般都是全都没考虑,很有可能一起卡死GG ),否则的话,消息就一直不会被消费,这样服务就不可用了。

解释完try{} catch{}的必要性,其实这里要处理的就不只是DuplicateKeyException了,业务上,应该是把所有可能发生的异常都提前catch起来。

但很容易忽略的是,兜底的必要性:最下层catch(Exception e),此时就应该try{}整个方法了

原因也不难理解,先不说异常可能会漏,运行时发生什么奇奇怪怪的异常都是常有的事。要保证服务的可用性,就必须要保证对消息进行响应

因此伪代码应该是:

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

            //进行原子操作:1.库存减1,2.下订单,3.写入秒杀订单--->是一个事务
            miaoshaService.miaosha(user,goodsvo);
        }catch (DuplicateKeyException e){
            统一操作
        }catch (其他可能的业务异常){
            这里就不一定是统一操作了,可能是Nack
        }catch (Exception e){
            同上,看业务处理。但必须对消息进行确认
        }
    }

考虑问题:能否用finally,这应该也算资源释放的范畴吧。

笔者没有用,原因主要有两点:

  1. 首先就是不同的业务异常,不一定都是回复ack、nack

  2. 1的解决办法很简单,就是其他逻辑不变,finally 统一 nack且重回队 requeue = false

    这样前面只要回应了,finally的回应就不会生效。

    但考虑网络等问题,如果正常的响应丢失了,而finally的响应却被成功接收了。那就是业务异常了。这是很严重的问题。

    当然了,这个也是看业务场景的


拓展两个问题:

  1. 回发的响应丢失问题。此时等同于没响应。简单的做法就是多发几次,搞个延时队列之类的 但其实这是个"死局" ,因为消费端是无法知道响应是否被接收的,并没有像生产者确认模式那样的相关接口。如果运气不好,重发的全都丢了,也没办法知道。所以笔者也没有作处理

    如果其实有接口,或者有相关解决办法,欢迎告知,进行讨论。

  2. 异常的重试问题。因为有些异常可能就是短时间的问题,并不是真的有错。

    如果一棍子打死,全部重回,这肯定是不可取的。因为真正的、无法被解决的异常消息会进行死循环:入队-> 分发 -> 拒绝且重回 -> 入队 ...

    而全部不重回,作为死信配合死信交换机处理。这其实不失为一种做法。但对那些"假异常"就有点"不公平" (死信队列的异常越少,维护也更容易啊)

    这里有更优的做法,就是进行有限次数的重试,排除网络等因素的影响。


异常的重试踩坑

那如何进行重试呢?这里可能第一反应想到的就是消费端自带的重试:

#启动重试
spring.rabbitmq.listener.direct.retry.enabled=true
#相关的配置
spring.rabbitmq.listener.direct.retry.max-attempts=3

这看起来是挺香的,笔者一开始也的确是这么想的。但是实操完了却发现一个很大的问题:

重试超过限制后抛出的异常,不能被catch{}捕获。

考虑过全局异常处理器,但发现全局异常处理器只能处理throw出的异常。直接触发的异常无法处理。
而且全局异常处理时,回发消息需要的信息太多,不知如何传输。没有采用

尽力看过源码以及测试后,还是没有找到较优雅的解决办法。

因此笔者采用的是最笨的办法: (注意配置的重试还是要开的,否则抛出异常是不会触发重试。但要注意重试次数 < 设置的max-attempts ,防止触发超限异常,导致无法回发消息)

通过内存记录,Map去保存(消息id,重试次数),完成重试限制。
不使用redis,一是减轻redis的压力,二是这样实现更方便

参考代码如下:

// 成员变量: private static Map errorLogMap = new HashMap<>();

catch (Exception e) {
            // 需要注意不同消息间的隔离问题,用correlationId作key
            // 此时注意关闭applicaiton.properties的重试配置。因为无法捕获超限异常。
            int retryTime = errorLogMap.getOrDefault(correlatonId, 0);
            // 注意次数 < 设置的最大重试次数
            if (retryTime < 3) {
                errorLogMap.put(correlatonId, retryTime + 1);
                throw e;
            } else {
                // 从Map中移除,防止内存溢出
                errorLogMap.remove(correlatonId);
                //重试超限,requeue = false。成为死信
 channel.basicNack(messages.getMessageProperties().getDeliveryTag(), false, false);
                入库,记录异常
            }
        }

关于如何正确使用自带的重试机制,记录一下笔者的收获及问题:(debug不易)

  1. @RabbitListener(errorHandler = "rabbitListenerErrorHandler") 通过自定义相关的RabbitListenerErrorHandler,的确可以监听队列内抛出的异常,也可以通过相关消息获取回channel进行应答。但问题是:该异常处理器仍然无法捕获到重试超过限制抛出的AmqpRejectAndDontRequeueException

    当然了,有了这个处理器,我们就可以把代码中的try{}catch给提取出来。还是有意义的。记录下代码:

    @Bean
        public RabbitListenerErrorHandler rabbitListenerErrorHandler() {
            return new RabbitListenerErrorHandler() {
    
                @Override
                public Object handleError(Message amqpMessage,
                                          org.springframework.messaging.Message<?> message,
                                          ListenerExecutionFailedException exception) throws Exception {
                    MessageHeaders messageHeaders = message.getHeaders();
                    // 通过debug找到的这个key字段,可以获取消费者中的channel,进而响应
                    Channel channel = messageHeaders.get("amqp_channel", Channel.class);
                    channel.basicAck(amqpMessage.getMessageProperties().getDeliveryTag(), false);
                    // 或者return null;
                    throw exception;
                }
            };
        }
    
  2. 配置MessageRecoverer,该类会在重试次数超限,还是抛出异常的情况下才会调用。

    通过设置为其实现类RepublishMessageRecoverer,完成消息的重发布。

    @Bean
        public MessageRecoverer messageRecoverer(RabbitTemplate rabbitTemplate) {
            return new RepublishMessageRecoverer(rabbitTemplate, DEAD_EXCHANGE);
        }
    

    经测试也确实成功了。而现在的问题是,无法获取原channel 及 原消息,即无法进行应答。

  3. 还有一个RetryTemplate,是在debug到最后抛出的。这个没研究懂

这部分参考博文:

消息手动确认模式的几点说明


本文完,有误欢迎指出

你可能感兴趣的:(secKill项目纠错/改进,RabbitMQ)