RocketMQ发送消息如何选择消息队列?

Producer发送消息的主要流程是验证消息---->查找Topic路由---->选择消息队列—>发送消息。验证消息没什么好说的,很简单。Topic路由的获取和保存、发送消息,之前的文章都有部分涉及,就不再赘述。这里我们对消息队列的选择做一个简单的展开。

背景

首先,要澄清一个误会。这里的选择消息队列发送消息,并不是真的往某个队列发送消息。RocketMQ的消息只存在一个叫CommitLog的逻辑文件中,对应于磁盘上的多个文件。消息队列的概念,仅仅是消息消费的时候才会用到。因为所有消息都是存放在一个CommitLog中的,这意味着不同的Topic的消息是先来后到的顺序插入到CommitLog中。如果Consumer要消费某个Topic下的消息,去CommitLog里面去一个个查询势必非常缓慢,是完全不可取的。

为了解决某Topic下消息查询的问题(当然不仅仅是解决这一个问题,还包括消费进度等),RocketMQ在原有的CommitLog的基础之上,为每一个Topic新建了一个ConsumerQueue的文件(同样对应于磁盘上的多个文件,也就是我们口中的消息队列),它保存了某个topic下的消息在CommitLog里偏移位置。这样消费某个Topic的消息,就可以直接读取该ConsumerQueue文件,拿到消息在CommitLog中的偏移位置,然后去CommitLog里面寻找消息实体即可。

我们再回顾一下创建topic的命令:

./mqadmin updateTopic -n 192.168.77.129:9876 -c DefaultCluster -t test

updateTopic命令有-r,-w两个参数,如下所示:
在这里插入图片描述
这里写队列的个数,就对应于broker master结点下面该topic下面ConsumerQueue的物理文件数(下面的order topic下面有8个队列):
在这里插入图片描述
Producer发送的消息选择了哪个队列,就会在broker哪个队列里存放(物理偏移位置),这就是我们这篇文章的背景。这个我们后面讲消息存储和消费的时候,还会再涉及。

选择消息队列

从消息队列里面选择哪个队列来发送消息,最简单的可以是轮询发送。但我们需要考虑的更多,例如发送异常了,重试的时候如何选择下一个队列?发送延时过大,如何剔除某个broker?这些问题,接下来会慢慢聊到。

Producer发送消息的主要逻辑在DefaultMQProducerImpl的sendDefaultImpl()方法里面:

private SendResult sendDefaultImpl(//
        Message msg, // 消息
        final CommunicationMode communicationMode, // 通讯模式,同步、异步等
        final SendCallback sendCallback, // 回调
        final long timeout// 超时时间
    ) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
        this.makeSureStateOK();
        Validators.checkMessage(msg, this.defaultMQProducer); // 验证消息

        final long invokeID = random.nextLong();
        long beginTimestampFirst = System.currentTimeMillis();
        long beginTimestampPrev = beginTimestampFirst;
        long endTimestamp = beginTimestampFirst;
        TopicPublishInfo topicPublishInfo = this.tryToFindTopicPublishInfo(msg.getTopic()); // 找到Topic路由信息
        if (topicPublishInfo != null && topicPublishInfo.ok()) {
            MessageQueue mq = null;
            Exception exception = null;
            SendResult sendResult = null;
            int timesTotal = communicationMode == CommunicationMode.SYNC ? 1 + this.defaultMQProducer.getRetryTimesWhenSendFailed() : 1; // 发送异常后重试次数
            int times = 0;
            
            for (; times < timesTotal; times++) {
                String lastBrokerName = null == mq ? null : mq.getBrokerName();
                MessageQueue tmpmq = this.selectOneMessageQueue(topicPublishInfo, lastBrokerName); // 选择消息队列
                if (tmpmq != null) {
                    mq = tmpmq;
                    try {
                        sendResult = this.sendKernelImpl(msg, mq, communicationMode, sendCallback, topicPublishInfo, timeout); // 发送消息
                       
                       // 省略代码
                    } catch (RemotingException e) {
                        // 省略代码
                        continue;
                    } catch (MQClientException e) {
                       // 省略代码
                        continue;
                    } catch (MQBrokerException e) {
                      // 省略代码
                    } catch (InterruptedException e) {
                      // 省略代码
                    }
                } else {
                    break;
                }
            }

            if (sendResult != null) {
                return sendResult;
            }

            // 省略代码
        }

        List<String> nsList = this.getmQClientFactory().getMQClientAPIImpl().getNameServerAddressList();
        if (null == nsList || nsList.isEmpty()) {
            throw new MQClientException(
                "No name server address, please set it." + FAQUrl.suggestTodo(FAQUrl.NAME_SERVER_ADDR_NOT_EXIST_URL), null).setResponseCode(ClientErrorCode.NO_NAME_SERVER_EXCEPTION);
        }

        throw new MQClientException("No route info of this topic, " + msg.getTopic() + FAQUrl.suggestTodo(FAQUrl.NO_TOPIC_ROUTE_INFO),
            null).setResponseCode(ClientErrorCode.NOT_FOUND_TOPIC_EXCEPTION);
    }

