【RocketMQ | 源码分析】消息队列负载均衡时机分析

602 阅读7分钟

前言

我们知道RocketMQ的同一个Topic有多个MessageQueue,RocketMQ的同一个ConsumerGroup中可以有多个Consumer,同一个MessageQueue只能被一个消费者消费,而一个Consumer可以消费多个MessageQueue,那么同一个ConsumerGroup下的不同Consumer是如何达成"共识",RocketMQ如何保证MessageQueue能够被正确的消费的呢?本篇文章我们将介绍消费者的负载均衡触发时机。

Rebalance触发时机源码分析

RebalanceService定时触发Rebalance

RebalanceService继承了ServiceThread,RebalanceService在MqClientInstance创建时创建,MQClientInsance服务启动时(start方法),会调用RebalanceService的start方法启动RebalanceService,RebalanceService启动后每隔20秒调用一次MQClientInstance#doRabalance方法触发rebalance。

public class RebalanceService extends ServiceThread {
    @Override
    public void run() {
        while (!this.isStopped()) {
            // 等待20s
            this.waitForRunning(waitInterval/*默认20s*/);
            this.mqClientFactory.doRebalance();
        }
    }
}

Consumer启动触发Rebalance

《Consumer整体介绍&启动源码分析》中我们了解到DefaultMQPushConsumerImpl#start时会调用MQClientInstance#rebalanceImmediately触发Rebalance。

// org.apache.rocketmq.client.impl.consumer.DefaultMQPushConsumerImpl#start
// 唤醒负载均衡服务rebalanceService,并进行rebalance
this.mQClientFactory.rebalanceImmediately();

MQClientInstance#rebalanceImmediately方法内部调用了RebalanceService#wakeup,该方法会唤醒正在等待的RebalanceService,立即触发Rebalance,由于RebalanceService继承ServiceThread,RebalanceService被唤醒后,还会调用MQClientInstance#doRabalance执行Rebalance

ServiceThread的唤醒机制(wakeup)可以参考《Broker消息是如何刷盘的? (下)》

// org.apache.rocketmq.client.impl.factory.MQClientInstance#rebalanceImmediately
public void rebalanceImmediately() {
    this.rebalanceService.wakeup();
}

Broker请求触发Rebalance

触发Broker请求Rebalance有如下两种情况

  1. 如果某个客户端连接异常时,则Broker会发送Rebalance给异常消费者组的所有消费者要求Rebalance
  2. 如果Broker收到Consumer心跳时发现是新的Consumer、订阅了某个Topic或者取消订阅某个Topic,则Broker会发送请求给消费者组的所有Consumer要求Rebalance
Consumer向Broker发送心跳

Consumer启动一个间隔30秒的定时任务,定时任务主要分为两步

  1. 清除MQClientInstance保存的下线Broker地址,根据从Namesrv中获取的topic路由信息表(ConcurrentMap<String/* Topic */, TopicRouteData> topicRouteTable),更新Broker地址信息表(ConcurrentMap<String/* Broker Name */, HashMap<Long/* brokerId */, String/* address */>> brokerAddrTable)
  2. 向所有Broker发送心跳
// org.apache.rocketmq.client.impl.factory.MQClientInstance#startScheduledTask
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
    @Override
    public void run() {
        try {
            // 清除下线的broker
            MQClientInstance.this.cleanOfflineBroker();
            // 发送心跳给Broker
            MQClientInstance.this.sendHeartbeatToAllBrokerWithLock();
        } catch (Exception e) {
            log.error("ScheduledTask sendHeartbeatToAllBroker exception", e);
        }
    }
}, 1000, this.clientConfig.getHeartbeatBrokerInterval()/*30s*/, TimeUnit.MILLISECONDS);

发送心跳代码如下所示,下面代码的核心逻辑会构建心跳包(HeartBeatData),然后遍历所有broker,逐个发送心跳包。

