前 言

消息队列是服务端必不可少的组件,其中Kafka可以说是数一数二的选择,对于大部分服务端的同学来说Kafka也是最熟悉的消息中间件之一。而当我们在生产上遇到kafka的使用问题时想要透过现象看到问题的本质,从而找到解决问题的办法。

这就要求对kafka的设计和实现有这较为深刻的认识。在这篇文章里我们就以生产实际的例子来展开讨论Kafka在消费端中的一个重要设计consumer group的rebalance。

只有理解了rebalance我们才能对消息消费过程有着更全面的掌握。

某一天我们收到消费端消费严重落后生产的告警。第一时间相关同学去看了consumer group的消费曲线监控,消费速率明显出现异常。下面这张示意图展示了这种情况。

kafka慢 kafka慢消费_java

我们能清楚的看到整个消费组在消费异常的时间段内经常出现消费停滞的情况如图上消费速率为0。

为什么消费会卡主呢?同时去看了相关服务的日志看到很多err kafka data maybe rebalancing。看了这篇文章后消费卡主的问题自然就知道答案了。

重要概念

为了说清楚 rebalance 有必要把最相关的重要概念回顾一下

| Consumer Group

consumer group是kafka提供的可扩展且具有容错性的消费者机制。既然是一个组,那么组内必然可以有多个消费者或消费者实例(consumer instance),它们共享一个公共的 ID,即group ID。

组内的所有消费者协调在一起来消费topic下的所有分区。总结一下就是以下几个关键点。

1. consumer group下可以有一个或多个consumer instance,consumer instance可以是一个进程,也可以是一个线程

2. group.id是一个字符串,唯一标识一个consumer group

3. consumer group订阅的topic下的每个分区只能分配给某个group下的一个consumer (当然该分区还可以被分配给其他group)

kafka慢 kafka慢消费_kafka慢_02

| Coordinator

Group Coordinator是一个服务,每个Broker在启动的时候都会启动一个该服务。Group Coordinator的作用是用来存储Group的相关Meta信息,并将对应Partition的 Offset信息记录到Kafka内置Topic(__consumer_offsets)中。

每个Group都会选择一个Coordinator来完成自己组内各Partition的Offset信息,选择的规则如下:

1. 计算Group对应在__consumer_offsets 上的Partition

2. 根据对应的Partition寻找该Partition的leader所对应的Broker,该Broker上的 Group Coordinator即就是该Group的Coordinator

Partition计算规则:

partition-Id(__consumer_offsets) = Math.abs(groupId.hashCode()%groupMetadataTopicPartitionCount)

其中groupMetadataTopicPartitionCount对应offsets.topic.num.partitions参数值,默认值是50个分区。

Rebalance 目的

我们知道topic的partition已经根据策略分配给了consumer group下的各个consumer。

那么当有新的consumer加入或者老的consumer离开这个partition与consumer的分配关系就会发生变化,如果这个时候不进行重新调配,就可能出现新consumer无partition消费或者有partition无消费者的情况。

那么这个重新调配指的就是consumer和partition rebalance。

consumer默认提供了2种分配策略:

range策略:将单个topic的分区按顺序排列,然后把这些分区划分成固定大小的分区段并依次分配给每个consumer。

round-robin:把topc的所有分区按顺序排开,以轮询的方式配给每个consumer。

下面给一个简单的例子,假设目前某个consumer group A有2个consumer C1和C2,当C3加入时,触发了rebalance条件,coordinator会进行rebalance,根据range策略重新分配了partition。

kafka慢 kafka慢消费_kafka慢_03

Rebalance 时机

Rebalance在以下情况会触发

1. consume group中的成员个数发生变化。例如有新的consumer实例加入该消费组或者离开组

2. 订阅Topic的分区数发生变化

3. 取消订阅Topic或新增订阅Topic

Rebalance 过程

kafka中的重要设计也会随着版本的升级而优化。rebalance也不例外,这里我们介绍的kafka rebalance流程以我们的线上版本1.1.1为例。

1. 当前consumer准备加入consumer group或GroupCoordinator发生故障转移时,consumer并不知道GroupCoordinator的host和port,所以consumer会向Kafka集群中的任一broker节点发送FindCoordinatorRequest请求,

收到请求的 broker 节点会返回ConsumerMetadataResponse响应,其中就包含了负责管理该Consumer Group的GroupCoordinator的地址

2. 当consumer通过FindCoordinatorRequest查找到其Consumer Group对应的 GroupCoordinator之后,就会进入Join Group阶段

