RocketMQ 是是如何管理消费进度的?又是如何保证消息成功消费的?

news2024/11/28 3:44:14

RocketMQ 消费者保障

  • 作者: 博学谷狂野架构师
  • GitHub:GitHub 地址 (有我精心准备的 130 本电子书 PDF)

只分享干货、不吹水,让我们一起加油!😄

消息确认机制

consumer 的每个实例是靠队列分配来决定如何消费消息的。那么消费进度具体是如何管理的,又是如何保证消息成功消费的?(RocketMQ 有保证消息肯定消费成功的特性,失败则重试)

什么是 ACK

消息确认机制

在实际使用 RocketMQ 的时候我们并不能保证每次发送的消息都刚好能被消费者一次性正常消费成功,可能会存在需要多次消费才能成功或者一直消费失败的情况,那作为发送者该做如何处理呢?

为了保证数据不被丢失,RocketMQ 支持消息确认机制,即 ack。发送者为了保证消息肯定消费成功,只有使用方明确表示消费成功,RocketMQ 才会认为消息消费成功。中途断电,抛出异常等都不会认为成功 —— 即都会重新投递。

保证数据能被正确处理而不仅仅是被 Consumer 收到,我们就不能采用 no-ack 或者 auto-ack,我们需要手动 ack (manual-ack)。在数据处理完成后手动发送 ack,这个时候 Server 才将 Message 删除。

RocketMQ ACK

由于以上工作所有的机制都实现在 PushConsumer 中,所以本文的原理均只适用于 RocketMQ 中的 PushConsumer 即 Java 客户端中的 DefaultPushConsumer。 若使用了 PullConsumer 模式,类似的工作如何 ack,如何保证消费等均需要使用方自己实现。

注:广播消费和集群消费的处理有部分区别,以下均特指集群消费(CLSUTER),广播(BROADCASTING)下部分可能不适用。

保证消费成功

PushConsumer 为了保证消息肯定消费成功,只有使用方明确表示消费成功,RocketMQ 才会认为消息消费成功。中途断电,抛出异常等都不会认为成功 —— 即都会重新投递。

代码示例

消费的时候,我们需要注入一个消费回调,具体 sample 代码如下:

COPYconsumer.registerMessageListener(new MessageListenerConcurrently() {
        @Override
        public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
            System.out.println(Thread.currentThread().getName() + " Receive New Messages: " + msgs);
            execute();//执行真正消费
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        }
    });

业务实现消费回调的时候,当且仅当此回调函数返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS,RocketMQ 才会认为这批消息(默认是 1 条)是消费完成的。

如果这时候消息消费失败,例如数据库异常,余额不足扣款失败等一切业务认为消息需要重试的场景,只要返回 ConsumeConcurrentlyStatus.RECONSUME_LATER,RocketMQ 就会认为这批消息消费失败了。

为了保证消息是肯定被至少消费成功一次,RocketMQ 会把这批消息重发回 Broker(topic 不是原 topic 而是这个消费租的 RETRY topic),在延迟的某个时间点(默认是 10 秒,业务可设置)后,再次投递到这个 ConsumerGroup。而如果一直这样重复消费都持续失败到一定次数(默认 16 次),就会投递到 DLQ 死信队列。应用可以监控死信队列来做人工干预。

ACK 进度保存

启动的时候从哪里消费

当新实例启动的时候,PushConsumer 会拿到本消费组 broker 已经记录好的消费进度(consumer offset),按照这个进度发起自己的第一次 Pull 请求。

如果这个消费进度在 Broker 并没有存储起来,证明这个是一个全新的消费组,这时候客户端有几个策略可以选择:

COPYCONSUME_FROM_LAST_OFFSET //默认策略,从该队列最尾开始消费,即跳过历史消息
CONSUME_FROM_FIRST_OFFSET //从队列最开始开始消费,即历史消息(还储存在broker的)全部消费一遍
CONSUME_FROM_TIMESTAMP//从某个时间点开始消费,和setConsumeTimestamp()配合使用,默认是半个小时以前

所以,社区中经常有人问:“为什么我设了 CONSUME_FROM_LAST_OFFSET,历史的消息还是被消费了”? 原因就在于只有全新的消费组才会使用到这些策略,老的消费组都是按已经存储过的消费进度继续消费。

对于老消费组想跳过历史消息需要自身做过滤,或者使用先修改消费进度

消息 ACK 消费进度

RocketMQ 是以 consumer group+queue 为单位是管理消费进度的,以一个 consumer offset 标记这个这个消费组在这条 queue 上的消费进度。

如果某已存在的消费组出现了新消费实例的时候,依靠这个组的消费进度,就可以判断第一次是从哪里开始拉取的,每次消息成功后,本地的消费进度会被更新,然后由定时器定时同步到 broker,以此持久化消费进度。

但是每次记录消费进度的时候,只会把一批消息中最小的 offset 值为消费进度值,如下图:

这钟方式和传统的一条 message 单独 ack 的方式有本质的区别。性能上提升的同时,会带来一个潜在的重复问题 —— 由于消费进度只是记录了一个下标,就可能出现拉取了 100 条消息如 2101-2200 的消息,后面 99 条都消费结束了,只有 2101 消费一直没有结束的情况。

在这种情况下,RocketMQ 为了保证消息肯定被消费成功,消费进度职能维持在 2101,直到 2101 也消费结束了,本地的消费进度才能标记 2200 消费结束了(注:consumerOffset=2201)。

重复消费

