源码分析RocketMQ之消息消费重试机制

本篇 Chat 将从以下几个方面进行讲解,帮助大家能够更加优雅高效的使用 RocketMQ 提升消息消费效率并从底层源码对 RocketMQ 消息重试机制有更加深入的了解。

  • RocketMQ 消息重试过程描述
  • RocketMQ 消费阶段消息重试的源码分析
  • 死信 DLQ 的处理方式
  • RocketMQ在消息发送阶段的消息重试手段

首先,我们需要明确,只有当消费模式为 MessageModel.CLUSTERING(集群模式) 时,Broker 才会自动进行重试,对于广播消息是不会重试的。

集群消费模式下,当消息消费失败,RocketMQ 会通过消息重试机制重新投递消息,努力使该消息消费成功。

当消费者消费该重试消息后,需要返回结果给 broker,告知 broker 消费成功(ConsumeConcurrentlyStatus.CONSUMESUCCESS)或者需要重新消费(ConsumeConcurrentlyStatus.RECONSUMELATER)。

这里有个问题,如果消费者业务本身故障导致某条消息一直无法消费成功,难道要一直重试下去吗?答案是显而易见的,并不会一直重试。

那如何返回消息消费失败呢?

RocketMQ 规定,以下三种情况统一按照消费失败处理并会发起重试。

  1. 业务消费方返回 ConsumeConcurrentlyStatus.RECONSUME_LATER
  2. 业务消费方返回 null
  3. 业务消费方主动/被动抛出异常

前两种情况较容易理解,当返回 ConsumeConcurrentlyStatus.RECONSUME_LATER 或者 null 时,broker 会知道消费失败,后续就会发起消息重试,重新投递该消息。