// org.apache.rocketmq.client.impl.factory.MQClientInstance#sendHeartbeatToAllBroker
private void sendHeartbeatToAllBroker() {
  final HeartbeatData heartbeatData = this.prepareHeartbeatData();
  final boolean producerEmpty = heartbeatData.getProducerDataSet().isEmpty();
  final boolean consumerEmpty = heartbeatData.getConsumerDataSet().isEmpty();
  // 如果producer&consumer都是空,则不需要发送心跳了
  if (producerEmpty && consumerEmpty) {
      return;
  }
  // brokerAddrTable如果不空,说明从namesrv获取到Broker
  if (!this.brokerAddrTable.isEmpty()) {
      Iterator<Entry<String, HashMap<Long, String>>> it = this.brokerAddrTable.entrySet().iterator();
      // 遍历所有broker
      while (it.hasNext()) {
          Entry<String, HashMap<Long, String>> entry = it.next();
          String brokerName = entry.getKey();
          HashMap<Long, String> oneTable = entry.getValue();
          if (oneTable != null) {
              for (Map.Entry<Long, String> entry1 : oneTable.entrySet()) {
                  Long id = entry1.getKey();
                  String addr = entry1.getValue();
                  if (addr != null) {
                      // 如果当前client只是producer,则不需要向从节点发送心跳
                      if (consumerEmpty) {
                          if (id != MixAll.MASTER_ID)
                              continue;
                      }
​
                      try {
                          // 向broker发送心跳
                          int version = this.mQClientAPIImpl.sendHeartbeat(addr, heartbeatData, clientConfig.getMqClientApiTimeout());
                      } catch (Exception e) {
                      }
                  }
              }
          }
      }
  }
}

值得注意的是上面发送心跳的代码中,心跳包是由MQClientInstance发送的,在同一个进程中,无论有多少个Producer和多少个Consumer,MQClientInstance都只有一个,心跳包的数据结构如下三个组成部分

  • clientId

clientId,默认值是ip@clientName(DEFAULT)

  • producerDataSet

producer信息set集合,集合中每个producerData包含生产者组(producerGroupName)

  • ConsumerDataSet

consumer信息set集合,集合中每个consumerData包含消费者组(consumerGroupName),消费类型(ConsumerType),消息模式(MessageModel),消息起始消费位置(ConsumeFromWhere)以及订阅信息集合(Topic和过滤逻辑)

image-20230514101655993

Broker处理心跳包

Broker心跳包处理类是ClientManageProcessor,心跳包处理入口方法是ClientManageProcessor#processRequest

// org.apache.rocketmq.broker.processor.ClientManageProcessor#processRequest
public RemotingCommand processRequest(ChannelHandlerContext ctx, RemotingCommand request) throws RemotingCommandException {
    switch (request.getCode()) {
        // 处理心跳请求
        case RequestCode.HEART_BEAT:
            return this.heartBeat(ctx, request);
        case RequestCode.UNREGISTER_CLIENT:
            return this.unregisterClient(ctx, request);
        case RequestCode.CHECK_CLIENT_CONFIG:
            return this.checkClientConfig(ctx, request);
        default:
            break;
    }
    return null;
}

处理心跳包的代码如下,主要逻辑如下

  1. 解码心跳包请求
  2. 遍历ConsumerData,首先获取ConsumerData中的订阅信息(SubscriptionGroupConfig),如果订阅信息不存在,说明当前订阅信息是第一次注册到Broker,需要创建一个名为%RETRY%${consumerGroup}的重试Topic,然后注册Consumer

如果Consumer消费消息失败,会将消息放入重试队列%RETRY%${consumerGroup}中,Broker会为每个ConsumerGroup创建一个重试队列

  1. 遍历ProducerData,将Producer信息(producerGroup)注册到Broker中