在这种设计下,就有消费大量重复的风险。如 2101 在还没有消费完成的时候消费实例突然退出(机器断电,或者被 kill)。这条 queue 的消费进度还是维持在 2101,当 queue 重新分配给新的实例的时候,新的实例从 broker 上拿到的消费进度还是维持在 2101,这时候就会又从 2101 开始消费,2102-2200 这批消息实际上已经被消费过还是会投递一次。

对于这个场景,RocketMQ 暂时无能为力,所以业务必须要保证消息消费的幂等性,这也是 RocketMQ 官方多次强调的态度。

实际上,从源码的角度上看,RocketMQ 可能是考虑过这个问题的,截止到 3.2.6 的版本的源码中,可以看到为了缓解这个问题的影响面,DefaultMQPushConsumer 中有个配置 consumeConcurrentlyMaxSpan

COPY/**
 * Concurrently max span offset.it has no effect on sequential consumption
 */
private int consumeConcurrentlyMaxSpan = 2000;

这个值默认是 2000,当 RocketMQ 发现本地缓存的消息的最大值 - 最小值差距大于这个值(2000)的时候,会触发流控 —— 也就是说如果头尾都卡住了部分消息,达到了这个阈值就不再拉取消息。

但作用实际很有限,像刚刚这个例子,2101 的消费是死循环,其他消费非常正常的话,是无能为力的。一旦退出,在不人工干预的情况下,2101 后所有消息全部重复!

Ack 卡进度解决方案

实际上对于卡住进度的场景,可以选择弃车保帅的方案:把消息卡住那些消息,先 ack 掉,让进度前移。但要保证这条消息不会因此丢失,ack 之前要把消息 sendBack 回去,这样这条卡住的消息就会必然重复,但会解决潜在的大量重复的场景。 这也是我们公司自己定制的解决方案。

部分源码如下:

COPYclass ConsumeRequestWithUnAck implements Runnable {
    final ConsumeRequest consumeRequest;
    final long resendAfterIfStillUnAck;//n毫秒没有消费完,就重发
 
    ConsumeRequestWithUnAck(ConsumeRequest consumeRequest,long resendAfterIfStillUnAck) {
        this.consumeRequest = consumeRequest;
        this.resendAfterIfStillUnAck = resendAfterIfStillUnAck;
    }
 
    @Override
    public void run() {
        //每次消费前,计划延时任务,超时则ack并重发
        final WeakReference<ConsumeRequest> crReff = new WeakReference<>(this.consumeRequest);
        ScheduledFuture scheduledFuture=null;
        if(!ConsumeDispatcher.this.ackAndResendScheduler.isShutdown()) {
            scheduledFuture= ConsumeDispatcher.this.ackAndResendScheduler.schedule(new ConsumeTooLongChecker(crReff),resendAfterIfStillUnAck,TimeUnit.MILLISECONDS);
        }
        try{
            this.consumeRequest.run();//正常执行并更新offset
        }
        finally {
            if (scheduledFuture != null) scheduledFuture.cancel(false);//消费结束后,取消任务
        }
    }
 
}
  1. 定义了一个装饰器,把原来的 ConsumeRequest 对象包了一层。

  2. 装饰器中,每条消息消费前都会调度一个调度器,定时触发,触发的时候如果发现消息还存在,就执行 sendback 并 ack 的操作。

    后来 RocketMQ 显然也发现了这个问题,RocketMQ 在 3.5.8 之后也是采用这样的方案去解决这个问题。只是实现方式上有所不同(事实上我认为 RocketMQ 的方案还不够完善)

  3. 在 pushConsumer 中 有一个 consumeTimeout 字段(默认 15 分钟),用于设置最大的消费超时时间。消费前会记录一个消费的开始时间,后面用于比对。

  4. 消费者启动的时候,会定期扫描所有消费的消息,达到这个 timeout 的那些消息,就会触发 sendBack 并 ack 的操作。这里扫描的间隔也是 consumeTimeout(单位分钟)的间隔。

核心源码如下:

COPY//ConsumeMessageConcurrentlyService.java
public void start() {
    this.CleanExpireMsgExecutors.scheduleAtFixedRate(new Runnable() {
 
        @Override
        public void run() {
            cleanExpireMsg();
        }
 
    }, this.defaultMQPushConsumer.getConsumeTimeout(), this.defaultMQPushConsumer.getConsumeTimeout(), TimeUnit.MINUTES);
}
//ConsumeMessageConcurrentlyService.java
private void cleanExpireMsg() {
    Iterator<Map.Entry<MessageQueue, ProcessQueue>> it =
            this.defaultMQPushConsumerImpl.getRebalanceImpl().getProcessQueueTable().entrySet().iterator();
    while (it.hasNext()) {
        Map.Entry<MessageQueue, ProcessQueue> next = it.next();
        ProcessQueue pq = next.getValue();
        pq.cleanExpiredMsg(this.defaultMQPushConsumer);
    }
}
 
