RocketMQ源码之长轮询实现

165 阅读10分钟

Push or Pull

MQ中消息传递的模式有Push和Pull两种。 Pull: 消费者主动从Broker拉取 Push: Broker主动推送给消费者

在RocketMQ虽然有对于消费者有DefaultMQPullConsumer和DefaultMQPushConsumer两个Api可供选择,但是底层其实都使用PullAPIWrapper这个类进行消息拉取,也就是说,RocketMQ使用的消费传递模型是Pull模型。

为什么pull?

push的最大好处的实时,但是也有以下缺点

  1. 在Broker端需要维护Consumer的状态,不利于Broker去支持大量的Consumer的场景
  2. Consumer的消费速度是不一致的,由Broker进行推送难以处理不同的Consumer的状况
  3. Broker难以处理Consumer无法消费消息的情况(Broker无法确定Consumer的故障是短暂的还是永久的)
  4. 大量的推送消息会加重Consumer的负载或者冲垮Consumer

pull很好的解决了上面的问题,但是也丢失了实时性。为了保证实时,我们可以把拉取消息的间隔设置的短一点,但这也带来了一个另外一个问题,在没有消息的时候时候会有大量pull请求,为了解决这个问题,就采用了本文讲解的长轮询技术。

什么是长轮询

轮询是以固定间隔请求服务器,它不在乎这次请求是否能拉取到消息。而长轮询,它请求的服务端,会等待一会儿时间,然后将等待时间内的消息返回。如果超时了,那么也返回空。有效的避免了无效的请求。

但是对于每次都能拉取到消息的情况下,长轮询也就退化成了轮询。

能不能在优化?

还有一种动态轮询的设计,我们的消息一般都是存在缓存区里的,比如RocketMQ存在processqueue里面,因此请求服务端的时候,我们可以把缓冲区剩余的空间告诉服务端,服务端不会返回确认拉取结束的Response,他会根据缓冲区剩余空间先把对应数量的消息分次返回,最后返回确认结束的Response。

在RocketMQ中采用了长轮询模式,动态轮询虽然好,但是也需要考虑一些极端场景的处理,比如丢包。

在H5与服务器的交互模式中,可以使用websocket协议来优化长轮询。websocket是一种基于tcp的全双工协议。

RocketMQ中的设计

从这里开始,建议阅读过一部分RocketMQ源码的读者消化

本文关注于长轮询拉取消息,就直接从DefaultMQPullConsumer和DefaultMQPushConsumer中用于拉取消息的PullAPIWrapper开始讲起。消息消费可以关注我后续文章。

PullAPIWrapper

PullAPIWrapper的pullKernelImpl方法内组装了拉取消息的逻辑

public PullResult pullKernelImpl(
        final MessageQueue mq,
        final String subExpression,
        final String expressionType,
        final long subVersion,
        final long offset,
        final int maxNums,
        final int sysFlag,
        final long commitOffset,
        final long brokerSuspendMaxTimeMillis,
        final long timeoutMillis,
        final CommunicationMode communicationMode,
        final PullCallback pullCallback
    ) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
        //获取broker
        FindBrokerResult findBrokerResult =
            this.mQClientFactory.findBrokerAddressInSubscribe(mq.getBrokerName(),
                this.recalculatePullFromWhichNode(mq), false);
        //如果查找不到broker 更新一下 再次获取
        if (null == findBrokerResult) {
            this.mQClientFactory.updateTopicRouteInfoFromNameServer(mq.getTopic());
            findBrokerResult =
                this.mQClientFactory.findBrokerAddressInSubscribe(mq.getBrokerName(),
                    this.recalculatePullFromWhichNode(mq), false);
        }

        //找到broker的情况下
        if (findBrokerResult != null) {
            {
                // check version
                if (!ExpressionType.isTagType(expressionType)
                    && findBrokerResult.getBrokerVersion() < MQVersion.Version.V4_1_0_SNAPSHOT.ordinal()) {
                    throw new MQClientException("The broker[" + mq.getBrokerName() + ", "
                        + findBrokerResult.getBrokerVersion() + "] does not upgrade to support for filter message by " + expressionType, null);
                }
            }
            int sysFlagInner = sysFlag;

            //如果拉取消息的是slave broker,不进行消息确认
            if (findBrokerResult.isSlave()) {
                sysFlagInner = PullSysFlag.clearCommitOffsetFlag(sysFlagInner);
            }

            PullMessageRequestHeader requestHeader = new PullMessageRequestHeader();
            requestHeader.setConsumerGroup(this.consumerGroup);
            requestHeader.setTopic(mq.getTopic());
            requestHeader.setQueueId(mq.getQueueId());
            requestHeader.setQueueOffset(offset);
            requestHeader.setMaxMsgNums(maxNums);
            requestHeader.setSysFlag(sysFlagInner);
            //
            requestHeader.setCommitOffset(commitOffset);
            requestHeader.setSuspendTimeoutMillis(brokerSuspendMaxTimeMillis);
            requestHeader.setSubscription(subExpression);
            requestHeader.setSubVersion(subVersion);
            requestHeader.setExpressionType(expressionType);

            String brokerAddr = findBrokerResult.getBrokerAddr();
            if (PullSysFlag.hasClassFilterFlag(sysFlagInner)) {
                brokerAddr = computPullFromWhichFilterServer(mq.getTopic(), brokerAddr);
            }

            // remote invoke
            // 这边根据communicationMode不同,有不同的拉取模式
            // 如果是异步 pullResult直接返回null
            PullResult pullResult = this.mQClientFactory.getMQClientAPIImpl().pullMessage(
                brokerAddr,
                requestHeader,
                timeoutMillis,
                communicationMode,
                pullCallback);

            return pullResult;
        }

        throw new MQClientException("The broker[" + mq.getBrokerName() + "] not exist", null);
    }