选择消息队列,由MQFaultStrategy类实现。MQFaultStrategy类主要实现了我们上述提到了几个问题,如何选择队列,延时过大或者broker异常时如何暂时剔除。MQFaultStrategy定义如下:

public class MQFaultStrategy {
    private final LatencyFaultTolerance<String> latencyFaultTolerance = new LatencyFaultToleranceImpl(); // 异常broker的容器

    private boolean sendLatencyFaultEnable = false; // 是否启用延迟故障功能

    private long[] latencyMax = {50L, 100L, 550L, 1000L, 2000L, 3000L, 15000L}; // 定义了延迟的几个级别,单位ms
    
    private long[] notAvailableDuration = {0L, 0L, 30000L, 60000L, 120000L, 180000L, 600000L};// 定义了故障后的不可用时长,单位ms,与延迟级别相关

    // 省略方法
}    

sendLatencyFaultEnable是一个开关,表明是否启用延迟故障功能,默认是关闭的。可以通过producer开启,如下所示:
在这里插入图片描述
选择消息队列,在启用和不启用故障延迟两种情况下的处理方式并不一样。如果不启用故障延迟功能,选择流程就非常简单,就沿着队列轮流往下选,这次是往队列1发,下次就是往队列2发。如果发送异常了,重试的时候就剔除掉该broker下面的所有队列,尝试另一个broker下面的队列。

这样会带来一个问题,发送消息的时候,我们发现broker-a故障了,重发消息的时候临时剔除了broker-a。但是这个并没有记录下来,发送下一条消息的时候,尝试下个队列,其实有可能还是broker-a下面的队列,这样还是会发送失败一次。

如果启用了故障延迟功能,那么对于高延迟的、有故障的broker,都会保存下来,并有一个不可用时间段,发送消息的时候都会临时避开这个broker。过了这个时间段,这个broker才可以重新参与选择。判断broker是否可用,就是判断当前时间是否已经超过了那个不可用时间点,如下所示:
在这里插入图片描述
现在我们来看源码,选择队列的方法如下:

public MessageQueue selectOneMessageQueue(final TopicPublishInfo tpInfo, final String lastBrokerName) {
        if (this.sendLatencyFaultEnable) {// 【1】 启用了故障延迟功能
            try {
                int index = tpInfo.getSendWhichQueue().getAndIncrement();
                for (int i = 0; i < tpInfo.getMessageQueueList().size(); i++) {
                    int pos = Math.abs(index++) % tpInfo.getMessageQueueList().size();
                    if (pos < 0)
                        pos = 0;
                    MessageQueue mq = tpInfo.getMessageQueueList().get(pos);
                    if (latencyFaultTolerance.isAvailable(mq.getBrokerName())) { // 该broker可用
                        if (null == lastBrokerName || mq.getBrokerName().equals(lastBrokerName)) // bug???
                            return mq;
                    }
                }

                final String notBestBroker = latencyFaultTolerance.pickOneAtLeast();// 如果没有可用的,还是要从故障broker容器中选择一个broker出来,选择的逻辑后面会讲到
                
                int writeQueueNums = tpInfo.getQueueIdByBroker(notBestBroker);
                if (writeQueueNums > 0) {
                    final MessageQueue mq = tpInfo.selectOneMessageQueue();
                    if (notBestBroker != null) { // 先选一个出来,然后替换掉brokerName、queueId,这里不去new一个MessageQueue,主要是为了方便拿到topic
                        mq.setBrokerName(notBestBroker);
                        mq.setQueueId(tpInfo.getSendWhichQueue().getAndIncrement() % writeQueueNums);
                    }
                    return mq;
                } else {
                    latencyFaultTolerance.remove(notBestBroker); //  选出来的notBestBroker可能是null,所以writeQueueNum可能是-1,或者该broker的写队列数设置错了(似乎不太可能),这里移除掉
                }
            } catch (Exception e) {
                log.error("Error occurred when selecting message queue", e);
            }

            return tpInfo.selectOneMessageQueue(); // 选择异常了,就回到没有启用延迟故障的模式
        }

        return tpInfo.selectOneMessageQueue(lastBrokerName); // 【2】没有启用故障延迟功能
    }

我们先看【2】,如果没有启用延迟故障功能,那么就直接递增的开始选择消息队列,如果broker有异常就排除掉:

public MessageQueue selectOneMessageQueue(final String lastBrokerName) {
        if (lastBrokerName == null) { // 第一次选择,lastBroker为null,出了故障lasterBrokerName会保存brokerName
            return selectOneMessageQueue();
        } else {
            int index = this.sendWhichQueue.getAndIncrement();
            for (int i = 0; i < this.messageQueueList.size(); i++) {
                int pos = Math.abs(index++) % this.messageQueueList.size();
                if (pos < 0)
                    pos = 0;
                MessageQueue mq = this.messageQueueList.get(pos);
                if (!mq.getBrokerName().equals(lastBrokerName)) { // 上个broker出现了故障,选择的时候,务必排除掉该broker下面的队列
                    return mq;
                }
            }
            return selectOneMessageQueue();
        }
    }

再看selectOneMessageQueue()方法:

 public MessageQueue selectOneMessageQueue() {
        int index = this.sendWhichQueue.getAndIncrement();
        int pos = Math.abs(index) % this.messageQueueList.size();
        if (pos < 0)
            pos = 0;
        return this.messageQueueList.get(pos);
    }

sendWhichQueue里面保存了上次的队列Index,这次就+1,如果超过队列数就重新开始循环。

然后我们再回到【1】,如果开启了延迟故障功能,发送A消息时,递增获取到队列时,首先会判断该队列所属的broker是否可用
如果可用就直接返回该队列。如果消息发送失败,那么重试的时候lastBrokerName不为空,这个时候需要排除掉该broker。从剩余可用broker里面获取。如果仍然没有可用的队列,那么就从故障的broker里面择优选择一个,然后递增选取一个队列。如果上述选择发生了异常,那么就回退到未开启延迟故障的选择模式。

bug???

这里在判断broker是否可用的时候,似乎有一个bug。

 if (latencyFaultTolerance.isAvailable(mq.getBrokerName())) { // 该broker可用
     if (null == lastBrokerName || mq.getBrokerName().equals(lastBrokerName)) // bug???
         return mq;
 }

按照我们的分析,第一次发送消息的时候lasterBrokerName == null,那么获取到可用的队列就可以返回了。如果发生了重试,那么该broker应该被剔除掉,但是这里居然判断的是mq.getBrokerName().equals(lastBrokerName),意思是只能在这个出问题的broker里面选择队列,这显然不合理。看了下RocketMQ最新版本的代码(4.5.2),仍然存在这个问题,不知道作者有什么特殊的考虑。合理的判断应该是这样:

if (latencyFaultTolerance.isAvailable(mq.getBrokerName())) { // 该broker可用
     if (null == lastBrokerName || !mq.getBrokerName().equals(lastBrokerName)) // 前面加!
         return mq;
 }

如何判断broker有延迟故障