//ProcessQueue.java
public void cleanExpiredMsg(DefaultMQPushConsumer pushConsumer) {
    if (pushConsumer.getDefaultMQPushConsumerImpl().isConsumeOrderly()) {
        return;
    }
 
    int loop = msgTreeMap.size() < 16 ? msgTreeMap.size() : 16;
    for (int i = 0; i < loop; i++) {
        MessageExt msg = null;
        try {
            this.lockTreeMap.readLock().lockInterruptibly();
            try {
                if (!msgTreeMap.isEmpty() && System.currentTimeMillis() - Long.parseLong(MessageAccessor.getConsumeStartTimeStamp(msgTreeMap.firstEntry().getValue())) > pushConsumer.getConsumeTimeout() * 60 * 1000) {
                    msg = msgTreeMap.firstEntry().getValue();
                } else {
 
                    break;
                }
            } finally {
                this.lockTreeMap.readLock().unlock();
            }
        } catch (InterruptedException e) {
            log.error("getExpiredMsg exception", e);
        }
 
        try {
 
            pushConsumer.sendMessageBack(msg, 3);
            log.info("send expire msg back. topic={}, msgId={}, storeHost={}, queueId={}, queueOffset={}", msg.getTopic(), msg.getMsgId(), msg.getStoreHost(), msg.getQueueId(), msg.getQueueOffset());
            try {
                this.lockTreeMap.writeLock().lockInterruptibly();
                try {
                    if (!msgTreeMap.isEmpty() && msg.getQueueOffset() == msgTreeMap.firstKey()) {
                        try {
                            msgTreeMap.remove(msgTreeMap.firstKey());
                        } catch (Exception e) {
                            log.error("send expired msg exception", e);
                        }
                    }
                } finally {
                    this.lockTreeMap.writeLock().unlock();
                }
            } catch (InterruptedException e) {
                log.error("getExpiredMsg exception", e);
            }
        } catch (Exception e) {
            log.error("send expired msg exception", e);
        }
    }
}

通过这个逻辑对比我定制的时间,可以看出有几个不太完善的问题:

  1. 消费 timeout 的时间非常不精确。由于扫描的间隔是 15 分钟,所以实际上触发的时候,消息是有可能卡住了接近 30 分钟(15*2)才被清理。
  2. 由于定时器一启动就开始调度了,中途这个 consumeTimeout 再更新也不会生效。

消息重试

顺序消息的重试

对于顺序消息,当消费者消费消息失败后,消息队列 RocketMQ 版会自动不断地进行消息重试(每次间隔时间为 1 秒),这时,应用会出现消息消费被阻塞的情况。因此,建议您使用顺序消息时,务必保证应用能够及时监控并处理消费失败的情况,避免阻塞现象的发生。

无序消息的重试

对于无序消息(普通、定时、延时、事务消息),当消费者消费消息失败时,您可以通过设置返回状态达到消息重试的结果。

无序消息的重试只针对集群消费方式生效;广播方式不提供失败重试特性,即消费失败后,失败消息不再重试,继续消费新的消息。

注意 以下内容都只针对无序消息生效。

重试次数

消息队列 RocketMQ 版默认允许每条消息最多重试 16 次,每次重试的间隔时间如下。

第几次重试与上次重试的间隔时间第几次重试与上次重试的间隔时间
110 秒97 分钟
230 秒108 分钟
31 分钟119 分钟
42 分钟1210 分钟
53 分钟1320 分钟
64 分钟1430 分钟
75 分钟151 小时
86 分钟162 小时

如果消息重试 16 次后仍然失败,消息将不再投递。如果严格按照上述重试时间间隔计算,某条消息在一直消费失败的前提下,将会在接下来的 4 小时 46 分钟之内进行 16 次重试,超过这个时间范围消息将不再重试投递。

注意 一条消息无论重试多少次,这些重试消息的 Message ID 不会改变。

和生产端重试区别

消费者和生产者的重试还是有区别的,主要有两点

  • 默认重试次数:Product 默认是 2 次,而 Consumer 默认是 16 次
  • 重试时间间隔:Product 是立刻重试,而 Consumer 是有一定时间间隔的。它照 1S,5S,10S,30S,1M,2M····2H 进行重试。
  • Product 在异步情况重试失效,而对于 Consumer 在广播情况下重试失效。

配置方式

重试配置方式

消费失败后,重试配置方式,集群消费方式下,消息消费失败后期望消息重试,需要在消息监听器接口的实现中明确进行配置(三种方式任选一种):

  • 方式 1:返回 RECONSUME_LATER(推荐)
  • 方式 2:返回 Null
  • 方式 3:抛出异常

示例代码

COPY//注册消息监听器
consumer.registerMessageListener(new MessageListenerConcurrently() {
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext context) {
        //消息处理逻辑抛出异常,消息将重试。
        doConsumeMessage(list);
        //方式1:返回ConsumeConcurrentlyStatus.RECONSUME_LATER,消息将重试。
        return ConsumeConcurrentlyStatus.RECONSUME_LATER;
        //方式2:返回null,消息将重试。
        //  return null;
        //方式3:直接抛出异常,消息将重试。
        // throw new RuntimeException("Consumer Message exception");
    }
});

无需重试的配置方式

集群消费方式下,消息失败后期望消息不重试,需要捕获消费逻辑中可能抛出的异常,最终返回 Action.CommitMessage,此后这条消息将不会再重试。

示例代码

COPY//注册消息监听器
consumer.registerMessageListener(new MessageListenerConcurrently() {
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext context) {
        //消息处理逻辑抛出异常,消息将重试。
        try {
            doConsumeMessage(list);
        }catch (Exception e){
            //捕获消费逻辑中的所有异常,并返回Action.CommitMessage;
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        }
        //业务方正常消费
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }
});

获取消息重试次数

消费者收到消息后,可按照以下方式获取消息的重试次数:

COPY//注册消息监听器
consumer.registerMessageListener(new MessageListenerConcurrently() {
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext context) {
        doConsumeMessage(list);
        //获取消息重试次数
        int retryTimes = list.get(0).getReconsumeTimes();
        //业务方正常消费
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }
});