大致逻辑如下

  1. 通过mq的brokerName获取broker地址
  2. 获取broker地址,更新下路由信息,再次获取
  3. 构造requestHeader,通过this.mQClientFactory.getMQClientAPIImpl().pullMessage发送到broker
  4. 返回pullResult

在与broker长轮询交互中,有几个参数比较重要,我们一一介绍。

第一个参数是requestHeader中的sysFlag,它作为pullKernelImpl的参数传入,它的构造代码如下

int sysFlag = PullSysFlag.buildSysFlag(
            commitOffsetEnable, 
            true, 
            subExpression != null, 
            classFilter 
        );
        
public static int buildSysFlag(
    final boolean commitOffset, //是否确认消息
    final boolean suspend, //是否长轮询
    final boolean subscription, //是否过滤消息
    final boolean classFilter//是否类过滤
    )

sysFlag的第二个bit位会告诉broker是否应该长轮询

第二个和长轮询有关的参数是brokerSuspendMaxTimeMillis,长轮询不可能无限期等待下去,因此需要传递这个长轮询时间给到broker,如果超过这个时间还没有消息到达,那么直接返回空的Response。

第三个重要的参数是timeoutMillis,broker在长轮询的时候,客户端也需要阻塞等待结果,单也不能无限制等待下去,如果超过timeoutMillis还没收到返回,那么我本地也需要做对应处理,。

brokerSuspendMaxTimeMillis会小于timeoutMillis,这个原因大家都懂的吧。

怕大家不理解,讲下在DefaultMQPullConsumerImpl的pullSyncImpl中如何使用到pullKernelImpl方法。