注意 对于抛出异常的情况,只要我们在业务逻辑中显式抛出异常或者非显式抛出异常,broker 也会重新投递消息,如果业务对异常做了捕获,那么该消息将不会发起重试。因此对于需要重试的业务,消费方在捕获异常的时候要注意返回 ConsumeConcurrentlyStatus.RECONSUMELATER 或 null 并输出异常日志,打印当前重试次数。(推荐返回ConsumeConcurrentlyStatus.RECONSUMELATER

RocketMQ 重试时间窗

这里介绍一下 Apache RocketMQ 的重试时间窗,当消息需要重试时,会按照该规则进行重试。

我们可以在 RocketMQ 的 broker.conf 配置文件中配置 Consumer 侧重试次数及时间间隔, 配置如下

    messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h

次数与重试时间间隔对应关系表如下:

重试次数距离第一次发送的时间间隔
11s
25s
310s
430s
51m
62m
73m
84m
95m
106m
117m
128m
139m
1410m
1520m
1630m
171h
182h

可以看到,RocketMQ 采用了“时间衰减策略”进行消息的重复投递,即重试次数越多,消息消费成功的可能性越小。

源码分析

在 RocketMQ 的客户端源码 DefaultMQPushConsumerImpl.java 中,对重试机制做了说明,源码如下:

   private int getMaxReconsumeTimes() {
            // default reconsume times: 16
            if (this.defaultMQPushConsumer.getMaxReconsumeTimes() == -1) {
                return 16;
            } else {
                return this.defaultMQPushConsumer.getMaxReconsumeTimes();
            }
        }

解释下,首先判断消费端有没有显式设置最大重试次数 MaxReconsumeTimes, 如果没有,则设置默认重试次数为 16,否则以设置的最大重试次数为准。

当消息消费失败,服务端会发起消费重试,具体逻辑在 broker 源码的 SendMessageProcessor.java 中的 consumerSendMsgBack 方法中涉及,源码如下:

 if (msgExt.getReconsumeTimes() >= maxReconsumeTimes
            || delayLevel < 0) {
            newTopic = MixAll.getDLQTopic(requestHeader.getGroup());
            queueIdInt = Math.abs(this.random.nextInt() % 99999999) % DLQ_NUMS_PER_GROUP;

            topicConfig = this.brokerController.getTopicConfigManager().createTopicInSendMessageBackMethod(newTopic,
                DLQ_NUMS_PER_GROUP,
                PermName.PERM_WRITE, 0
            );
            if (null == topicConfig) {
                response.setCode(ResponseCode.SYSTEM_ERROR);
                response.setRemark("topic[" + newTopic + "] not exist");
                return response;
            }
        } else {
            if (0 == delayLevel) {
                delayLevel = 3 + msgExt.getReconsumeTimes();
            }

            msgExt.setDelayTimeLevel(delayLevel);
        }

判断消息当前重试次数是否大于等于最大重试次数,如果达到最大重试次数,或者配置的重试级别小于 0,则获取死信队列的 Topic,后续将超时的消息 send 到死信队列中。

正常的消息会进入 else 分支,对于首次重试的消息,默认的 delayLevel 是 0,rocketMQ 会将给该 level + 3,也就是加到 3,这就是说,如果没有显示的配置延时级别,消息消费重试首次,是延迟了第三个级别发起的重试,从表格中看也就是距离首次发送 10s 后重试。

当延时级别设置完成,刷新消息的重试次数为当前次数加 1,broker 将该消息刷盘,逻辑如下

 MessageExtBrokerInner msgInner = new MessageExtBrokerInner();
        msgInner.setTopic(newTopic);
        msgInner.setBody(msgExt.getBody());
        msgInner.setFlag(msgExt.getFlag());
        MessageAccessor.setProperties(msgInner, msgExt.getProperties());
        msgInner.setPropertiesString(MessageDecoder.messageProperties2String(msgExt.getProperties()));
        msgInner.setTagsCode(MessageExtBrokerInner.tagsString2tagsCode(null, msgExt.getTags()));

        msgInner.setQueueId(queueIdInt);
        msgInner.setSysFlag(msgExt.getSysFlag());
        msgInner.setBornTimestamp(msgExt.getBornTimestamp());
        msgInner.setBornHost(msgExt.getBornHost());
        msgInner.setStoreHost(this.getStoreHost());
        // 刷新消息的重试次数为当前次数加 1
        msgInner.setReconsumeTimes(msgExt.getReconsumeTimes() + 1);

        String originMsgId = MessageAccessor.getOriginMessageId(msgExt);
        MessageAccessor.setOriginMessageId(msgInner, UtilAll.isBlank(originMsgId) ? msgExt.getMsgId() : originMsgId);
        // 消息刷盘
        PutMessageResult putMessageResult = this.brokerController.getMessageStore().putMessage(msgInner);

那么什么是 msgInner 呢,即:MessageExtBrokerInner,也就是对重试的消息,rocketMQ 会创建一个新的 MessageExtBrokerInner 对象,它实际上是继承了 MessageExt。

我们继续进入消息刷盘逻辑,即 putMessage(msgInner)方法,实现类为:DefaultMessageStore.java, 核心代码如下:

 long beginTime = this.getSystemClock().now();
        PutMessageResult result = this.commitLog.putMessage(msg);

主要关注 this.commitLog.putMessage(msg); 这句代码,通过 commitLog 我们可以认为这里是真实刷盘操作,也就是消息被持久化了。

我们继续进入 commitLog 的 putMessage 方法,看到如下核心代码段:

if (tranType == MessageSysFlag.TRANSACTION_NOT_TYPE
            || tranType == MessageSysFlag.TRANSACTION_COMMIT_TYPE) {
            // 处理延时级别
            if (msg.getDelayTimeLevel() > 0) {
                if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
                    msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
                }
                // 更换 Topic
                topic = ScheduleMessageService.SCHEDULE_TOPIC;
                // 队列 ID 为延迟级别-1
                queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());

                // Backup real topic, queueId
                MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
                MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
                msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties()));

                // 重置 topic 及 queueId
                msg.setTopic(topic);
                msg.setQueueId(queueId);
            }
        }

        ScheduleMessageService.java
        public static int delayLevel2QueueId(final int delayLevel) {
            return delayLevel - 1;
        }   