效果演示

消费端只发送一条消息进行测试重试

消费端主动拒绝

演示代码

COPYpublic class RetryConsumer {
    public static void main(String[] args) throws Exception {
        //创建一个消息消费者,并设置一个消息消费者组
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("rocket_test_consumer_group");
        //指定 NameServer 地址
        consumer.setNamesrvAddr("127.0.0.1:9876");
        //设置 Consumer 第一次启动时从队列头部开始消费还是队列尾部开始消费
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
        //订阅指定 Topic 下的所有消息
        consumer.subscribe("topicTest", "*");

        //注册消息监听器
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext context) {
                if (list != null) {
                    for (MessageExt ext : list) {
                        //获取消息重试次数
                        int retryTimes = ext.getReconsumeTimes();
                        try {
                            String message = new String(ext.getBody(), RemotingHelper.DEFAULT_CHARSET);
                            System.out.println("Consumer-线程名称=[" + Thread.currentThread().getId() + "],消息重试次数:[" + retryTimes + "],接收时间:[" + new Date().getTime() + "],消息=[" + message + "]");
                        } catch (UnsupportedEncodingException e) {
                            e.printStackTrace();
                        }
                    }
                }
                //业务主动拒绝消息
                return ConsumeConcurrentlyStatus.RECONSUME_LATER;
            }
        });

        // 消费者对象在使用之前必须要调用 start 初始化
        consumer.start();
        System.out.println("消息消费者已启动");
    }
}

重试消息

消费端返回 ConsumeConcurrentlyStatus.RECONSUME_LATER,消费端就会不断的重试。

COPYConsumer-线程名称=[35],消息重试次数:[0],接收时间:[1608117780264],消息=[Hello Java demo RocketMQ ]
Consumer-线程名称=[36],消息重试次数:[1],接收时间:[1608117790840],消息=[Hello Java demo RocketMQ ]
Consumer-线程名称=[37],消息重试次数:[2],接收时间:[1608117820876],消息=[Hello Java demo RocketMQ ]

异常重试

演示代码

这里的代码意思很明显:主动抛出一个异常,然后如果超过 3 次,那么就不继续重试下去,而是将该条记录保存到数据库由人工来兜底。

COPYpublic class RetryConsumer {
    public static void main(String[] args) throws Exception {
        //创建一个消息消费者,并设置一个消息消费者组
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("rocket_test_consumer_group");
        //指定 NameServer 地址
        consumer.setNamesrvAddr("127.0.0.1:9876");
        //设置 Consumer 第一次启动时从队列头部开始消费还是队列尾部开始消费
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
        //订阅指定 Topic 下的所有消息
        consumer.subscribe("topicTest", "*");

        //注册消息监听器
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext context) {
                if (list != null) {
                    for (MessageExt ext : list) {
                        //获取消息重试次数
                        int retryTimes = ext.getReconsumeTimes();
                        try {
                            String message = new String(ext.getBody(), RemotingHelper.DEFAULT_CHARSET);
                            System.out.println("Consumer-线程名称=[" + Thread.currentThread().getId() + "],消息重试次数:[" + retryTimes + "],接收时间:[" + new Date().getTime() + "],消息=[" + message + "]");
                            //这里设置重试大于3次 那么通过保存数据库 人工来兜底
                            if (retryTimes >= 2) {
                                System.out.println("该消息已经重试3次,保存数据库。topic=[" + ext.getTags() + "],keys=[" + ext.getKeys() + "],msg=[" + message + "]");
                                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                            }
                        } catch (UnsupportedEncodingException e) {
                            e.printStackTrace();
                        }
                    }
                }
                //主动抛出异常
                throw new RuntimeException("=======这里出错了============");
                //业务方正常消费
                //return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });

        // 消费者对象在使用之前必须要调用 start 初始化
        consumer.start();
        System.out.println("消息消费者已启动");
    }
}

重试消息

COPYConsumer-线程名称=[36],消息重试次数:[0],接收时间:[1608118304600],消息=[Hello Java demo RocketMQ ]
Consumer-线程名称=[37],消息重试次数:[1],接收时间:[1608118315165],消息=[Hello Java demo RocketMQ ]
Consumer-线程名称=[38],消息重试次数:[2],接收时间:[1608118345191],消息=[Hello Java demo RocketMQ ]
该消息已经重试3次,保存数据库。topic=[TagA],keys=[null],msg=[Hello Java demo RocketMQ ]

超时重试

这里的超时异常并非真正意义上的超时,它指的是指获取消息后,因为某种原因没有给 RocketMQ 返回消费的状态,即没有 return ConsumeConcurrentlyStatus.CONSUME_SUCCESS 或 return ConsumeConcurrentlyStatus.RECONSUME_LATER

那么 RocketMQ 会认为该消息没有发送,会一直发送。因为它会认为该消息根本就没有发送给消费者,所以肯定没消费。

演示代码