pullSyncImpl用来同步拉取消息,它支持选择是否使用长轮询模式。其他用到pullKernelImpl的地方,使用方式大同小异。

    //同步拉取消息
    private PullResult pullSyncImpl(MessageQueue mq, SubscriptionData subscriptionData, long offset, int maxNums, boolean block,
        long timeout)
        throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
        this.makeSureStateOK();

        if (null == mq) {
            throw new MQClientException("mq is null", null);
        }

        if (offset < 0) {
            throw new MQClientException("offset < 0", null);
        }

        if (maxNums <= 0) {
            throw new MQClientException("maxNums <= 0", null);
        }

        this.subscriptionAutomatically(mq.getTopic());

        //第一个参数为false 代表 pull不会顺带确认消息
        //第二个参数 block代表是否开启长轮询
        //第三个参数 表示broker需要处理消息过滤
        //第四个参数为false 表示不需要通过过滤类过滤消息
        int sysFlag = PullSysFlag.buildSysFlag(false, block, true, false);

        //如果是长轮询使用系统设置的长轮询等待时间
        //如果不是长轮序,使用调用者配置的超时时间
        long timeoutMillis = block ? this.defaultMQPullConsumer.getConsumerTimeoutMillisWhenSuspend() : timeout;

        boolean isTagType = ExpressionType.isTagType(subscriptionData.getExpressionType());
        //同步拉取
        PullResult pullResult = this.pullAPIWrapper.pullKernelImpl(
            mq,
            subscriptionData.getSubString(),
            subscriptionData.getExpressionType(),
            isTagType ? 0L : subscriptionData.getSubVersion(),
            offset,
            maxNums,
            sysFlag,
            0,
            this.defaultMQPullConsumer.getBrokerSuspendMaxTimeMillis(),//broker长轮询时间
            timeoutMillis,//客户端阻塞等待时间
            CommunicationMode.SYNC,//这个同步只与本地netty客户端请求方式有关 和远程实现无关
            null
        );
        // 处理拉取结果
        // 本地再次进行消息过滤
        this.pullAPIWrapper.processPullResult(mq, pullResult, subscriptionData);
        // 一些其他操作
        // 执行一些回调
        if (!this.consumeMessageHookList.isEmpty()) {
            ConsumeMessageContext consumeMessageContext = null;
            consumeMessageContext = new ConsumeMessageContext();
            consumeMessageContext.setConsumerGroup(this.groupName());
            consumeMessageContext.setMq(mq);
            consumeMessageContext.setMsgList(pullResult.getMsgFoundList());
            consumeMessageContext.setSuccess(false);
            this.executeHookBefore(consumeMessageContext);
            consumeMessageContext.setStatus(ConsumeConcurrentlyStatus.CONSUME_SUCCESS.toString());
            consumeMessageContext.setSuccess(true);
            this.executeHookAfter(consumeMessageContext);
        }
        return pullResult;
    }

讲好了,客户端是如何设置长轮询,现在来看下服务端是怎么进行长轮询的。

我们从处理消息拉取的PullMessageProcessor的processRequest方法看起。

在这个方法中,首先会解析出我们刚才讲的那两个和长轮询有关的参数

        //是否允许长轮询
        final boolean hasSuspendFlag = PullSysFlag.hasSuspendFlag(requestHeader.getSysFlag());

        //长轮询等待时间
        final long suspendTimeoutMillisLong = hasSuspendFlag ? requestHeader.getSuspendTimeoutMillis() : 0;

然后通过客户端的请求去拉取消息

//从messageStore获取消息
        final GetMessageResult getMessageResult =
            this.brokerController.getMessageStore().getMessage(requestHeader.getConsumerGroup(), requestHeader.getTopic(),
                requestHeader.getQueueId(), requestHeader.getQueueOffset(), requestHeader.getMaxMsgNums(), messageFilter);

如果拉取到消息,那么直接返回到客户端就行了。如果拉取不到,那么就触发我们的长轮询了。

            case ResponseCode.PULL_NOT_FOUND:
                //注意这个brokerAllowSuspend 长轮序后的拉取这个默认为false
                if (brokerAllowSuspend && hasSuspendFlag) {
                    //如果pull不到消息 应该会等待pollingTimeMills时间
                    // 专业术语 长轮询
                    long pollingTimeMills = suspendTimeoutMillisLong;
                    if (!this.brokerController.getBrokerConfig().isLongPollingEnable()) {
                        //如果配置不支持长轮询 改为一个比较小的时间 1秒
                        pollingTimeMills = this.brokerController.getBrokerConfig().getShortPollingTimeMills();
                    }

                    String topic = requestHeader.getTopic();
                    long offset = requestHeader.getQueueOffset();
                    int queueId = requestHeader.getQueueId();
                    //注意这边3,4参数 用于计算长轮询间隔
                    PullRequest pullRequest = new PullRequest(request, channel, pollingTimeMills,
                        this.brokerController.getMessageStore().now(), offset, subscriptionData, messageFilter);
                    //这边应该会阻塞等待下
                    this.brokerController.getPullRequestHoldService().suspendPullRequest(topic, queueId, pullRequest);
                    //这次请求在这里先不返回 suspendPullRequest那边会返回
                    response = null;
                    break;
                }

接下来的事情就比较关键了,长轮询在broker是如何实现的。

  1. 如果一直没有消息达到,我需要等待pollingTimeMills时间,然后返回客户端
  2. 如果在第一步的等待中有消息到达,立刻返回客户端

第一步的等待由PullRequestHoldService实现,在上述代码中我们可以看到