可以看到,如果是重试消息,在进行延时级别判断时候,返回 true,则进入分支逻辑,通过这段逻辑我们可以知道,对于重试的消息,rocketMQ 并不会从原队列中获取消息,而是创建了一个新的 Topic 进行消息存储的。也就是代码中的 SCHEDULE_TOPIC,看一下具体是什么内容

        public static final String SCHEDULE_TOPIC = "SCHEDULE_TOPIC_XXXX";

emmm,这个名字比较有意思,就叫 SCHEDULETOPICXXXX

到这里我们可以得到一个结论:

对于所有消费者消费失败的消息,rocketMQ 都会把重试的消息 重新 new 出来(即上文提到的 MessageExtBrokerInner 对象),然后投递到主题 SCHEDULETOPICXXXX 下的队列中,然后由定时任务进行调度重试,而重试的周期符合我们在上文中提到的 delayLevel 周期,也就是:

MessageStoreConfig.java // 消息延迟级别定义 private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";

同时为了保证消息可被找到,也会将原先的 topic 存储到 properties 中,也就是如下这段代码

// Backup real topic, queueId
        MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
        MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
        msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties())); 

这里将原先的 topic 和队列 id 做了备份。

死信的业务处理方式

默认的处理机制中,如果我们只对消息做重复消费,达到最大重试次数之后消息就进入死信队列了。

我们也可以根据业务的需要,定义消费的最大重试次数,每次消费的时候判断当前消费次数是否等于最大重试次数的阈值。

如:重试三次就认为当前业务存在异常,继续重试下去也没有意义了,那么我们就可以将当前的这条消息进行提交,返回 broker 状态ConsumeConcurrentlyStatus.CONSUME_SUCCES,让消息不再重发,同时将该消息存入我们业务自定义的死信消息表,将业务参数入库,相关的运营通过查询死信表来进行对应的业务补偿操作。

RocketMQ 的处理方式为将达到最大重试次数(16 次)的消息标记为死信消息,将该死信消息投递到 DLQ 死信队列中,业务需要进行人工干预。实现的逻辑在 SendMessageProcessor 的 consumerSendMsgBack 方法中,大致思路为首先判断重试次数是否超过 16 或者消息发送延时级别是否小于 0,如果已经超过 16 或者发送延时级别小于 0,则将消息设置为新的死信。死信 topic 为:%DLQ%+consumerGroup。

我们接着看一下死信的源码实现机制。