COPYpublic class RetryConsumer {
    public static void main(String[] args) throws Exception {
        //创建一个消息消费者,并设置一个消息消费者组
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("rocket_test_consumer_group");
        //指定 NameServer 地址
        consumer.setNamesrvAddr("127.0.0.1:9876");
        //设置 Consumer 第一次启动时从队列头部开始消费还是队列尾部开始消费
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
        //订阅指定 Topic 下的所有消息
        consumer.subscribe("topicTest", "*");

        //注册消息监听器
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext context) {
                if (list != null) {
                    for (MessageExt ext : list) {
                        //获取消息重试次数
                        int retryTimes = ext.getReconsumeTimes();
                        try {
                            String message = new String(ext.getBody(), RemotingHelper.DEFAULT_CHARSET);
                            System.out.println("Consumer-线程名称=[" + Thread.currentThread().getId() + "],消息重试次数:[" + retryTimes + "],接收时间:[" + new Date().getTime() + "],消息=[" + message + "]");
                        } catch (UnsupportedEncodingException e) {
                            e.printStackTrace();
                        }
                    }
                }
                //这里睡眠60秒
                try {
                    TimeUnit.SECONDS.sleep(60);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("休眠60秒 看还能不能走到这里...");
                //  业务方正常消费
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });

        // 消费者对象在使用之前必须要调用 start 初始化
        consumer.start();
        System.out.println("消息消费者已启动");
    }
}

重试消息

当获得 当前消费重试次数为 = 0 后,关掉该进程。再重新启动该进程,那么依然能够获取该条消息

COPYConsumer-线程名称=[33],消息重试次数:[0],接收时间:[1608118652598],消息=[Hello Java demo RocketMQ ]    

重启消费者

COPYConsumer-线程名称=[28],消息重试次数:[0],接收时间:[1608118683304],消息=[Hello Java demo RocketMQ ]
休眠60秒 看还能不能走到这里...

重试消息的处理

一般情况下我们在实际生产中是不需要重试 16 次,这样既浪费时间又浪费性能,理论上当尝试重复次数达到我们想要的结果时如果还是消费失败,那么我们需要将对应的消息进行记录,并且结束重复尝试。

COPYpublic class RetryConsumer {
    public static void main(String[] args) throws Exception {
        //创建一个消息消费者,并设置一个消息消费者组
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("rocket_test_consumer_group");
        //指定 NameServer 地址
        consumer.setNamesrvAddr("127.0.0.1:9876");
        //设置 Consumer 第一次启动时从队列头部开始消费还是队列尾部开始消费
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
        //订阅指定 Topic 下的所有消息
        consumer.subscribe("topicTest", "*");

        //注册消息监听器
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext context) {
                if (list != null) {
                    for (MessageExt ext : list) {
                        //获取消息重试次数
                        int retryTimes = ext.getReconsumeTimes();
                        try {
                            String message = new String(ext.getBody(), RemotingHelper.DEFAULT_CHARSET);
                            System.out.println("Consumer-线程名称=[" + Thread.currentThread().getId() + "],消息重试次数:[" + retryTimes + "],接收时间:[" + new Date().getTime() + "],消息=[" + message + "]");
                            //这里设置重试大于3次 那么通过保存数据库 人工来兜底
                            if (retryTimes >= 2) {
                                System.out.println("该消息已经重试3次,保存数据库。topic=[" + ext.getTags() + "],keys=[" + ext.getKeys() + "],msg=[" + message + "]");
                                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                            }
                        } catch (UnsupportedEncodingException e) {
                            e.printStackTrace();
                        }
                    }
                }
                //主动抛出异常
                throw new RuntimeException("=======这里出错了============");
                //业务方正常消费
                //return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });

        // 消费者对象在使用之前必须要调用 start 初始化
        consumer.start();
        System.out.println("消息消费者已启动");
    }
}

所以任何异常都要捕获返回 ConsumeConcurrentlyStatus.RECONSUME_LATER,rocketmq 会放到重试队列,这个重试 TOPIC 的名字是 % RETRY%+consumergroup 的名字,如下图:

注意点

  1. 如果业务的回调没有处理好而抛出异常,会认为是消费失败当 ConsumeConcurrentlyStatus.RECONSUME_LATER 处理。
  2. 当使用顺序消费的回调 MessageListenerOrderly 时,由于顺序消费是要前者消费成功才能继续消费,所以没有 ConsumeConcurrentlyStatus.RECONSUME_LATER 的这个状态,只有 ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT 来暂停队列的其余消费,直到原消息不断重试成功为止才能继续消费。

RocketMQ 重试流程

重试队列与死信队列

在介绍 RocketMQ 的消费重试机制之前,需要先来说下 “重试队列” 和 “死信队列” 两个概念。

重试队列

如果 Consumer 端因为各种类型异常导致本次消费失败,为防止该消息丢失而需要将其重新回发给 Broker 端保存,保存这种因为异常无法正常消费而回发给 MQ 的消息队列称之为重试队列。RocketMQ 会为每个消费组都设置一个 Topic 名称为 **“% RETRY%+consumerGroup” 的重试队列 **(这里需要注意的是,这个 Topic 的重试队列是针对消费组,而不是针对每个 Topic 设置的),用于暂时保存因为各种异常而导致 Consumer 端无法消费的消息。考虑到异常恢复起来需要一些时间,会为重试队列设置多个重试级别,每个重试级别都有与之对应的重新投递延时,重试次数越多投递延时就越大。RocketMQ 对于重试消息的处理是先保存至 Topic 名称为 **“SCHEDULE_TOPIC_XXXX” 的延迟队列中,后台定时任务按照对应的时间进行 Delay 后重新保存至 “% RETRY%+consumerGroup”** 的重试队列中。

死信队列