如何判断broker有延迟故障呢?或者说什么情况下会发生重试?
在发送消息正常的情况下,都会计算一个发送延时,如下所示:
在这里插入图片描述
如果开启了延迟故障功能,那么会根据这个延时,计算出一个不可用时长,并保存下来。这个不可用时长的计算,已经在我们MQFaultStrategy定义好了:

 private long[] latencyMax = {50L, 100L, 550L, 1000L, 2000L, 3000L, 15000L}; // 定义了延迟的几个级别,单位ms
 private long[] notAvailableDuration = {0L, 0L, 30000L, 60000L, 120000L, 180000L, 600000L};// 定义了故障后的不可用时长,单位ms,与延迟级别相关

从上面可以看到100ms以内的延迟都是正常了,不可用时长为0,也就是说该broker仍然是可用的。
在消息发送出现异常的情况下,也会计算一个延时,但是isolation变成了true,也就是屏蔽掉原来的延迟时间,采用定好的延时时长,这里是30秒,如下所示:

public void updateFaultItem(final String brokerName, final long currentLatency, boolean isolation) {
        if (this.sendLatencyFaultEnable) {
            long duration = computeNotAvailableDuration(isolation ? 30000 : currentLatency);
            this.latencyFaultTolerance.updateFaultItem(brokerName, currentLatency, duration);
        }
    }

通过updateFaultItem,客户端保存了有延迟故障的(不一定真的有故障)broker列表,这样在选择消息队列的时候,就会先判断队列的broker是否可用,排除掉不可用的broker。
当正常发送延时过大、或者发送发生异常时,就说明该broker发生了延迟故障。而当发送异常时,才会引发重试。

pickOneAtLeast做了什么

前面讲到了当启用故障延迟功能后,会排除掉故障的broker,从剩下的里面选出可用的队列。如果仍然没有合适,那么就会从故障broker列表里面择优选择一个,那么这个择优录取是怎么做的呢?

public String pickOneAtLeast() {
        final Enumeration<FaultItem> elements = this.faultItemTable.elements();
        List<FaultItem> tmpList = new LinkedList<FaultItem>();
        while (elements.hasMoreElements()) { // 先把故障的broker列表复制一份,后面好做打乱
            final FaultItem faultItem = elements.nextElement();
            tmpList.add(faultItem);
        }

        if (!tmpList.isEmpty()) {
            Collections.shuffle(tmpList); // 打乱该列表

            Collections.sort(tmpList); // 排序

            final int half = tmpList.size() / 2; // 从前50%里面递增选取一个
            if (half <= 0) {
                return tmpList.get(0).getName();
            } else {
                final int i = this.whichItemWorst.getAndIncrement() % half;
                return tmpList.get(i).getName();
            }
        }

        return null;
    }

原来RocketMQ把故障broker列表重新打乱,然后进行了排序,再从50%里面依次递增选择一个broker。那么重点就是如何排序了,FaultItem实现了Comparable接口,排序代码如下:

public int compareTo(final FaultItem other) {
            if (this.isAvailable() != other.isAvailable()) { // 【1】
                if (this.isAvailable())
                    return -1;

                if (other.isAvailable())
                    return 1;
            }

            if (this.currentLatency < other.currentLatency) // 【2】
                return -1;
            else if (this.currentLatency > other.currentLatency) {
                return 1;
            }

            if (this.startTimestamp < other.startTimestamp) // 【3】
                return -1;
            else if (this.startTimestamp > other.startTimestamp) {
                return 1;
            }

            return 0;
        }

排序有3个依据:
【1】谁目前可用,谁优先级高
【2】谁延迟低,谁优先级高
【3】谁不可用时间点早,谁优先级高
然后【1】>【2】>【3】。为了避免消息都集中到最优的那个broker,我们选取前50%,然后递增的获取broker,分散压力。
拿到broker之后,然后递增的获取一个队列发送消息。

小结

Producer发送消息的主要流程是验证消息---->查找Topic路由---->选择消息队列—>发送消息。其中选择消息队列根据是否开启故障延迟,选择的策略稍有不同。其中如果启用了延迟故障功能,那么对于高延迟的、有故障的broker,都会保存下来,并有一个不可用时间段,发送消息的时候都会临时避开这个broker。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值