死信源码分析

 private RemotingCommand consumerSendMsgBack(final ChannelHandlerContext ctx, final RemotingCommand request)
            throws RemotingCommandException {
            final RemotingCommand response = RemotingCommand.createResponseCommand(null);
            final ConsumerSendMsgBackRequestHeader requestHeader =
                (ConsumerSendMsgBackRequestHeader)request.decodeCommandCustomHeader(ConsumerSendMsgBackRequestHeader.class);

            ......

            // 0.首先判断重试次数是否大于等于 16,或者消息延迟级别是否小于 0
            if (msgExt.getReconsumeTimes() >= maxReconsumeTimes
                || delayLevel < 0) {
                // 1. 如果满足判断条件,设置死信队列 topic= %DLQ%+consumerGroup
                newTopic = MixAll.getDLQTopic(requestHeader.getGroup());
                queueIdInt = Math.abs(this.random.nextInt() % 99999999) % DLQ_NUMS_PER_GROUP;

                topicConfig = this.brokerController.getTopicConfigManager().createTopicInSendMessageBackMethod(newTopic,
                    DLQ_NUMS_PER_GROUP,
                    PermName.PERM_WRITE, 0
                );
                if (null == topicConfig) {
                    response.setCode(ResponseCode.SYSTEM_ERROR);
                    response.setRemark("topic[" + newTopic + "] not exist");
                    return response;
                }
            } else {
                // 如果延迟级别为 0,则设置下一次延迟级别为 3+当前重试消费次数,达到时间衰减效果
                if (0 == delayLevel) {
                    delayLevel = 3 + msgExt.getReconsumeTimes();
                }

                msgExt.setDelayTimeLevel(delayLevel);
            }

            MessageExtBrokerInner msgInner = new MessageExtBrokerInner();
            msgInner.setTopic(newTopic);
            msgInner.setBody(msgExt.getBody());
            msgInner.setFlag(msgExt.getFlag());
            MessageAccessor.setProperties(msgInner, msgExt.getProperties());
            msgInner.setPropertiesString(MessageDecoder.messageProperties2String(msgExt.getProperties()));
            msgInner.setTagsCode(MessageExtBrokerInner.tagsString2tagsCode(null, msgExt.getTags()));

            msgInner.setQueueId(queueIdInt);
            msgInner.setSysFlag(msgExt.getSysFlag());
            msgInner.setBornTimestamp(msgExt.getBornTimestamp());
            msgInner.setBornHost(msgExt.getBornHost());
            msgInner.setStoreHost(this.getStoreHost());
            msgInner.setReconsumeTimes(msgExt.getReconsumeTimes() + 1);

            String originMsgId = MessageAccessor.getOriginMessageId(msgExt);
            MessageAccessor.setOriginMessageId(msgInner, UtilAll.isBlank(originMsgId) ? msgExt.getMsgId() : originMsgId);

            // 3.死信消息投递到死信队列中并落盘
            PutMessageResult putMessageResult = this.brokerController.getMessageStore().putMessage(msgInner);
            ......
            return response;
        }

我们总结一下死信的处理逻辑:

  1. 首先判断消息当前重试次数是否大于等于 16,或者消息延迟级别是否小于 0
  2. 只要满足上述的任意一个条件,设置新的 topic(死信 topic)为:%DLQ%+consumerGroup
  3. 进行前置属性的添加
  4. 将死信消息投递到上述步骤 2 建立的死信 topic 对应的死信队列中并落盘,使消息持久化。

发送失败如何重试

上文中,我们讲解了对于消费失败的重试策略,这个章节中我们来了解下消息发送失败如何进行重试。

当发生网络抖动等异常情况,Producer 生产者侧往 broker 发送消息失败,即:生产者侧没收到 broker 返回的 ACK,导致 Consumer 无法进行消息消费,这时 RocketMQ 会进行发送重试。

使用 DefaultMQProducer 进行普通消息发送时,我们可以设置消息发送失败后最大重试次数,并且能够灵活的配合超时时间进行业务重试逻辑的开发,使用的 API 如下:

 /**设置消息发送失败时最大重试次数*/
    public void setRetryTimesWhenSendFailed(int retryTimesWhenSendFailed) {
        this.retryTimesWhenSendFailed = retryTimesWhenSendFailed;
    }

    /**同步发送消息,并指定超时时间*/
    public SendResult send(Message msg,
                        long timeout) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
        return this.defaultMQProducerImpl.send(msg, timeout);
    }

通过 API 可以看出,生产者侧的重试是比较简单的,例如:设置生产者在 3s 内没有发送成功则重试 3 次的代码如下:

  /**同步发送消息,如果 3 秒内没有发送成功,则重试 3 次*/
    DefaultMQProducer producer = new DefaultMQProducer("DefaultProducerGroup");
    producer.setRetryTimesWhenSendFailed(3);
    producer.send(msg, 3000L);

小结

本文中,我们主要介绍了 RocketMQ 的消息重试机制,该机制能够最大限度的保证业务能够往我们期望的方向流转。

这里还需要注意,业务重试的时候我们的消息消费端需要保证消费的 幂等性, 关于消息消费的幂等如何处理,我们在后续的文章会展开讲解。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值