3. Consumer先向GroupCoordinator发送JoinGroupRequest请求,其中包含consumer的相关信息

4. GroupCoordinator收到JoinGroupRequest后会暂存该 consumer信息,然后等待全部consumer的JoinGroupRequest请求。

JoinGroupRequest中的session.timeout.ms和rebalance_timeout_ms(max.poll.interval.ms)决定了consumer如果没有响应过多久会被踢出出组

kafka慢 kafka慢消费_Group_04

5. GroupCoordinator会根据全部consumer的JoinGroupRequest请求来确定 Consumer Group中可用的consumer,从中选取一个consumer成为Group Leader,

同时还会决定partition分配策略,最后会将这些信息封装成JoinGroupResponse 返回给Group Leader Consumer

6. 每个consumer 都会收到JoinGroupResponse 响应,但是只有Group Leader 收到的JoinGroupResponse响应中封装的所有consumer 信息以及Group Leader信息。

当其中一个consumer确定了自己的Group Leader后,会根据consumer 信息、kafka 集群元数据以及partition分配策略计算partition的分片结果。

其他非Group Leader consumer收到 JoinResponse为空响应,也就不会进行任何操作,只是原地等待

kafka慢 kafka慢消费_kafka慢_05

7. 接下来,所有consumer进入Synchronizing Group State阶段,所有consumer会向GroupCoordinator发送SyncGroupRequest

Group Leader Consumer的SyncGroupRequest请求包含了partition分配结果,普通consumer的SyncGroupRequest为空请求

kafka慢 kafka慢消费_kafka_06

8. GroupCoordinator接下来会将partition分配结果封装成SyncGroupResponse返回给所有consumer, consumer收到SyncGroupResponse后进行解析,就可以明确partition与consumer的映射关系

kafka慢 kafka慢消费_分布式_07

9. 后续consumer还是会与GroupCoordinator保持定期的心跳(heartbeat.interval.ms)。当rebalance正在进行中coordinator会通过hearbeat response告诉consumers是否要rejoin group触发。即心跳响应中包含 IllegalGeneration异常

kafka慢 kafka慢消费_kafka_08

Rebalance 问题

在整个rebalance的过程中,所有partition都会被回收,consumer是无法消费任何 partition的。Join阶段会等待原先组内存活的成员发送JoinGroupRequest过来,如果原先组内的成员因为业务处理一直没有发送请求过来,服务端就会一直等待,直到超时。

这个超时时间就是max.poll.interval.ms的值,默认是5分钟,因此这种情况下rebalance的耗时就会长达5分钟,导致所有消费者都无法进行正常消费,这对生产来说是个很大的问题。

Rebalance 改进

| Static Membership

为了减少因为consumer短暂不可用造成的rebalance,kafka在2.3版本中引入了Static Membership。

Static Membership优化的核心是:

1. 在consumer端增加group.instance.id配置(group.instance.id 是 consumer 的唯一标识)。如果consumer启动的时候明确指定了group.instance.id配置值,consumer会JoinGroup Request中携带该值,表示该consumer为static member。为了保证group.instance.id的唯一性,我们可以考虑使用hostname、ip等。

2. 在GroupCoordinator端会记录group.instance.id → member.id的映射关系,以及已有的 partition 分配关系。

当 GroupCoordinator 收到已知 group.instance.id 的 consumer 的 JoinGroup Request 时,不会进行 rebalance,而是将其原来对应的 partition 分配给它。

Static Membership 可以让 consumer group 只在下面的 4 种情况下进行 rebalance:

有新consumer加入consumer group

Group Leader重新加入Group时

consumer下线时间超过阈值

   session.timeout.ms

GroupCoordinator收到static member的

   LeaveGroup Request

这样的话,在使用Static Membership场景下,只要在consumer重新启动的时候,不发送LeaveGroup Request且在session.timeout.ms时长内重启成功,就不会触发rebalance。所以,这里推荐设置一个足够consumer重启的时长session.timeout.ms,这样能有效降低因consumer短暂不可用导致的reblance次数。

| Incremental Cooperative Rebalancing

从名字中我们就能看出这个版本的rebalance过程两个关键词增量和协作,增量指的是原先版本的rebalance被分解成了多次小规模的rebalance, 协作自然指的是consumer 之间的关系。核心思想:

1. consumer比较新旧两个partition分配结果,只停止消费回收(revoke)的partition,对于两次都分配给自己的partition,consumer不需要停止消费

2. 通过多轮的局部 rebalance 来最终实现全局的 rebalance

我们以文章开始的例子来理解一下这个版本的改进