// org.apache.rocketmq.broker.processor.ClientManageProcessor#heartBeat
public RemotingCommand heartBeat(ChannelHandlerContext ctx, RemotingCommand request) {
    RemotingCommand response = RemotingCommand.createResponseCommand(null);
    // 解码心跳包请求
    HeartbeatData heartbeatData = HeartbeatData.decode(request.getBody(), HeartbeatData.class);
    ClientChannelInfo clientChannelInfo = new ClientChannelInfo(
        ctx.channel(),
        heartbeatData.getClientID(),
        request.getLanguage(),
        request.getVersion()
    );
    // 遍历consumerData
    for (ConsumerData data : heartbeatData.getConsumerDataSet()) {
        // 获取订阅信息配置,如果订阅信息不存在,则创建新的SubscriptionGroupConfig
        SubscriptionGroupConfig subscriptionGroupConfig =
            this.brokerController.getSubscriptionGroupManager().findSubscriptionGroupConfig(
                data.getGroupName());
        if (null != subscriptionGroupConfig) {
            // 获取重试Topic(%RETRY%ConsumerGroup)
            String newTopic = MixAll.getRetryTopic(data.getGroupName());
            // 创建重试topic
            this.brokerController.getTopicConfigManager().createTopicInSendMessageBackMethod(
                newTopic,
                subscriptionGroupConfig.getRetryQueueNums()/*默认是1*/,
                PermName.PERM_WRITE | PermName.PERM_READ, topicSysFlag);
        }
        // 注册consumer,如果订阅关系变化,则会通知所有Consumer
        boolean changed = this.brokerController.getConsumerManager().registerConsumer(
            data.getGroupName(),
            clientChannelInfo,
            data.getConsumeType(),
            data.getMessageModel(),
            data.getConsumeFromWhere(),
            data.getSubscriptionDataSet(),
            isNotifyConsumerIdsChangedEnable
        );
    }
    // 注册producer信息
    for (ProducerData data : heartbeatData.getProducerDataSet()) {
        this.brokerController.getProducerManager().registerProducer(data.getGroupName(),
            clientChannelInfo);
    }
    //... 
}

注册Consumer代码如下所示,主要流程如下所示

  1. 从consumerTable(ConcurrentMap<String/* Group */, ConsumerGroupInfo>)获取ConsumerGroupInfo,如果不存在说明是第一个注册,则创建一个新的ConsumerGroupInfo并put到consumerTable中

  2. 更新ConsumerGroupInfo中的Channel信息。在ConsumerGroupInfo中有一个维护着Channel与Client信息(主要是Channel与clientId的关系)的缓存表channelInfoTable(ConcurrentMap<Channel, ClientChannelInfo> channelInfoTable)

    channelInfoTable的更新逻辑是:

    如果在channelInfoTable中不包含当前心跳包中的Channel,则说明当前Consumer是新加入的Consumer,场景就是我们如果扩容了Consumer,则扩容的Consumer向Broker发送心跳时,会更新channelInfoTable并返回true。

  3. 更新consumerGroup的订阅信息。在ConsumerGroupInfo中有一个维护着当前consumerGroup订阅信息的缓存表subscriptionTable(ConcurrentMap<String/* Topic */, SubscriptionData>),它保存了每个ConsumerGroup订阅了哪些Topic,每个Topic要如何过滤消息。

    subscriptionTable的更新逻辑是:

    1)向subscriptionTable添加心跳包中不存在的topic订阅信息,如果某个topic订阅信息在心跳包中存在,但是在subscriptionTable不存在,说明当前consumerGroup要订阅新的topic

    2)删除subscriptionTable中在心跳包中不存在的topic订阅信息,如果某个topic订阅信息在心跳包中不存在,但是在subscriptionTable中存在,说明当前consumerGroup不订阅某个topic

  4. 如果ConsumerGroupInfo中的Channel信息更新了或者consumerGroup的订阅信息更新了,则会通知当前consumerGroup下的所有client,给每个client发送一个NOTIFY_CONSUMER_IDS_CHANGED请求,告诉他们订阅信息发生变化

