RocketMQ源码分析10:Consumer重平衡

1,264 阅读11分钟

本文已参与[新人创作礼]活动,一路开启掘金创作之路。

基于rocketmq-4.9.0 版本分析rocketmq

前面我们在分析Consumer消费过程时,有提到一个非常重要的概念,就是重平衡,只有在经过重平衡后,消息的拉取对象PullMessageService才可以去Broker拉取消息,那么这篇文章就单独分析下什么是重平衡?

重平衡分析的前提是:集群消费模式。 重平衡要做的也很简单,就是给当前消费者分配属于它的逻辑消费队列。

1.什么是重平衡?

Rebalance(重平衡)机制指的是:将一个Topic下的多个队列,在同一个消费者组(consumer group)下的多个消费者实例(consumer instance)之间进行重新分配。

Rebalance机制的本意是为了提升消息的并行消费能力。例如,⼀个Topic下5个队列,在只有1个消费者的情况下,这个消费者将负责消费这5个队列的消息。如果此时我们增加⼀个消费者,那么就可以给其中⼀个消费者分配2个队列,给另⼀个分配3个队列,从而提升消息的并行消费能力。

由于⼀个队列最多分配给消费者组下的⼀个消费者,因此当某个消费者组下的消费者实例数量大于队列的数量时,多余的消费者实例将分配不到任何队列。

未命名文件.png

那么接下来我们就从源码的角度分析下重平衡的过程。

2.重平衡之queue的分配

rocketmq中的rebalance是consumer实例自身完成的

public class RebalanceService extends ServiceThread {
    private static long waitInterval = Long.parseLong(System.getProperty(
            "rocketmq.client.rebalance.waitInterval", "20000"));
            
    //TODO:...略.....        
  
