This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,304 @@
<audio id="audio" title="27 | 消费者组元数据(上):消费者组都有哪些元数据?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bd/04/bd23f952e3fe9e8d397ae69fddac8c04.mp3"></audio>
你好,我是胡夕。从今天这节课开始,我们进入到最后一个模块的源码学习:消费者组管理模块。
在这个模块中我将会带你详细阅读Kafka消费者组在Broker端的源码实现包括消费者组元数据的定义与管理、组元数据管理器、内部主题__consumer_offsets和重要的组件GroupCoordinator。
我先来给你简单介绍下这4部分的功能以方便你对消费者组管理有一个大概的理解。
- 消费者组元数据这部分源码主要包括GroupMetadata和MemberMetadata。这两个类共同定义了消费者组的元数据都由哪些内容构成。
- 组元数据管理器由GroupMetadataManager类定义可被视为消费者组的管理引擎提供了消费者组的增删改查功能。
- __consumer_offsetsKafka的内部主题。除了我们熟知的消费者组提交位移记录功能之外它还负责保存消费者组的注册记录消息。
- GroupCoordinator组协调者组件提供通用的组成员管理和位移管理。
我把这4部分源码的功能梳理到了一张思维导图中你可以保存下来随时查阅
<img src="https://static001.geekbang.org/resource/image/2e/d8/2e2f823737fe479a853204a1cb5f8dd8.jpg" alt="">
今天,我们首先学习消费者组元数据的源码实现,这是我们理解消费者组工作机制和深入学习消费者组管理组件的基础。除此之外,掌握这部分代码对我们还有什么实际意义吗?
当然有了。我想你肯定对下面这条命令不陌生吧它是查询消费者组状态的命令行工具。我们在输出中看到了GROUP、COORDINATOR、ASSIGNMENT-STRATEGY、STATE和MEMBERS等数据。实际上这些数据就是消费者组元数据的一部分。
```
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group mygroup --verbose --state
GROUP COORDINATOR (ID) ASSIGNMENT-STRATEGY STATE #MEMBERS
mygroup 172.25.4.76:9092 (0) range Stable 2
```
所以你看,我们日常使用的命令和源码知识的联系是非常紧密的,弄明白了今天的内容,之后你在实际使用一些命令行工具时,就能理解得更加透彻了。
好了,我们现在就正式开始今天的学习吧。
就像我前面说的元数据主要是由GroupMetadata和MemberMetadata两个类组成它们分别位于GroupMetadata.scala和MemberMetadata.scala这两个源码文件中。从它们的名字上也可以看出来前者是保存消费者组的元数据后者是保存消费者组下成员的元数据。
由于一个消费者组下有多个成员因此一个GroupMetadata实例会对应于多个MemberMetadata实例。接下来我们先学习下MemberMetadata.scala源文件。
## 组成员元数据MemberMetadata
MemberMetadata.scala源文件位于coordinator.group包下。事实上coordinator.group包下的所有源代码都是与消费者组功能息息相关的。下图是coordinator.group包的源码文件列表你可以看到MemberMetdata.scala和稍后我们要学到的GroupMetadata.scala都在其中。
<img src="https://static001.geekbang.org/resource/image/26/49/263b1a4cd251bcd312a2c1a640167049.png" alt="">
从这个包结构图中我们还可以发现后面几节课中要学习的源码类比如GroupCoordinator、GroupMetadataManager也都在里面。当然了你一定也发现了coordinator包下还有个transcation包里面保存了Kafka事务相关的所有源代码。如果你想深入学习事务机制的话可以去阅读下这个包下的源代码。
现在我们聚焦到MemberMetadata.scala文件包括3个类和对象。
- MemberSummary类组成员概要数据提取了最核心的元数据信息。上面例子中工具行命令返回的结果就是这个类提供的数据。
- MemberMetadata伴生对象仅仅定义了一个工具方法供上层组件调用。
- MemberMetadata类消费者组成员的元数据。Kafka为消费者组成员定义了很多数据一会儿我们将会详细学习。
按照难易程度我们从最简单的MemberSummary类开始学起。
### MemberSummary类
MemberSummary类就是组成员元数据的一个概要数据类它的代码本质上是一个POJO类仅仅承载数据没有定义任何逻辑。代码如下
```
case class MemberSummary(
memberId: String, // 成员ID由Kafka自动生成
groupInstanceId: Option[String], // Consumer端参数group.instance.id值
clientId: String, // client.id参数值
clientHost: String, // Consumer端程序主机名
metadata: Array[Byte], // 消费者组成员使用的分配策略
assignment: Array[Byte]) // 成员订阅分区
```
可以看到这个类定义了6个字段我来详细解释下。
- memberId标识消费者组成员的ID这个ID是Kafka自动生成的规则是consumer-组ID-&lt;序号&gt;-<uuid>。虽然现在社区有关于是否放开这个限制的讨论即是否允许用户自己设定这个ID但目前它还是硬编码的不能让你设置。</uuid>
- groupInstanceId消费者组静态成员的ID。静态成员机制的引入能够规避不必要的消费者组Rebalance操作。它是非常新且高阶的功能这里你只要稍微知道它的含义就可以了。如果你对此感兴趣建议你去官网看看group.instance.id参数的说明。
- clientId消费者组成员配置的client.id参数。由于memberId不能被设置因此你可以用这个字段来区分消费者组下的不同成员。
- clientHost运行消费者程序的主机名。它记录了这个客户端是从哪台机器发出的消费请求。
- metadata标识消费者组成员分区分配策略的字节数组由消费者端参数partition.assignment.strategy值设定默认的RangeAssignor策略是按照主题平均分配分区。
- assignment保存分配给该成员的订阅分区。每个消费者组都要选出一个Leader消费者组成员负责给所有成员分配消费方案。之后Kafka将制定好的分配方案序列化成字节数组赋值给assignment分发给各个成员。
总之MemberSummary类是成员概要数据的容器类似于Java中的POJO类不涉及任何操作逻辑所以还是很好理解的。
### MemberMetadata伴生对象
接下来我们学习MemberMetadata伴生对象的代码。它只定义了一个plainProtocolSet方法供上层组件调用。这个方法只做一件事儿即从一组给定的分区分配策略详情中提取出分区分配策略的名称并将其封装成一个集合对象然后返回
```
private object MemberMetadata {
// 提取分区分配策略集合
def plainProtocolSet(supportedProtocols: List[(String, Array[Byte])]) = supportedProtocols.map(_._1).toSet
}
```
我举个例子说明下。如果消费者组下有3个成员它们的partition.assignment.strategy参数分别设置成RangeAssignor、RangeAssignor和RoundRobinAssignor那么plainProtocolSet方法的返回值就是集合[RangeAssignorRoundRobinAssignor]。实际上,它经常被用来统计一个消费者组下的成员到底配置了多少种分区分配策略。
### MemberMetadata类
现在我们看下MemberMetadata类的源码。首先我们看下**该类的构造函数以及字段定义**,了解下一个成员的元数据都有哪些。
```
@nonthreadsafe
private[group] class MemberMetadata(
var memberId: String,
val groupId: String,
val groupInstanceId: Option[String],
val clientId: String,
val clientHost: String,
val rebalanceTimeoutMs: Int, // Rebalane操作超时时间
val sessionTimeoutMs: Int, // 会话超时时间
val protocolType: String, // 对消费者组而言,是&quot;consumer&quot;
// 成员配置的多套分区分配策略
var supportedProtocols: List[(String, Array[Byte])]) {
// 分区分配方案
var assignment: Array[Byte] = Array.empty[Byte]
var awaitingJoinCallback: JoinGroupResult =&gt; Unit = null
var awaitingSyncCallback: SyncGroupResult =&gt; Unit = null
var isLeaving: Boolean = false
var isNew: Boolean = false
val isStaticMember: Boolean = groupInstanceId.isDefined
var heartbeatSatisfied: Boolean = false
......
}
```
MemberMetadata类保存的数据很丰富在它的构造函数中除了包含MemberSummary类定义的6个字段外还定义了4个新字段。
- rebalanceTimeoutMsRebalance操作的超时时间即一次Rebalance操作必须在这个时间内完成否则被视为超时。这个字段的值是Consumer端参数max.poll.interval.ms的值。
- sessionTimeoutMs会话超时时间。当前消费者组成员依靠心跳机制“保活”。如果在会话超时时间之内未能成功发送心跳组成员就被判定成“下线”从而触发新一轮的Rebalance。这个字段的值是Consumer端参数session.timeout.ms的值。
- protocolType直译就是协议类型。它实际上标识的是消费者组被用在了哪个场景。这里的场景具体有两个第一个是作为普通的消费者组使用该字段对应的值就是consumer第二个是供Kafka Connect组件中的消费者使用该字段对应的值是connect。当然不排除后续社区会增加新的协议类型。但现在你只要知道它是用字符串的值标识应用场景就足够了。除此之外该字段并无太大作用。
- supportedProtocols标识成员配置的多组分区分配策略。目前Consumer端参数partition.assignment.strategy的类型是List说明你可以为消费者组成员设置多组分配策略因此这个字段也是一个List类型每个元素是一个元组Tuple。元组的第一个元素是策略名称第二个元素是序列化后的策略详情。
除了构造函数中的10个字段之外该类还定义了7个额外的字段用于保存元数据和判断状态。这些扩展字段都是var型变量说明它们的值是可以变更的。MemberMetadata源码正是依靠这些字段来不断地调整组成员的元数据信息和状态。
我选择了5个比较重要的扩展字段和你介绍下。
- assignment保存分配给该成员的分区分配方案。
- awaitingJoinCallback表示组成员是否正在等待加入组。
- awaitingSyncCallback表示组成员是否正在等待GroupCoordinator发送分配方案。
- isLeaving表示组成员是否发起“退出组”的操作。
- isNew表示是否是消费者组下的新成员。
以上就是MemberMetadata类的构造函数以及字段定义了。它定义的方法都是操作这些元数据的而且大多都是逻辑很简单的操作。这里我选取metadata方法带你熟悉下它们的编码实现风格。你可以在课后自行阅读其他的方法代码掌握它们的工作原理。
我们看下metadata方法的代码
```
def metadata(protocol: String): Array[Byte] = {
// 从配置的分区分配策略中寻找给定策略
supportedProtocols.find(_._1 == protocol) match {
case Some((_, metadata)) =&gt; metadata
case None =&gt;
throw new IllegalArgumentException(&quot;Member does not support protocol&quot;)
}
}
```
它实现的逻辑是:从该成员配置的分区分配方案列表中寻找给定策略的详情。如果找到,就直接返回详情字节数组数据,否则,就抛出异常。怎么样,是不是很简单?
## 组元数据GroupMetadata类
说完了组成员元数据类我们进入到组元数据类GroupMetadata的学习。它位于coordinator.group包下的同名scala文件下。
GroupMetadata管理的是消费者组而不是消费者组成员级别的元数据所以它的代码结构要比MemberMetadata类复杂得多。我先画一张思维导图帮你梳理下它的代码结构。
<img src="https://static001.geekbang.org/resource/image/c8/yy/c860782ca34a103efef3e6d0147ce5yy.jpg" alt="">
总体上来看GroupMetadata.scala文件由6部分构成。
- GroupState类定义了消费者组的状态空间。当前有5个状态分别是Empty、PreparingRebalance、CompletingRebalance、Stable和Dead。其中Empty表示当前无成员的消费者组PreparingRebalance表示正在执行加入组操作的消费者组CompletingRebalance表示等待Leader成员制定分配方案的消费者组Stable表示已完成Rebalance操作可正常工作的消费者组Dead表示当前无成员且元数据信息被删除的消费者组。
- GroupMetadata类组元数据类。这是该scala文件下最重要的类文件也是我们今天要学习的重点内容。
- GroupMetadata伴生对象该对象提供了创建GroupMetadata实例的方法。
- GroupOverview类定义了非常简略的消费者组概览信息。
- GroupSummary类与MemberSummary类类似它定义了消费者组的概要信息。
- CommitRecordMetadataAndOffset类保存写入到位移主题中的消息的位移值以及其他元数据信息。这个类的主要职责就是保存位移值因此我就不展开说它的详细代码了。
接下来我们依次看下这些代码结构中都保存了哪些元数据信息。我们从最简单的GroupState类开始。
### GroupState类及实现对象
GroupState类定义了消费者组的状态。这个类及其实现对象Stable的代码如下
```
// GroupState trait
private[group] sealed trait GroupState {
// 合法前置状态
val validPreviousStates: Set[GroupState]
}
// Stable状态
private[group] case object Stable extends GroupState {
val validPreviousStates: Set[GroupState] = Set(CompletingRebalance)
}
......
```
这里我只展示了Stable状态的代码其他4个状态的代码都差不多。为了方便你理解消费者组之间的状态流转我绘制了一张完整的状态流转图。
<img src="https://static001.geekbang.org/resource/image/47/85/479e5a91684b6eb625104a894d353585.jpg" alt="">
你需要记住的是一个消费者组从创建到正常工作它的状态流转路径是Empty -&gt; PreparingRebalance -&gt; CompletingRebalance -&gt; Stable。
### GroupOverview类
接下来我们看下GroupOverview类的代码。就像我刚才说的这是一个非常简略的组概览信息。当我们在命令行运行kafka-consumer-groups.sh --list的时候Kafka就会创建GroupOverview实例返回给命令行。
我们来看下它的代码:
```
case class GroupOverview(
groupId: String, // 组ID信息即group.id参数值
protocolType: String, // 消费者组的协议类型
state: String) // 消费者组的状态
```
怎么样很简单吧。GroupOverview类封装了最基础的组数据包括组ID、协议类型和状态信息。如果你熟悉Java Web开发的话可以把GroupOverview和GroupMetadata的关系理解为DAO和DTO的关系。
### GroupSummary类
它的作用和GroupOverview非常相似只不过它保存的数据要稍微多一点。我们看下它的代码
```
case class GroupSummary(
state: String, // 消费者组状态
protocolType: String, // 协议类型
protocol: String, // 消费者组选定的分区分配策略
members: List[MemberSummary]) // 成员元数据
```
GroupSummary类有4个字段它们的含义也都很清晰看字段名就能理解。你需要关注的是**members字段**它是一个MemberSummary类型的列表里面保存了消费者组所有成员的元数据信息。通过这个字段我们可以看到**消费者组元数据和组成员元数据是1对多的关系**。
### GroupMetadata类
最后我们看下GroupMetadata类的源码。我们先看下该类构造函数所需的字段和自定义的扩展元数据
```
@nonthreadsafe
private[group] class GroupMetadata(
val groupId: String, // 组ID
initialState: GroupState, // 消费者组初始状态
time: Time) extends Logging {
type JoinCallback = JoinGroupResult =&gt; Unit
// 组状态
private var state: GroupState = initialState
// 记录状态最近一次变更的时间戳
var currentStateTimestamp: Option[Long] = Some(time.milliseconds())
var protocolType: Option[String] = None
var protocolName: Option[String] = None
var generationId = 0
// 记录消费者组的Leader成员可能不存在
private var leaderId: Option[String] = None
// 成员元数据列表信息
private val members = new mutable.HashMap[String, MemberMetadata]
// 静态成员Id列表
private val staticMembers = new mutable.HashMap[String, String]
private var numMembersAwaitingJoin = 0
// 分区分配策略支持票数
private val supportedProtocols = new mutable.HashMap[String, Integer]().withDefaultValue(0)
// 保存消费者组订阅分区的提交位移值
private val offsets = new mutable.HashMap[TopicPartition, CommitRecordMetadataAndOffset]
// 消费者组订阅的主题列表
private var subscribedTopics: Option[Set[String]] = None
......
}
```
GroupMetadata类定义的字段非常多也正因为这样它保存的数据是最全的绝对担得起消费者组元数据类的称号。
除了我们之前反复提到的字段外它还定义了很多其他字段。不过有些字段要么是与事务相关的元数据要么是属于中间状态的临时元数据不属于核心的元数据我们不需要花很多精力去学习它们。我们要重点关注的是上面的代码中所展示的字段这些是GroupMetadata类最重要的字段。
- currentStateTimestamp记录最近一次状态变更的时间戳用于确定位移主题中的过期消息。位移主题中的消息也要遵循Kafka的留存策略所有当前时间与该字段的差值超过了留存阈值的消息都被视为“已过期”Expired
- generationId消费组Generation号。Generation等同于消费者组执行过Rebalance操作的次数每次执行Rebalance时Generation数都要加1。
- leaderId消费者组中Leader成员的Member ID信息。当消费者组执行Rebalance过程时需要选举一个成员作为Leader负责为所有成员制定分区分配方案。在Rebalance早期阶段这个Leader可能尚未被选举出来。这就是leaderId字段是Option类型的原因。
- members保存消费者组下所有成员的元数据信息。组元数据是由MemberMetadata类建模的因此members字段是按照Member ID分组的MemberMetadata类。
- offsets保存按照主题分区分组的位移主题消息位移值的HashMap。Key是主题分区Value是前面讲过的CommitRecordMetadataAndOffset类型。当消费者组成员向Kafka提交位移时源码都会向这个字段插入对应的记录。
- subscribedTopics保存消费者组订阅的主题列表用于帮助从offsets字段中过滤订阅主题分区的位移值。
- supportedProtocols保存分区分配策略的支持票数。它是一个HashMap类型其中Key是分配策略的名称Value是支持的票数。前面我们说过每个成员可以选择多个分区分配策略因此假设成员A选择[“range”“round-robin”]、B选择[“range”]、C选择[“round-robin”“sticky”]那么这个字段就有3项分别是&lt;“range”2&gt;&lt;“round-robin”2&gt;&lt;“sticky”1&gt;
这些扩展字段和构造函数中的字段,共同构建出了完整的消费者组元数据。就我个人而言,我认为这些字段中最重要的就是**members和offsets**它们分别保存了组内所有成员的元数据以及这些成员提交的位移值。这样看的话这两部分数据不就是一个消费者组最关心的3件事吗**组里面有多少个成员**、**每个成员都负责做什么**、**它们都做到了什么程度**。
## 总结
今天我带你深入到了GroupMetadata.scala和MemberMetadata.scala这两个源码文件中学习了消费者组元数据和组成员元数据的定义。它们封装了一个消费者组及其成员的所有数据。后续的GroupCoordinator和其他消费者组组件都会大量利用这部分元数据执行消费者组的管理。
为了让你更好地掌握今天的内容,我们来回顾下这节课的重点。
- 消费者组元数据包括组元数据和组成员元数据两部分分别由GroupMetadata和MemberMetadata类表征。
- MemberMetadata类保存组成员元数据比如组ID、Consumer主机名、协议类型等。同时它还提供了MemberSummary类封装了组成员元数据的概要信息。
- GroupMetadata类保存组元数据包括组状态、组成员元数据列表等等。
- 1对多关系组元数据与组成员元数据是1对多的关系。这是因为每个消费者组下存在若干个组成员。
<img src="https://static001.geekbang.org/resource/image/14/1f/14a63dda57facee5f686ea539848131f.jpg" alt="">
今天这节课的逻辑不是特别复杂我们重点学习了消费者组元数据的构成几乎未曾涉及元数据的操作。在下节课我们将继续在这两个scala文件中探索去学习操作这些元数据的方法实现。
但我要再次强调的是,今天学习的这些方法是上层组件调用的基础。如果你想彻底了解消费者组的工作原理,就必须先把这部分基础“铺平夯实”了,这样你才能借由它们到达“完全掌握消费者组实现源码”的目的地。
## 课后讨论
请你思考下这节课最开始的工具行命令输出中的ASSIGNMENT-STRATEGY项对应于咱们今天学习的哪一项元数据呢
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,388 @@
<audio id="audio" title="28 | 消费者组元数据Kafka如何管理这些元数据" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/12/84/129fd863d8d850f4ab5b6a0603e02784.mp3"></audio>
你好,我是胡夕。今天我们继续学习消费者组元数据。
学完上节课之后我们知道Kafka定义了非常多的元数据那么这就必然涉及到对元数据的管理问题了。
这些元数据的类型不同管理策略也就不一样。这节课我将从消费者组状态、成员、位移和分区分配策略四个维度对这些元数据进行拆解带你一起了解下Kafka管理这些元数据的方法。
这些方法定义在MemberMetadata和GroupMetadata这两个类中其中GroupMetadata类中的方法最为重要是我们要重点学习的对象。在后面的课程中你会看到这些方法会被上层组件GroupCoordinator频繁调用因此它们是我们学习Coordinator组件代码的前提条件你一定要多花些精力搞懂它们。
## 消费者组状态管理方法
消费者组状态是很重要的一类元数据。管理状态的方法,要做的事情也就是设置和查询。这些方法大多比较简单,所以我把它们汇总在一起,直接介绍给你。
```
// GroupMetadata.scala
// 设置/更新状态
def transitionTo(groupState: GroupState): Unit = {
assertValidTransition(groupState) // 确保是合法的状态转换
state = groupState // 设置状态到给定状态
currentStateTimestamp = Some(time.milliseconds() // 更新状态变更时间戳
// 查询状态
def currentState = state
// 判断消费者组状态是指定状态
def is(groupState: GroupState) = state == groupState
// 判断消费者组状态不是指定状态
def not(groupState: GroupState) = state != groupState
// 消费者组能否Rebalance的条件是当前状态是PreparingRebalance状态的合法前置状态
def canRebalance = PreparingRebalance.validPreviousStates.contains(state)
```
**1.transitionTo方法**
transitionTo方法的作用是**将消费者组状态变更成给定状态**。在变更前代码需要确保这次变更必须是合法的状态转换。这是依靠每个GroupState实现类定义的**validPreviousStates集合**来完成的。只有在这个集合中的状态,才是合法的前置状态。简单来说,只有集合中的这些状态,才能转换到当前状态。
同时,该方法还会**更新状态变更的时间戳字段**。Kafka有个定时任务会定期清除过期的消费者组位移数据它就是依靠这个时间戳字段来判断过期与否的。
**2.canRebalance方法**
它用于判断消费者组是否能够开启Rebalance操作。判断依据是**当前状态是否是PreparingRebalance状态的合法前置状态**。只有**Stable**、**CompletingRebalance**和**Empty**这3类状态的消费者组才有资格开启Rebalance。
**3.is和not方法**
至于is和not方法它们分别判断消费者组的状态与给定状态吻合还是不吻合主要被用于**执行状态校验**。特别是is方法被大量用于上层调用代码中执行各类消费者组管理任务的前置状态校验工作。
总体来说,管理消费者组状态数据,依靠的就是这些方法,还是很简单的吧?
## 成员管理方法
在介绍管理消费者组成员的方法之前我先帮你回忆下GroupMetadata中保存成员的字段。GroupMetadata中使用members字段保存所有的成员信息。该字段是一个HashMapKey是成员的member ID字段Value是MemberMetadata类型该类型保存了成员的元数据信息。
所谓的管理成员也就是添加成员add方法、移除成员remove方法和查询成员has、get、size方法等。接下来我们就逐一来学习。
### 添加成员
先说添加成员的方法add。add方法的主要逻辑是将成员对象添加到members字段同时更新其他一些必要的元数据比如Leader成员字段、分区分配策略支持票数等。下面是add方法的源码
```
def add(member: MemberMetadata, callback: JoinCallback = null): Unit = {
// 如果是要添加的第一个消费者组成员
if (members.isEmpty)
// 就把该成员的procotolType设置为消费者组的protocolType
this.protocolType = Some(member.protocolType)
// 确保成员元数据中的groupId和组Id相同
assert(groupId == member.groupId)
// 确保成员元数据中的protoclType和组protocolType相同
assert(this.protocolType.orNull == member.protocolType)
// 确保该成员选定的分区分配策略与组选定的分区分配策略相匹配
assert(supportsProtocols(member.protocolType, MemberMetadata.plainProtocolSet(member.supportedProtocols)))
// 如果尚未选出Leader成员
if (leaderId.isEmpty)
// 把该成员设定为Leader成员
leaderId = Some(member.memberId)
// 将该成员添加进members
members.put(member.memberId, member)
// 更新分区分配策略支持票数
member.supportedProtocols.foreach{ case (protocol, _) =&gt; supportedProtocols(protocol) += 1 }
// 设置成员加入组后的回调逻辑
member.awaitingJoinCallback = callback
// 更新已加入组的成员数
if (member.isAwaitingJoin)
numMembersAwaitingJoin += 1
}
```
我再画一张流程图,帮助你更直观地理解这个方法的作用。
<img src="https://static001.geekbang.org/resource/image/67/d5/679b34908b9d3cf7e10905fcf96e69d5.jpg" alt="">
我再具体解释一下这个方法的执行逻辑。
第一步add方法要判断members字段是否包含已有成员。如果没有就说明要添加的成员是该消费者组的第一个成员那么就令该成员协议类型protocolType成为组的protocolType。我在上节课中讲过对于普通的消费者而言protocolType就是字符串"consumer"。如果不是首个成员,就进入到下一步。
第二步add方法会连续进行三次校验分别确保**待添加成员的组ID、protocolType**和组配置一致,以及该成员选定的分区分配策略与组选定的分区分配策略相匹配。如果这些校验有任何一个未通过,就会立即抛出异常。
第三步判断消费者组的Leader成员是否已经选出了。如果还没有选出就将该成员设置成Leader成员。当然了如果Leader已经选出了自然就不需要做这一步了。需要注意的是这里的Leader和我们在学习副本管理器时学到的Leader副本是不同的概念。这里的Leader成员是指**消费者组下的一个成员**。该成员负责为所有成员制定分区分配方案,制定方法的依据,就是消费者组选定的分区分配策略。
第四步更新消费者组分区分配策略支持票数。关于supportedProtocols字段的含义我在上节课的末尾用一个例子形象地进行了说明这里就不再重复说了。如果你没有印象了可以再复习一下。
最后一步,设置成员加入组后的回调逻辑,同时更新已加入组的成员数。至此,方法结束。
作为关键的成员管理方法之一add方法是实现消费者组Rebalance流程至关重要的一环。每当Rebalance开启第一大步——加入组的操作时本质上就是在利用这个add方法实现新成员入组的逻辑。
### 移除成员
有add方法自然也就有remove方法下面是remove方法的完整源码
```
def remove(memberId: String): Unit = {
// 从members中移除给定成员
members.remove(memberId).foreach { member =&gt;
// 更新分区分配策略支持票数
member.supportedProtocols.foreach{ case (protocol, _) =&gt; supportedProtocols(protocol) -= 1 }
// 更新已加入组的成员数
if (member.isAwaitingJoin)
numMembersAwaitingJoin -= 1
}
// 如果该成员是Leader选择剩下成员列表中的第一个作为新的Leader成员
if (isLeader(memberId))
leaderId = members.keys.headOption
}
```
remove方法比add要简单一些。**首先**代码从members中移除给定成员。**之后**,更新分区分配策略支持票数,以及更新已加入组的成员数。**最后**代码判断该成员是否是Leader成员如果是的话就选择成员列表中尚存的第一个成员作为新的Leader成员。
### 查询成员
查询members的方法有很多大多都是很简单的场景。我给你介绍3个比较常见的。
```
def has(memberId: String) = members.contains(memberId)
def get(memberId: String) = members(memberId)
def size = members.size
```
- has方法判断消费者组是否包含指定成员
- get方法获取指定成员对象
- size方法统计总成员数。
其它的查询方法逻辑也都很简单比如allMemberMetadata、rebalanceTimeoutMs等等我就不多讲了。课后你可以自行阅读下重点是体会这些方法利用members都做了什么事情。
## 位移管理方法
除了组状态和成员管理之外GroupMetadata还有一大类管理功能就是**管理消费者组的提交位移**Committed Offsets主要包括添加和移除位移值。
不过在学习管理位移的功能之前我再带你回顾一下保存位移的offsets字段的定义。毕竟接下来我们要学习的方法主要操作的就是这个字段。
```
private val offsets = new mutable.HashMap[TopicPartition, CommitRecordMetadataAndOffset]
```
它是HashMap类型Key是TopicPartition类型表示一个主题分区而Value是CommitRecordMetadataAndOffset类型。该类封装了位移提交消息的位移值。
在详细阅读位移管理方法之前,我先解释下这里的“位移”和“位移提交消息”。
消费者组需要向Coordinator提交已消费消息的进度在Kafka中这个进度有个专门的术语叫作提交位移。Kafka使用它来定位消费者组要消费的下一条消息。那么提交位移在Coordinator端是如何保存的呢它实际上是保存在内部位移主题中。提交的方式是消费者组成员向内部主题写入符合特定格式的事件消息这类消息就是所谓的位移提交消息Commit Record。关于位移提交消息的事件格式我会在第30讲具体介绍这里你可以暂时不用理会。而这里所说的CommitRecordMetadataAndOffset类就是标识位移提交消息的地方。我们看下它的代码
```
case class CommitRecordMetadataAndOffset(appendedBatchOffset: Option[Long], offsetAndMetadata: OffsetAndMetadata) {
def olderThan(that: CommitRecordMetadataAndOffset): Boolean = appendedBatchOffset.get &lt; that.appendedBatchOffset.get
}
```
这个类的构造函数有两个参数。
- appendedBatchOffset保存的是位移主题消息自己的位移值
- offsetAndMetadata保存的是位移提交消息中保存的消费者组的位移值。
### 添加位移值
在GroupMetadata中有3个向offsets中添加订阅分区的已消费位移值的方法分别是initializeOffsets、onOffsetCommitAppend和completePendingTxnOffsetCommit。
initializeOffsets方法的代码非常简单如下所示
```
def initializeOffsets(
offsets: collection.Map[TopicPartition, CommitRecordMetadataAndOffset],
pendingTxnOffsets: Map[Long, mutable.Map[TopicPartition, CommitRecordMetadataAndOffset]]): Unit = {
this.offsets ++= offsets
this.pendingTransactionalOffsetCommits ++= pendingTxnOffsets
}
```
它仅仅是将给定的一组订阅分区提交位移值加到offsets中。当然同时它还会更新pendingTransactionalOffsetCommits字段。
不过由于这个字段是给Kafka事务机制使用的因此你只需要关注这个方法的第一行语句就行了。当消费者组的协调者组件启动时它会创建一个异步任务定期地读取位移主题中相应消费者组的提交位移数据并把它们加载到offsets字段中。
我们再来看第二个方法onOffsetCommitAppend的代码。
```
def onOffsetCommitAppend(topicPartition: TopicPartition, offsetWithCommitRecordMetadata: CommitRecordMetadataAndOffset): Unit = {
if (pendingOffsetCommits.contains(topicPartition)) {
if (offsetWithCommitRecordMetadata.appendedBatchOffset.isEmpty)
throw new IllegalStateException(&quot;Cannot complete offset commit write without providing the metadata of the record &quot; +
&quot;in the log.&quot;)
// offsets字段中没有该分区位移提交数据或者
// offsets字段中该分区对应的提交位移消息在位移主题中的位移值小于待写入的位移值
if (!offsets.contains(topicPartition) || offsets(topicPartition).olderThan(offsetWithCommitRecordMetadata))
// 将该分区对应的提交位移消息添加到offsets中
offsets.put(topicPartition, offsetWithCommitRecordMetadata)
}
pendingOffsetCommits.get(topicPartition) match {
case Some(stagedOffset) if offsetWithCommitRecordMetadata.offsetAndMetadata == stagedOffset =&gt;
pendingOffsetCommits.remove(topicPartition)
case _ =&gt;
}
}
```
该方法在提交位移消息被成功写入后调用。主要判断的依据是offsets中是否已包含该主题分区对应的消息值或者说offsets字段中该分区对应的提交位移消息在位移主题中的位移值是否小于待写入的位移值。如果是的话就把该主题已提交的位移值添加到offsets中。
第三个方法completePendingTxnOffsetCommit的作用是完成一个待决事务Pending Transaction的位移提交。所谓的待决事务就是指正在进行中、还没有完成的事务。在处理待决事务的过程中可能会出现将待决事务中涉及到的分区的位移值添加到offsets中的情况。不过由于该方法是与Kafka事务息息相关的你不需要重点掌握这里我就不展开说了。
### 移除位移值
offsets中订阅分区的已消费位移值也是能够被移除的。你还记得Kafka主题中的消息有默认的留存时间设置吗位移主题是普通的Kafka主题所以也要遵守相应的规定。如果当前时间与已提交位移消息时间戳的差值超过了Broker端参数offsets.retention.minutes值Kafka就会将这条记录从offsets字段中移除。这就是方法removeExpiredOffsets要做的事情。
这个方法的代码有点长为了方便你掌握我分块给你介绍下。我先带你了解下它的内部嵌套类方法getExpireOffsets然后再深入了解它的实现逻辑这样你就能很轻松地掌握Kafka移除位移值的代码原理了。
首先,该方法定义了一个内部嵌套方法**getExpiredOffsets**,专门用于获取订阅分区过期的位移值。我们来阅读下源码,看看它是如何做到的。
```
def getExpiredOffsets(
baseTimestamp: CommitRecordMetadataAndOffset =&gt; Long,
subscribedTopics: Set[String] = Set.empty): Map[TopicPartition, OffsetAndMetadata] = {
// 遍历offsets中的所有分区过滤出同时满足以下3个条件的所有分区
// 条件1分区所属主题不在订阅主题列表之内
// 条件2该主题分区已经完成位移提交
// 条件3该主题分区在位移主题中对应消息的存在时间超过了阈值
offsets.filter {
case (topicPartition, commitRecordMetadataAndOffset) =&gt;
!subscribedTopics.contains(topicPartition.topic()) &amp;&amp;
!pendingOffsetCommits.contains(topicPartition) &amp;&amp; {
commitRecordMetadataAndOffset
.offsetAndMetadata.expireTimestamp match {
case None =&gt;
currentTimestamp - baseTimestamp(commitRecordMetadataAndOffset) &gt;= offsetRetentionMs
case Some(expireTimestamp) =&gt;
currentTimestamp &gt;= expireTimestamp
}
}
}.map {
// 为满足以上3个条件的分区提取出commitRecordMetadataAndOffset中的位移值
case (topicPartition, commitRecordOffsetAndMetadata) =&gt;
(topicPartition, commitRecordOffsetAndMetadata.offsetAndMetadata)
}.toMap
}
```
该方法接收两个参数。
- baseTimestamp它是一个函数类型接收CommitRecordMetadataAndOffset类型的字段然后计算时间戳并返回
- subscribedTopics即订阅主题集合默认是空。
方法开始时代码从offsets字段中过滤出同时满足3个条件的所有分区。
**条件1**:分区所属主题不在订阅主题列表之内。当方法传入了不为空的主题集合时,就说明该消费者组此时正在消费中,正在消费的主题是不能执行过期位移移除的。
**条件2**主题分区已经完成位移提交那种处于提交中状态也就是保存在pendingOffsetCommits字段中的分区不予考虑。
**条件3**该主题分区在位移主题中对应消息的存在时间超过了阈值。老版本的Kafka消息直接指定了过期时间戳因此只需要判断当前时间是否越过了这个过期时间。但是目前新版Kafka判断过期与否主要是**基于消费者组状态**。如果是Empty状态过期的判断依据就是当前时间与组变为Empty状态时间的差值是否超过Broker端参数offsets.retention.minutes值如果不是Empty状态就看当前时间与提交位移消息中的时间戳差值是否超过了offsets.retention.minutes值。如果超过了就视为已过期对应的位移值需要被移除如果没有超过就不需要移除了。
当过滤出同时满足这3个条件的分区后提取出它们对应的位移值对象并返回。
学过了getExpiredOffsets方法代码的实现之后removeExpiredOffsets方法剩下的代码就很容易理解了。
```
def removeExpiredOffsets(
currentTimestamp: Long, offsetRetentionMs: Long): Map[TopicPartition, OffsetAndMetadata] = {
// getExpiredOffsets方法代码......
// 调用getExpiredOffsets方法获取主题分区的过期位移
val expiredOffsets: Map[TopicPartition, OffsetAndMetadata] = protocolType match {
case Some(_) if is(Empty) =&gt;
getExpiredOffsets(
commitRecordMetadataAndOffset =&gt; currentStateTimestamp .getOrElse(commitRecordMetadataAndOffset.offsetAndMetadata.commitTimestamp)
)
case Some(ConsumerProtocol.PROTOCOL_TYPE) if subscribedTopics.isDefined =&gt;
getExpiredOffsets(
_.offsetAndMetadata.commitTimestamp,
subscribedTopics.get
)
case None =&gt;
getExpiredOffsets(_.offsetAndMetadata.commitTimestamp)
case _ =&gt;
Map()
}
if (expiredOffsets.nonEmpty)
debug(s&quot;Expired offsets from group '$groupId': ${expiredOffsets.keySet}&quot;)
// 将过期位移对应的主题分区从offsets中移除
offsets --= expiredOffsets.keySet
// 返回主题分区对应的过期位移
expiredOffsets
}
```
代码根据消费者组的protocolType类型和组状态调用getExpiredOffsets方法同时决定传入什么样的参数
- 如果消费者组状态是Empty就传入组变更为Empty状态的时间若该时间没有被记录则使用提交位移消息本身的写入时间戳来获取过期位移
- 如果是普通的消费者组类型,且订阅主题信息已知,就传入提交位移消息本身的写入时间戳和订阅主题集合共同确定过期位移值;
- 如果protocolType为None就表示这个消费者组其实是一个Standalone消费者依然是传入提交位移消息本身的写入时间戳来决定过期位移值
- 如果消费者组的状态不符合刚刚说的这些情况,那就说明,没有过期位移值需要被移除。
当确定了要被移除的位移值集合后代码会将它们从offsets中移除然后返回这些被移除的位移值信息。至此方法结束。
## 分区分配策略管理方法
最后我们讨论下消费者组分区分配策略的管理也就是字段supportedProtocols的管理。supportedProtocols是分区分配策略的支持票数这个票数在添加成员、移除成员时会进行相应的更新。
消费者组每次Rebalance的时候都要重新确认本次Rebalance结束之后要使用哪个分区分配策略因此就需要特定的方法来对这些票数进行统计把票数最多的那个策略作为新的策略。
GroupMetadata类中定义了两个方法来做这件事情分别是candidateProtocols和selectProtocol方法。
### 确认消费者组支持的分区分配策略集
首先来看candidateProtocols方法。它的作用是**找出组内所有成员都支持的分区分配策略集**。代码如下:
```
private def candidateProtocols: Set[String] = {
val numMembers = members.size // 获取组内成员数
// 找出支持票数=总成员数的策略,返回它们的名称
supportedProtocols.filter(_._2 == numMembers).map(_._1).toSet
}
```
该方法首先会获取组内的总成员数然后找出supportedProtocols中那些支持票数等于总成员数的分配策略并返回它们的名称。**支持票数等于总成员数的意思,等同于所有成员都支持该策略**。
### 选出消费者组的分区消费分配策略
接下来我们看下selectProtocol方法它的作用是**选出消费者组的分区消费分配策略**。
```
def selectProtocol: String = {
// 如果没有任何成员,自然无法确定选用哪个策略
if (members.isEmpty)
throw new IllegalStateException(&quot;Cannot select protocol for empty group&quot;)
// 获取所有成员都支持的策略集合
val candidates = candidateProtocols
// 让每个成员投票,票数最多的那个策略当选
val (protocol, _) = allMemberMetadata
.map(_.vote(candidates))
.groupBy(identity)
.maxBy { case (_, votes) =&gt; votes.size }
protocol
}
```
这个方法首先会判断组内是否有成员。如果没有任何成员自然就无法确定选用哪个策略了方法就会抛出异常并退出。否则的话代码会调用刚才的candidateProtocols方法获取所有成员都支持的策略集合然后让每个成员投票票数最多的那个策略当选。
你可能会好奇这里的vote方法是怎么实现的呢其实它就是简单地查找而已。我举一个简单的例子来帮助你理解。
比如candidates字段的值是[“策略A”“策略B”]成员1支持[“策略B”“策略A”]成员2支持[“策略A”“策略B”“策略C”]成员3支持[“策略D”“策略B”“策略A”]那么vote方法会将candidates与每个成员的支持列表进行比对找出成员支持列表中第一个包含在candidates中的策略。因此对于这个例子来说成员1投票策略B成员2投票策略A成员3投票策略B。可以看到投票的结果是策略B是两票策略A是1票。所以selectProtocol方法返回策略B作为新的策略。
有一点你需要注意,**成员支持列表中的策略是有顺序的**。这就是说,[“策略B”“策略A”]和[“策略A”“策略B”]是不同的,成员会倾向于选择靠前的策略。
## 总结
今天我们结合GroupMetadata源码学习了Kafka对消费者组元数据的管理主要包括组状态、成员、位移和分区分配策略四个维度。我建议你在课下再仔细地阅读一下这些管理数据的方法对照着源码和注释走一遍完整的操作流程。
另外在这两节课中我没有谈及待决成员列表Pending Members和待决位移Pending Offsets的管理因为这两个元数据项属于中间临时状态因此我没有展开讲不理解这部分代码的话也不会影响我们理解消费者组元数据以及Coordinator是如何使用它们的。不过我建议你可以阅读下与它们相关的代码部分。要知道Kafka是非常喜欢引用中间状态变量来管理各类元数据或状态的。
现在,我们再来简单回顾下这节课的重点。
- 消费者组元数据管理:主要包括对组状态、成员、位移和分区分配策略的管理。
- 组状态管理transitionTo方法负责设置状态is、not和get方法用于查询状态。
- 成员管理add、remove方法用于增减成员has和get方法用于查询特定成员。
- 分区分配策略管理定义了专属方法selectProtocols用于在每轮Rebalance时选举分区分配策略。
<img src="https://static001.geekbang.org/resource/image/a3/e7/a3eafee6b5d17b97f7661c24ccdcd4e7.jpg" alt="">
至此我们花了两节课的时间详细地学习了消费者组元数据及其管理方法的源码。这些操作元数据的方法被上层调用方GroupCoordinator大量使用就像我在开头提到的如果现在我们不彻底掌握这些元数据被操作的手法等我们学到GroupCoordinator代码时就会感到有些吃力所以你一定要好好地学习这两节课。有了这些基础等到学习GroupCoordinator源码时你就能更加深刻地理解它的底层实现原理了。
## 课后讨论
在讲到MemberMetadata时我说过每个成员都有自己的Rebalance超时时间设置那么Kafka是怎么确认消费者组使用哪个成员的超时时间作为整个组的超时时间呢
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,533 @@
<audio id="audio" title="29 | GroupMetadataManager组元数据管理器是个什么东西" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4f/13/4f3cbe72afcd2cde53yy3a10deb18213.mp3"></audio>
你好我是胡夕。今天我们学习GroupMetadataManager类的源码。从名字上来看它是组元数据管理器但是从它提供的功能来看我更愿意将它称作消费者组管理器因为它定义的方法提供的都是添加消费者组、移除组、查询组这样组级别的基础功能。
不过这个类的知名度不像KafkaController、GroupCoordinator那么高你之前可能都没有听说过它。但是它其实是非常重要的消费者组管理类。
GroupMetadataManager类是在消费者组Coordinator组件被创建时被实例化的。这就是说每个Broker在启动过程中都会创建并维持一个GroupMetadataManager实例以实现对该Broker负责的消费者组进行管理。更重要的是生产环境输出日志中的与消费者组相关的大多数信息都和它息息相关。
我举一个简单的例子。你应该见过这样的日志输出:
```
Removed ××× expired offsets in ××× milliseconds.
```
这条日志每10分钟打印一次。你有没有想过它为什么要这么操作呢其实这是由GroupMetadataManager类创建的定时任务引发的。如果你不清楚GroupMetadataManager的原理虽然暂时不会影响你使用但是一旦你在实际环境中看到了有关消费者组的错误日志仅凭日志输出你是无法定位错误原因的。要解决这个问题就只有一个办法**通过阅读源码,彻底搞懂底层实现原理,做到以不变应万变**。
关于这个类,最重要的就是要掌握它是如何管理消费者组的,以及它对内部位移主题的操作方法。这两个都是重磅功能,我们必须要吃透它们的原理,这也是我们这三节课的学习重点。今天,我们先学习它的类定义和管理消费者组的方法。
# 类定义与字段
GroupMetadataManager类定义在coordinator.group包下的同名scala文件中。这个类的代码将近1000行逐行分析的话显然效率不高也没有必要。所以我从类定义和字段、重要方法两个维度给出主要逻辑的代码分析。下面的代码是该类的定义以及我选取的重要字段信息。
```
// brokerId所在Broker的Id
// interBrokerProtocolVersionBroker端参数inter.broker.protocol.version值
// config: 内部位移主题配置类
// replicaManager: 副本管理器类
// zkClient: ZooKeeper客户端
class GroupMetadataManager(
brokerId: Int,
interBrokerProtocolVersion: ApiVersion,
config: OffsetConfig,
replicaManager: ReplicaManager,
zkClient: KafkaZkClient,
time: Time,
metrics: Metrics) extends Logging with KafkaMetricsGroup {
// 压缩器类型。向位移主题写入消息时执行压缩操作
private val compressionType: CompressionType = CompressionType.forId(config.offsetsTopicCompressionCodec.codec)
// 消费者组元数据容器保存Broker管理的所有消费者组的数据
private val groupMetadataCache = new Pool[String, GroupMetadata]
// 位移主题下正在执行加载操作的分区
private val loadingPartitions: mutable.Set[Int] = mutable.Set()
// 位移主题下完成加载操作的分区
private val ownedPartitions: mutable.Set[Int] = mutable.Set()
// 位移主题总分区数
private val groupMetadataTopicPartitionCount = getGroupMetadataTopicPartitionCount
......
}
```
这个类的构造函数需要7个参数后面的time和metrics只是起辅助作用因此我重点解释一下前5个参数的含义。
- brokerId这个参数我们已经无比熟悉了。它是所在Broker的ID值也就是broker.id参数值。
- interBrokerProtocolVersion保存Broker间通讯使用的请求版本。它是Broker端参数inter.broker.protocol.version值。这个参数的主要用途是**确定位移主题消息格式的版本**。
- config这是一个OffsetConfig类型。该类型定义了与位移管理相关的重要参数比如位移主题日志段大小设置、位移主题备份因子、位移主题分区数配置等。
- replicaManager副本管理器类。GroupMetadataManager类使用该字段实现获取分区对象、日志对象以及写入分区消息的目的。
- zkClientZooKeeper客户端。该类中的此字段只有一个目的从ZooKeeper中获取位移主题的分区数。
除了构造函数所需的字段,该类还定义了其他关键字段,我给你介绍几个非常重要的。
**1.compressionType**
**压缩器类型**。Kafka向位移主题写入消息前可以选择对消息执行压缩操作。是否压缩取决于Broker端参数offsets.topic.compression.codec值默认是不进行压缩。如果你的位移主题占用的磁盘空间比较多的话可以考虑启用压缩以节省资源。
**2.groupMetadataCache**
**该字段是GroupMetadataManager类上最重要的属性它****保存这个Broker上GroupCoordinator组件管理的所有消费者组元数据。**它的Key是消费者组名称Value是消费者组元数据也就是GroupMetadata。源码通过该字段实现对消费者组的添加、删除和遍历操作。
**3.loadingPartitions**
**位移主题下正在执行加载操作的分区号集合**。这里需要注意两点首先这些分区都是位移主题分区也就是__consumer_offsets主题下的分区其次所谓的加载是指读取位移主题消息数据填充GroupMetadataCache字段的操作。
**4.ownedPartitions**
**位移主题下完成加载操作的分区号集合**。与loadingPartitions类似的是该字段保存的分区也是位移主题下的分区。和loadingPartitions不同的是它保存的分区都是**已经完成加载操作**的分区。
**5.groupMetadataTopicPartitionCount**
**位移主题的分区数**。它是Broker端参数offsets.topic.num.partitions的值默认是50个分区。若要修改分区数除了变更该参数值之外你也可以手动创建位移主题并指定不同的分区数。
在这些字段中groupMetadataCache是最重要的GroupMetadataManager类大量使用该字段实现对消费者组的管理。接下来我们就重点学习一下该类是如何管理消费者组的。
# 重要方法
管理消费者组包含两个方面对消费者组元数据的管理以及对消费者组位移的管理。组元数据和组位移都是Coordinator端重要的消费者组管理对象。
## 消费者组元数据管理
消费者组元数据管理分为查询获取组信息、添加组、移除组和加载组信息。从代码复杂度来讲,查询获取、移除和添加的逻辑相对简单,加载的过程稍微费事些。我们先说说查询获取。
### 查询获取消费者组元数据
GroupMetadataManager类中查询及获取组数据的方法有很多。大多逻辑简单你一看就能明白比如下面的getGroup方法和getOrMaybeCreateGroup方法
```
// getGroup方法返回给定消费者组的元数据信息。
// 若该组信息不存在返回None
def getGroup(groupId: String): Option[GroupMetadata] = {
Option(groupMetadataCache.get(groupId))
}
// getOrMaybeCreateGroup方法返回给定消费者组的元数据信息。
// 若不存在则视createIfNotExist参数值决定是否需要添加该消费者组
def getOrMaybeCreateGroup(groupId: String, createIfNotExist: Boolean): Option[GroupMetadata] = {
if (createIfNotExist)
// 若不存在且允许添加则添加一个状态是Empty的消费者组元数据对象
Option(groupMetadataCache.getAndMaybePut(groupId, new GroupMetadata(groupId, Empty, time)))
else
Option(groupMetadataCache.get(groupId))
}
```
GroupMetadataManager类的上层组件GroupCoordinator会大量使用这两个方法来获取给定消费者组的数据。这两个方法都会返回给定消费者组的元数据信息但是它们之间是有区别的。
对于getGroup方法而言如果该组信息不存在就返回None而这通常表明消费者组确实不存在或者是该组对应的Coordinator组件变更到其他Broker上了。
而对于getOrMaybeCreateGroup方法而言若组信息不存在就根据createIfNotExist参数值决定是否需要添加该消费者组。而且getOrMaybeCreateGroup方法是在消费者组第一个成员加入组时被调用的用于把组创建出来。
在GroupMetadataManager类中还有一些地方也散落着组查询获取的逻辑。不过它们与这两个方法中的代码大同小异很容易理解课下你可以自己阅读下。
### 移除消费者组元数据
接下来我们看下如何移除消费者组信息。当Broker卸任某些消费者组的Coordinator角色时它需要将这些消费者组从groupMetadataCache中全部移除掉这就是removeGroupsForPartition方法要做的事情。我们看下它的源码
```
def removeGroupsForPartition(offsetsPartition: Int,
onGroupUnloaded: GroupMetadata =&gt; Unit): Unit = {
// 位移主题分区
val topicPartition = new TopicPartition(Topic.GROUP_METADATA_TOPIC_NAME, offsetsPartition)
info(s&quot;Scheduling unloading of offsets and group metadata from $topicPartition&quot;)
// 创建异步任务,移除组信息和位移信息
scheduler.schedule(topicPartition.toString, () =&gt; removeGroupsAndOffsets)
// 内部方法,用于移除组信息和位移信息
def removeGroupsAndOffsets(): Unit = {
var numOffsetsRemoved = 0
var numGroupsRemoved = 0
inLock(partitionLock) {
// 移除ownedPartitions中特定位移主题分区记录
ownedPartitions.remove(offsetsPartition)
// 遍历所有消费者组信息
for (group &lt;- groupMetadataCache.values) {
// 如果该组信息保存在特定位移主题分区中
if (partitionFor(group.groupId) == offsetsPartition) {
// 执行组卸载逻辑
onGroupUnloaded(group)
// 关键步骤将组信息从groupMetadataCache中移除
groupMetadataCache.remove(group.groupId, group)
// 把消费者组从producer对应的组集合中移除
removeGroupFromAllProducers(group.groupId)
// 更新已移除组计数器
numGroupsRemoved += 1
// 更新已移除位移值计数器
numOffsetsRemoved += group.numOffsets
}
}
}
info(s&quot;Finished unloading $topicPartition. Removed $numOffsetsRemoved cached offsets &quot; +
s&quot;and $numGroupsRemoved cached groups.&quot;)
}
}
```
该方法的主要逻辑是先定义一个内部方法removeGroupsAndOffsets然后创建一个异步任务调用该方法来执行移除消费者组信息和位移信息。
那么,怎么判断要移除哪些消费者组呢?这里的依据就是**传入的位移主题分区**。每个消费者组及其位移的数据都只会保存在位移主题的一个分区下。一旦给定了位移主题分区那么元数据保存在这个位移主题分区下的消费者组就要被移除掉。removeGroupsForPartition方法传入的offsetsPartition参数表示Leader发生变更的位移主题分区因此这些分区保存的消费者组都要从该Broker上移除掉。
具体的执行逻辑是什么呢?我来解释一下。
首先异步任务从ownedPartitions中移除给定位移主题分区。
其次,遍历消费者组元数据缓存中的所有消费者组对象,如果消费者组正是在给定位移主题分区下保存的,就依次执行下面的步骤。
- 第1步调用onGroupUnloaded方法执行组卸载逻辑。这个方法的逻辑是上层组件GroupCoordinator传过来的。它主要做两件事情将消费者组状态变更到Dead状态封装异常表示Coordinator已发生变更然后调用回调函数返回。
- 第2步把消费者组信息从groupMetadataCache中移除。这一步非常关键目的是彻底清除掉该组的“痕迹”。
- 第3步把消费者组从producer对应的组集合中移除。这里的producer是给Kafka事务用的。
- 第4步增加已移除组计数器。
- 第5步更新已移除位移值计数器。
到这里,方法结束。
### 添加消费者组元数据
下面我们学习添加消费者组的管理方法即addGroup。它特别简单仅仅是调用putIfNotExists将给定组添加进groupMetadataCache中而已。代码如下
```
def addGroup(group: GroupMetadata): GroupMetadata = {
val currentGroup = groupMetadataCache.putIfNotExists(group.groupId, group)
if (currentGroup != null) {
currentGroup
} else {
group
}
}
```
### 加载消费者组元数据
现在轮到相对复杂的加载消费者组了。GroupMetadataManager类中定义了一个loadGroup方法执行对应的加载过程。
```
private def loadGroup(
group: GroupMetadata, offsets: Map[TopicPartition, CommitRecordMetadataAndOffset],
pendingTransactionalOffsets: Map[Long, mutable.Map[TopicPartition, CommitRecordMetadataAndOffset]]): Unit = {
trace(s&quot;Initialized offsets $offsets for group ${group.groupId}&quot;)
// 初始化消费者组的位移信息
group.initializeOffsets(offsets, pendingTransactionalOffsets.toMap)
// 调用addGroup方法添加消费者组
val currentGroup = addGroup(group)
if (group != currentGroup)
debug(s&quot;Attempt to load group ${group.groupId} from log with generation ${group.generationId} failed &quot; +
s&quot;because there is already a cached group with generation ${currentGroup.generationId}&quot;)
}
```
该方法的逻辑有两步。
第1步通过initializeOffsets方法将位移值添加到offsets字段标识的消费者组提交位移元数据中实现加载消费者组订阅分区提交位移的目的。
第2步调用addGroup方法将该消费者组元数据对象添加进消费者组元数据缓存实现加载消费者组元数据的目的。
## 消费者组位移管理
除了消费者组的管理GroupMetadataManager类的另一大类功能是提供消费者组位移的管理主要包括位移数据的保存和查询。我们总说位移主题是保存消费者组位移信息的地方。实际上**当消费者组程序在查询位移时Kafka总是从内存中的位移缓存数据查询而不会直接读取底层的位移主题数据。**
### 保存消费者组位移
storeOffsets方法负责保存消费者组位移。该方法的代码很长我先画一张图来展示下它的完整流程帮助你建立起对这个方法的整体认知。接下来我们再从它的方法签名和具体代码两个维度来具体了解一下它的执行逻辑。
<img src="https://static001.geekbang.org/resource/image/76/e6/76116b323c0c7b024ebe95c3c08e6ae6.jpg" alt="">
我先给你解释一下保存消费者组位移的全部流程。
**首先**storeOffsets方法要过滤出满足特定条件的待保存位移信息。是否满足特定条件要看validateOffsetMetadataLength方法的返回值。这里的特定条件是指位移提交记录中的自定义数据大小要小于Broker端参数offset.metadata.max.bytes的值默认值是4KB。
如果没有一个分区满足条件就构造OFFSET_METADATA_TOO_LARGE异常并调用回调函数。这里的回调函数执行发送位移提交Response的动作。
倘若有分区满足了条件,**接下来**方法会判断当前Broker是不是该消费者组的Coordinator如果不是的话就构造NOT_COORDINATOR异常并提交给回调函数如果是的话就构造位移主题消息并将消息写入进位移主题下。
**然后**调用一个名为putCacheCallback的内置方法填充groupMetadataCache中各个消费者组元数据中的位移值**最后**,调用回调函数返回。
接下来我们结合代码来查看下storeOffsets方法的实现逻辑。
首先我们看下它的方法签名。既然是保存消费者组提交位移的,那么,我们就要知道上层调用方都给这个方法传入了哪些参数。
```
// group消费者组元数据
// consumerId消费者组成员ID
// offsetMetadata待保存的位移值按照分区分组
// responseCallback处理完成后的回调函数
// producerId事务型Producer ID
// producerEpoch事务型Producer Epoch值
def storeOffsets(
group: GroupMetadata,
consumerId: String,
offsetMetadata: immutable.Map[TopicPartition, OffsetAndMetadata],
responseCallback: immutable.Map[TopicPartition, Errors] =&gt; Unit,
producerId: Long = RecordBatch.NO_PRODUCER_ID,
producerEpoch: Short = RecordBatch.NO_PRODUCER_EPOCH): Unit = {
......
}
```
这个方法接收6个参数它们的含义我都用注释的方式标注出来了。producerId和producerEpoch这两个参数是与Kafka事务相关的你简单了解下就行。我们要重点掌握前面4个参数的含义。
- group消费者组元数据信息。该字段的类型就是我们之前学到的GroupMetadata类。
- consumerId消费者组成员ID仅用于DEBUG调试。
- offsetMetadata待保存的位移值按照分区分组。
- responseCallback位移保存完成后需要执行的回调函数。
接下来我们看下storeOffsets的代码。为了便于你理解我删除了与Kafka事务操作相关的部分。
```
// 过滤出满足特定条件的待保存位移数据
val filteredOffsetMetadata = offsetMetadata.filter { case (_, offsetAndMetadata) =&gt;
validateOffsetMetadataLength(offsetAndMetadata.metadata)
}
......
val isTxnOffsetCommit = producerId != RecordBatch.NO_PRODUCER_ID
// 如果没有任何分区的待保存位移满足特定条件
if (filteredOffsetMetadata.isEmpty) {
// 构造OFFSET_METADATA_TOO_LARGE异常并调用responseCallback返回
val commitStatus = offsetMetadata.map { case (k, _) =&gt; k -&gt; Errors.OFFSET_METADATA_TOO_LARGE }
responseCallback(commitStatus)
None
} else {
// 查看当前Broker是否为给定消费者组的Coordinator
getMagic(partitionFor(group.groupId)) match {
// 如果是Coordinator
case Some(magicValue) =&gt;
val timestampType = TimestampType.CREATE_TIME
val timestamp = time.milliseconds()
// 构造位移主题的位移提交消息
val records = filteredOffsetMetadata.map { case (topicPartition, offsetAndMetadata) =&gt;
val key = GroupMetadataManager.offsetCommitKey(group.groupId, topicPartition)
val value = GroupMetadataManager.offsetCommitValue(offsetAndMetadata, interBrokerProtocolVersion)
new SimpleRecord(timestamp, key, value)
}
val offsetTopicPartition = new TopicPartition(Topic.GROUP_METADATA_TOPIC_NAME, partitionFor(group.groupId))
// 为写入消息创建内存Buffer
val buffer = ByteBuffer.allocate(AbstractRecords.estimateSizeInBytes(magicValue, compressionType, records.asJava))
if (isTxnOffsetCommit &amp;&amp; magicValue &lt; RecordBatch.MAGIC_VALUE_V2)
throw Errors.UNSUPPORTED_FOR_MESSAGE_FORMAT.exception(&quot;Attempting to make a transaction offset commit with an invalid magic: &quot; + magicValue)
val builder = MemoryRecords.builder(buffer, magicValue, compressionType, timestampType, 0L, time.milliseconds(),
producerId, producerEpoch, 0, isTxnOffsetCommit, RecordBatch.NO_PARTITION_LEADER_EPOCH)
records.foreach(builder.append)
val entries = Map(offsetTopicPartition -&gt; builder.build())
// putCacheCallback函数定义......
if (isTxnOffsetCommit) {
......
} else {
group.inLock {
group.prepareOffsetCommit(offsetMetadata)
}
}
// 写入消息到位移主题同时调用putCacheCallback方法更新消费者元数据
appendForGroup(group, entries, putCacheCallback)
// 如果是Coordinator
case None =&gt;
// 构造NOT_COORDINATOR异常并提交给responseCallback方法
val commitStatus = offsetMetadata.map {
case (topicPartition, _) =&gt;
(topicPartition, Errors.NOT_COORDINATOR)
}
responseCallback(commitStatus)
None
}
}
```
我为方法的关键步骤都标注了注释具体流程前面我也介绍过了应该很容易理解。不过这里还需要注意两点也就是appendForGroup和putCacheCallback方法。前者是向位移主题写入消息后者是填充元数据缓存的。我们结合代码来学习下。
appendForGroup方法负责写入消息到位移主题同时传入putCacheCallback方法更新消费者元数据。以下是它的代码
```
private def appendForGroup(
group: GroupMetadata,
records: Map[TopicPartition, MemoryRecords],
callback: Map[TopicPartition, PartitionResponse] =&gt; Unit): Unit = {
replicaManager.appendRecords(
timeout = config.offsetCommitTimeoutMs.toLong,
requiredAcks = config.offsetCommitRequiredAcks,
internalTopicsAllowed = true,
origin = AppendOrigin.Coordinator,
entriesPerPartition = records,
delayedProduceLock = Some(group.lock),
responseCallback = callback)
}
```
可以看到该方法就是调用ReplicaManager的appendRecords方法将消息写入到位移主题中。
下面我们再关注一下putCacheCallback方法的实现也就是将写入的位移值填充到缓存中。我先画一张图来展示下putCacheCallback的逻辑。
<img src="https://static001.geekbang.org/resource/image/bc/42/bc2fcf199a685a5cc6d32846c53c3042.jpg" alt="">
现在,我们结合代码,学习下它的逻辑实现。
```
def putCacheCallback(responseStatus: Map[TopicPartition, PartitionResponse]): Unit = {
// 确保消息写入到指定位移主题分区,否则抛出异常
if (responseStatus.size != 1 || !responseStatus.contains(offsetTopicPartition))
throw new IllegalStateException(&quot;Append status %s should only have one partition %s&quot;
.format(responseStatus, offsetTopicPartition))
// 更新已提交位移数指标
offsetCommitsSensor.record(records.size)
val status = responseStatus(offsetTopicPartition)
val responseError = group.inLock {
// 写入结果没有错误
if (status.error == Errors.NONE) {
// 如果不是Dead状态
if (!group.is(Dead)) {
filteredOffsetMetadata.foreach { case (topicPartition, offsetAndMetadata) =&gt;
if (isTxnOffsetCommit)
......
else
// 调用GroupMetadata的onOffsetCommitAppend方法填充元数据
group.onOffsetCommitAppend(topicPartition, CommitRecordMetadataAndOffset(Some(status.baseOffset), offsetAndMetadata))
}
}
Errors.NONE
// 写入结果有错误
} else {
if (!group.is(Dead)) {
......
filteredOffsetMetadata.foreach { case (topicPartition, offsetAndMetadata) =&gt;
if (isTxnOffsetCommit)
group.failPendingTxnOffsetCommit(producerId, topicPartition)
else
// 取消未完成的位移消息写入
group.failPendingOffsetWrite(topicPartition, offsetAndMetadata)
}
}
......
// 确认异常类型
status.error match {
case Errors.UNKNOWN_TOPIC_OR_PARTITION
| Errors.NOT_ENOUGH_REPLICAS
| Errors.NOT_ENOUGH_REPLICAS_AFTER_APPEND =&gt;
Errors.COORDINATOR_NOT_AVAILABLE
case Errors.NOT_LEADER_FOR_PARTITION
| Errors.KAFKA_STORAGE_ERROR =&gt;
Errors.NOT_COORDINATOR
case Errors.MESSAGE_TOO_LARGE
| Errors.RECORD_LIST_TOO_LARGE
| Errors.INVALID_FETCH_SIZE =&gt;
Errors.INVALID_COMMIT_OFFSET_SIZE
case other =&gt; other
}
}
}
// 利用异常类型构建提交返回状态
val commitStatus = offsetMetadata.map { case (topicPartition, offsetAndMetadata) =&gt;
if (validateOffsetMetadataLength(offsetAndMetadata.metadata))
(topicPartition, responseError)
else
(topicPartition, Errors.OFFSET_METADATA_TOO_LARGE)
}
// 调用回调函数
responseCallback(commitStatus)
}
```
putCacheCallback方法的主要目的是将多个消费者组位移值填充到GroupMetadata的offsets元数据缓存中。
**首先**,该方法要确保位移消息写入到指定位移主题分区,否则就抛出异常。
**之后**,更新已提交位移数指标,然后判断写入结果是否有错误。
如果没有错误只要组状态不是Dead状态就调用GroupMetadata的onOffsetCommitAppend方法填充元数据。onOffsetCommitAppend方法的主体逻辑是将消费者组订阅分区的位移值写入到offsets字段保存的集合中。当然如果状态是Dead则什么都不做。
如果刚才的写入结果有错误那么就通过failPendingOffsetWrite方法取消未完成的位移消息写入。
**接下来**代码要将日志写入的异常类型转换成表征提交状态错误的异常类型。具体来说就是将UNKNOWN_TOPIC_OR_PARTITION、NOT_LEADER_FOR_PARTITION和MESSAGE_TOO_LARGE这样的异常转换到COORDINATOR_NOT_AVAILABLE和NOT_COORDINATOR这样的异常。之后再将这些转换后的异常封装进commitStatus字段中传给回调函数。
**最后**,调用回调函数返回。至此,方法结束。
好了保存消费者组位移信息的storeOffsets方法我们就学完了它的关键逻辑是构造位移主题消息并写入到位移主题然后将位移值填充到消费者组元数据中。
### 查询消费者组位移
现在我再说说查询消费者组位移也就是getOffsets方法的代码实现。比起storeOffsets这个方法要更容易理解。我们看下它的源码
```
def getOffsets(
groupId: String,
requireStable: Boolean,
topicPartitionsOpt: Option[Seq[TopicPartition]]): Map[TopicPartition, PartitionData] = {
......
// 从groupMetadataCache字段中获取指定消费者组的元数据
val group = groupMetadataCache.get(groupId)
// 如果没有组数据,返回空数据
if (group == null) {
topicPartitionsOpt.getOrElse(Seq.empty[TopicPartition]).map { topicPartition =&gt;
val partitionData = new PartitionData(OffsetFetchResponse.INVALID_OFFSET,
Optional.empty(), &quot;&quot;, Errors.NONE)
topicPartition -&gt; partitionData
}.toMap
// 如果存在组数据
} else {
group.inLock {
// 如果组处于Dead状态则返回空数据
if (group.is(Dead)) {
topicPartitionsOpt.getOrElse(Seq.empty[TopicPartition]).map { topicPartition =&gt;
val partitionData = new PartitionData(OffsetFetchResponse.INVALID_OFFSET,
Optional.empty(), &quot;&quot;, Errors.NONE)
topicPartition -&gt; partitionData
}.toMap
} else {
val topicPartitions = topicPartitionsOpt.getOrElse(group.allOffsets.keySet)
topicPartitions.map { topicPartition =&gt;
if (requireStable &amp;&amp; group.hasPendingOffsetCommitsForTopicPartition(topicPartition)) {
topicPartition -&gt; new PartitionData(OffsetFetchResponse.INVALID_OFFSET,
Optional.empty(), &quot;&quot;, Errors.UNSTABLE_OFFSET_COMMIT)
} else {
val partitionData = group.offset(topicPartition) match {
// 如果没有该分区位移数据,返回空数据
case None =&gt;
new PartitionData(OffsetFetchResponse.INVALID_OFFSET,
Optional.empty(), &quot;&quot;, Errors.NONE)
// 从消费者组元数据中返回指定分区的位移数据
case Some(offsetAndMetadata) =&gt;
new PartitionData(offsetAndMetadata.offset,
offsetAndMetadata.leaderEpoch, offsetAndMetadata.metadata, Errors.NONE)
}
topicPartition -&gt; partitionData
}
}.toMap
}
}
}
}
```
getOffsets方法首先会读取groupMetadataCache中的组元数据如果不存在对应的记录则返回空数据集如果存在就接着判断组是否处于Dead状态。
如果是Dead状态就说明消费者组已经被销毁了位移数据也被视为不可用了依然返回空数据集若状态不是Dead就提取出消费者组订阅的分区信息再依次为它们获取对应的位移数据并返回。至此方法结束。
# 总结
今天我们学习了GroupMetadataManager类的源码。作为消费者组管理器它负责管理消费者组的方方面面。其中非常重要的两个管理功能是消费者组元数据管理和消费者组位移管理分别包括查询获取、移除、添加和加载消费者组元数据以及保存和查询消费者组位移这些方法是上层组件GroupCoordinator倚重的重量级功能载体你一定要彻底掌握它们。
我画了一张思维导图,帮助你复习一下今天的重点内容。
<img src="https://static001.geekbang.org/resource/image/eb/5a/eb8fe45e1d152e2ac9cb52c81390265a.jpg" alt="">
实际上GroupMetadataManager类的地位举足轻重。虽然它在Coordinator组件中不显山不露水但却是一些线上问题的根源所在。
我再跟你分享一个小案例。
之前我碰到过一个问题在消费者组成员超多的情况下无法完成位移加载这导致Consumer端总是接收到Marking the coordinator dead的错误。
当时我查遍各种资料都无法定位问题最终还是通过阅读源码发现是这个类的doLoadGroupsAndOffsets方法中创建的buffer过小导致的。后来通过调大offsets.load.buffer.size参数值我们顺利地解决了问题。
试想一下如果当时没有阅读这部分的源码仅凭日志我们肯定无法解决这个问题。因此我们花三节课的时间专门阅读GroupMetadataManager类源码是非常值得的。下节课我将带你继续研读GroupMetadataManager源码去探寻有关位移主题的那些代码片段。
# 课后讨论
请思考这样一个问题在什么场景下需要移除GroupMetadataManager中保存的消费者组记录
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,310 @@
<audio id="audio" title="30 | GroupMetadataManager位移主题保存的只是位移吗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/49/1e/498106d7ca8a7d82470d6bd49ed5451e.mp3"></audio>
你好,我是胡夕。今天,我们学习位移主题管理的源码。
位移主题即__consumer_offsets是Kafka的两大内部主题之一另一个内部主题是管理Kafka事务的名字是__transaction_state用于保存Kafka事务的状态信息
Kafka创建位移主题的目的是**保存消费者组的注册消息和提交位移消息**。前者保存能够标识消费者组的身份信息后者保存消费者组消费的进度信息。在Kafka源码中GroupMetadataManager类定义了操作位移主题消息类型以及操作位移主题的方法。该主题下都有哪些消息类型是我们今天学习的重点。
说到位移主题你是否对它里面的消息内容感到很好奇呢我见过很多人直接使用kafka-console-consumer命令消费该主题想要知道里面保存的内容可输出的结果却是一堆二进制乱码。其实如果你不阅读今天的源码是无法知晓如何通过命令行工具查询该主题消息的内容的。因为这些知识只包含在源码中官方文档并没有涉及到。
好了我不卖关子了。简单来说你在运行kafka-console-consumer命令时必须指定`--formatter "kafka.coordinator.group.GroupMetadataManager\$OffsetsMessageFormatter"`才能查看提交的位移消息数据。类似地你必须指定GroupMetadataMessageFormatter才能读取消费者组的注册消息数据。
今天我们就来学习位移主题下的这两大消息类型。除此之外我还会给你介绍消费者组是如何寻找自己的Coordinator的。毕竟对位移主题进行读写的前提就是要能找到正确的Coordinator所在。
## 消息类型
位移主题有两类消息:**消费者组注册消息**Group Metadata和**消费者组的已提交位移消息**Offset Commit。很多人以为位移主题里面只保存消费者组位移这是错误的它还保存了消费者组的注册信息或者说是消费者组的元数据。这里的元数据主要是指消费者组名称以及成员分区消费分配方案。
在分别介绍这两类消息的实现代码之前我们先看下Kafka为它们定义的公共服务代码。毕竟它们是这两类消息都会用到的代码组件。这些公共代码主要由两部分组成GroupTopicPartition类和BaseKey接口。
我们首先来看POJO类**GroupTopicPartition**。它的作用是封装&lt;消费者组名,主题,分区号&gt;的三元组,代码如下:
```
case class GroupTopicPartition(group: String, topicPartition: TopicPartition) {
def this(group: String, topic: String, partition: Int) =
this(group, new TopicPartition(topic, partition))
// toString方法......
}
```
显然,这个类就是一个数据容器类。我们后面在学习已提交位移消息时,还会看到它的身影。
其次是**BaseKey接口**它表示位移主题的两类消息的Key类型。强调一下无论是该主题下的哪类消息都必须定义Key。这里的BaseKey接口定义的就是这两类消息的Key类型。我们看下它的代码
```
trait BaseKey{
def version: Short // 消息格式版本
def key: Any // 消息key
}
```
这里的version是Short型的消息格式版本。随着Kafka代码的不断演进位移主题的消息格式也在不断迭代因此这里出现了版本号的概念。至于key字段它保存的是实际的Key值。在Scala中Any类型类似于Java中的Object类表示该值可以是任意类型。稍后讲到具体的消息类型时你就会发现这两类消息的Key类型其实是不同的数据类型。
好了基础知识铺垫完了有了对GroupTopicPartition和BaseKey的理解你就能明白位移主题的具体消息类型是如何构造Key的。
接下来我们开始学习具体消息类型的实现代码包括注册消息、提交位移消息和Tombstone消息。由于消费者组必须要先向Coordinator组件注册然后才能提交位移所以我们先阅读注册消息的代码。
### 注册消息
所谓的注册消息,就是指消费者组向位移主题写入注册类的消息。该类消息的写入时机有两个。
- **所有成员都加入组后**Coordinator向位移主题写入注册消息只是该消息不含分区消费分配方案
- **Leader成员发送方案给Coordinator后**当Leader成员将分区消费分配方案发给Coordinator后Coordinator写入携带分配方案的注册消息。
我们首先要知道注册消息的Key是如何定义以及如何被封装到消息里的。
Key的定义在GroupMetadataKey类代码中
```
case class GroupMetadataKey(version: Short, key: String) extends BaseKey {
override def toString: String = key
}
```
该类的key字段是一个字符串类型保存的是消费者组的名称。可见**注册消息的Key就是消费者组名**。
GroupMetadataManager对象有个groupMetadataKey方法负责将注册消息的Key转换成字节数组用于后面构造注册消息。这个方法的代码如下
```
def groupMetadataKey(group: String): Array[Byte] = {
val key = new Struct(CURRENT_GROUP_KEY_SCHEMA)
key.set(GROUP_KEY_GROUP_FIELD, group)
// 构造一个ByteBuffer对象容纳version和key数据
val byteBuffer = ByteBuffer.allocate(2 /* version */ + key.sizeOf)
byteBuffer.putShort(CURRENT_GROUP_KEY_SCHEMA_VERSION)
key.writeTo(byteBuffer)
byteBuffer.array()
}
```
该方法首先会接收消费者组名构造ByteBuffer对象然后依次向Buffer写入Short型的消息格式版本以及消费者组名最后返回该Buffer底层的字节数组。
你不用关心这里的格式版本变量以及Struct类型都是怎么实现的因为它们不是我们理解位移主题内部原理的关键。你需要掌握的是**注册消息的Key和Value都是怎么定义的**。
接下来我们就来了解下消息体Value的代码实现。既然有groupMetadataKey方法那么源码也提供了相应的groupMetadataValue方法。它的目的是**将消费者组重要的元数据写入到字节数组**。我们看下它的代码实现:
```
def groupMetadataValue(
groupMetadata: GroupMetadata, // 消费者组元数据对象
assignment: Map[String, Array[Byte]], // 分区消费分配方案
apiVersion: ApiVersion // Kafka API版本号
): Array[Byte] = {
// 确定消息格式版本以及格式结构
val (version, value) = {
if (apiVersion &lt; KAFKA_0_10_1_IV0)
(0.toShort, new Struct(GROUP_METADATA_VALUE_SCHEMA_V0))
else if (apiVersion &lt; KAFKA_2_1_IV0)
(1.toShort, new Struct(GROUP_METADATA_VALUE_SCHEMA_V1))
else if (apiVersion &lt; KAFKA_2_3_IV0)
(2.toShort, new Struct(GROUP_METADATA_VALUE_SCHEMA_V2))
else
(3.toShort, new Struct(GROUP_METADATA_VALUE_SCHEMA_V3))
}
// 依次写入消费者组主要的元数据信息
// 包括协议类型、Generation ID、分区分配策略和Leader成员ID
value.set(PROTOCOL_TYPE_KEY, groupMetadata.protocolType.getOrElse(&quot;&quot;))
value.set(GENERATION_KEY, groupMetadata.generationId)
value.set(PROTOCOL_KEY, groupMetadata.protocolName.orNull)
value.set(LEADER_KEY, groupMetadata.leaderOrNull)
// 写入最近一次状态变更时间戳
if (version &gt;= 2)
value.set(CURRENT_STATE_TIMESTAMP_KEY, groupMetadata.currentStateTimestampOrDefault)
// 写入各个成员的元数据信息
// 包括成员ID、client.id、主机名以及会话超时时间
val memberArray = groupMetadata.allMemberMetadata.map { memberMetadata =&gt;
val memberStruct = value.instance(MEMBERS_KEY)
memberStruct.set(MEMBER_ID_KEY, memberMetadata.memberId)
memberStruct.set(CLIENT_ID_KEY, memberMetadata.clientId)
memberStruct.set(CLIENT_HOST_KEY, memberMetadata.clientHost)
memberStruct.set(SESSION_TIMEOUT_KEY, memberMetadata.sessionTimeoutMs)
// 写入Rebalance超时时间
if (version &gt; 0)
memberStruct.set(REBALANCE_TIMEOUT_KEY, memberMetadata.rebalanceTimeoutMs)
// 写入用于静态消费者组管理的Group Instance ID
if (version &gt;= 3)
memberStruct.set(GROUP_INSTANCE_ID_KEY, memberMetadata.groupInstanceId.orNull)
// 必须定义分区分配策略,否则抛出异常
val protocol = groupMetadata.protocolName.orNull
if (protocol == null)
throw new IllegalStateException(&quot;Attempted to write non-empty group metadata with no defined protocol&quot;)
// 写入成员消费订阅信息
val metadata = memberMetadata.metadata(protocol)
memberStruct.set(SUBSCRIPTION_KEY, ByteBuffer.wrap(metadata))
val memberAssignment = assignment(memberMetadata.memberId)
assert(memberAssignment != null)
// 写入成员消费分配信息
memberStruct.set(ASSIGNMENT_KEY, ByteBuffer.wrap(memberAssignment))
memberStruct
}
value.set(MEMBERS_KEY, memberArray.toArray)
// 向Buffer依次写入版本信息和以上写入的元数据信息
val byteBuffer = ByteBuffer.allocate(2 /* version */ + value.sizeOf)
byteBuffer.putShort(version)
value.writeTo(byteBuffer)
// 返回Buffer底层的字节数组
byteBuffer.array()
}
```
代码比较长,我结合一张图来帮助你理解这个方法的执行逻辑。
<img src="https://static001.geekbang.org/resource/image/03/07/032bdb247859f796a5ca21c3db710007.jpg" alt="">
第1步代码根据传入的apiVersion字段确定要使用哪个格式版本并创建对应版本的结构体Struct来保存这些元数据。apiVersion的取值是Broker端参数inter.broker.protocol.version的值。你打开Kafka官网的话就可以看到这个参数的值永远指向当前最新的Kafka版本。
第2步代码依次向结构体写入消费者组的协议类型Protocol Type、Generation ID、分区分配策略Protocol Name和Leader成员ID。在学习GroupMetadata时我说过对于普通的消费者组而言协议类型就是"consumer"字符串,分区分配策略可能是"range""round-robin"等。之后代码还会为格式版本≥2的结构体写入消费者组状态最近一次变更的时间戳。
第3步遍历消费者组的所有成员为每个成员构建专属的结构体对象并依次向结构体写入成员的ID、Client ID、主机名以及会话超时时间信息。对于格式版本≥0的结构体代码要写入成员配置的Rebalance超时时间而对于格式版本≥3的结构体代码还要写入用于静态消费者组管理的Group Instance ID。待这些都做完之后groupMetadataValue方法必须要确保消费者组选出了分区分配策略否则就抛出异常。再之后方法依次写入成员消费订阅信息和成员消费分配信息。
第4步代码向Buffer依次写入版本信息和刚刚说到的写入的元数据信息并返回Buffer底层的字节数组。至此方法逻辑结束。
关于注册消息Key和Value的内容我就介绍完了。为了帮助你更直观地理解注册消息到底包含了什么数据我再用一张图向你展示一下它们的构成。
<img src="https://static001.geekbang.org/resource/image/4d/8f/4d5ecbdc21d5bb29d054443e31eab28f.jpg" alt="">
这张图完整地总结了groupMetadataKey和groupMetadataValue方法要生成的注册消息内容。灰色矩形中的字段表示可选字段有可能不会包含在Value中。
### 已提交位移消息
接下来我们再学习一下提交位移消息的Key和Value构成。
OffsetKey类定义了提交位移消息的Key值代码如下
```
case class OffsetKey(version: Short, key: GroupTopicPartition) extends BaseKey {
override def toString: String = key.toString
}
```
可见这类消息的Key是一个GroupTopicPartition类型也就是&lt;消费者组名,主题,分区号&gt;三元组。
offsetCommitKey方法负责将这个三元组转换成字节数组用于后续构造提交位移消息。
```
def offsetCommitKey(
group: String, // 消费者组名
topicPartition: TopicPartition // 主题 + 分区号
): Array[Byte] = {
// 创建结构体,依次写入消费者组名、主题和分区号
val key = new Struct(CURRENT_OFFSET_KEY_SCHEMA)
key.set(OFFSET_KEY_GROUP_FIELD, group)
key.set(OFFSET_KEY_TOPIC_FIELD, topicPartition.topic)
key.set(OFFSET_KEY_PARTITION_FIELD, topicPartition.partition)
// 构造ByteBuffer写入格式版本和结构体
val byteBuffer = ByteBuffer.allocate(2 /* version */ + key.sizeOf)
byteBuffer.putShort(CURRENT_OFFSET_KEY_SCHEMA_VERSION)
key.writeTo(byteBuffer)
// 返回字节数组
byteBuffer.array()
}
```
该方法接收三元组中的数据然后创建一个结构体对象依次写入消费者组名、主题和分区号。接下来构造ByteBuffer写入格式版本和结构体最后返回它底层的字节数组。
说完了Key我们看下Value的定义。
offsetCommitValue方法决定了Value中都有哪些元素我们一起看下它的代码。这里我只列出了最新版本对应的结构体对象其他版本要写入的元素大同小异课下你可以阅读下其他版本的结构体内容也就是我省略的if分支下的代码。
```
def offsetCommitValue(offsetAndMetadata: OffsetAndMetadata,
apiVersion: ApiVersion): Array[Byte] = {
// 确定消息格式版本以及创建对应的结构体对象
val (version, value) = {
if (......) {
......
} else {
val value = new Struct(OFFSET_COMMIT_VALUE_SCHEMA_V3)
// 依次写入位移值、Leader Epoch值、自定义元数据以及时间戳
value.set(
OFFSET_VALUE_OFFSET_FIELD_V3, offsetAndMetadata.offset)
value.set(OFFSET_VALUE_LEADER_EPOCH_FIELD_V3,
offsetAndMetadata.leaderEpoch.orElse(RecordBatch.NO_PARTITION_LEADER_EPOCH))
value.set(OFFSET_VALUE_METADATA_FIELD_V3, offsetAndMetadata.metadata)
value.set(OFFSET_VALUE_COMMIT_TIMESTAMP_FIELD_V3, offsetAndMetadata.commitTimestamp)
(3, value)
}
}
// 构建ByteBuffer写入消息格式版本和结构体
val byteBuffer = ByteBuffer.allocate(2 /* version */ + value.sizeOf)
byteBuffer.putShort(version.toShort)
value.writeTo(byteBuffer)
// 返回ByteBuffer底层字节数组
byteBuffer.array()
}
```
offsetCommitValue方法首先确定消息格式版本以及创建对应的结构体对象。对于当前最新版本V3而言结构体的元素包括位移值、Leader Epoch值、自定义元数据和时间戳。如果我们使用Java Consumer API的话那么在提交位移时这个自定义元数据一般是空。
接下来构建ByteBuffer写入消息格式版本和结构体。
最后返回ByteBuffer底层字节数组。
与注册消息的消息体相比提交位移消息的Value要简单得多。我再用一张图展示一下提交位移消息的Key、Value构成。
<img src="https://static001.geekbang.org/resource/image/90/bd/90f52b9fbf2b8daced15717aafdd24bd.jpg" alt="">
### Tombstone消息
关于位移主题Kafka源码中还存在一类消息那就是Tombstone消息。其实它并没有任何稀奇之处就是Value为null的消息。因此注册消息和提交位移消息都有对应的Tombstone消息。这个消息的主要作用是让Kafka识别哪些Key对应的消息是可以被删除的有了它Kafka就能保证内部位移主题不会持续增加磁盘占用空间。
你可以看下下面两行代码它们分别表示两类消息对应的Tombstone消息。
```
// 提交位移消息对应的Tombstone消息
tombstones += new SimpleRecord(timestamp, commitKey, null)
// 注册消息对应的Tombstone消息
tombstones += new SimpleRecord(timestamp, groupMetadataKey, null)
```
无论是哪类消息,**它们的Value字段都是null**。一旦注册消息中出现了Tombstone消息就表示Kafka可以将该消费者组元数据从位移主题中删除一旦提交位移消息中出现了Tombstone就表示Kafka能够将该消费者组在某主题分区上的位移提交数据删除。
## 如何确定Coordinator
接下来我们要再学习一下位移主题和消费者组Coordinator之间的关系。**Coordinator组件是操作位移主题的唯一组件它在内部对位移主题进行读写操作**。
每个Broker在启动时都会启动Coordinator组件但是一个消费者组只能被一个Coordinator组件所管理。Kafka是如何确定哪台Broker上的Coordinator组件为消费者组服务呢答案是位移主题某个特定分区Leader副本所在的Broker被选定为指定消费者组的Coordinator。
那么这个特定分区是怎么计算出来的呢我们来看GroupMetadataManager类的partitionFor方法代码
```
def partitionFor(groupId: String): Int = Utils.abs(groupId.hashCode) % groupMetadataTopicPartitionCount
```
看到了吧,消费者组名哈希值与位移主题分区数求模的绝对值结果,就是该消费者组要写入位移主题的目标分区。
假设位移主题默认是50个分区我们的消费者组名是“testgroup”因此Math.abs(“testgroup”.hashCode % 50)的结果是27那么目标分区号就是27。也就是说这个消费者组的注册消息和提交位移消息都会写入到位移主题的分区27中而分区27的Leader副本所在的Broker就成为该消费者组的Coordinator。
## 总结
Kafka内部位移主题是Coordinator端用来保存和记录消费者组信息的重要工具。具体而言消费者组信息包括消费者组元数据以及已提交位移它们分别对应于我们今天讲的位移主题中的注册消息和已提交位移消息。前者定义了消费者组的元数据信息包括组名、成员列表和分区消费分配方案后者则是消费者组各个成员提交的位移值。这两部分信息共同构成了位移主题的消息类型。
除了消息类型我还介绍了消费者组确定Coordinator端的代码。明白了这一点下次你的消费者组成员出现问题的时候你就会知道要去哪台Broker上去查找相应的日志了。
我们来回顾一下这节课的重点。
- 位移主题即__consumer_offsets。该主题是内部主题默认有50个分区Kafka负责将其创建出来因此你不需要亲自执行创建主题操作。
- 消息类型:位移主题分为注册消息和已提交位移消息。
- Tombstone消息Value为null的位移主题消息用于清除消费者组已提交的位移值和注册信息。
- Coordinator确认原则消费者组名的哈希值与位移主题分区数求模的绝对值即为目标分区目标分区Leader副本所在的Broker即为Coordinator。
<img src="https://static001.geekbang.org/resource/image/03/e8/03843d5742157064dbb8bd227b9fb7e8.jpg" alt="">
定义了消息格式明确了Coordinator下一步就是Coordinator对位移主题进行读写操作了。具体来说就是构建今天我们所学的两类消息并将其序列化成字节数组写入到位移主题以及从位移主题中读取出字节数组并反序列化成对应的消息类型。下节课我们一起研究下这个问题。
## 课后讨论
请你根据今天的内容用kafka-console-consumer脚本去读取一下你线上环境中位移主题的已提交位移消息并结合readOffsetMessageValue方法的源码说一下输出中的每个字段都是什么含义。
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,379 @@
<audio id="audio" title="31 | GroupMetadataManager查询位移时不用读取位移主题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cc/e8/cc65d5cd5e47f8262f73f51940008be8.mp3"></audio>
你好,我是胡夕。
上节课,我们学习了位移主题中的两类消息:**消费者组注册消息**和**消费者组已提交位移消息**。今天,我们接着学习位移主题,重点是掌握写入位移主题和读取位移主题。
我们总说,位移主题是个神秘的主题,除了它并非我们亲自创建之外,它的神秘之处还体现在,它的读写也不由我们控制。默认情况下,我们没法向这个主题写入消息,而且直接读取该主题的消息时,看到的更是一堆乱码。因此,今天我们学习一下读写位移主题,这正是去除它神秘感的重要一步。
## 写入位移主题
我们先来学习一下位移主题的写入。在[第29讲](https://time.geekbang.org/column/article/257053)学习storeOffsets方法时我们已经学过了appendForGroup方法。Kafka定义的两类消息类型都是由它写入的。在源码中storeGroup方法调用它写入消费者组注册消息storeOffsets方法调用它写入已提交位移消息。
首先我们需要知道storeGroup方法它的作用是**向Coordinator注册消费者组**。我们看下它的代码实现:
```
def storeGroup(group: GroupMetadata,
groupAssignment: Map[String, Array[Byte]],
responseCallback: Errors =&gt; Unit): Unit = {
// 判断当前Broker是否是该消费者组的Coordinator
getMagic(partitionFor(group.groupId)) match {
// 如果当前Broker不是Coordinator
case Some(magicValue) =&gt;
val timestampType = TimestampType.CREATE_TIME
val timestamp = time.milliseconds()
// 构建注册消息的Key
val key = GroupMetadataManager.groupMetadataKey(group.groupId)
// 构建注册消息的Value
val value = GroupMetadataManager.groupMetadataValue(group, groupAssignment, interBrokerProtocolVersion)
// 使用Key和Value构建待写入消息集合
val records = {
val buffer = ByteBuffer.allocate(AbstractRecords.estimateSizeInBytes(magicValue, compressionType,
Seq(new SimpleRecord(timestamp, key, value)).asJava))
val builder = MemoryRecords.builder(buffer, magicValue, compressionType, timestampType, 0L)
builder.append(timestamp, key, value)
builder.build()
}
// 计算要写入的目标分区
val groupMetadataPartition = new TopicPartition(Topic.GROUP_METADATA_TOPIC_NAME, partitionFor(group.groupId))
val groupMetadataRecords = Map(groupMetadataPartition -&gt; records)
val generationId = group.generationId
// putCacheCallback方法填充Cache
......
// 向位移主题写入消息
appendForGroup(group, groupMetadataRecords, putCacheCallback)
// 如果当前Broker不是Coordinator
case None =&gt;
// 返回NOT_COORDINATOR异常
responseCallback(Errors.NOT_COORDINATOR)
None
}
}
```
为了方便你理解我画一张图来展示一下storeGroup方法的逻辑。
<img src="https://static001.geekbang.org/resource/image/a6/47/a6248981bc588722c09e38d6f1294447.jpg" alt="">
storeGroup方法的第1步是调用getMagic方法来判断当前Broker是否是该消费者组的Coordinator组件。判断的依据是尝试去获取位移主题目标分区的底层日志对象。如果能够获取到就说明当前Broker是Coordinator程序进入到下一步反之则表明当前Broker不是Coordinator就构造一个NOT_COORDINATOR异常返回。
第2步调用我们上节课学习的groupMetadataKey和groupMetadataValue方法去构造注册消息的Key和Value字段。
第3步使用Key和Value构建待写入消息集合。这里的消息集合类是MemoryRecords。
当前建模Kafka消息集合的类有两个。
- MemoryRecords表示内存中的消息集合
- FileRecords表示磁盘文件中的消息集合。
这两个类的源码不是我们学习的重点你只需要知道它们的含义就行了。不过我推荐你课下阅读一下它们的源码它们在clients工程中这可以进一步帮助你理解Kafka如何在内存和磁盘上保存消息。
第4步调用partitionFor方法计算要写入的位移主题目标分区。
第5步调用appendForGroup方法将待写入消息插入到位移主题的目标分区下。至此方法返回。
需要提一下的是在上面的代码中我省略了putCacheCallback方法的源码我们在第29讲已经详细地学习过它了。它的作用就是当消息被写入到位移主题后填充Cache。
可以看到写入位移主题和写入其它的普通主题并无差别。Coordinator会构造符合规定格式的消息数据并把它们传给storeOffsets和storeGroup方法由它们执行写入操作。因此我们可以认为Coordinator相当于位移主题的消息生产者。
## 读取位移主题
其实除了生产者这个角色以外Coordinator还扮演了消费者的角色也就是读取位移主题。跟写入相比读取操作的逻辑更加复杂一些不光体现在代码长度上更体现在消息读取之后的处理上。
首先,我们要知道,什么时候需要读取位移主题。
你可能会觉得当消费者组查询位移时会读取该主题下的数据。其实不然。查询位移时Coordinator只会从GroupMetadata元数据缓存中查找对应的位移值而不会读取位移主题。真正需要读取位移主题的时机**是在当前Broker当选Coordinator**也就是Broker成为了位移主题某分区的Leader副本时。
一旦当前Broker当选为位移主题某分区的Leader副本它就需要将它内存中的元数据缓存填充起来因此需要读取位移主题。在代码中这是由**scheduleLoadGroupAndOffsets**方法完成的。该方法会创建一个异步任务来读取位移主题消息并填充缓存。这个异步任务要执行的逻辑就是loadGroupsAndOffsets方法。
如果你翻开loadGroupsAndOffsets方法的源码就可以看到它本质上是调用doLoadGroupsAndOffsets方法实现的位移主题读取。下面我们就重点学习下这个方法。
这个方法的代码很长,为了让你能够更加清晰地理解它,我先带你了解下它的方法签名,然后再给你介绍具体的实现逻辑。
首先我们来看它的方法签名以及内置的一个子方法logEndOffset。
```
private def doLoadGroupsAndOffsets(topicPartition: TopicPartition, onGroupLoaded: GroupMetadata =&gt; Unit): Unit = {
// 获取位移主题指定分区的LEO值
// 如果当前Broker不是该分区的Leader副本则返回-1
def logEndOffset: Long = replicaManager.getLogEndOffset(topicPartition).getOrElse(-1L)
......
}
```
doLoadGroupsAndOffsets方法顾名思义它要做两件事请加载消费者组加载消费者组的位移。再强调一遍所谓的加载就是指读取位移主题下的消息并将这些信息填充到缓存中。
该方法接收两个参数第一个参数topicPartition是位移主题目标分区第二个参数onGroupLoaded是加载完成后要执行的逻辑这个逻辑是在上层组件中指定的我们不需要掌握它的实现这不会影响我们学习位移主题的读取。
doLoadGroupsAndOffsets还定义了一个内置子方法logEndOffset。它的目的很简单就是**获取位移主题指定分区的LEO值如果当前Broker不是该分区的Leader副本就返回-1**。
这是一个特别重要的事实因为Kafka依靠它来判断分区的Leader副本是否发生变更。一旦发生变更那么在当前Broker执行logEndOffset方法的返回值就是-1此时Broker就不再是Leader副本了。
doLoadGroupsAndOffsets方法会**读取位移主题目标分区的日志对象**,并执行核心的逻辑动作,代码如下:
```
......
replicaManager.getLog(topicPartition) match {
// 如果无法获取到日志对象
case None =&gt;
warn(s&quot;Attempted to load offsets and group metadata from $topicPartition, but found no log&quot;)
case Some(log) =&gt;
// 核心逻辑......
```
我把核心的逻辑分成3个部分来介绍。
- 第1部分初始化4个列表+读取位移主题;
- 第2部分处理读到的数据并填充4个列表
- 第3部分分别处理这4个列表。
在具体讲解这个方法所做的事情之前,我先画一张流程图,从宏观层面展示一下这个流程。
<img src="https://static001.geekbang.org/resource/image/d0/fb/d03d553361f14695917f6b62528008fb.jpg" alt="">
### 第1部分
首先,我们来学习一下第一部分的代码,完成了对位移主题的读取操作。
```
// 已完成位移值加载的分区列表
val loadedOffsets = mutable.Map[GroupTopicPartition, CommitRecordMetadataAndOffset]()
// 处于位移加载中的分区列表只用于Kafka事务
val pendingOffsets = mutable.Map[Long, mutable.Map[GroupTopicPartition, CommitRecordMetadataAndOffset]]()
// 已完成组信息加载的消费者组列表
val loadedGroups = mutable.Map[String, GroupMetadata]()
// 待移除的消费者组列表
val removedGroups = mutable.Set[String]()
// 保存消息集合的ByteBuffer缓冲区
var buffer = ByteBuffer.allocate(0)
// 位移主题目标分区日志起始位移值
var currOffset = log.logStartOffset
// 至少要求读取一条消息
var readAtLeastOneRecord = true
// 当前读取位移&lt;LEO且至少要求读取一条消息且GroupMetadataManager未关闭
while (currOffset &lt; logEndOffset &amp;&amp; readAtLeastOneRecord &amp;&amp; !shuttingDown.get()) {
// 读取位移主题指定分区
val fetchDataInfo = log.read(currOffset,
maxLength = config.loadBufferSize,
isolation = FetchLogEnd,
minOneMessage = true)
// 如果无消息可读,则不再要求至少读取一条消息
readAtLeastOneRecord = fetchDataInfo.records.sizeInBytes &gt; 0
// 创建消息集合
val memRecords = fetchDataInfo.records match {
case records: MemoryRecords =&gt; records
case fileRecords: FileRecords =&gt;
val sizeInBytes = fileRecords.sizeInBytes
val bytesNeeded = Math.max(config.loadBufferSize, sizeInBytes)
if (buffer.capacity &lt; bytesNeeded) {
if (config.loadBufferSize &lt; bytesNeeded)
warn(s&quot;Loaded offsets and group metadata from $topicPartition with buffer larger ($bytesNeeded bytes) than &quot; +
s&quot;configured offsets.load.buffer.size (${config.loadBufferSize} bytes)&quot;)
buffer = ByteBuffer.allocate(bytesNeeded)
} else {
buffer.clear()
}
fileRecords.readInto(buffer, 0)
MemoryRecords.readableRecords(buffer)
}
......
}
```
**首先**这部分代码创建了4个列表。
- loadedOffsets已完成位移值加载的分区列表
- pendingOffsets位移值加载中的分区列表
- loadedGroups已完成组信息加载的消费者组列表
- removedGroups待移除的消费者组列表。
**之后**代码又创建了一个ByteBuffer缓冲区用于保存消息集合。**接下来**,计算位移主题目标分区的日志起始位移值,这是要读取的起始位置。**再之后**,代码定义了一个布尔类型的变量,该变量表示本次至少要读取一条消息。
这些初始化工作都做完之后代码进入到while循环中。循环的条件有3个而且需要同时满足
- 读取位移值小于日志LEO值
- 布尔变量值是True
- GroupMetadataManager未关闭。
只要满足这3个条件代码就会一直执行while循环下的语句逻辑。整个while下的逻辑被分成了3个步骤我们现在学习的第1部分代码包含了前两步。最后一步在第3部分中实现即处理上面的这4个列表。我们先看前两步。
第1步是**读取位移主题目标分区的日志对象**,从日志中取出真实的消息数据。读取日志这个操作,是使用我们在[第3讲](https://time.geekbang.org/column/article/225993)中学过的Log.read方法完成的。当读取到完整的日志之后doLoadGroupsAndOffsets方法会查看返回的消息集合如果一条消息都没有返回则取消“至少要求读取一条消息”的限制即把刚才的布尔变量值设置为False。
第2步是根据上一步获取到的消息数据创建保存在内存中的消息集合对象也就是MemoryRecords对象。
由于doLoadGroupsAndOffsets方法要将读取的消息填充到缓存中因此这里必须做出MemoryRecords类型的消息集合。这就是第二路case分支要将FileRecords转换成MemoryRecords类型的原因。
至此第1部分逻辑完成。这一部分的产物就是成功地从位移主题目标分区读取到消息然后转换成MemoryRecords对象等待后续处理。
### 第2部分
现在代码进入到第2部分**处理消息集合**。
值得注意的是这部分代码依然在while循环下我们看下它是如何实现的
```
// 遍历消息集合的每个消息批次(RecordBatch)
memRecords.batches.forEach { batch =&gt;
val isTxnOffsetCommit = batch.isTransactional
// 如果是控制类消息批次
// 控制类消息批次属于Kafka事务范畴这里不展开讲
if (batch.isControlBatch) {
......
} else {
// 保存消息批次第一条消息的位移值
var batchBaseOffset: Option[Long] = None
// 遍历消息批次下的所有消息
for (record &lt;- batch.asScala) {
// 确保消息必须有Key否则抛出异常
require(record.hasKey, &quot;Group metadata/offset entry key should not be null&quot;)
// 记录消息批次第一条消息的位移值
if (batchBaseOffset.isEmpty)
batchBaseOffset = Some(record.offset)
// 读取消息Key
GroupMetadataManager.readMessageKey(record.key) match {
// 如果是OffsetKey说明是提交位移消息
case offsetKey: OffsetKey =&gt;
......
val groupTopicPartition = offsetKey.key
// 如果该消息没有Value
if (!record.hasValue) {
if (isTxnOffsetCommit)
pendingOffsets(batch.producerId)
.remove(groupTopicPartition)
else
// 将目标分区从已完成位移值加载的分区列表中移除
loadedOffsets.remove(groupTopicPartition)
} else {
val offsetAndMetadata = GroupMetadataManager.readOffsetMessageValue(record.value)
if (isTxnOffsetCommit)
pendingOffsets(batch.producerId).put(groupTopicPartition, CommitRecordMetadataAndOffset(batchBaseOffset, offsetAndMetadata))
else
// 将目标分区加入到已完成位移值加载的分区列表
loadedOffsets.put(groupTopicPartition, CommitRecordMetadataAndOffset(batchBaseOffset, offsetAndMetadata))
}
// 如果是GroupMetadataKey说明是注册消息
case groupMetadataKey: GroupMetadataKey =&gt;
val groupId = groupMetadataKey.key
val groupMetadata = GroupMetadataManager.readGroupMessageValue(groupId, record.value, time)
// 如果消息Value不为空
if (groupMetadata != null) {
// 把该消费者组从待移除消费者组列表中移除
removedGroups.remove(groupId)
// 将消费者组加入到已完成加载的消费组列表
loadedGroups.put(groupId, groupMetadata)
// 如果消息Value为空说明是Tombstone消息
} else {
// 把该消费者组从已完成加载的组列表中移除
loadedGroups.remove(groupId)
// 将消费者组加入到待移除消费组列表
removedGroups.add(groupId)
}
// 如果是未知类型的Key抛出异常
case unknownKey =&gt;
throw new IllegalStateException(s&quot;Unexpected message key $unknownKey while loading offsets and group metadata&quot;)
}
}
}
// 更新读取位置到消息批次最后一条消息的位移值+1等待下次while循环
currOffset = batch.nextOffset
}
```
这一部分的主要目的是处理上一步获取到的消息集合然后把相应数据添加到刚刚说到的4个列表中具体逻辑是代码遍历消息集合的每个消息批次Record Batch。我来解释一下这个流程。
**首先**判断该批次是否是控制类消息批次如果是就执行Kafka事务专属的一些逻辑。由于我们不讨论Kafka事务因此这里我就不详细展开了。如果不是就进入到下一步。
**其次**,遍历该消息批次下的所有消息,并依次执行下面的步骤。
第1步记录消息批次中第一条消息的位移值。
第2步读取消息Key并判断Key的类型判断的依据如下
- 如果是提交位移消息就判断消息有无Value。如果没有那么方法将目标分区从已完成位移值加载的分区列表中移除如果有则将目标分区加入到已完成位移值加载的分区列表中。
- 如果是注册消息依然是判断消息有无Value。如果存在Value就把该消费者组从待移除消费者组列表中移除并加入到已完成加载的消费组列表如果不存在Value就说明这是一条Tombstone消息那么代码把该消费者组从已完成加载的组列表中移除并加入到待移除消费组列表。
- 如果是未知类型的Key就直接抛出异常。
最后更新读取位置等待下次while循环这个位置就是整个消息批次中最后一条消息的位移值+1。
至此这部分代码宣告结束它的主要产物就是被填充了的4个列表。那么第3部分就要开始处理这4个列表了。
### 第3部分
最后一部分的完整代码如下:
```
// 处理loadedOffsets
val (groupOffsets, emptyGroupOffsets) = loadedOffsets
.groupBy(_._1.group)
.map { case (k, v) =&gt;
// 提取出&lt;组名,主题名,分区号&gt;与位移值对
k -&gt; v.map { case (groupTopicPartition, offset) =&gt; (groupTopicPartition.topicPartition, offset) }
}.partition { case (group, _) =&gt; loadedGroups.contains(group) }
......
// 处理loadedGroups
loadedGroups.values.foreach { group =&gt;
// 提取消费者组的已提交位移
val offsets = groupOffsets.getOrElse(group.groupId, Map.empty[TopicPartition, CommitRecordMetadataAndOffset])
val pendingOffsets = pendingGroupOffsets.getOrElse(group.groupId, Map.empty[Long, mutable.Map[TopicPartition, CommitRecordMetadataAndOffset]])
debug(s&quot;Loaded group metadata $group with offsets $offsets and pending offsets $pendingOffsets&quot;)
// 为已完成加载的组执行加载组操作
loadGroup(group, offsets, pendingOffsets)
// 为已完成加载的组执行加载组操作之后的逻辑
onGroupLoaded(group)
}
(emptyGroupOffsets.keySet ++ pendingEmptyGroupOffsets.keySet).foreach { groupId =&gt;
val group = new GroupMetadata(groupId, Empty, time)
val offsets = emptyGroupOffsets.getOrElse(groupId, Map.empty[TopicPartition, CommitRecordMetadataAndOffset])
val pendingOffsets = pendingEmptyGroupOffsets.getOrElse(groupId, Map.empty[Long, mutable.Map[TopicPartition, CommitRecordMetadataAndOffset]])
debug(s&quot;Loaded group metadata $group with offsets $offsets and pending offsets $pendingOffsets&quot;)
// 为空的消费者组执行加载组操作
loadGroup(group, offsets, pendingOffsets)
// 为空的消费者执行加载组操作之后的逻辑
onGroupLoaded(group)
}
// 处理removedGroups
removedGroups.foreach { groupId =&gt;
if (groupMetadataCache.contains(groupId) &amp;&amp; !emptyGroupOffsets.contains(groupId))
throw new IllegalStateException(s&quot;Unexpected unload of active group $groupId while &quot; +
s&quot;loading partition $topicPartition&quot;)
}
```
**首先**代码对loadedOffsets进行分组将那些已经完成组加载的消费者组位移值分到一组保存在字段groupOffsets中将那些有位移值但没有对应组信息的分成另外一组也就是字段emptyGroupOffsets保存的数据。
**其次**代码为loadedGroups中的所有消费者组执行加载组操作以及加载之后的操作onGroupLoaded。还记得吧loadedGroups中保存的都是已完成组加载的消费者组。这里的onGroupLoaded是上层调用组件Coordinator传入的。它主要的作用是处理消费者组下所有成员的心跳超时设置并指定下一次心跳的超时时间。
**再次**代码为emptyGroupOffsets的所有消费者组创建空的消费者组元数据然后执行和上一步相同的组加载逻辑以及加载后的逻辑。
**最后**代码检查removedGroups中的所有消费者组确保它们不能出现在消费者组元数据缓存中否则将抛出异常。
至此doLoadGroupsAndOffsets方法的逻辑全部完成。经过调用该方法后Coordinator成功地读取了位移主题目标分区下的数据并把它们填充到了消费者组元数据缓存中。
## 总结
今天我们重点学习了GroupMetadataManager类中读写位移主题的方法代码。Coordinator会使用这些方法对位移主题进行操作实现对消费者组的管理。写入操作比较简单它和一般的消息写入并无太大区别而读取操作相对复杂一些。更重要的是和我们的直观理解可能相悖的是Kafka在查询消费者组已提交位移时是不会读取位移主题的而是直接从内存中的消费者组元数据缓存中查询。这一点你一定要重点关注。
我们来简单回顾一下这节课的重点。
- 读写方法appendForGroup方法负责写入位移主题doLoadGroupsAndOffsets负责读取位移主题并加载组信息和位移值。
- 查询消费者组位移:查询位移时不读取位移主题,而是读取消费者组元数据缓存。
<img src="https://static001.geekbang.org/resource/image/19/3b/19304a381e75783fd584dyye5cc0733b.jpg" alt="">
至此GroupMetadataManager类的重要源码我们就学完了。作为一个有着将近1000行代码而且集这么多功能于一身的大文件这个类的代码绝对值得你多读几遍。
除了我们集中介绍的这些功能之外GroupMetadataManager类其实还是连接GroupMetadata和Coordinator的重要纽带Coordinator利用GroupMetadataManager类实现操作GroupMetadata的目的。
我刚开始学习这部分源码的时候居然不清楚GroupMetadata和GroupMetadataManager的区别是什么。现在经过这3节课的内容相信你已经知道GroupMetadata建模的是元数据信息而GroupMetadataManager类建模的是管理元数据的方法也是管理内部位移主题的唯一组件。以后碰到任何有关位移主题的问题你都可以直接到这个类中去寻找答案。
## 课后讨论
其实除了读写位移主题之外GroupMetadataManager还提供了清除位移主题数据的方法。代码中的cleanGroupMetadata就是做这个事儿的。请你结合源码分析一下cleanGroupMetadata方法的流程。
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,409 @@
<audio id="audio" title="32 | GroupCoordinator在Rebalance中Coordinator如何处理成员入组" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7c/93/7cefc2ca03365f3469717e23350e7693.mp3"></audio>
你好我是胡夕。不知不觉间课程已经接近尾声了最后这两节课我们来学习一下消费者组的Rebalance流程是如何完成的。
提到Rebalance你的第一反应一定是“爱恨交加”。毕竟如果使用得当它能够自动帮我们实现消费者之间的负载均衡和故障转移但如果配置失当我们就可能触碰到它被诟病已久的缺陷耗时长而且会出现消费中断。
在使用消费者组的实践中你肯定想知道应该如何避免Rebalance。如果你不了解Rebalance的源码机制的话就很容易掉进它无意中铺设的“陷阱”里。
举个小例子。有些人认为Consumer端参数session.timeout.ms决定了完成一次Rebalance流程的最大时间。这种认知是不对的实际上这个参数是用于检测消费者组成员存活性的即如果在这段超时时间内没有收到该成员发给Coordinator的心跳请求则把该成员标记为Dead而且要显式地将其从消费者组中移除并触发新一轮的Rebalance。而真正决定单次Rebalance所用最大时长的参数是Consumer端的**max.poll.interval.ms**。显然,如果没有搞懂这部分的源码,你就没办法为这些参数设置合理的数值。
总体而言, Rebalance的流程大致分为两大步加入组JoinGroup和组同步SyncGroup
**加入组是指消费者组下的各个成员向Coordinator发送JoinGroupRequest请求加入进组的过程**。这个过程有一个超时时间如果有成员在超时时间之内无法完成加入组操作它就会被排除在这轮Rebalance之外。
组同步是指当所有成员都成功加入组之后Coordinator指定其中一个成员为Leader然后将订阅分区信息发给Leader成员。接着所有成员包括Leader成员向Coordinator发送SyncGroupRequest请求。需要注意的是**只有Leader成员发送的请求中包含了订阅分区消费分配方案在其他成员发送的请求中这部分的内容为空**。当Coordinator接收到分配方案后会通过向成员发送响应的方式通知各个成员要消费哪些分区。
当组同步完成后Rebalance宣告结束。此时消费者组处于正常工作状态。
今天我们就学习下第一大步也就是加入组的源码实现它们位于GroupCoordinator.scala文件中。下节课我们再深入地学习组同步的源码实现。
要搞懂加入组的源码机制我们必须要学习4个方法分别是handleJoinGroup、doUnknownJoinGroup、doJoinGroup和addMemberAndRebalance。handleJoinGroup是执行加入组的顶层方法被KafkaApis类调用该方法依据给定消费者组成员是否了设置成员ID来决定是调用doUnknownJoinGroup还是doJoinGroup前者对应于未设定成员ID的情形后者对应于已设定成员ID的情形。而这两个方法都会调用addMemberAndRebalance执行真正的加入组逻辑。为了帮助你理解它们之间的交互关系我画了一张图借用它展示了这4个方法的调用顺序。
<img src="https://static001.geekbang.org/resource/image/b7/20/b7ed79cbf4eba29b39f32015b527c220.jpg" alt="">
## handleJoinGroup方法
如果你翻开KafkaApis.scala这个API入口文件就可以看到处理JoinGroupRequest请求的方法是handleJoinGroupRequest。而它的主要逻辑就是**调用GroupCoordinator的handleJoinGroup方法来处理消费者组成员发送过来的加入组请求所以我们要具体学习一下handleJoinGroup方法**。先看它的方法签名:
```
def handleJoinGroup(
groupId: String, // 消费者组名
memberId: String, // 消费者组成员ID
groupInstanceId: Option[String], // 组实例ID用于标识静态成员
requireKnownMemberId: Boolean, // 是否需要成员ID不为空
clientId: String, // client.id值
clientHost: String, // 消费者程序主机名
rebalanceTimeoutMs: Int, // Rebalance超时时间,默认是max.poll.interval.ms值
sessionTimeoutMs: Int, // 会话超时时间
protocolType: String, // 协议类型
protocols: List[(String, Array[Byte])], // 按照分配策略分组的订阅分区
responseCallback: JoinCallback // 回调函数
): Unit = {
......
}
```
这个方法的参数有很多,我介绍几个比较关键的。接下来在阅读其他方法的源码时,你还会看到这些参数,所以,这里你一定要提前掌握它们的含义。
- groupId消费者组名。
- memberId消费者组成员ID。如果成员是新加入的那么该字段是空字符串。
- groupInstanceId这是社区于2.4版本引入的静态成员字段。静态成员的引入可以有效避免因系统升级或程序更新而导致的Rebalance场景。它属于比较高阶的用法而且目前还没有被大规模使用因此这里你只需要简单了解一下它的作用。另外后面在讲其他方法时我会直接省略静态成员的代码我们只关注核心逻辑就行了。
- requireKnownMemberId是否要求成员ID不为空即是否要求成员必须设置ID的布尔字段。这个字段如果为True的话那么Kafka要求消费者组成员必须设置ID。未设置ID的成员会被拒绝加入组。直到它设置了ID之后才能重新加入组。
- clientId消费者端参数client.id值。Coordinator使用它来生成memberId。memberId的格式是clientId值-UUID。
- clientHost消费者程序的主机名。
- rebalanceTimeoutMsRebalance超时时间。如果在这个时间段内消费者组成员没有完成加入组的操作就会被禁止入组。
- sessionTimeoutMs会话超时时间。如果消费者组成员无法在这段时间内向Coordinator汇报心跳那么将被视为“已过期”从而引发新一轮Rebalance。
- responseCallback完成加入组之后的回调逻辑方法。当消费者组成员成功加入组之后需要执行该方法。
说完了方法签名,我们看下它的主体代码:
```
// 验证消费者组状态的合法性
validateGroupStatus(groupId, ApiKeys.JOIN_GROUP).foreach { error =&gt;
responseCallback(JoinGroupResult(memberId, error))
return
}
// 确保sessionTimeoutMs介于
// [group.min.session.timeout.ms值group.max.session.timeout.ms值]之间
// 否则抛出异常,表示超时时间设置无效
if (sessionTimeoutMs &lt; groupConfig.groupMinSessionTimeoutMs ||
sessionTimeoutMs &gt; groupConfig.groupMaxSessionTimeoutMs) {
responseCallback(JoinGroupResult(memberId, Errors.INVALID_SESSION_TIMEOUT))
} else {
// 消费者组成员ID是否为空
val isUnknownMember = memberId == JoinGroupRequest.UNKNOWN_MEMBER_ID
// 获取消费者组信息,如果组不存在,就创建一个新的消费者组
groupManager.getOrMaybeCreateGroup(groupId, isUnknownMember) match {
case None =&gt;
responseCallback(JoinGroupResult(memberId, Errors.UNKNOWN_MEMBER_ID))
case Some(group) =&gt;
group.inLock {
// 如果该消费者组已满员
if (!acceptJoiningMember(group, memberId)) {
// 移除该消费者组成员
group.remove(memberId)
group.removeStaticMember(groupInstanceId)
// 封装异常表明组已满员
responseCallback(JoinGroupResult(
JoinGroupRequest.UNKNOWN_MEMBER_ID,
Errors.GROUP_MAX_SIZE_REACHED))
// 如果消费者组成员ID为空
} else if (isUnknownMember) {
// 为空ID成员执行加入组操作
doUnknownJoinGroup(group, groupInstanceId, requireKnownMemberId, clientId, clientHost, rebalanceTimeoutMs, sessionTimeoutMs, protocolType, protocols, responseCallback)
} else {
// 为非空ID成员执行加入组操作
doJoinGroup(group, memberId, groupInstanceId, clientId, clientHost, rebalanceTimeoutMs, sessionTimeoutMs, protocolType, protocols, responseCallback)
}
// 如果消费者组正处于PreparingRebalance状态
if (group.is(PreparingRebalance)) {
// 放入Purgatory等待后面统一延时处理
joinPurgatory.checkAndComplete(GroupKey(group.groupId))
}
}
}
}
```
为了方便你更直观地理解,我画了一张图来说明它的完整流程。
<img src="https://static001.geekbang.org/resource/image/4b/89/4b4624d5cced2be6a77c7659e048b089.jpg" alt="">
第1步调用validateGroupStatus方法验证消费者组状态的合法性。所谓的合法性也就是消费者组名groupId不能为空以及JoinGroupRequest请求发送给了正确的Coordinator这两者必须同时满足。如果没有通过这些检查那么handleJoinGroup方法会封装相应的错误并调用回调函数返回。否则就进入到下一步。
第2步代码检验sessionTimeoutMs的值是否介于[group.min.session.timeout.msgroup.max.session.timeout.ms]之间,如果不是,就认定该值是非法值,从而封装一个对应的异常调用回调函数返回,这两个参数分别表示消费者组允许配置的最小和最大会话超时时间;如果是的话,就进入下一步。
第3步代码获取当前成员的ID信息并查看它是否为空。之后通过GroupMetadataManager获取消费者组的元数据信息如果该组的元数据信息存在则进入到下一步如果不存在代码会看当前成员ID是否为空如果为空就创建一个空的元数据对象然后进入到下一步如果不为空则返回None。一旦返回了NonehandleJoinGroup方法会封装“未知成员ID”的异常调用回调函数返回。
第4步检查当前消费者组是否已满员。该逻辑是通过**acceptJoiningMember方法**实现的。这个方法根据**消费者组状态**确定是否满员。这里的消费者组状态有三种。
**状态一**如果是Empty或Dead状态肯定不会是满员直接返回True表示可以接纳申请入组的成员
**状态二**如果是PreparingRebalance状态那么批准成员入组的条件是必须满足一下两个条件之一。
- 该成员是之前已有的成员,且当前正在等待加入组;
- 当前等待加入组的成员数小于Broker端参数group.max.size值。
只要满足这两个条件中的任意一个,当前消费者组成员都会被批准入组。
**状态三**:如果是其他状态,那么,入组的条件是**该成员是已有成员或者是当前组总成员数小于Broker端参数group.max.size值**。需要注意的是,这里比较的是**组当前的总成员数**而不是等待入组的成员数这是因为一旦Rebalance过渡到CompletingRebalance之后没有完成加入组的成员就会被移除。
倘若成员不被批准入组那么代码需要将该成员从元数据缓存中移除同时封装“组已满员”的异常并调用回调函数返回如果成员被批准入组则根据Member ID是否为空就执行doUnknownJoinGroup或doJoinGroup方法执行加入组的逻辑。
第5步是尝试完成JoinGroupRequest请求的处理。如果消费者组处于PreparingRebalance状态那么就将该请求放入Purgatory尝试立即完成如果是其它状态则无需将请求放入Purgatory。毕竟我们处理的是加入组的逻辑而此时消费者组的状态应该要变更到PreparingRebalance后Rebalance才能完成加入组操作。当然如果延时请求不能立即完成则交由Purgatory统一进行延时处理。
至此handleJoinGroup逻辑结束。
实际上我们可以看到真正执行加入组逻辑的是doUnknownJoinGroup和doJoinGroup这两个方法。那么接下来我们就来学习下这两个方法。
## doUnknownJoinGroup方法
如果是全新的消费者组成员加入组那么就需要为它们执行doUnknownJoinGroup方法因为此时它们的Member ID尚未生成。
除了memberId之外该方法的输入参数与handleJoinGroup方法几乎一模一样我就不一一地详细介绍了我们直接看它的源码。为了便于你理解我省略了关于静态成员以及DEBUG/INFO调试的部分代码。
```
group.inLock {
// Dead状态
if (group.is(Dead)) {
// 封装异常调用回调函数返回
responseCallback(JoinGroupResult(
JoinGroupRequest.UNKNOWN_MEMBER_ID,
Errors.COORDINATOR_NOT_AVAILABLE))
// 成员配置的协议类型/分区消费分配策略与消费者组的不匹配
} else if (!group.supportsProtocols(protocolType, MemberMetadata.plainProtocolSet(protocols))) {
responseCallback(JoinGroupResult(JoinGroupRequest.UNKNOWN_MEMBER_ID, Errors.INCONSISTENT_GROUP_PROTOCOL))
} else {
// 根据规则为该成员创建成员ID
val newMemberId = group.generateMemberId(clientId, groupInstanceId)
// 如果配置了静态成员
if (group.hasStaticMember(groupInstanceId)) {
......
// 如果要求成员ID不为空
} else if (requireKnownMemberId) {
......
group.addPendingMember(newMemberId)
addPendingMemberExpiration(group, newMemberId, sessionTimeoutMs)
responseCallback(JoinGroupResult(newMemberId, Errors.MEMBER_ID_REQUIRED))
} else {
......
// 添加成员
addMemberAndRebalance(rebalanceTimeoutMs, sessionTimeoutMs, newMemberId, groupInstanceId,
clientId, clientHost, protocolType, protocols, group, responseCallback)
}
}
}
```
为了方便你理解,我画了一张图来展示下这个方法的流程。
<img src="https://static001.geekbang.org/resource/image/49/95/497aef4be2afa50f34ddc99a6788b695.jpg" alt="">
首先,代码会检查消费者组的状态。
如果是Dead状态则封装异常然后调用回调函数返回。你可能会觉得奇怪既然是向该组添加成员为什么组状态还能是Dead呢实际上这种情况是可能的。因为在成员加入组的同时可能存在另一个线程已经把组的元数据信息从Coordinator中移除了。比如组对应的Coordinator发生了变更移动到了其他的Broker上此时代码封装一个异常返回给消费者程序后者会去寻找最新的Coordinator然后重新发起加入组操作。
如果状态不是Dead就检查该成员的协议类型以及分区消费分配策略是否与消费者组当前支持的方案匹配如果不匹配依然是封装异常然后调用回调函数返回。这里的匹配与否是指成员的协议类型与消费者组的是否一致以及成员设定的分区消费分配策略是否被消费者组下的其它成员支持。
如果这些检查都顺利通过接着代码就会为该成员生成成员ID生成规则是clientId-UUID。这便是generateMemberId方法做的事情。然后handleJoinGroup方法会根据requireKnownMemberId的取值来决定下面的逻辑路径
- 如果该值为True则将该成员加入到待决成员列表Pending Member List然后封装一个异常以及生成好的成员ID将该成员的入组申请“打回去”令其分配好了成员ID之后再重新申请
- 如果为False则无需这么苛刻直接调用addMemberAndRebalance方法将其加入到组中。至此handleJoinGroup方法结束。
通常来说如果你没有启用静态成员机制的话requireKnownMemberId的值是True这是由KafkaApis中handleJoinGroupRequest方法的这行语句决定的
```
val requireKnownMemberId = joinGroupRequest.version &gt;= 4 &amp;&amp; groupInstanceId.isEmpty
```
可见, 如果你使用的是比较新的Kafka客户端版本而且没有配置过Consumer端参数group.instance.id的话那么这个字段的值就是True这说明Kafka要求消费者成员加入组时必须要分配好成员ID。
关于addMemberAndRebalance方法的源码一会儿在学习doJoinGroup方法时我再给你具体解释。
## doJoinGroup方法
接下来我们看下doJoinGroup方法。这是为那些设置了成员ID的成员执行加入组逻辑的方法。它的输入参数全部承袭自handleJoinGroup方法输入参数你应该已经很熟悉了因此我们直接看它的源码实现。由于代码比较长我分成两个部分来介绍。同时我再画一张图帮助你理解整个方法的逻辑。
<img src="https://static001.geekbang.org/resource/image/46/4f/4658881317dc5d8afdeb3bac07cfae4f.jpg" alt="">
### 第1部分
这部分主要做一些校验和条件检查。
```
// 如果是Dead状态封装COORDINATOR_NOT_AVAILABLE异常调用回调函数返回
if (group.is(Dead)) {
responseCallback(JoinGroupResult(memberId, Errors.COORDINATOR_NOT_AVAILABLE))
// 如果协议类型或分区消费分配策略与消费者组的不匹配
// 封装INCONSISTENT_GROUP_PROTOCOL异常调用回调函数返回
} else if (!group.supportsProtocols(protocolType, MemberMetadata.plainProtocolSet(protocols))) {
responseCallback(JoinGroupResult(memberId, Errors.INCONSISTENT_GROUP_PROTOCOL))
// 如果是待决成员由于这次分配了成员ID故允许加入组
} else if (group.isPendingMember(memberId)) {
if (groupInstanceId.isDefined) {
......
} else {
......
// 令其加入组
addMemberAndRebalance(rebalanceTimeoutMs, sessionTimeoutMs, memberId, groupInstanceId,
clientId, clientHost, protocolType, protocols, group, responseCallback)
}
} else {
// 第二部分代码......
}
```
doJoinGroup方法开头和doUnkwownJoinGroup非常类似也是判断是否处于Dead状态并且检查协议类型和分区消费分配策略是否与消费者组的相匹配。
不同的是doJoinGroup要判断当前申请入组的成员是否是待决成员。如果是的话那么这次成员已经分配好了成员ID因此就直接调用addMemberAndRebalance方法令其入组如果不是的话那么方法进入到第2部分即处理一个非待决成员的入组申请。
### 第2部分
代码如下:
```
// 获取该成员的元数据信息
val member = group.get(memberId)
group.currentState match {
// 如果是PreparingRebalance状态
case PreparingRebalance =&gt;
// 更新成员信息并开始准备Rebalance
updateMemberAndRebalance(group, member, protocols, responseCallback)
// 如果是CompletingRebalance状态
case CompletingRebalance =&gt;
// 如果成员以前申请过加入组
if (member.matches(protocols)) {
// 直接返回当前组信息
responseCallback(JoinGroupResult(
members = if (group.isLeader(memberId)) {
group.currentMemberMetadata
} else {
List.empty
},
memberId = memberId,
generationId = group.generationId,
protocolType = group.protocolType,
protocolName = group.protocolName,
leaderId = group.leaderOrNull,
error = Errors.NONE))
// 否则更新成员信息并开始准备Rebalance
} else {
updateMemberAndRebalance(group, member, protocols, responseCallback)
}
// 如果是Stable状态
case Stable =&gt;
val member = group.get(memberId)
// 如果成员是Leader成员或者成员变更了分区分配策略
if (group.isLeader(memberId) || !member.matches(protocols)) {
// 更新成员信息并开始准备Rebalance
updateMemberAndRebalance(group, member, protocols, responseCallback)
} else {
responseCallback(JoinGroupResult(
members = List.empty,
memberId = memberId,
generationId = group.generationId,
protocolType = group.protocolType,
protocolName = group.protocolName,
leaderId = group.leaderOrNull,
error = Errors.NONE))
}
// 如果是其它状态,封装异常调用回调函数返回
case Empty | Dead =&gt;
warn(s&quot;Attempt to add rejoining member $memberId of group ${group.groupId} in &quot; +
s&quot;unexpected group state ${group.currentState}&quot;)
responseCallback(JoinGroupResult(memberId, Errors.UNKNOWN_MEMBER_ID))
}
```
这部分代码的**第1步**,是获取要加入组成员的元数据信息。
**第2步**是查询消费者组的当前状态。这里有4种情况。
<li>
如果是PreparingRebalance状态就说明消费者组正要开启Rebalance流程那么调用updateMemberAndRebalance方法更新成员信息并开始准备Rebalance即可。
</li>
<li>
如果是CompletingRebalance状态那么就判断一下该成员的分区消费分配策略与订阅分区列表是否和已保存记录中的一致如果相同就说明该成员已经应该发起过加入组的操作并且Coordinator已经批准了只是该成员没有收到因此针对这种情况代码构造一个JoinGroupResult对象直接返回当前的组信息给成员。但是如果protocols不相同那么就说明成员变更了订阅信息或分配策略就要调用updateMemberAndRebalance方法更新成员信息并开始准备新一轮Rebalance。
</li>
<li>
如果是Stable状态那么就判断该成员是否是Leader成员或者是它的订阅信息或分配策略发生了变更。如果是这种情况就调用updateMemberAndRebalance方法强迫一次新的Rebalance。否则的话返回当前组信息给该成员即可通知它们可以发起Rebalance的下一步操作。
</li>
<li>
如果这些状态都不是而是Empty或Dead状态那么就封装UNKNOWN_MEMBER_ID异常并调用回调函数返回。
</li>
可以看到这部分代码频繁地调用updateMemberAndRebalance方法。如果你翻开它的代码会发现它仅仅做两件事情。
- 更新组成员信息调用GroupMetadata的updateMember方法来更新消费者组成员
- 准备Rebalance这一步的核心思想是将消费者组状态变更到PreparingRebalance然后创建DelayedJoin对象并交由Purgatory等待延时处理加入组操作。
这个方法的代码行数不多而且逻辑很简单就是变更消费者组状态以及处理延时请求并放入Purgatory因此我不展开说了你可以自行阅读下这部分代码。
## addMemberAndRebalance方法
现在我们学习下doUnknownJoinGroup和doJoinGroup方法都会用到的addMemberAndRebalance方法。从名字上来看它的作用有两个
- 向消费者组添加成员;
- 准备Rebalance。
```
private def addMemberAndRebalance(
rebalanceTimeoutMs: Int,
sessionTimeoutMs: Int,
memberId: String,
groupInstanceId: Option[String],
clientId: String,
clientHost: String,
protocolType: String,
protocols: List[(String, Array[Byte])],
group: GroupMetadata,
callback: JoinCallback): Unit = {
// 创建MemberMetadata对象实例
val member = new MemberMetadata(
memberId, group.groupId, groupInstanceId,
clientId, clientHost, rebalanceTimeoutMs,
sessionTimeoutMs, protocolType, protocols)
// 标识该成员是新成员
member.isNew = true
// 如果消费者组准备开启首次Rebalance设置newMemberAdded为True
if (group.is(PreparingRebalance) &amp;&amp; group.generationId == 0)
group.newMemberAdded = true
// 将该成员添加到消费者组
group.add(member, callback)
// 设置下次心跳超期时间
completeAndScheduleNextExpiration(group, member, NewMemberJoinTimeoutMs)
if (member.isStaticMember) {
info(s&quot;Adding new static member $groupInstanceId to group ${group.groupId} with member id $memberId.&quot;)
group.addStaticMember(groupInstanceId, memberId)
} else {
// 从待决成员列表中移除
group.removePendingMember(memberId)
}
// 准备开启Rebalance
maybePrepareRebalance(group, s&quot;Adding new member $memberId with group instance id $groupInstanceId&quot;)
}
```
这个方法的参数列表虽然很长,但我相信,你对它们已经非常熟悉了,它们都是承袭自其上层调用方法的参数。
我来介绍一下这个方法的执行逻辑。
**第1步**该方法会根据传入参数创建一个MemberMetadata对象实例并设置isNew字段为True标识其是一个新成员。isNew字段与心跳设置相关联你可以阅读下MemberMetadata的hasSatisfiedHeartbeat方法的代码搞明白该字段是如何帮助Coordinator确认消费者组成员心跳的。
**第2步**代码会判断消费者组是否是首次开启Rebalance。如果是的话就把newMemberAdded字段设置为True如果不是则无需执行这个赋值操作。这个字段的作用是Kafka为消费者组Rebalance流程做的一个性能优化。大致的思想是在消费者组首次进行Rebalance时让Coordinator多等待一段时间从而让更多的消费者组成员加入到组中以免后来者申请入组而反复进行Rebalance。这段多等待的时间就是Broker端参数**group.initial.rebalance.delay.ms的值**。这里的newMemberAdded字段就是用于判断是否需要多等待这段时间的一个变量。
我们接着说回addMemberAndRebalance方法。该方法的**第3步**是调用GroupMetadata的add方法将新成员信息加入到消费者组元数据中同时设置该成员的下次心跳超期时间。
**第4步**,代码将该成员从待决成员列表中移除。毕竟,它已经正式加入到组中了,就不需要待在待决列表中了。
**第5步**调用maybePrepareRebalance方法准备开启Rebalance。
## 总结
至此我们学完了Rebalance流程的第一大步也就是加入组的源码学习。在这一步中你要格外注意**加入组时是区分有无消费者组成员ID**。对于未设定成员ID的分支代码调用doUnkwonwJoinGroup为成员生成ID信息对于已设定成员ID的分支则调用doJoinGroup方法。而这两个方法底层都是调用addMemberAndRebalance方法实现将消费者组成员添加进组的逻辑。
我们来简单回顾一下这节课的重点。
- Rebalance流程包括JoinGroup和SyncGroup两大步。
- handleJoinGroup方法Coordinator端处理成员加入组申请的方法。
- Member Id成员ID。Kafka源码根据成员ID的有无决定调用哪种加入组逻辑方法比如doUnknownJoinGroup或doJoinGroup方法。
- addMemberAndRebalance方法实现加入组功能的实际方法用于完成“加入组+开启Rebalance”这两个操作。
<img src="https://static001.geekbang.org/resource/image/41/01/41212f50defaffd79b04f851a278eb01.jpg" alt="">
当所有成员都成功加入到组之后所有成员会开启Rebalance的第二大步组同步。在这一步中成员会发送SyncGroupRequest请求给Coordinator。那么Coordinator又是如何应对的呢咱们下节课见分晓。
## 课后讨论
今天我们曾多次提到maybePrepareRebalance方法从名字上看它并不一定会开启Rebalance。那么你能否结合源码说说看到底什么情况下才能开启Rebalance
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,273 @@
<audio id="audio" title="33 | GroupCoordinator在Rebalance中如何进行组同步" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b4/5e/b4ab98241cba50f41626476877bd7a5e.mp3"></audio>
你好我是胡夕。今天我们继续学习消费者组Rebalance流程这节课我们重点学习这个流程的第2大步也就是组同步。
组同步也就是成员向Coordinator发送SyncGroupRequest请求等待Coordinator发送分配方案。在GroupCoordinator类中负责处理这个请求的入口方法就是handleSyncGroup。它进一步调用doSyncGroup方法完成组同步的逻辑。后者除了给成员下发分配方案之外还需要在元数据缓存中注册组消息以及把组状态变更为Stable。一旦完成了组同步操作Rebalance宣告结束消费者组开始正常工作。
接下来我们就来具体学习下组同步流程的实现逻辑。我们先从顶层的入口方法handleSyncGroup方法开始学习**该方法被KafkaApis类的handleSyncGroupRequest方法调用用于处理消费者组成员发送的SyncGroupRequest请求**。顺着这个入口方法我们会不断深入下沉到具体实现组同步逻辑的私有化方法doSyncGroup。
## handleSyncGroup方法
我们从handleSyncGroup的方法签名开始学习代码如下
```
def handleSyncGroup(
groupId: String, // 消费者组名
generation: Int, // 消费者组Generation号
memberId: String, // 消费者组成员ID
protocolType: Option[String], // 协议类型
protocolName: Option[String], // 分区消费分配策略名称
groupInstanceId: Option[String], // 静态成员Instance ID
groupAssignment: Map[String, Array[Byte]], // 按照成员分组的分配方案
responseCallback: SyncCallback // 回调函数
): Unit = {
......
}
```
该方法总共定义了8个参数你可以看下注释了解它们的含义我重点介绍6个比较关键的参数。
- **groupId**:消费者组名,标识这个成员属于哪个消费者组。
- **generation**消费者组Generation号。Generation类似于任期的概念标识了Coordinator负责为该消费者组处理的Rebalance次数。每当有新的Rebalance开启时Generation都会自动加1。
- **memberId**消费者组成员ID。该字段由Coordinator根据一定的规则自动生成。具体的规则上节课我们已经学过了我就不多说了。总体而言成员ID的值不是由你直接指定的但是你可以通过client.id参数间接影响该字段的取值。
- **protocolType**标识协议类型的字段这个字段可能的取值有两个consumer和connect。对于普通的消费者组而言这个字段的取值就是consumer该字段是Option类型因此实际的取值是Some(“consumer”)Kafka Connect组件中也会用到消费者组机制那里的消费者组的取值就是connect。
- **protocolName**消费者组选定的分区消费分配策略名称。这里的选择方法就是我们之前学到的GroupMetadata.selectProtocol方法。
- **groupAssignment**按照成员ID分组的分配方案。需要注意的是**只有Leader成员发送的SyncGroupRequest请求才包含这个方案**因此Coordinator在处理Leader成员的请求时该字段才有值。
你可能已经注意到了protocolType和protocolName都是Option类型这说明它们的取值可能是None即表示没有值。这是为什么呢
目前这两个字段的取值其实都是Coordinator帮助消费者组确定的也就是在Rebalance流程的上一步加入组中确定的。
如果成员成功加入组那么Coordinator会给这两个字段赋上正确的值并封装进JoinGroupRequest的Response里发送给消费者程序。一旦消费者拿到了Response中的数据就提取出这两个字段的值封装进SyncGroupRequest请求中再次发送给Coordinator。
如果成员没有成功加入组那么Coordinator会将这两个字段赋值成None加到Response中。因此在这里的handleSyncGroup方法中它们的类型就是Option。
说完了handleSyncGroup的方法签名我们看下它的代码
```
// 验证消费者状态及合法性
validateGroupStatus(groupId, ApiKeys.SYNC_GROUP) match {
// 如果未通过合法性检查且错误原因是Coordinator正在加载
// 那么封装REBALANCE_IN_PROGRESS异常并调用回调函数返回
case Some(error) if error == Errors.COORDINATOR_LOAD_IN_PROGRESS =&gt;
responseCallback(SyncGroupResult(Errors.REBALANCE_IN_PROGRESS))
// 如果是其它错误,则封装对应错误,并调用回调函数返回
case Some(error) =&gt; responseCallback(SyncGroupResult(error))
case None =&gt;
// 获取消费者组元数据
groupManager.getGroup(groupId) match {
// 如果未找到则封装UNKNOWN_MEMBER_ID异常并调用回调函数返回
case None =&gt;
responseCallback(SyncGroupResult(Errors.UNKNOWN_MEMBER_ID))
// 如果找到的话则调用doSyncGroup方法执行组同步任务
case Some(group) =&gt; doSyncGroup(
group, generation, memberId, protocolType, protocolName,
groupInstanceId, groupAssignment, responseCallback)
}
}
```
为了方便你理解,我画了一张流程图来说明此方法的主体逻辑。
<img src="https://static001.geekbang.org/resource/image/a2/a7/a252eb065397fc8a78e92b26fe2fc6a7.jpg" alt="">
handleSyncGroup方法首先会调用上一节课我们学习过的validateGroupStatus方法校验消费者组状态及合法性。这些检查项包括
1. 消费者组名不能为空;
1. Coordinator组件处于运行状态
1. Coordinator组件当前没有执行加载过程
1. SyncGroupRequest请求发送给了正确的Coordinator组件。
前两个检查项很容易理解,我重点解释一下最后两项的含义。
当Coordinator变更到其他Broker上时需要从内部位移主题中读取消息数据并填充到内存上的消费者组元数据缓存这就是所谓的加载。
- 如果Coordinator变更了那么发送给老Coordinator所在Broker的请求就失效了因为它没有通过第4个检查项即发送给正确的Coordinator
- 如果发送给了正确的Coordinator但此时Coordinator正在执行加载过程那么它就没有通过第3个检查项因为Coordinator尚不能对外提供服务要等加载完成之后才可以。
代码对消费者组依次执行上面这4项校验一旦发现有项目校验失败validateGroupStatus方法就会将检查失败的原因作为结果返回。如果是因为Coordinator正在执行加载就意味着**本次Rebalance的所有状态都丢失了**。这里的状态,指的是消费者组下的成员信息。那么,此时最安全的做法,是**让消费者组重新从加入组开始**因此代码会封装REBALANCE_IN_PROGRESS异常然后调用回调函数返回。一旦消费者组成员接收到此异常就会知道它至少找到了正确的Coordinator只需要重新开启Rebalance而不需要在开启Rebalance之前再大费周章地去定位Coordinator组件了。但如果是其它错误就封装该错误然后调用回调函数返回。
倘若消费者组通过了以上校验那么代码就会获取该消费者组的元数据信息。如果找不到对应的元数据就封装UNKNOWN_MEMBER_ID异常之后调用回调函数返回如果找到了元数据信息就调用doSyncGroup方法执行真正的组同步逻辑。
显然接下来我们应该学习doSyncGroup方法的源码了这才是真正实现组同步功能的地方。
## doSyncGroup方法
doSyncGroup方法接收的输入参数与它的调用方法handleSyncGroup如出一辙所以这里我就不再展开讲了我们重点关注一下它的源码实现。
鉴于它的代码很长,我把它拆解成两个部分,并配以流程图进行介绍。
- 第1部分主要**对消费者组做各种校验**,如果没有通过校验,就封装对应的异常给回调函数;
- 第2部分**根据不同的消费者组状态选择不同的执行逻辑**。你需要特别关注一下在CompletingRebalance状态下代码是如何实现组同步的。
我先给出第1部分的流程图你可以先看一下对这个流程有个整体的感知。
<img src="https://static001.geekbang.org/resource/image/f0/4b/f0d4274cc5d37663fb3d0da6b0af954b.jpg" alt="">
下面,我们来看这部分的代码:
```
if (group.is(Dead)) {
responseCallback(
SyncGroupResult(Errors.COORDINATOR_NOT_AVAILABLE))
} else if (group.isStaticMemberFenced(memberId, groupInstanceId, &quot;sync-group&quot;)) {
responseCallback(SyncGroupResult(Errors.FENCED_INSTANCE_ID))
} else if (!group.has(memberId)) {
responseCallback(SyncGroupResult(Errors.UNKNOWN_MEMBER_ID))
} else if (generationId != group.generationId) {
responseCallback(SyncGroupResult(Errors.ILLEGAL_GENERATION))
} else if (protocolType.isDefined &amp;&amp; !group.protocolType.contains(protocolType.get)) {
responseCallback(SyncGroupResult(Errors.INCONSISTENT_GROUP_PROTOCOL))
} else if (protocolName.isDefined &amp;&amp; !group.protocolName.contains(protocolName.get)) {
responseCallback(SyncGroupResult(Errors.INCONSISTENT_GROUP_PROTOCOL))
} else {
// 第2部分源码......
}
```
可以看到代码非常工整全是if-else类型的判断。
**首先**这部分代码会判断消费者组的状态是否是Dead。如果是的话就说明该组的元数据信息已经被其他线程从Coordinator中移除了这很可能是因为Coordinator发生了变更。此时最佳的做法是**拒绝该成员的组同步操作**封装COORDINATOR_NOT_AVAILABLE异常显式告知它去寻找最新Coordinator所在的Broker节点然后再尝试重新加入组。
接下来的isStaticMemberFenced方法判断是有关静态成员的我们可以不用理会。
**之后**代码判断memberId字段标识的成员是否属于这个消费者组。如果不属于的话就封装UNKNOWN_MEMBER_ID异常并调用回调函数返回如果属于的话则继续下面的判断。
**再之后**,代码**判断成员的Generation是否和消费者组的相同**。如果不同的话则封装ILLEGAL_GENERATION异常给回调函数如果相同的话则继续下面的判断。
接下来,代码**判断成员和消费者组的协议类型是否一致**。如果不一致则封装INCONSISTENT_GROUP_PROTOCOL异常给回调函数如果一致就进行下一步。
**最后**,判断**成员和消费者组的分区消费分配策略是否一致**。如果不一致同样封装INCONSISTENT_GROUP_PROTOCOL异常给回调函数。
如果这些都一致则顺利进入到第2部分。在开始之前我依然用一张图来展示一下这里的实现逻辑。
<img src="https://static001.geekbang.org/resource/image/91/bf/9184344e316c3cb5e6e797c1b574acbf.jpg" alt="">
进入到这部分之后,代码要做什么事情,完全**取决于消费者组的当前状态**。如果消费者组处于CompletingRebalance状态这部分代码要做的事情就比较复杂我们一会儿再说现在先看除了这个状态之外的逻辑代码。
```
group.currentState match {
case Empty =&gt;
// 封装UNKNOWN_MEMBER_ID异常调用回调函数返回
responseCallback(SyncGroupResult(Errors.UNKNOWN_MEMBER_ID))
case PreparingRebalance =&gt;
// 封装REBALANCE_IN_PROGRESS异常调用回调函数返回
responseCallback(SyncGroupResult(Errors.REBALANCE_IN_PROGRESS))
case CompletingRebalance =&gt;
// 下面详细展开......
case Stable =&gt;
// 获取消费者组成员元数据
val memberMetadata = group.get(memberId)
// 封装组协议类型、分配策略、成员分配方案,调用回调函数返回
responseCallback(SyncGroupResult(group.protocolType, group.protocolName, memberMetadata.assignment, Errors.NONE))
// 设定成员下次心跳时间
completeAndScheduleNextHeartbeatExpiration(group, group.get(memberId))
case Dead =&gt;
// 抛出异常
throw new IllegalStateException(s&quot;Reached unexpected condition for Dead group ${group.groupId}&quot;)
}
```
如果消费者组的当前状态是Empty或PreparingRebalance那么代码会封装对应的异常给回调函数供其调用。
如果是Stable状态则说明此时消费者组已处于正常工作状态无需进行组同步的操作。因此在这种情况下简单返回消费者组当前的分配方案给回调函数供它后面发送给消费者组成员即可。
如果是Dead状态那就说明这是一个异常的情况了因为理论上不应该为处于Dead状态的组执行组同步因此代码只能选择抛出IllegalStateException异常让上层方法处理。
如果这些状态都不是那么消费者组就只能处于CompletingRebalance状态这也是执行组同步操作时消费者组最有可能处于的状态。因此这部分的逻辑要复杂一些我们看下代码
```
// 为该消费者组成员设置组同步回调函数
group.get(memberId).awaitingSyncCallback = responseCallback
// 组Leader成员发送的SyncGroupRequest请求需要特殊处理
if (group.isLeader(memberId)) {
info(s&quot;Assignment received from leader for group ${group.groupId} for generation ${group.generationId}&quot;)
// 如果有成员没有被分配任何消费方案,则创建一个空的方案赋给它
val missing = group.allMembers.diff(groupAssignment.keySet)
val assignment = groupAssignment ++ missing.map(_ -&gt; Array.empty[Byte]).toMap
if (missing.nonEmpty) {
warn(s&quot;Setting empty assignments for members $missing of ${group.groupId} for generation ${group.generationId}&quot;)
}
// 把消费者组信息保存在消费者组元数据中,并且将其写入到内部位移主题
groupManager.storeGroup(group, assignment, (error: Errors) =&gt; {
group.inLock {
// 如果组状态是CompletingRebalance以及成员和组的generationId相同
if (group.is(CompletingRebalance) &amp;&amp; generationId == group.generationId) {
// 如果有错误
if (error != Errors.NONE) {
// 清空分配方案并发送给所有成员
resetAndPropagateAssignmentError(group, error)
// 准备开启新一轮的Rebalance
maybePrepareRebalance(group, s&quot;error when storing group assignment during SyncGroup (member: $memberId)&quot;)
// 如果没错误
} else {
// 在消费者组元数据中保存分配方案并发送给所有成员
setAndPropagateAssignment(group, assignment)
// 变更消费者组状态到Stable
group.transitionTo(Stable)
}
}
}
})
groupCompletedRebalanceSensor.record()
}
```
第1步为该消费者组成员设置组同步回调函数。我们总说回调函数其实它的含义很简单也就是将传递给回调函数的数据通过Response的方式发送给消费者组成员。
第2步判断当前成员是否是消费者组的Leader成员。如果不是Leader成员方法直接结束因为只有Leader成员的groupAssignment字段才携带了分配方案其他成员是没有分配方案的如果是Leader成员则进入到下一步。
第3步为没有分配到任何分区的成员创建一个空的分配方案并赋值给这些成员。这一步的主要目的是构造一个统一格式的分配方案字段assignment。
第4步调用storeGroup方法保存消费者组信息到消费者组元数据同时写入到内部位移主题中。一旦完成这些动作则进入到下一步。
第5步在组状态是CompletingRebalance而且成员和组的Generation ID相同的情况下就判断一下刚刚的storeGroup操作过程中是否出现过错误
- 如果有错误则清空分配方案并发送给所有成员同时准备开启新一轮的Rebalance
- 如果没有错误则在消费者组元数据中保存分配方案然后发送给所有成员并将消费者组状态变更到Stable。
倘若组状态不是CompletingRebalance或者是成员和组的Generation ID不相同这就说明消费者组可能开启了新一轮的Rebalance那么此时就不能继续给成员发送分配方案。
至此CompletingRebalance状态下的组同步操作完成。总结一下组同步操作完成了以下3件事情
1. 将包含组成员分配方案的消费者组元数据,添加到消费者组元数据缓存以及内部位移主题中;
1. 将分配方案通过SyncGroupRequest响应的方式下发给组下所有成员。
1. 将消费者组状态变更到Stable。
我建议你对照着代码自行寻找并阅读一下完成这3件事情的源码这不仅有助于你复习下今天所学的内容还可以帮你加深对源码的理解。阅读的时候你思考一下这些代码的含义是否真的如我所说。如果你有不一样的理解欢迎写在留言区我们可以开放式讨论。
## 总结
今天我们重点学习了Rebalance流程的第2步也就是组同步。至此关于Rebalance的完整流程我们就全部学完了。
Rebalance流程是Kafka提供的一个非常关键的消费者组功能。由于它非常重要所以社区在持续地对它进行着改进包括引入增量式的Rebalance以及静态成员等。我们在这两节课学的Rebalance流程是理解这些高级功能的基础。如果你不清楚Rebalance过程中的这些步骤都是做什么的你就无法深入地掌握增量式Rebalance或静态成员机制所做的事情。
因此我建议你结合上节课的内容好好学习一下消费者组的Rebalance彻底弄明白一个消费者组成员是如何参与其中并最终完成Rebalance过程的。
我们来回顾一下这节课的重点。
- 组同步成员向Coordinator发送SyncGroupRequest请求以获取分配方案。
- handleSyncGroup方法接收KafkaApis发来的SyncGroupRequest请求体数据执行组同步逻辑。
- doSyncGroup方法真正执行组同步逻辑的方法执行组元数据保存、分配方案下发以及状态变更操作。
<img src="https://static001.geekbang.org/resource/image/fc/e9/fccc73c2867102f2ec6e8e3666f101e9.jpg" alt="">
讲到这里Coordinator组件的源码我就介绍完了。在这个模块中我们基本上还是践行“自上而下+自下而上”的学习方式。我们先从最低层次的消费者组元数据类开始学习逐渐上浮到它的管理器类GroupMetadataManager类以及顶层类GroupCoordinator类。接着在学习Rebalance流程时我们反其道而行之先从GroupCoordinator类的入口方法进行拆解又逐渐下沉到GroupMetadataManager和更底层的GroupMetadata以及MemberMetadata。
如果你追随着课程的脚步一路走来,你就会发现,我经常采用这种方式讲解源码。我希望,你在日后的源码学习中,也可以多尝试运用这种方法。所谓择日不如撞日,我今天就给你推荐一个课后践行此道的绝佳例子。
我建议你去阅读下clients工程中的实现消息、消息批次以及消息集合部分的源码也就是Record、RecordBatch和Records这些接口和类的代码去反复实践“自上而下”和“自下而上”这两种阅读方法。
其实这种方式不只适用于Kafka源码在阅读其他框架的源码时也可以采用这种方式。希望你可以不断总结经验最终提炼出一套适合自己的学习模式。
## 课后讨论
Coordinator不会将所有消费者组的所有成员的分配方案下发给单个成员这就是说成员A看不到成员B的分区消费分配方案。那么你能找出来源码中的哪行语句做了这件事情吗
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。