首先C1 -> {P0, p3},C2 -> {P1}, C3 -> {P2}这是consumer和partition的分配关系,我们假设C2宕机超过了session.timeout.ms, 此时GroupCoordinator会触发第一轮 rebalance

| 第一轮 Rebalance

1. GroupCoordinator会在下一轮心跳响应中通知C1和C3发起第一轮rebalance

2. C1和C3会将自己当前正在处理的partition信息封装到JoinGroupRequest中(metadata字段)发往GroupCoordinator:

C1 发送的 JoinGroupRequest(assigned: P0、P3)

C3 发送的 JoinGroupRequest(assigned: P2)

3. 假设GroupCoordinator在这里选择1作为Group Leader,GroupCoordinator会将 partition目前的分配状态通过JoinGroupResponse发送给C1

4. C1发现P1并未出现(处于lost状态),此时C1并不会立即解决当前的不平衡问题,返回的partition分配结果不变(同时会携带一个delay时间,scheduled.rebalance.max.delay.ms,默认5分钟)。

GroupCoordinator据C1的SyncGroupRequest,生成SyncGroupResponse返回给两个存活的consumer

C1 收到的SyncGroup Response(delay,assigned: P0、P3,revoked:)

C3 收到的SyncGroup Response(delay,assigned: P2,revoked:)

到此为止,第一轮rebalance结束。整个rebalance过程中,C1和C3并不会停止消费。

| 第二轮 Rebalance

1. 在scheduled.rebalance.max.delay.ms这个时间段内,C2故障恢复,重新加入到 consumer group时,会向GroupCoordinator发送JoinGroup Request,触发第二轮的rebalance。

GroupCoordinator在下一次心跳响应中会通知C1和C3参与第二轮rebalance

2. C1和C3收到心跳后,发送JoinGroupRequest参与第二轮rebalance:

C1 发送的JoinGroupRequest(assigned: P0、P3)

C3 发送的JoinGroupRequest(assigned: P2)

3. 本轮中,C1依旧被选为Group Leader,它检查delay时间(scheduled.rebalance.max.delay.ms)是否已经到了,如果没到,则依旧不会立即解决当前的不平衡问题,继续返回目前的分配结果,并且返回的SyncGroupResponse 中更新了delay的剩余时间(remaining delay = delay - pass_time) 到此为止,第二轮 rebalance结束。

整个rebalance过程中,C1和C3并不会停止消费。

C1收到的SyncGroup Response(remaining delay,assigned:P0、P3,revoked:)

C2收到的SyncGroup Response(remaining delay,assigned:,revoked:)

C3收到的SyncGroup Response(remaining delay,assigned:P2,revoked:)

| 第三轮 Rebalance

1. 当remaining delay时间到期之后,consumer全部重新送JoinGroupRequest,触发第三轮rebalance

C1 发送的JoinGroupRequest(assigned: P0、P3)

C2 发送的JoinGroupRequest(assigned: )

C3 发送的JoinGroupRequest(assigned: P2)

2. 在此次rebalance中,C1依旧被选为Group Leader,它会发现delay已经到期了,开始解决不平衡的问题,对partition进行重新分配。最新的分配结果最终通过SyncGroupResponse返回到各个consumer:

到此为止,第三轮rebalance结束。整个rebalance过程中,C1和C3的消费都不会停止

C1收到的SyncGroup Response(assigned:P0、P3,revoked:)

C2收到的SyncGroup Response(assigned:P1,revoked:)

C3收到的SyncGroup Response(assigned:P2,revoked:)

下面这张图展示了上述的Rebalance过程

kafka慢 kafka慢消费_分布式_09

通过上述我们应该对Kafka的Rebalance有了比较完整的认识。我们现在来回答文章开始提出的消费卡主问题:消费端拿到了异常的消息,这样的消息业务上处理时间过超过了max.poll.interval.ms, 从而触发了rebalance, 在rebalance过程中所有消费者都暂停了消费。

为了解决这个问题我们首先优化业务逻辑尽可能提高处理消息速度,对异常消息做特殊处理;然后合理的设置max.poll.interval.ms cover住业务的处理时间。

为了尽可能减少Rebalance次数我们也要注意设置session.timeout.ms和heartbeat.interval.ms的值。一种推荐的方案session.timeout.ms >= 3 * heartbeat.interval.ms, 比如session.timeout.ms = 6s; heartbeat.interval.ms = 2s。这样consumer如果宕机且6s之内未恢复, Coordinator能够较快地定位已经挂掉的 consumer,把它踢出Group。