本文深入的分析了RocketMQ的Rebalance机制,主要包括以下内容:
- Rebalance必要的元数据信息的维护
- Broker协调通知机制;
- 消费者/启动/运行时/停止时Rebalance触发时机
- 单个Topic的Rebalance流程
- 分区分配策略
- RocketMQ与Kafka Rebalance机制的区别,
文章篇幅较长,感兴趣的用户可以先收藏,再阅读。
1 Rebalance简介
Rebalance(再均衡)机制指的是:将一个Topic下的多个队列(或称之为分区),在同一个消费者组(consumer group)下的多个消费者实例(consumer instance)之间进行重新分配。
Rebalance机制本意是为了提升消息的并行处理能力。例如,一个Topic下5个队列,在只有1个消费者的情况下,那么这个消费者将负责处理这5个队列的消息。如果此时我们增加一个消费者,那么可以给其中一个消费者分配2个队列,给另一个分配3个队列,从而提升消息的并行处理能力。如下图:
点击添加图片描述(最多60个字)编辑
但是Rebalance机制也存在明显的限制与危害。
Rebalance限制:由于一个队列最多分配给一个消费者,因此当某个消费者组下的消费者实例数量大于队列的数量时,多余的消费者实例将分配不到任何队列。
Rebalance危害: 除了以上限制,更加严重的是,在发生Rebalance时,存在着一些危害,如下所述:
- 消费暂停:考虑在只有Consumer 1的情况下,其负责消费所有5个队列;在新增Consumer 2,触发Rebalance时,需要分配2个队列给其消费。那么Consumer 1就需要停止这2个队列的消费,等到这两个队列分配给Consumer 2后,这两个队列才能继续被消费。
- 重复消费:Consumer 2 在消费分配给自己的2个队列时,必须接着从Consumer 1之前已经消费到的offset继续开始消费。然而默认情况下,offset是异步提交的,如consumer 1当前消费到offset为10,但是异步提交给broker的offset为8;那么如果consumer 2从8的offset开始消费,那么就会有2条消息重复。也就是说,Consumer 2 并不会等待Consumer1提交完offset后,再进行Rebalance,因此提交间隔越长,可能造成的重复消费就越多。
- 消费突刺:由于rebalance可能导致重复消费,如果需要重复消费的消息过多;或者因为rebalance暂停时间过长,导致积压了部分消息。那么都有可能导致在rebalance结束之后瞬间可能需要消费很多消息。
基于Rebalance可能会给业务造成的负面影响,我们有必要对其内部原理进行深入剖析,以便于问题排查。我们将从Broker端和Consumer端两个角度来进行说明。Broker端主要负责Rebalance元数据维护,以及通知机制,在整个消费者组Rebalance过程中扮演协调者的作用;而Consumer端分析,主要聚焦于单个Consumer的Rebalance流程。
2 Broker端Rebalance协调机制
从本质上来说,触发Rebalance的根本因素无非是两个:1 ) 订阅Topic的队列数量变化 2)消费者组信息变化。导致二者发生变化的典型场景如下所示:
1 队列信息变化典型场景:
- broker宕机
- broker升级等运维操作
- 队列扩容/缩容
2 消费者组信息变化
典型场景:
- 日常发布过程中的停止与启动
- 消费者异常宕机
- 网络异常导致消费者与Broker断开连接
- 主动进行消费者数量扩容/缩容
- Topic订阅信息发生变化
在这里,笔者将队列信息和消费者组信息称之为Rebalance元数据,Broker负责维护这些元数据,并在二者信息发生变化时,以某种通知机制告诉消费者组下所有实例,需要进行Rebalance。从这个角度来说,Broker在Rebalance过程中,是一个协调者的角色。
在Broker内部,通过元数据管理器维护了Rebalance元数据信息,如下图所示:
点击添加图片描述(最多60个字)编辑
这些管理器,内部实现都是一个Map。其中:
- 队列信息:由TopicConfigManager维护。Map 的key是Topic名称,Value是TopicConfig。Broker通过实时的或者周期性的上报自己的Topic配置信息给NameServer,在NameServer组装成Topic的完整路由信息。消费者定时向NameServer定时拉取最新路由信息,以实现间接通知,当发现队列信息变化,触发Rebalance。
- 消费者组信息:由ConsumerManager、ConsumerOffsetManager、SubscriptionGroupManager三者共同维护。ConsumerManager维护了消费者组订阅信息,以及消费者组下当前的消费者实例信息,当消费者组的订阅信息或者实例发生变化,Broker都会主动给所有消费者实例发送通知,触发Rebalance。而在Rebalance时,消费者需要从ConsumerOffsetManager查询应该从那个位置继续开始消费。SubscriptionGroupManager主要是维护消费者组的一些附加信息,方便运维。
2.1 队列信息变化
队列信息通过Broker内的TopicConfigManager来维护,每个Broker都会将自己的信息上报给NameServer,由NameServer组装成完整的Topic路由信息。
通常情况下,一个Topic下的队列数量不会频繁的变化,但是如果遇到,Topic队列数量扩/缩容,、broker日常运维时的停止/启动或者broker异常宕机,也有可能导致队列数量发生变化。
这里我们重点讲一下为什么broker异常停止/宕机会导致数量变化。一些读者可能会认为创建Topic时,已经明确指定了队列的数量,那么之后不论怎样,队列的数量信息都不会发生变化,这是一种典型误解。
下图展示了一个RocketMQ集群双主部署模式下,某个broker宕机后,Topic路由信息的变化。
点击添加图片描述(最多60个字)编辑
可以看到,在宕机前,主题TopicX下队列分布在broker-a和broker-b两个broker上,每个broker上各有8个队列。当broker-a宕机后,其路由信息会被移除,此时我们就只能看到TopicX在broker-b上的路由信息。
因此,在RocketMQ中,Topic的路由信息实际上是动态变化的。不论是停止/启动/扩容导致的所有变化最终都会上报给NameServer。客户端可以给NameServer发送GET_ROUTEINTO_BY_TOPIC请求,来获得某个Topic的完整路由信息。如果发现队列信息发生变化,则触发Reabalance。
2.2 消费者组信息变化
Rebalance的另外一个条件:消费者组信息,由ConsumerManager、ConsumerOffsetManager、SubscriptionGroupManager三个组件共同维护。
2.2.1 ConsumerManager
ConsumerManager是最重要的一个消费者组元数据管理器,其维护了某个消费者组的订阅信息,以及所有消费者实例的详细信息,并在发生变化时提供通知机制。
- 数据添加:客户端通过发送HEART_BEAT请求给Broker,将自己添加到ConsumerManager中维护的某个消费者组中。需要注意的是,每个Consumer都会向所有的Broker进行心跳,因此每个Broker都维护了所有消费者的信息。
- 数据删除:客户端正常停止时,发送UNREGISTER_CLIENT请求,将自己从ConsumerManager移除;此外在发生网络异常时,Broker也会主动将消费者从ConsumerManager中移除。
- 数据查询:消费者可以向任意一个Broker发送GET_CONSUMER_LIST_BY_GROUP请求,来获得一个消费者组下的所有消费者实例信息。
我们可以通过mqadmin命令行工具的consumerConnection子命令,来查看ConsumerManager中,某个消费者的信息,如:
点击添加图片描述(最多60个字)编辑
输出主要分为2个部分:
- 消费者组实例信息:展示了groupA下当前有2个消费者,以及对应的详细信息,包括:消费者id,消费者ip/port,消费者语言,消费者版本。
- 消费者组订阅信息:包括订阅的Topic,过滤条件,消费模式,以及从什么位置开始消费等。
这二者不论哪个信息发生变化,Broker都会主动通知这个消费者组下的所有实例进行Rebalance。在ConsumerManager的registerConsumer方法中,我们可以看到这个通知机制。如以下源码片段第四步中所示:
ConsumerManager#registerConsumer
- public boolean registerConsumer(final String group, final ClientChannelInfo clientChannelInfo,
- ConsumeType consumeType, MessageModel messageModel, ConsumeFromWhere consumeFromWhere,
- final Set subList, boolean isNotifyConsumerIdsChangedEnable) {
- //1 查找consumer group信息,如果没有,创建一个新的
- ConsumerGroupInfo consumerGroupInfo = this.consumerTable.get(group);
- if (null == consumerGroupInfo) {
- ConsumerGroupInfo tmp = new ConsumerGroupInfo(group, consumeType,
- messageModel, consumeFromWhere);
- ConsumerGroupInfo prev = this.consumerTable.putIfAbsent(group