mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 14:43:42 +08:00
del
This commit is contained in:
304
极客时间专栏/geek/Kafka核心源码解读/消费者组管理模块/27 | 消费者组元数据(上):消费者组都有哪些元数据?.md
Normal file
304
极客时间专栏/geek/Kafka核心源码解读/消费者组管理模块/27 | 消费者组元数据(上):消费者组都有哪些元数据?.md
Normal 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_offsets:Kafka的内部主题。除了我们熟知的消费者组提交位移记录功能之外,它还负责保存消费者组的注册记录消息。
|
||||
- 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-<序号>-<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方法的返回值就是集合[RangeAssignor,RoundRobinAssignor]。实际上,它经常被用来统计一个消费者组下的成员到底配置了多少种分区分配策略。
|
||||
|
||||
### 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, // 对消费者组而言,是"consumer"
|
||||
// 成员配置的多套分区分配策略
|
||||
var supportedProtocols: List[(String, Array[Byte])]) {
|
||||
// 分区分配方案
|
||||
var assignment: Array[Byte] = Array.empty[Byte]
|
||||
var awaitingJoinCallback: JoinGroupResult => Unit = null
|
||||
var awaitingSyncCallback: SyncGroupResult => Unit = null
|
||||
var isLeaving: Boolean = false
|
||||
var isNew: Boolean = false
|
||||
val isStaticMember: Boolean = groupInstanceId.isDefined
|
||||
var heartbeatSatisfied: Boolean = false
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
MemberMetadata类保存的数据很丰富,在它的构造函数中,除了包含MemberSummary类定义的6个字段外,还定义了4个新字段。
|
||||
|
||||
- rebalanceTimeoutMs:Rebalance操作的超时时间,即一次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)) => metadata
|
||||
case None =>
|
||||
throw new IllegalArgumentException("Member does not support protocol")
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
它实现的逻辑是:从该成员配置的分区分配方案列表中寻找给定策略的详情。如果找到,就直接返回详情字节数组数据,否则,就抛出异常。怎么样,是不是很简单?
|
||||
|
||||
## 组元数据(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 -> PreparingRebalance -> CompletingRebalance -> 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 => 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项,分别是:<“range”,2>、<“round-robin”,2>和<“sticky”,1>。
|
||||
|
||||
这些扩展字段和构造函数中的字段,共同构建出了完整的消费者组元数据。就我个人而言,我认为这些字段中最重要的就是**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项,对应于咱们今天学习的哪一项元数据呢?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
@@ -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字段保存所有的成员信息。该字段是一个HashMap,Key是成员的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, _) => 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 =>
|
||||
// 更新分区分配策略支持票数
|
||||
member.supportedProtocols.foreach{ case (protocol, _) => 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 < 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("Cannot complete offset commit write without providing the metadata of the record " +
|
||||
"in the log.")
|
||||
// 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 =>
|
||||
pendingOffsetCommits.remove(topicPartition)
|
||||
case _ =>
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
该方法在提交位移消息被成功写入后调用。主要判断的依据,是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 => Long,
|
||||
subscribedTopics: Set[String] = Set.empty): Map[TopicPartition, OffsetAndMetadata] = {
|
||||
// 遍历offsets中的所有分区,过滤出同时满足以下3个条件的所有分区
|
||||
// 条件1:分区所属主题不在订阅主题列表之内
|
||||
// 条件2:该主题分区已经完成位移提交
|
||||
// 条件3:该主题分区在位移主题中对应消息的存在时间超过了阈值
|
||||
offsets.filter {
|
||||
case (topicPartition, commitRecordMetadataAndOffset) =>
|
||||
!subscribedTopics.contains(topicPartition.topic()) &&
|
||||
!pendingOffsetCommits.contains(topicPartition) && {
|
||||
commitRecordMetadataAndOffset
|
||||
.offsetAndMetadata.expireTimestamp match {
|
||||
case None =>
|
||||
currentTimestamp - baseTimestamp(commitRecordMetadataAndOffset) >= offsetRetentionMs
|
||||
case Some(expireTimestamp) =>
|
||||
currentTimestamp >= expireTimestamp
|
||||
}
|
||||
}
|
||||
}.map {
|
||||
// 为满足以上3个条件的分区提取出commitRecordMetadataAndOffset中的位移值
|
||||
case (topicPartition, commitRecordOffsetAndMetadata) =>
|
||||
(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) =>
|
||||
getExpiredOffsets(
|
||||
commitRecordMetadataAndOffset => currentStateTimestamp .getOrElse(commitRecordMetadataAndOffset.offsetAndMetadata.commitTimestamp)
|
||||
)
|
||||
case Some(ConsumerProtocol.PROTOCOL_TYPE) if subscribedTopics.isDefined =>
|
||||
getExpiredOffsets(
|
||||
_.offsetAndMetadata.commitTimestamp,
|
||||
subscribedTopics.get
|
||||
)
|
||||
case None =>
|
||||
getExpiredOffsets(_.offsetAndMetadata.commitTimestamp)
|
||||
case _ =>
|
||||
Map()
|
||||
}
|
||||
if (expiredOffsets.nonEmpty)
|
||||
debug(s"Expired offsets from group '$groupId': ${expiredOffsets.keySet}")
|
||||
// 将过期位移对应的主题分区从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("Cannot select protocol for empty group")
|
||||
// 获取所有成员都支持的策略集合
|
||||
val candidates = candidateProtocols
|
||||
// 让每个成员投票,票数最多的那个策略当选
|
||||
val (protocol, _) = allMemberMetadata
|
||||
.map(_.vote(candidates))
|
||||
.groupBy(identity)
|
||||
.maxBy { case (_, votes) => 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是怎么确认消费者组使用哪个成员的超时时间作为整个组的超时时间呢?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
@@ -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
|
||||
// interBrokerProtocolVersion:Broker端参数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类使用该字段实现获取分区对象、日志对象以及写入分区消息的目的。
|
||||
- zkClient:ZooKeeper客户端。该类中的此字段只有一个目的:从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 => Unit): Unit = {
|
||||
// 位移主题分区
|
||||
val topicPartition = new TopicPartition(Topic.GROUP_METADATA_TOPIC_NAME, offsetsPartition)
|
||||
info(s"Scheduling unloading of offsets and group metadata from $topicPartition")
|
||||
// 创建异步任务,移除组信息和位移信息
|
||||
scheduler.schedule(topicPartition.toString, () => removeGroupsAndOffsets)
|
||||
// 内部方法,用于移除组信息和位移信息
|
||||
def removeGroupsAndOffsets(): Unit = {
|
||||
var numOffsetsRemoved = 0
|
||||
var numGroupsRemoved = 0
|
||||
inLock(partitionLock) {
|
||||
// 移除ownedPartitions中特定位移主题分区记录
|
||||
ownedPartitions.remove(offsetsPartition)
|
||||
// 遍历所有消费者组信息
|
||||
for (group <- 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"Finished unloading $topicPartition. Removed $numOffsetsRemoved cached offsets " +
|
||||
s"and $numGroupsRemoved cached groups.")
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
该方法的主要逻辑是,先定义一个内部方法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"Initialized offsets $offsets for group ${group.groupId}")
|
||||
// 初始化消费者组的位移信息
|
||||
group.initializeOffsets(offsets, pendingTransactionalOffsets.toMap)
|
||||
// 调用addGroup方法添加消费者组
|
||||
val currentGroup = addGroup(group)
|
||||
if (group != currentGroup)
|
||||
debug(s"Attempt to load group ${group.groupId} from log with generation ${group.generationId} failed " +
|
||||
s"because there is already a cached group with generation ${currentGroup.generationId}")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
该方法的逻辑有两步。
|
||||
|
||||
第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] => 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) =>
|
||||
validateOffsetMetadataLength(offsetAndMetadata.metadata)
|
||||
}
|
||||
......
|
||||
val isTxnOffsetCommit = producerId != RecordBatch.NO_PRODUCER_ID
|
||||
// 如果没有任何分区的待保存位移满足特定条件
|
||||
if (filteredOffsetMetadata.isEmpty) {
|
||||
// 构造OFFSET_METADATA_TOO_LARGE异常并调用responseCallback返回
|
||||
val commitStatus = offsetMetadata.map { case (k, _) => k -> Errors.OFFSET_METADATA_TOO_LARGE }
|
||||
responseCallback(commitStatus)
|
||||
None
|
||||
} else {
|
||||
// 查看当前Broker是否为给定消费者组的Coordinator
|
||||
getMagic(partitionFor(group.groupId)) match {
|
||||
// 如果是Coordinator
|
||||
case Some(magicValue) =>
|
||||
val timestampType = TimestampType.CREATE_TIME
|
||||
val timestamp = time.milliseconds()
|
||||
// 构造位移主题的位移提交消息
|
||||
val records = filteredOffsetMetadata.map { case (topicPartition, offsetAndMetadata) =>
|
||||
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 && magicValue < RecordBatch.MAGIC_VALUE_V2)
|
||||
throw Errors.UNSUPPORTED_FOR_MESSAGE_FORMAT.exception("Attempting to make a transaction offset commit with an invalid magic: " + 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 -> builder.build())
|
||||
// putCacheCallback函数定义......
|
||||
if (isTxnOffsetCommit) {
|
||||
......
|
||||
} else {
|
||||
group.inLock {
|
||||
group.prepareOffsetCommit(offsetMetadata)
|
||||
}
|
||||
}
|
||||
// 写入消息到位移主题,同时调用putCacheCallback方法更新消费者元数据
|
||||
appendForGroup(group, entries, putCacheCallback)
|
||||
// 如果是Coordinator
|
||||
case None =>
|
||||
// 构造NOT_COORDINATOR异常并提交给responseCallback方法
|
||||
val commitStatus = offsetMetadata.map {
|
||||
case (topicPartition, _) =>
|
||||
(topicPartition, Errors.NOT_COORDINATOR)
|
||||
}
|
||||
responseCallback(commitStatus)
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我为方法的关键步骤都标注了注释,具体流程前面我也介绍过了,应该很容易理解。不过,这里还需要注意两点,也就是appendForGroup和putCacheCallback方法。前者是向位移主题写入消息;后者是填充元数据缓存的。我们结合代码来学习下。
|
||||
|
||||
appendForGroup方法负责写入消息到位移主题,同时传入putCacheCallback方法,更新消费者元数据。以下是它的代码:
|
||||
|
||||
```
|
||||
private def appendForGroup(
|
||||
group: GroupMetadata,
|
||||
records: Map[TopicPartition, MemoryRecords],
|
||||
callback: Map[TopicPartition, PartitionResponse] => 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("Append status %s should only have one partition %s"
|
||||
.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) =>
|
||||
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) =>
|
||||
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 =>
|
||||
Errors.COORDINATOR_NOT_AVAILABLE
|
||||
|
||||
case Errors.NOT_LEADER_FOR_PARTITION
|
||||
| Errors.KAFKA_STORAGE_ERROR =>
|
||||
Errors.NOT_COORDINATOR
|
||||
|
||||
case Errors.MESSAGE_TOO_LARGE
|
||||
| Errors.RECORD_LIST_TOO_LARGE
|
||||
| Errors.INVALID_FETCH_SIZE =>
|
||||
Errors.INVALID_COMMIT_OFFSET_SIZE
|
||||
case other => other
|
||||
}
|
||||
}
|
||||
}
|
||||
// 利用异常类型构建提交返回状态
|
||||
val commitStatus = offsetMetadata.map { case (topicPartition, offsetAndMetadata) =>
|
||||
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 =>
|
||||
val partitionData = new PartitionData(OffsetFetchResponse.INVALID_OFFSET,
|
||||
Optional.empty(), "", Errors.NONE)
|
||||
topicPartition -> partitionData
|
||||
}.toMap
|
||||
// 如果存在组数据
|
||||
} else {
|
||||
group.inLock {
|
||||
// 如果组处于Dead状态,则返回空数据
|
||||
if (group.is(Dead)) {
|
||||
topicPartitionsOpt.getOrElse(Seq.empty[TopicPartition]).map { topicPartition =>
|
||||
val partitionData = new PartitionData(OffsetFetchResponse.INVALID_OFFSET,
|
||||
Optional.empty(), "", Errors.NONE)
|
||||
topicPartition -> partitionData
|
||||
}.toMap
|
||||
} else {
|
||||
val topicPartitions = topicPartitionsOpt.getOrElse(group.allOffsets.keySet)
|
||||
topicPartitions.map { topicPartition =>
|
||||
if (requireStable && group.hasPendingOffsetCommitsForTopicPartition(topicPartition)) {
|
||||
topicPartition -> new PartitionData(OffsetFetchResponse.INVALID_OFFSET,
|
||||
Optional.empty(), "", Errors.UNSTABLE_OFFSET_COMMIT)
|
||||
} else {
|
||||
val partitionData = group.offset(topicPartition) match {
|
||||
// 如果没有该分区位移数据,返回空数据
|
||||
case None =>
|
||||
new PartitionData(OffsetFetchResponse.INVALID_OFFSET,
|
||||
Optional.empty(), "", Errors.NONE)
|
||||
// 从消费者组元数据中返回指定分区的位移数据
|
||||
case Some(offsetAndMetadata) =>
|
||||
new PartitionData(offsetAndMetadata.offset,
|
||||
offsetAndMetadata.leaderEpoch, offsetAndMetadata.metadata, Errors.NONE)
|
||||
}
|
||||
topicPartition -> 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中保存的消费者组记录?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
@@ -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**。它的作用是封装<消费者组名,主题,分区号>的三元组,代码如下:
|
||||
|
||||
```
|
||||
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 < KAFKA_0_10_1_IV0)
|
||||
(0.toShort, new Struct(GROUP_METADATA_VALUE_SCHEMA_V0))
|
||||
else if (apiVersion < KAFKA_2_1_IV0)
|
||||
(1.toShort, new Struct(GROUP_METADATA_VALUE_SCHEMA_V1))
|
||||
else if (apiVersion < 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(""))
|
||||
value.set(GENERATION_KEY, groupMetadata.generationId)
|
||||
value.set(PROTOCOL_KEY, groupMetadata.protocolName.orNull)
|
||||
value.set(LEADER_KEY, groupMetadata.leaderOrNull)
|
||||
// 写入最近一次状态变更时间戳
|
||||
if (version >= 2)
|
||||
value.set(CURRENT_STATE_TIMESTAMP_KEY, groupMetadata.currentStateTimestampOrDefault)
|
||||
// 写入各个成员的元数据信息
|
||||
// 包括成员ID、client.id、主机名以及会话超时时间
|
||||
val memberArray = groupMetadata.allMemberMetadata.map { memberMetadata =>
|
||||
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 > 0)
|
||||
memberStruct.set(REBALANCE_TIMEOUT_KEY, memberMetadata.rebalanceTimeoutMs)
|
||||
// 写入用于静态消费者组管理的Group Instance ID
|
||||
if (version >= 3)
|
||||
memberStruct.set(GROUP_INSTANCE_ID_KEY, memberMetadata.groupInstanceId.orNull)
|
||||
// 必须定义分区分配策略,否则抛出异常
|
||||
val protocol = groupMetadata.protocolName.orNull
|
||||
if (protocol == null)
|
||||
throw new IllegalStateException("Attempted to write non-empty group metadata with no defined protocol")
|
||||
// 写入成员消费订阅信息
|
||||
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类型,也就是<消费者组名,主题,分区号>三元组。
|
||||
|
||||
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方法的源码,说一下输出中的每个字段都是什么含义。
|
||||
|
||||
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
@@ -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 => Unit): Unit = {
|
||||
// 判断当前Broker是否是该消费者组的Coordinator
|
||||
getMagic(partitionFor(group.groupId)) match {
|
||||
// 如果当前Broker不是Coordinator
|
||||
case Some(magicValue) =>
|
||||
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 -> records)
|
||||
val generationId = group.generationId
|
||||
// putCacheCallback方法,填充Cache
|
||||
......
|
||||
// 向位移主题写入消息
|
||||
appendForGroup(group, groupMetadataRecords, putCacheCallback)
|
||||
// 如果当前Broker不是Coordinator
|
||||
case None =>
|
||||
// 返回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 => 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 =>
|
||||
warn(s"Attempted to load offsets and group metadata from $topicPartition, but found no log")
|
||||
case Some(log) =>
|
||||
// 核心逻辑......
|
||||
|
||||
```
|
||||
|
||||
我把核心的逻辑分成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
|
||||
// 当前读取位移<LEO,且至少要求读取一条消息,且GroupMetadataManager未关闭
|
||||
while (currOffset < logEndOffset && readAtLeastOneRecord && !shuttingDown.get()) {
|
||||
// 读取位移主题指定分区
|
||||
val fetchDataInfo = log.read(currOffset,
|
||||
maxLength = config.loadBufferSize,
|
||||
isolation = FetchLogEnd,
|
||||
minOneMessage = true)
|
||||
// 如果无消息可读,则不再要求至少读取一条消息
|
||||
readAtLeastOneRecord = fetchDataInfo.records.sizeInBytes > 0
|
||||
// 创建消息集合
|
||||
val memRecords = fetchDataInfo.records match {
|
||||
case records: MemoryRecords => records
|
||||
case fileRecords: FileRecords =>
|
||||
val sizeInBytes = fileRecords.sizeInBytes
|
||||
val bytesNeeded = Math.max(config.loadBufferSize, sizeInBytes)
|
||||
if (buffer.capacity < bytesNeeded) {
|
||||
if (config.loadBufferSize < bytesNeeded)
|
||||
warn(s"Loaded offsets and group metadata from $topicPartition with buffer larger ($bytesNeeded bytes) than " +
|
||||
s"configured offsets.load.buffer.size (${config.loadBufferSize} bytes)")
|
||||
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 =>
|
||||
val isTxnOffsetCommit = batch.isTransactional
|
||||
// 如果是控制类消息批次
|
||||
// 控制类消息批次属于Kafka事务范畴,这里不展开讲
|
||||
if (batch.isControlBatch) {
|
||||
......
|
||||
} else {
|
||||
// 保存消息批次第一条消息的位移值
|
||||
var batchBaseOffset: Option[Long] = None
|
||||
// 遍历消息批次下的所有消息
|
||||
for (record <- batch.asScala) {
|
||||
// 确保消息必须有Key,否则抛出异常
|
||||
require(record.hasKey, "Group metadata/offset entry key should not be null")
|
||||
// 记录消息批次第一条消息的位移值
|
||||
if (batchBaseOffset.isEmpty)
|
||||
batchBaseOffset = Some(record.offset)
|
||||
// 读取消息Key
|
||||
GroupMetadataManager.readMessageKey(record.key) match {
|
||||
// 如果是OffsetKey,说明是提交位移消息
|
||||
case offsetKey: OffsetKey =>
|
||||
......
|
||||
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 =>
|
||||
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 =>
|
||||
throw new IllegalStateException(s"Unexpected message key $unknownKey while loading offsets and group metadata")
|
||||
}
|
||||
}
|
||||
}
|
||||
// 更新读取位置到消息批次最后一条消息的位移值+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) =>
|
||||
// 提取出<组名,主题名,分区号>与位移值对
|
||||
k -> v.map { case (groupTopicPartition, offset) => (groupTopicPartition.topicPartition, offset) }
|
||||
}.partition { case (group, _) => loadedGroups.contains(group) }
|
||||
......
|
||||
// 处理loadedGroups
|
||||
loadedGroups.values.foreach { group =>
|
||||
// 提取消费者组的已提交位移
|
||||
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"Loaded group metadata $group with offsets $offsets and pending offsets $pendingOffsets")
|
||||
// 为已完成加载的组执行加载组操作
|
||||
loadGroup(group, offsets, pendingOffsets)
|
||||
// 为已完成加载的组执行加载组操作之后的逻辑
|
||||
onGroupLoaded(group)
|
||||
}
|
||||
(emptyGroupOffsets.keySet ++ pendingEmptyGroupOffsets.keySet).foreach { groupId =>
|
||||
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"Loaded group metadata $group with offsets $offsets and pending offsets $pendingOffsets")
|
||||
// 为空的消费者组执行加载组操作
|
||||
loadGroup(group, offsets, pendingOffsets)
|
||||
// 为空的消费者执行加载组操作之后的逻辑
|
||||
onGroupLoaded(group)
|
||||
}
|
||||
// 处理removedGroups
|
||||
removedGroups.foreach { groupId =>
|
||||
if (groupMetadataCache.contains(groupId) && !emptyGroupOffsets.contains(groupId))
|
||||
throw new IllegalStateException(s"Unexpected unload of active group $groupId while " +
|
||||
s"loading partition $topicPartition")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**首先**,代码对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方法的流程。
|
||||
|
||||
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
@@ -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:消费者程序的主机名。
|
||||
- rebalanceTimeoutMs:Rebalance超时时间。如果在这个时间段内,消费者组成员没有完成加入组的操作,就会被禁止入组。
|
||||
- sessionTimeoutMs:会话超时时间。如果消费者组成员无法在这段时间内向Coordinator汇报心跳,那么将被视为“已过期”,从而引发新一轮Rebalance。
|
||||
- responseCallback:完成加入组之后的回调逻辑方法。当消费者组成员成功加入组之后,需要执行该方法。
|
||||
|
||||
说完了方法签名,我们看下它的主体代码:
|
||||
|
||||
```
|
||||
// 验证消费者组状态的合法性
|
||||
validateGroupStatus(groupId, ApiKeys.JOIN_GROUP).foreach { error =>
|
||||
responseCallback(JoinGroupResult(memberId, error))
|
||||
return
|
||||
}
|
||||
// 确保sessionTimeoutMs介于
|
||||
// [group.min.session.timeout.ms值,group.max.session.timeout.ms值]之间
|
||||
// 否则抛出异常,表示超时时间设置无效
|
||||
if (sessionTimeoutMs < groupConfig.groupMinSessionTimeoutMs ||
|
||||
sessionTimeoutMs > 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 =>
|
||||
responseCallback(JoinGroupResult(memberId, Errors.UNKNOWN_MEMBER_ID))
|
||||
case Some(group) =>
|
||||
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.ms,group.max.session.timeout.ms]之间,如果不是,就认定该值是非法值,从而封装一个对应的异常调用回调函数返回,这两个参数分别表示消费者组允许配置的最小和最大会话超时时间;如果是的话,就进入下一步。
|
||||
|
||||
第3步,代码获取当前成员的ID信息,并查看它是否为空。之后,通过GroupMetadataManager获取消费者组的元数据信息,如果该组的元数据信息存在,则进入到下一步;如果不存在,代码会看当前成员ID是否为空,如果为空,就创建一个空的元数据对象,然后进入到下一步,如果不为空,则返回None。一旦返回了None,handleJoinGroup方法会封装“未知成员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 >= 4 && 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 =>
|
||||
// 更新成员信息并开始准备Rebalance
|
||||
updateMemberAndRebalance(group, member, protocols, responseCallback)
|
||||
// 如果是CompletingRebalance状态
|
||||
case CompletingRebalance =>
|
||||
// 如果成员以前申请过加入组
|
||||
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 =>
|
||||
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 =>
|
||||
warn(s"Attempt to add rejoining member $memberId of group ${group.groupId} in " +
|
||||
s"unexpected group state ${group.currentState}")
|
||||
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) && group.generationId == 0)
|
||||
group.newMemberAdded = true
|
||||
// 将该成员添加到消费者组
|
||||
group.add(member, callback)
|
||||
// 设置下次心跳超期时间
|
||||
completeAndScheduleNextExpiration(group, member, NewMemberJoinTimeoutMs)
|
||||
if (member.isStaticMember) {
|
||||
info(s"Adding new static member $groupInstanceId to group ${group.groupId} with member id $memberId.")
|
||||
group.addStaticMember(groupInstanceId, memberId)
|
||||
} else {
|
||||
// 从待决成员列表中移除
|
||||
group.removePendingMember(memberId)
|
||||
}
|
||||
// 准备开启Rebalance
|
||||
maybePrepareRebalance(group, s"Adding new member $memberId with group instance id $groupInstanceId")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个方法的参数列表虽然很长,但我相信,你对它们已经非常熟悉了,它们都是承袭自其上层调用方法的参数。
|
||||
|
||||
我来介绍一下这个方法的执行逻辑。
|
||||
|
||||
**第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?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
@@ -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 =>
|
||||
responseCallback(SyncGroupResult(Errors.REBALANCE_IN_PROGRESS))
|
||||
// 如果是其它错误,则封装对应错误,并调用回调函数返回
|
||||
case Some(error) => responseCallback(SyncGroupResult(error))
|
||||
case None =>
|
||||
// 获取消费者组元数据
|
||||
groupManager.getGroup(groupId) match {
|
||||
// 如果未找到,则封装UNKNOWN_MEMBER_ID异常,并调用回调函数返回
|
||||
case None =>
|
||||
responseCallback(SyncGroupResult(Errors.UNKNOWN_MEMBER_ID))
|
||||
// 如果找到的话,则调用doSyncGroup方法执行组同步任务
|
||||
case Some(group) => 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, "sync-group")) {
|
||||
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 && !group.protocolType.contains(protocolType.get)) {
|
||||
responseCallback(SyncGroupResult(Errors.INCONSISTENT_GROUP_PROTOCOL))
|
||||
} else if (protocolName.isDefined && !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 =>
|
||||
// 封装UNKNOWN_MEMBER_ID异常,调用回调函数返回
|
||||
responseCallback(SyncGroupResult(Errors.UNKNOWN_MEMBER_ID))
|
||||
case PreparingRebalance =>
|
||||
// 封装REBALANCE_IN_PROGRESS异常,调用回调函数返回
|
||||
responseCallback(SyncGroupResult(Errors.REBALANCE_IN_PROGRESS))
|
||||
case CompletingRebalance =>
|
||||
// 下面详细展开......
|
||||
case Stable =>
|
||||
// 获取消费者组成员元数据
|
||||
val memberMetadata = group.get(memberId)
|
||||
// 封装组协议类型、分配策略、成员分配方案,调用回调函数返回
|
||||
responseCallback(SyncGroupResult(group.protocolType, group.protocolName, memberMetadata.assignment, Errors.NONE))
|
||||
// 设定成员下次心跳时间
|
||||
completeAndScheduleNextHeartbeatExpiration(group, group.get(memberId))
|
||||
case Dead =>
|
||||
// 抛出异常
|
||||
throw new IllegalStateException(s"Reached unexpected condition for Dead group ${group.groupId}")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果消费者组的当前状态是Empty或PreparingRebalance,那么,代码会封装对应的异常给回调函数,供其调用。
|
||||
|
||||
如果是Stable状态,则说明,此时消费者组已处于正常工作状态,无需进行组同步的操作。因此,在这种情况下,简单返回消费者组当前的分配方案给回调函数,供它后面发送给消费者组成员即可。
|
||||
|
||||
如果是Dead状态,那就说明,这是一个异常的情况了,因为理论上,不应该为处于Dead状态的组执行组同步,因此,代码只能选择抛出IllegalStateException异常,让上层方法处理。
|
||||
|
||||
如果这些状态都不是,那么,消费者组就只能处于CompletingRebalance状态,这也是执行组同步操作时消费者组最有可能处于的状态。因此,这部分的逻辑要复杂一些,我们看下代码:
|
||||
|
||||
```
|
||||
// 为该消费者组成员设置组同步回调函数
|
||||
group.get(memberId).awaitingSyncCallback = responseCallback
|
||||
// 组Leader成员发送的SyncGroupRequest请求需要特殊处理
|
||||
if (group.isLeader(memberId)) {
|
||||
info(s"Assignment received from leader for group ${group.groupId} for generation ${group.generationId}")
|
||||
// 如果有成员没有被分配任何消费方案,则创建一个空的方案赋给它
|
||||
val missing = group.allMembers.diff(groupAssignment.keySet)
|
||||
val assignment = groupAssignment ++ missing.map(_ -> Array.empty[Byte]).toMap
|
||||
|
||||
if (missing.nonEmpty) {
|
||||
warn(s"Setting empty assignments for members $missing of ${group.groupId} for generation ${group.generationId}")
|
||||
}
|
||||
// 把消费者组信息保存在消费者组元数据中,并且将其写入到内部位移主题
|
||||
groupManager.storeGroup(group, assignment, (error: Errors) => {
|
||||
group.inLock {
|
||||
// 如果组状态是CompletingRebalance以及成员和组的generationId相同
|
||||
if (group.is(CompletingRebalance) && generationId == group.generationId) {
|
||||
// 如果有错误
|
||||
if (error != Errors.NONE) {
|
||||
// 清空分配方案并发送给所有成员
|
||||
resetAndPropagateAssignmentError(group, error)
|
||||
// 准备开启新一轮的Rebalance
|
||||
maybePrepareRebalance(group, s"error when storing group assignment during SyncGroup (member: $memberId)")
|
||||
// 如果没错误
|
||||
} 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的分区消费分配方案。那么,你能找出来,源码中的哪行语句做了这件事情吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
Reference in New Issue
Block a user