    @Override
    public void run() {
        log.info(this.getServiceName() + " service started");

        while (!this.isStopped()) {
            this.waitForRunning(waitInterval);
            this.mqClientFactory.doRebalance();
        }

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

从代码中可以看到,它默认是每隔20s触发一次重平衡。

那么我们继续看下consumer客户端是如何完成重平衡的:

RebalanceImpl

public void doRebalance(final boolean isOrder) {
        Map<String, SubscriptionData> subTable = this.getSubscriptionInner();
        if (subTable != null) {
            for (final Map.Entry<String, SubscriptionData> entry : subTable.entrySet()) {
                final String topic = entry.getKey();
                try {
                    //TODO:根据topic进行重平衡
                    this.rebalanceByTopic(topic, isOrder);
                } catch (Throwable e) {
                    if (!topic.startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
                        log.warn("rebalanceByTopic Exception", e);
                    }
                }
            }
        }

        this.truncateMessageQueueNotMyTopic();
}

我们继续往下走:

RebalanceImpl

private void rebalanceByTopic(final String topic, final boolean isOrder) {
    switch (messageModel) {
        //TODO:广播模式
        case BROADCASTING: {
            Set<MessageQueue> mqSet = this.topicSubscribeInfoTable.get(topic);
            if (mqSet != null) {
                boolean changed = this.updateProcessQueueTableInRebalance(topic, mqSet, isOrder);
                if (changed) {
                    this.messageQueueChanged(topic, mqSet, mqSet);
                    log.info("messageQueueChanged {} {} {} {}",
                        consumerGroup,
                        topic,
                        mqSet,
                        mqSet);
                }
            } else {
                log.warn("doRebalance, {}, but the topic[{}] not exist.", consumerGroup, topic);
            }
            break;
        }
        //TODO:集群模式
        case CLUSTERING: {
            //TODO: 获取这个topic下的所有队列(默认是4个)
            Set<MessageQueue> mqSet = this.topicSubscribeInfoTable.get(topic);
            //TODO: 获取集群下所有客户端的id,我这里目前就一个消费者
            List<String> cidAll = this.mQClientFactory.findConsumerIdList(topic, consumerGroup);
           
           //TODO: ....忽略一些判断代码

            if (mqSet != null && cidAll != null) {
                List<MessageQueue> mqAll = new ArrayList<MessageQueue>();
                mqAll.addAll(mqSet);

                Collections.sort(mqAll);
                Collections.sort(cidAll);

                //TODO: 默认是平均分配策略
                AllocateMessageQueueStrategy strategy = this.allocateMessageQueueStrategy;

                //TODO: 分配结果
                List<MessageQueue> allocateResult = null;
                try {
                    //TODO: 第一个参数是消费者组
                    //TODO: 第二个参数是当前的客户端id
                    //TODO: 第三个参数是所有的queue(默认4个)
                    //TODO: 第四个参数是所有的客户端id
                    allocateResult = strategy.allocate(
                        this.consumerGroup,
                        this.mQClientFactory.getClientId(),
                        mqAll,
                        cidAll);
                } catch (Throwable e) {
                    log.error("AllocateMessageQueueStrategy.allocate Exception. allocateMessageQueueStrategyName={}", strategy.getName(),
                        e);
                    return;
                }

                //TODO:保存了当前消费者需要消费的队列
                Set<MessageQueue> allocateResultSet = new HashSet<MessageQueue>();
                if (allocateResult != null) {
                    allocateResultSet.addAll(allocateResult);
                }

                //TODO:这个方法内部要做的内容很多,在下面的核心逻辑中进行阐述
                boolean changed = this.updateProcessQueueTableInRebalance(topic, allocateResultSet, isOrder);
                if (changed) {
                    log.info(
                        "rebalanced result changed. allocateMessageQueueStrategyName={}, group={}, topic={}, clientId={}, mqAllSize={}, cidAllSize={}, rebalanceResultSize={}, rebalanceResultSet={}",
                        strategy.getName(), consumerGroup, topic, this.mQClientFactory.getClientId(), mqSet.size(), cidAll.size(),
1.                         allocateResultSet.size(), allocateResultSet);
                    this.messageQueueChanged(topic, mqSet, allocateResultSet);
                }
            }
            break;
        }
        default:
            break;
    }
}

这里根据消费模式进行了判断

  1. 广播模式:消费者要消费所有队列的数据,所以如果queue变动了,就更新消费者的消费队列,总之就是消费者要消费所有队列。
  2. 集群模式:相同Consumer Group的每个Consumer实例平均分摊同一个Topic的消息。即每条消息只会被发送到Consumer Group中的某个Consumer。

我们这里就关注集群模式。

在继续探讨重平衡之前,先提一下queue的分配算法:

  1. 平均分配策略(默认)

它会根据当前消费者在消费者集群中的位置(索引),以及queueCount%consumerCount的余数,计算当前消费者可以分配的queue。

举例:如果是4个queue,3个消费者; consumer1分配queueid=0,1;consumer2分配queueid=2,consumer3分配queueid=3。

  1. 环形平均策略

环形平均策略根据消费者的顺序,依次在由queue队列组成的环形图中逐个分配;该算法不用事先计算每个Consumer需要分配几个Queue,直接一个一个分即可。

举例:如果是4个queue,3个消费者; consumer1分配queueid=0,3;consumer2分配queueid=1,consumer3分配queueid=2。它和平均分配不同的是,它先给consumer1分配queueid=0,consumer2分配queueid=1,consumer3分配queueid=2,然后再将多余的queueid=3分配给consumer1。

  1. 一致性hash策略

该策略会将consumer的hash值作为Node节点存放到hash环上,然后将queue的hash值也放到hash环上,通过顺时针方向,距离queue最近的那个consumer就是该queue要分配的consumer。其可以有效减少由于消费者组扩容或缩容所带来的大量的Rebalance

  1. 同机房策略

该算法会根据queue的部署机房位置和consumer的位置,过滤出当前consumer相同机房的queue。不过,这种情况下,brokerName在命名上要按照的机房名@brokerName格式。

  1. 就近机房策略

它是上面同机房策略的补充。比如同机房中只有queue而没有consumer,此时就可以使用就近机房策略进行补充。

对应的接口:

/**
 * queue分配策略
 */
public interface AllocateMessageQueueStrategy {

    /**
     * Allocating by consumer id
     *
     * @param consumerGroup:消费者组
     * @param currentCID:当前的消费者id
     * @param mqAll:topic下的所有队列
     * @param cidAl:消费者组下的所有消费者id
     * @return :返回当前消费者需要消费的队列
     */
    List<MessageQueue> allocate(
        final String consumerGroup,
        final String currentCID,
        final List<MessageQueue> mqAll,
        final List<String> cidAll
    );
}

默认使用的是平均分配策略 AllocateMessageQueueAveragely

3.重平衡核心逻辑分析

private boolean updateProcessQueueTableInRebalance(final String topic, final Set<MessageQueue> mqSet,
    final boolean isOrder) {
    boolean changed = false;

    Iterator<Entry<MessageQueue, ProcessQueue>> it = this.processQueueTable.entrySet().iterator();
    while (it.hasNext()) {
        Entry<MessageQueue, ProcessQueue> next = it.next();
        MessageQueue mq = next.getKey();
        ProcessQueue pq = next.getValue();

        if (mq.getTopic().equals(topic)) {
            if (!mqSet.contains(mq)) {
                pq.setDropped(true);
                if (this.removeUnnecessaryMessageQueue(mq, pq)) {
                    it.remove();
                    changed = true;
                    log.info("doRebalance, {}, remove unnecessary mq, {}", consumerGroup, mq);
                }
            } else if (pq.isPullExpired()) {
                switch (this.consumeType()) {
                    case CONSUME_ACTIVELY:
                        break;
                    case CONSUME_PASSIVELY:
                        pq.setDropped(true);
                        if (this.removeUnnecessaryMessageQueue(mq, pq)) {
                            it.remove();
                            changed = true;
                            log.error("[BUG]doRebalance, {}, remove unnecessary mq, {}, because pull is pause, so try to fixed it",
                                consumerGroup, mq);
                        }
                        break;
                    default:
                        break;
                }
            }
        }
    }

    List<PullRequest> pullRequestList = new ArrayList<PullRequest>();
    for (MessageQueue mq : mqSet) {
        if (!this.processQueueTable.containsKey(mq)) {
            if (isOrder && !this.lock(mq)) {
                log.warn("doRebalance, {}, add a new mq failed, {}, because lock failed", consumerGroup, mq);
                continue;
            }

            this.removeDirtyOffset(mq);
            ProcessQueue pq = new ProcessQueue();

            long nextOffset = -1L;
            try {
                //TODO:从broker读取当前queue的消费偏移量
                nextOffset = this.computePullFromWhereWithException(mq);
            } catch (MQClientException e) {
                log.info("doRebalance, {}, compute offset failed, {}", consumerGroup, mq);
                continue;
            }

            if (nextOffset >= 0) {
                ProcessQueue pre = this.processQueueTable.putIfAbsent(mq, pq);
                if (pre != null) {
                    log.info("doRebalance, {}, mq already exists, {}", consumerGroup, mq);
                } else {
                    log.info("doRebalance, {}, add a new mq, {}", consumerGroup, mq);
                    PullRequest pullRequest = new PullRequest();
                    pullRequest.setConsumerGroup(consumerGroup);
                    pullRequest.setNextOffset(nextOffset);
                    pullRequest.setMessageQueue(mq);
                    pullRequest.setProcessQueue(pq);
                    pullRequestList.add(pullRequest);
                    changed = true;
                }
            } else {
                log.warn("doRebalance, {}, add new mq failed, {}", consumerGroup, mq);
            }
        }
    }

    //TODO:只有"新增"queue了,pullRequestList 才不会empty
    this.dispatchPullRequest(pullRequestList);

    return changed;
}
  1. 先将分配到的消息队列集合(mqSet)与processQueueTable做一个过滤比对

image.png

  • 上图中processQueueTable标注的红色部分,表示与分配到的消息队列集合mqSet互不包含。将这些队列设置dropped属性为true,然后查看这些队列是否可以移除出processQueueTable缓存变量,这里具体执行removeUnnecessaryMessageQueue()方法。
  • 上图中processQueueTable的绿色部分,表示与分配到的消息队列集合mqSet的交集。判断该ProcessQueue是否已经过期了,在Pull模式的不用管,如果是Push模式的,设置dropped属性为true,并且调用removeUnnecessaryMessageQueue()方法,像上面一样尝试移除Entry。

dropped属性很重要,后面讲重复消费消费暂停都会提到它。

  1. 为过滤后的消息队列集合(mqSet)中的每个MessageQueue创建一个ProcessQueue对象并存入RebalanceImpl的processQueueTable队列中(其中调用RebalanceImpl实例的computePullFromWhere(MessageQueue mq)方法获取该MessageQueue对象的下一个进度消费值offset,随后填充至接下来要创建的pullRequest对象属性中),并创建拉取请求对象—pullRequest添加到拉取列表—pullRequestList中,最后执行dispatchPullRequest()方法,将Pull消息的请求对象PullRequest依次放入PullMessageService服务线程的阻塞队列pullRequestQueue中,待该服务线程取出后向Broker端发起Pull消息的请求。

消息消费队列在同一消费组不同消费者之间的负载均衡,其核心设计理念是在一个消息消费队列在同一时间只允许被同一消费组内的一个消费者消费,一个消息消费者能同时消费多个消息队列

关于上面的核心逻辑方法我在举个栗子:假设有两个消费者c1,c2; 队列默认是4个。
消费者c1第一次重平衡:

  1. 消费者c1先启动,由于只有一个消费者,所以分配给c1的队列是4个(mqSet.size()=4);
  2. 然后开始处理重平衡逻辑,第一次进来 processQueueTable肯定是empty的,所以先跳过遍历逻辑。
  3. 遍历分配到的mqSet集合(遍历4次)
  • 创建ProcessQueue对象
  • 读取当前MessageQueue的偏移量,去broker中读取
  • 将当前MessageQueueProcessQueue 放入processQueueTable
  • 创建PullRequest对象,设置队列MessageQueue,缓存消息队列ProcessQueue,以及当前MessageQueue的偏移量,然后放入pullRequestList集合中
  1. 然后分发pullRequestList,将拉取消息的请求对象PullRequest依次放入PullMessageService服务线程的阻塞队列pullRequestQueue中,待该服务线程取出后向Broker端发起拉取消息的请求。

消费者c2重平衡:

  1. 消费者c2启动,先不关注它重平衡的逻辑,就假设它分配了2个队列

消费者c1第二次重平衡

  1. 消费者c1再次重平衡,经过平均分配算法后,c1也分配了2个queue(因为c2分配了2个,他们平均分),所以mqSet.size()=2
  2. 然后开始处理重平衡逻辑,由于消费者c1第一次重平衡时,processQueueTable已经放入了4个queue(MessageQueue),所以这里开始遍历。如果mqSet(这里是2个)不包含当前entry的key(MessageQueue),则:
  • 将当前entry中的ProcessQueue的属性 dropped 设置为true.
  • RemoteBrokerOffsetStore#offsetTable属性中,移除当前queue(MessageQueue).因为这个队列已经部署了c1了。
  • 将当前entry从processQueueTable中移除
  1. 这样遍历完,processQueueTable 还剩下两个entry(这两个entry的key是在mqSet集合中的)
  2. 继续遍历mqSet(2个queue),但是继续判断(!processQueueTable.containsKey(mq)),这里肯定是false,因为前面分析过了,它是还剩下2个MessageQueue,所以跳过if,最终装PullRequest的容器集合是empty的,所以就没有以后了。。。。 不难发现,c1在第2次重平衡后,并没有构建全新的pullRequestList,所以dispatchPullRequest(pullRequestList)方法内部不会做任何逻辑。这样c1还是有4个PullRequest, 那么第2次重平衡好像就没有什么用处了?
    不是的,这就是前面提到的一个非常重要的属性设置dropped=true
    在拉取消息时,首先就会判断这个属性
public void pullMessage(final PullRequest pullRequest) {
    final ProcessQueue processQueue = pullRequest.getProcessQueue();
    //TODO:如果是dropped=true,则本次拉取请求直接丢弃,不会在将PullRequest放回阻塞队列中
    if (processQueue.isDropped()) {
        log.info("the pull request[{}] is dropped.", pullRequest.toString());
        return;
    }
    
    //TODO:.......
}    

如果是dropped=true,则本次拉取请求直接丢弃,不会在将PullRequest放回阻塞队列中,这样c1就只消费2个队列了。所以在消息拉取之前,首先判断该队列是否被丢弃,如果已丢弃,则直接放弃本次拉取任务。

3.重平衡导致的重复消费

我们直接看消费相关的代码:

ConsumeMessageConcurrentlyService

class ConsumeRequest implements Runnable {
    private final List<MessageExt> msgs;
    private final ProcessQueue processQueue;
    private final MessageQueue messageQueue;

    //TODO:......

    @Override
    public void run() {

        //TODO:暂停消费了。因为接下来的真正的消费过程不会执行了
        if (this.processQueue.isDropped()) {
            log.info("the message queue not be able to consume, because it's dropped. group={} {}", ConsumeMessageConcurrentlyService.this.consumerGroup, this.messageQueue);
            return;
        }

        MessageListenerConcurrently listener = ConsumeMessageConcurrentlyService.this.messageListener;
        ConsumeConcurrentlyContext context = new ConsumeConcurrentlyContext(messageQueue);
        ConsumeConcurrentlyStatus status = null;
        defaultMQPushConsumerImpl.resetRetryAndNamespace(msgs, defaultMQPushConsumer.getConsumerGroup());

        ConsumeMessageContext consumeMessageContext = null;
        //TODO:......

        long beginTimestamp = System.currentTimeMillis();
        boolean hasException = false;
        ConsumeReturnType returnType = ConsumeReturnType.SUCCESS;
        try {
            if (msgs != null && !msgs.isEmpty()) {
                for (MessageExt msg : msgs) {
                    MessageAccessor.setConsumeStartTimeStamp(msg, String.valueOf(System.currentTimeMillis()));
                }
            }
            //TODO:消费者消费
            status = listener.consumeMessage(Collections.unmodifiableList(msgs), context);
        } catch (Throwable e) {
            log.warn("consumeMessage exception: {} Group: {} Msgs: {} MQ: {}",
                RemotingHelper.exceptionSimpleDesc(e),
                ConsumeMessageConcurrentlyService.this.consumerGroup,
                msgs,
                messageQueue);
            hasException = true;
        }
        //TODO:.......
        
        

        //TODO:可能会导致重复消费
        if (!processQueue.isDropped()) {
            ConsumeMessageConcurrentlyService.this.processConsumeResult(status, context, this);
        } else {
            log.warn("processQueue is dropped without process consume result. messageQueue={}, msgs={}", messageQueue, msgs);
        }
    }

    public MessageQueue getMessageQueue() {
        return messageQueue;
    }

}

而导致重复消费的就是在处理消费结果这里:

//TODO:可能会导致重复消费
if (!processQueue.isDropped()) {
    ConsumeMessageConcurrentlyService.this.processConsumeResult(status, context, this);
} else {
    log.warn("processQueue is dropped without process consume result. messageQueue={}, msgs={}", messageQueue, msgs);
}

因为走到这里,说明消费者已经消费完了,但是可能由于重平衡可能导致我现在正消费的队列dropped=true,所以它不会去处理消费结果,而不处理消费结果最痛的就是不会去更新消费偏移量,这样同一个消费者组里的其他消费者可能还会消费到这条消息。这样就产生了重复消费。

注意:一条消息,被同一个消费者组里的不同消费者都消费过,这也是重复消费。

4.总结

本文主要是从源码角度分析了RocketMQ的重平衡过程,也分析了产生重复消费的原因。简单总结下:

  1. queue的分配算法
  2. 重平衡的代码分析
  3. 重复消费产生的原因

好了,就写到这里吧。

限于作者水平,文中难免有错误之处,欢迎指正,勿喷,感谢感谢