由于有些原因导致 Consumer 端长时间的无法正常消费从 Broker 端 Pull 过来的业务消息,为了确保消息不会被无故的丢弃,那么超过配置的 “最大重试消费次数” 后就会移入到这个死信队列中。在 RocketMQ 中,SubscriptionGroupConfig 配置常量默认地设置了两个参数,一个是 retryQueueNums 为 1(重试队列数量为 1 个),另外一个是 retryMaxTimes 为 16(最大重试消费的次数为 16 次)。Broker 端通过校验判断,如果超过了最大重试消费次数则会将消息移至这里所说的死信队列。这里,RocketMQ 会为每个消费组都设置一个 Topic 命名为 **“% DLQ%+consumerGroup” 的死信队列 **。一般在实际应用中,移入至死信队列的消息,需要人工干预处理;

Consumer 端回发消息至 Broker 端

在业务工程中的 Consumer 端(Push 消费模式下),如果消息能够正常消费需要在注册的消息监听回调方法中返回 CONSUME_SUCCESS 的消费状态,否则因为各类异常消费失败则返回 RECONSUME_LATER 的消费状态。消费状态的枚举类型如下所示:

COPYpublic enum ConsumeConcurrentlyStatus {
    //业务方消费成功
    CONSUME_SUCCESS,
    //业务方消费失败,之后进行重新尝试消费
    RECONSUME_LATER;
}

如果业务工程对消息消费失败了,那么则会抛出异常并且返回这里的 RECONSUME_LATER 状态。这里,在消费消息的服务线程 —consumeMessageService 中,将封装好的消息消费任务 ConsumeRequest 提交至线程池 —consumeExecutor 异步执行。从消息消费任务 ConsumeRequest 的 run () 方法中会执行业务工程中注册的消息监听回调方法,并在 processConsumeResult 方法中根据业务工程返回的状态(CONSUME_SUCCESS 或者 RECONSUME_LATER)进行判断和做对应的处理。

CONSUME_SUCCESS

业务方正常消费

正常情况下,设置 ackIndex 的值为 consumeRequest.getMsgs ().size () - 1,因此后面的遍历 consumeRequest.getMsgs () 消息集合条件不成立,不会调用回发消费失败消息至 Broker 端的方法 —sendMessageBack (msg, context)。最后,更新消费的偏移量;

RECONSUME_LATER

业务方消费失败

异常情况下,设置 ackIndex 的值为 - 1,这时就会进入到遍历 consumeRequest.getMsgs () 消息集合的 for 循环中,执行回发消息的方法 —sendMessageBack (msg, context)。这里,首先会根据 brokerName 得到 Broker 端的地址信息,然后通过网络通信的 Remoting 模块发送 RPC 请求到指定的 Broker 上,如果上述过程失败,则创建一条新的消息重新发送给 Broker,此时新消息的 Topic 为 **“% RETRY%+ConsumeGroupName”**— 重试队列的主题。其中,在 MQClientAPIImpl 实例的 consumerSendMessageBack () 方法中封装了 ConsumerSendMsgBackRequestHeader 的请求体,随后完成回发消费失败消息的 RPC 通信请求(业务请求码为:CONSUMER_SEND_MSG_BACK)。倘若上面的回发消息流程失败,则会延迟 5S 后重新在 Consumer 端进行重新消费。与正常消费的情况一样,在最后更新消费的偏移量;

Broker 端对于回发消息处理的主要流程

Broker 端收到这条 Consumer 端回发过来的消息后,通过业务请求码(CONSUMER_SEND_MSG_BACK)匹配业务处理器 —SendMessageProcessor 来处理。在完成一系列的前置校验(这里主要是 “消费分组是否存在”、“检查 Broker 是否有写入权限”、“检查重试队列数是否大于 0” 等)后,尝试获取重试队列的 TopicConfig 对象(如果是第一次无法获取到,则调用 createTopicInSendMessageBackMethod () 方法进行创建)。根据回发过来的消息偏移量尝试从 commitlog 日志文件中查询消息内容,若不存在则返回异常错误。

然后,设置重试队列的 Topic—“%RETRY%+consumerGroup” 至 MessageExt 的扩展属性 “RETRY_TOPIC” 中,并对根据延迟级别 delayLevel 和最大重试消费次数 maxReconsumeTimes 进行判断,如果超过最大重试消费次数(默认 16 次),则会创建死信队列的 TopicConfig 对象(用于后面将回发过来的消息移入死信队列)。在构建完成需要落盘的 MessageExtBrokerInner 对象后,调用 “commitLog.putMessage (msg)” 方法做消息持久化。这里,需要注意的是,在 putMessage (msg) 的方法里会使用 “SCHEDULE_TOPIC_XXXX” 和对应的延迟级别队列 Id 分别替换 MessageExtBrokerInner 对象的 Topic 和 QueueId 属性值,并将原来设置的重试队列主题(“%RETRY%+consumerGroup”)的 Topic 和 QueueId 属性值做一个备份分别存入扩展属性 properties 的 “REAL_TOPIC” 和 “REAL_QID” 属性中。看到这里也就大致明白了,回发给 Broker 端的消费失败的消息并非直接保存至重试队列中,而是会先存至 Topic 为 **“SCHEDULE_TOPIC_XXXX”** 的定时延迟队列中。

疑问:上面说了 RocketMQ 的重试队列的 Topic 是 “% RETRY%+consumerGroup”,为啥这里要保存至 Topic 是 “SCHEDULE_TOPIC_XXXX” 的这个延迟队列中呢?