PullRequest pullRequest = new PullRequest(request, channel, pollingTimeMills,this.brokerController.getMessageStore().now(), offset, subscriptionData, messageFilter);
//这边应该会阻塞等待下
this.brokerController.getPullRequestHoldService().suspendPullRequest(topic, queueId, pullRequest);

构造PullRequest,然后放入到PullRequestHoldService中,在PullRequestHoldService会定期判断pullRequest是否可以唤醒

    @Override
    public void run() {
        log.info("{} service started", this.getServiceName());
        while (!this.isStopped()) {
            try {
                //阻塞时间根据是否支持长轮询不同
                if (this.brokerController.getBrokerConfig().isLongPollingEnable()) {
                    //这边固定休息5秒。。应该有其他地方会激活吧
                    this.waitForRunning(5 * 1000);
                } else {
                    this.waitForRunning(this.brokerController.getBrokerConfig().getShortPollingTimeMills());
                }

                long beginLockTimestamp = this.systemClock.now();
                //唤醒
                this.checkHoldRequest();
                long costTime = this.systemClock.now() - beginLockTimestamp;
                if (costTime > 5 * 1000) {
                    log.info("[NOTIFYME] check hold request cost {} ms.", costTime);
                }
            } catch (Throwable e) {
                log.warn(this.getServiceName() + " service has exception. ", e);
            }
        }

        log.info("{} service end", this.getServiceName());
    }

在run方法中会每隔5秒,调用checkHoldRequest方法,检查是否有消息打到,并不是直接阻塞pollingTimeMills时间后返回。

checkHoldRequest逻辑如下

private void checkHoldRequest() {
        //对pullRequestTable中每个pullRequest进行notify
        for (String key : this.pullRequestTable.keySet()) {
            String[] kArray = key.split(TOPIC_QUEUEID_SEPARATOR);
            if (2 == kArray.length) {
                String topic = kArray[0];
                int queueId = Integer.parseInt(kArray[1]);
                final long offset = this.brokerController.getMessageStore().getMaxOffsetInQueue(topic, queueId);
                try {
                    this.notifyMessageArriving(topic, queueId, offset);
                } catch (Throwable e) {
                    log.error("check hold request failed. topic={}, queueId={}", topic, queueId, e);
                }
            }
        }
    }

checkHoldRequest会对每个在pullRequestTable的pullRequest进行检查,检查逻辑在notifyMessageArriving方法中

    public void notifyMessageArriving(final String topic, final int queueId, final long maxOffset, final Long tagsCode,
        long msgStoreTime, byte[] filterBitMap, Map<String, String> properties) {
        String key = this.buildKey(topic, queueId);
        ManyPullRequest mpr = this.pullRequestTable.get(key);
        if (mpr != null) {
            //注意这边会清除
            List<PullRequest> requestList = mpr.cloneListAndClear();
            if (requestList != null) {
                List<PullRequest> replayList = new ArrayList<PullRequest>();

                for (PullRequest request : requestList) {
                    long newestOffset = maxOffset;
                    //如果newestOffset小于客户端拉取的offset 更新下
                    if (newestOffset <= request.getPullFromThisOffset()) {
                        newestOffset = this.brokerController.getMessageStore().getMaxOffsetInQueue(topic, queueId);
                    }

                    if (newestOffset > request.getPullFromThisOffset()) {
                        // 如果tagcode=null 默认返回true 来自上一个方法的调用 相当于这边=true
                        boolean match = request.getMessageFilter().isMatchedByConsumeQueue(tagsCode,
                            new ConsumeQueueExt.CqExtUnit(tagsCode, msgStoreTime, filterBitMap));
                        // match by bit map, need eval again when properties is not null.
                        //同上
                        if (match && properties != null) {
                            match = request.getMessageFilter().isMatchedByCommitLog(null, properties);
                        }

                        if (match) {
                            try {
                                //下面这个调用会再次触发拉取消息
                                this.brokerController.getPullMessageProcessor().executeRequestWhenWakeup(request.getClientChannel(),
                                    request.getRequestCommand());
                            } catch (Throwable e) {
                                log.error("execute request when wakeup failed.", e);
                            }
                            continue;
                        }
                    }

                    // 如果超过了长轮询时间 直接去拉取消息 拉取不到也返回
                    if (System.currentTimeMillis() >= (request.getSuspendTimestamp() + request.getTimeoutMillis())) {
                        try {
                            this.brokerController.getPullMessageProcessor().executeRequestWhenWakeup(request.getClientChannel(),
                                request.getRequestCommand());
                        } catch (Throwable e) {
                            log.error("execute request when wakeup failed.", e);
                        }
                        continue;
                    }

                    replayList.add(request);
                }

                //这边将拉取不到的消息重新放入pullRequestTable 等待下次调度
                if (!replayList.isEmpty()) {
                    mpr.addPullRequest(replayList);
                }
            }
        }
    }