// org.apache.rocketmq.broker.client.ConsumerManager#registerConsumer
public boolean registerConsumer(final String group, final ClientChannelInfo clientChannelInfo,
    ConsumeType consumeType, MessageModel messageModel, ConsumeFromWhere consumeFromWhere,
    final Set<SubscriptionData> subList, boolean isNotifyConsumerIdsChangedEnable) {
​
    ConsumerGroupInfo consumerGroupInfo = this.consumerTable.get(group);
    // consumerGroupInfo是空,说明是第一个注册的Consumer,创建一个ConsumerGroupInfo
    if (null == consumerGroupInfo) {
        ConsumerGroupInfo tmp = new ConsumerGroupInfo(group, consumeType, messageModel, consumeFromWhere);
        ConsumerGroupInfo prev = this.consumerTable.putIfAbsent(group, tmp);
        consumerGroupInfo = prev != null ? prev : tmp;
    }
    // 更新channel信息,这里维护了channel与clientId之间的关系
    boolean r1 = consumerGroupInfo.updateChannel(clientChannelInfo, consumeType, messageModel,
            consumeFromWhere);
    // consumer订阅信息是否更新(让Broker中的consumerGroup的订阅信息(topic,过滤subString)与心跳包中是否一致)
    boolean r2 = consumerGroupInfo.updateSubscription(subList);
    // 订阅信息是否更新
    if (r1 || r2) {
        // 通知所有consumer
        if (isNotifyConsumerIdsChangedEnable) {
            this.consumerIdsChangeListener.handle(ConsumerGroupEvent.CHANGE, group, consumerGroupInfo.getAllChannel());
        }
    }
    return r1 || r2;
}

【小提问】如果ConsumerGroup中的consumer订阅的topic不一致会有什么影响?

假设某个consumerGroup中有两个消费者1和2,消费者1先启动并且订阅了TopicX,消费者1先向Broker发送心跳,那么ConsumerGroupInfo的subscriptionTable中消费者组里面会包含TopicX的信息。

然后消费者2启动并订阅了TopicY,消费者2也向Broker发送心跳,那么根据前面的源码分析,ConsumerGroupInfo的subscriptionTable中TopicX的信息将会被删除,subscriptionTable将会保存TopicY的信息。

下次消费者1发送心跳又会将subscriptionTable中的信息改为TopicX的信息,依次反复循环,最终将导致该consumerGroup中的消费者消息消费异常。

Consumer处理Rebalance请求

Consumer收到rebalance请求是由ClientRemotingProcessor#notifyConsumerIdsChanged处理的,源码如下,可以看到notifyConsumerIdsChanged主要分为2步,第一步先解析请求头,然后调用MQClientInstance#rebalanceImmediately执行rebalance,由前面文章我们知道rebalanceImmediately内部调用了ServiceThread#wakeup立刻唤醒rebalance服务。

// org.apache.rocketmq.client.impl.ClientRemotingProcessor#notifyConsumerIdsChanged
public RemotingCommand notifyConsumerIdsChanged(ChannelHandlerContext ctx,
    RemotingCommand request) throws RemotingCommandException {
    try {
      	// 解析ConsumerId变更请求头
        final NotifyConsumerIdsChangedRequestHeader requestHeader =
            (NotifyConsumerIdsChangedRequestHeader) request.decodeCommandCustomHeader(NotifyConsumerIdsChangedRequestHeader.class);
        // 立刻唤醒rebalance
        this.mqClientFactory.rebalanceImmediately();
    } catch (Exception e) {
        log.error("notifyConsumerIdsChanged exception", RemotingHelper.exceptionSimpleDesc(e));
    }
    return null;
}

总结

本篇文章我们了解到了Comsumer负载均衡机制触发时机有三种

  • RebalanceService每隔20秒触发一次Rebalance
  • Consumer启动时触发Rebalance
  • 某个consumerGroup扩容,新的Consumer加入时或者某个Consumer的订阅信息发生变更时会触发Rebalance