在源码中搜索下关键字 —“SCHEDULE_TOPIC_XXXX”,会发现 Broker 端还存在着一个后台服务线程 —ScheduleMessageService(通过消息存储服务 —DefaultMessageStore 启动),通过查看源码可以知道其中有一个 DeliverDelayedMessageTimerTask 定时任务线程会根据 Topic(“SCHEDULE_TOPIC_XXXX”)与 QueueId,先查到逻辑消费队列 ConsumeQueue,然后根据偏移量,找到 ConsumeQueue 中的内存映射对象,从 commitlog 日志中找到消息对象 MessageExt,并做一个消息体的转换(messageTimeup () 方法,由定时延迟队列消息转化为重试队列的消息),再次做持久化落盘,这时候才会真正的保存至重试队列中。看到这里就可以解释上面的疑问了,定时延迟队列只是为了用于暂存的,然后延迟一段时间再将消息移入至重试队列中。RocketMQ 设定不同的延时级别 delayLevel,并且与定时延迟队列相对应,具体源码如下:

COPY//省略
private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";
/**
  * 定时延时消息主题的队列与延迟等级对应关系
  * @param delayLevel
  * @return
  */
public static int delayLevel2QueueId(final int delayLevel) {
    return delayLevel - 1;
}

Consumer 端消费重试机制

每个 Consumer 实例在启动的时候就默认订阅了该消费组的重试队列主题,DefaultMQPushConsumerImpl 的 copySubscription () 方法中的相关代码如下:

COPYprivate void copySubscription() throws MQClientException {
            //省略其他代码...
            switch (this.defaultMQPushConsumer.getMessageModel()) {
                case BROADCASTING:
                    break;
                case CLUSTERING://如果消息消费模式为集群模式,还需要为该消费组对应一个重试主题
                    final String retryTopic = MixAll.getRetryTopic(this.defaultMQPushConsumer.getConsumerGroup());
                    SubscriptionData subscriptionData = FilterAPI.buildSubscriptionData(this.defaultMQPushConsumer.getConsumerGroup(),
                        retryTopic, SubscriptionData.SUB_ALL);
                    this.rebalanceImpl.getSubscriptionInner().put(retryTopic, subscriptionData);
                    break;
                default:
                    break;
            }
            //省略其他代码...
      }

因此,这里也就清楚了,Consumer 端会一直订阅该重试队列主题的消息,向 Broker 端发送如下的拉取消息的 PullRequest 请求,以尝试重新再次消费重试队列中积压的消息。

COPYPullRequest [consumerGroup=CID_JODIE_1, messageQueue=MessageQueue [topic=%RETRY%CID_JODIE_1, brokerName=HQSKCJJIDRRD6KC, queueId=0], nextOffset=51]

最后,给出一张 RocketMQ 消息重试机制的框图(ps:这里只是描述了消息消费失败后重试拉取的部分重要过程):

原文链接:RocketMQ是是如何管理消费进度的?又是如何保证消息成功消费的? - 博学谷狂野架构师的个人空间 - OSCHINA - 中文开源技术交流社区 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/423389.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

五、MyBatis各种查询功能

MyBatis的各种查询功能 如果查询出的数据只有一条&#xff0c;可以通过 实体类对象接收List集合接收Map集合接收 如果查询出的数据有多条&#xff0c;一定不能用实体对象接收&#xff0c;会抛TooManyResultsException&#xff0c;可以通过 实体类类型的List集合接收Map类型的L…

Python爬虫自动化从入门到精通第10天(Scrapy框架的基本使用)

Scrapy框架的基本使用Scrapy框架简介Scrapy框架的运作流程安装Scrapy框架Scrapy框架的基本操作Scrapy常用命令Scrapy框架简介 Scrapy框架主要包含以下组件&#xff1a; Scrapy Engine(引擎)&#xff1a;负责Spiders、Item Pipeline、Downloader、Scheduler之间的通信&#xf…

手把手教你安装Visual Studio 2019(史上最全)

前言: 本文是以Visual Studio Community 2019为例子,介绍如何在微软官网下载Visual Studio Community 2019并安装.net桌面开发程序环境(主要是winform开发环境)。 下载请点击这里Visual Studio Community 2019下载,然后点击下图的箭头的DownLoad下载,要注意的是下载时要…

微信自定义菜单

系列文章目录 提示&#xff1a;这里可以添加系列文章的所有文章的目录&#xff0c;目录需要自己手动添加 例如&#xff1a;第一章 Python 机器学习入门之pandas的使用 提示&#xff1a;写完文章后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目…

全链路压测时动态路由数据源MySQL、MongoDB、Redis

目录 一、全链路压测 二、动态路由Mysql 1. 参数配置application-localDynamic.yml 2. 加载配置参数DynamicDataSourceProperties.java 3. 动态数据源DynamicDataSource.java 4. 动态数据源供应DynamicDataSourceProvider.java 5. 动态数据源bean 6. 动态数据源上下文D…

PWN-ret2shellcode原理

我们之前做过很简单的pwn题目 buuctf-rip这种 是在程序中存在shellcode 直接返回地址改为这个shellcode的地址即可 但是如果程序里面没有呢 这种类型就是ret2shellcode 常见的shellcode shellcode "\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x56\x53\x54\x5…

一起读源码 —— Fastjson 的核心方法及其实现原理

源码介绍 Fastjson 是阿里巴巴开源的一个 Java 工具库&#xff0c;它常常被用来完成 Java 的对象与 JSON 格式的字符串的相互转化。 此文读的源码是撰写此文时 Fastjson 的最新的发布版本&#xff0c;即 1.2.83 下载源码 请前去 github 找到 release 最新版下载后解压&…