如果newestOffset > request.getPullFromThisOffset()的话,也就是对应consumequeue内有新消息到达,那么会通过this.brokerController.getPullMessageProcessor().executeRequestWhenWakeup(request.getClientChannel(), request.getRequestCommand())再次触发拉取消息并且返回客户端

    //用于长轮询拉取消息
    public void executeRequestWhenWakeup(final Channel channel,
        final RemotingCommand request) throws RemotingCommandException {
        Runnable run = new Runnable() {
            @Override
            public void run() {
                try {
                    //再次从messagestore拉取消息
                    //注意第三个参数为false 在消息拉取不到的情况下不在进行长轮询
                    final RemotingCommand response = PullMessageProcessor.this.processRequest(channel, request, false);

                    //拉取结果 返回客户端
                    //可能这边还是没拉取到
                    if (response != null) {
                        response.setOpaque(request.getOpaque());
                        response.markResponseType();
                        try {
                            channel.writeAndFlush(response).addListener(new ChannelFutureListener() {
                                @Override
                                public void operationComplete(ChannelFuture future) throws Exception {
                                    if (!future.isSuccess()) {
                                        log.error("processRequestWrapper response to {} failed",
                                            future.channel().remoteAddress(), future.cause());
                                        log.error(request.toString());
                                        log.error(response.toString());
                                    }
                                }
                            });
                        } catch (Throwable e) {
                            log.error("processRequestWrapper process request over, but response failed", e);
                            log.error(request.toString());
                            log.error(response.toString());
                        }
                    }
                } catch (RemotingCommandException e1) {
                    log.error("excuteRequestWhenWakeup run", e1);
                }
            }
        };
        this.brokerController.getPullMessageExecutor().submit(new RequestTask(run, channel, request));
    }

可以看到在executeRequestWhenWakeup中还是调用了PullMessageProcessor的processRequest方法。 不过需要注意第三个参数为false了 ,也就是长轮询不能循环触发长轮询,这个细节上的设计读者可以细细品味下。

上面讲解了,broker端在长轮询时间段中,定时检查是否有消息到达,然后返回客户端。

但是现在有一种场景,在PullRequest休眠的5秒钟,如果有消息到达,也需要等待下次调度。 RocketMQ在这边做了优化,在上面是通过notifyMessageArriving来做消息是否达到的处理以及再次触发消息拉取。因此可以在消息达到的时候直接触发notifyMessageArriving,来拉取消息返回到客户端。

这个逻辑封装在NotifyMessageArrivingListener中

public class NotifyMessageArrivingListener implements MessageArrivingListener {
    private final PullRequestHoldService pullRequestHoldService;

    public NotifyMessageArrivingListener(final PullRequestHoldService pullRequestHoldService) {
        this.pullRequestHoldService = pullRequestHoldService;
    }

    @Override
    public void arriving(String topic, int queueId, long logicOffset, long tagsCode,
        long msgStoreTime, byte[] filterBitMap, Map<String, String> properties) {
        //消息一到达 这边也会触发长轮询返回
        this.pullRequestHoldService.notifyMessageArriving(topic, queueId, logicOffset, tagsCode,
            msgStoreTime, filterBitMap, properties);
    }
}

而这个Listener会在消息做reput的时候触发

reput是啥? 简单的来讲,我们生产的消息落到broker之后,先是持久化到commitlog,然后在通过reput持久化到consumequeue和index。也正因为持久化到consumequeue,我们的客户端才能感知到这条消息的存在。然后在reput这个操作中顺带激活了长轮询休眠的PullRequest。

好了,RocketMQ长轮询的实现讲解完了,还是不熟悉的话,多看几遍源码,我也是看了好久。

魔鬼在于细节

参考

Push or Pull? 这个哥们的MQ系列文章很不错,从架构的角度讲解MQ的设计