智慧水务之排水系统物联网监测

1.1排水系统 1.1.1监测范围选择依据 &#xff08;1&#xff09;管网老化、设计标准低、合流制管网区域 管网建设年代久远&#xff0c;通常管网发生破损问题较大&#xff1b;管网设计标准较低&#xff0c;易引发淤堵或溢流&#xff1b;合流制管网受天气影响大&#xff0c;会对…

FFMPEG: [ API ] >打开/关闭一个输入文件

它们是成对出现的. ffmpeg 把输入文件--转换成--->AVFormatContext实例 ◆ avformat_open_input() int avformat_open_input(AVFormatContext ** ps,const char * url,ff_const59 AVInputFormat * fmt,AVDictionary ** options )Open an input stream and read the header.…

跨越语言的艺术:Weblogic序列化漏洞与IIOP协议

0x01 概述 Weblogic 的序列化漏洞主要依赖于 T3 和 IIOP 协议&#xff0c;这两种协议在通信交互的过程中存在如跨语言、网络传输等方面的诸多问题&#xff0c;会给漏洞的检测和利用带来许多不便。在白帽汇安全研究院的理念中&#xff0c;漏洞检测和利用是一项需要创造性的工作…

速锐得新能源电动汽车整车能耗热管理CAN总线模块开发方案

一、新能源时代背景 新能源汽车浪潮席卷而来&#xff0c;随着汽车向电动化和智能化方向发展&#xff0c;对汽车能量管理的要求也越来越高。而直冷直热热泵空调热管理系统是新能源汽车领域的新蓝海&#xff0c;随着热管理系统的崛起&#xff0c;在整车能耗热管理采集模块开发方…

计算机组成原理——第四章指令系统(下)

本是青灯不归客&#xff0c;却因浊酒恋红尘 文章目录前言4.3.1 高级语言与机器级代码之间的对应4.3.2 常用的X86汇编指令4.3.3 ATu0026T格式和Intel格式4.3.4 选择语句的机器级表示4.3.5 循环语句的机器级表示4.4 CiSC和RiSC前言 接下来这部分主要讲的就是高级语言与汇编语言的…

Thymeleaf select回显并选中多个

语法&#xff1a; selected"selected" 或 selectedtrue ${#strings.indexOf(name,frag)} 或者 ${#lists.contains(list, element)} 或者 ${#strings.contains(name,ez)} 或者 ${#strings.containsIgnoreCase(name,ez)} 都可以实现。 多选示例 &#xff1a; &…

linux 集群时间同步

前言 由于搭建hadoop集群需要进行集群时间同步&#xff0c;记录下具体操作过程。 这里我的集群环境为192.168.184.129&#xff08;主&#xff09;、192.168.184.130&#xff08;从&#xff09;、192.168.184.131&#xff08;从&#xff09;&#xff0c;设置从机器从主机器同步…

Windows XP设置Outlook电子邮箱

一、问题描述 在Windows XP操作系统中进行Outlook电子邮箱的设置。 二、具体步骤 1、点击“开始”&#xff0c;找到“电子邮件&#xff08;Outlook Express&#xff09;并点击&#xff1a; 2、点击“设置邮件账户”。 3、输入自己的姓名&#xff0c;点击“下一步”。 4、…

Hive UDTF、窗口函数、自定义函数

目录 1 UDTF 1.1 概述 1.2 explode 1.3 posexplode 1.4 inline 1.5 Lateral View 2 窗口函数&#xff08;开窗函数&#xff09; 2.1 定义 2.2 语法 2.2.1 语法--函数 2.2.2 语法--窗口 2.2.3 常用窗口函数 3 自定义函数 3.1 基本知识 3.2 实现自定义函数 3.2.1 …

RestClient操作文档

RestClient操作文档5.RestClient操作文档5.1.新增文档5.1.1.索引库实体类5.1.2.语法说明5.1.3.完整代码5.2.查询文档5.2.1.语法说明5.2.2.完整代码5.3.删除文档5.4.修改文档5.4.1.语法说明5.4.2.完整代码5.5.批量导入文档5.5.1.语法说明5.5.2.完整代码5.6.小结5.RestClient操作…

JavaSE学习进阶day04_02 Calendar类

第三章 Calendar类 3.1 概述 java.util.Calendar类表示一个“日历类”&#xff0c;可以进行日期运算。它是一个抽象类&#xff0c;不能创建对象&#xff0c;我们可以使用它的子类&#xff1a;java.util.GregorianCalendar类。 有两种方式可以获取GregorianCalendar对象&#…

《Advanced R》学习笔记 | Chapter3 Vectors

专注系列化、高质量的R语言教程推文索引 | 联系小编 | 付费合集本篇推文是学堂君学习第3章“Vectors”的笔记&#xff0c;原文链接是https://adv-r.hadley.nz/vectors-chap.html&#xff0c;可在文末“阅读原文”处直达。通过本章的学习&#xff0c;我们可以更清晰地理解R语言中…

IDPChat:探索基于LLaMA和Stable Diffusion的「开源」中文多模态AI大模型

中文多模态模型 IDPChat 和大家见面了。 随着GPT4、文心一言等的发布&#xff0c;预训练大模型正式开启由单模态向多模态模型演进。多模态的特性为语言模型带来更加丰富的应用场景。 我们认为&#xff0c;未来的AI应用将主要以大模型为核心基石。 而在大模型的领域&#xff0c;…