GroupMetadata 中定义了很多管理消费者组状态的方法,这里介绍几个常用的。
消费者组状态管理
transitionTo 方法
transitionTo 方法的作用是将消费者组状态变更成给定状态。在变更前,代码需要确保这次变更必须是合法的状态转换。
同时,该方法还会更新状态变更的时间戳字段。Kafka 有个定时任务,会定期清除过期的消费者组位移数据,它就是依靠这个时间戳字段,来判断过期与否的。
def transitionTo(groupState: GroupState): Unit = {
assertValidTransition(groupState) // 确保是合法的状态转换
state = groupState // 设置状态到给定状态
currentStateTimestamp = Some(time.milliseconds()) // 更新状态变更时间戳
// 更新状态变更的时间戳字段。Kafka 有个定时任务,会定期清除过期的消费者组位移数据,它就是依靠这个时间戳字段,来判断过期与否的。
}
canRebalance 方法
消费者组能否Rebalance的条件是当前状态是PreparingRebalance状态的合法前置状态,只有 Stable、CompletingRebalance 和 Empty 这 3 类状态的消费者组,才有资格开启 Rebalance。
def canRebalance = GroupMetadata.validPreviousStates(PreparingRebalance).contains(state)
is 和 not 方法
它们分别判断消费者组的状态与给定状态吻合还是不吻合
// 判断消费者组状态是指定状态
def is(groupState: GroupState) = state == groupState
// 判断消费者组状态不是指定状态
def not(groupState: GroupState) = state != groupState
成员管理
add 方法
add是添加成员的方法。add 方法的主要逻辑,是将成员对象添加到 members 字段,同时更新其他一些必要的元数据,比如 Leader 成员字段、分区分配策略支持票数等。
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
}
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
}
查询成员方法
// has 方法,判断消费者组是否包含指定成员;
def has(memberId: String) = members.contains(memberId)
// get 方法,获取指定成员对象;
def get(memberId: String) = members(memberId)
// size 方法,统计总成员数。
def size = members.size
位移管理
添加位移值
在 GroupMetadata 中,有 3 个向 offsets 中添加订阅分区的已消费位移值的方法,分别是 initializeOffsets、onOffsetCommitAppend 和 completePendingTxnOffsetCommit。
initializeOffsets方法,当消费者组的协调者组件启动时,它会创建一个异步任务,定期地读取位移主题中相应消费者组的提交位移数据,并把它们加载到 offsets 字段中。
def initializeOffsets(offsets: collection.Map[TopicPartition, CommitRecordMetadataAndOffset],
pendingTxnOffsets: Map[Long, mutable.Map[TopicPartition, CommitRecordMetadataAndOffset]]): Unit = {
this.offsets ++= offsets // 将给定的一组订阅分区提交位移值加到 offsets 中
this.pendingTransactionalOffsetCommits ++= pendingTxnOffsets
}
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.put(topicPartition, offsetWithCommitRecordMetadata)
}
pendingOffsetCommits.get(topicPartition) match {
case Some(stagedOffset) if offsetWithCommitRecordMetadata.offsetAndMetadata == stagedOffset =>
pendingOffsetCommits.remove(topicPartition)
case _ =>
// The pendingOffsetCommits for this partition could be empty if the topic was deleted, in which case
// its entries would be removed from the cache by the `removeOffsets` method.
}
}
移除位移值
offsets 中订阅分区的已消费位移值也是能够被移除的。位移主题是普通的 Kafka 主题,所以也要遵守相应的规定。如果当前时间与已提交位移消息时间戳的差值,超过了 Broker 端参数 offsets.retention.minutes 值,Kafka 就会将这条记录从 offsets 字段中移除。这就是方法 removeExpiredOffsets 要做的事情。
def removeExpiredOffsets(currentTimestamp: Long, offsetRetentionMs: Long): Map[TopicPartition, OffsetAndMetadata] = {
def getExpiredOffsets(baseTimestamp: CommitRecordMetadataAndOffset => Long, // 它是一个函数类型,接收 CommitRecordMetadataAndOffset 类型的字段,然后计算时间戳,并返回;
subscribedTopics: Set[String] = Set.empty): Map[TopicPartition, OffsetAndMetadata] = { // 即订阅主题集合,默认是空。
offsets.filter {
// 遍历offsets中的所有分区,过滤出同时满足以下3个条件的所有分区
case (topicPartition, commitRecordMetadataAndOffset) =>
// 条件1:分区所属主题不在订阅主题列表之内,当方法传入了不为空的主题集合时,
// 就说明该消费者组此时正在消费中,正在消费的主题是不能执行过期位移移除的。
!subscribedTopics.contains(topicPartition.topic()) &&
// 条件2:该主题分区已经完成位移提交
// 那种处于提交中状态,也就是保存在 pendingOffsetCommits 字段中的分区,不予考虑。
!pendingOffsetCommits.contains(topicPartition) && {
commitRecordMetadataAndOffset.offsetAndMetadata.expireTimestamp match { // 条件3:该主题分区在位移主题中对应消息的存在时间超过了阈值
// Kafka 判断过期与否,主要是基于消费者组状态。
// 如果是 Empty 状态,过期的判断依据就是当前时间与组变为 Empty 状态时间的差值,是否超过 Broker 端参数 offsets.retention.minutes 值;
// 如果不是 Empty 状态,就看当前时间与提交位移消息中的时间戳差值是否超过了 offsets.retention.minutes 值。
// 如果超过了,就视为已过期,
case None =>
// current version with no per partition retention
currentTimestamp - baseTimestamp(commitRecordMetadataAndOffset) >= offsetRetentionMs
case Some(expireTimestamp) =>
// older versions with explicit expire_timestamp field => old expiration semantics is used
currentTimestamp >= expireTimestamp
}
}
}.map {
// 为满足以上3个条件的分区提取出commitRecordMetadataAndOffset中的位移值
case (topicPartition, commitRecordOffsetAndMetadata) =>
(topicPartition, commitRecordOffsetAndMetadata.offsetAndMetadata)
}.toMap
}
// 调用getExpiredOffsets方法获取主题分区的过期位移
val expiredOffsets: Map[TopicPartition, OffsetAndMetadata] = protocolType match {
case Some(_) if is(Empty) =>
// no consumer exists in the group =>
// - if current state timestamp exists and retention period has passed since group became Empty,
// expire all offsets with no pending offset commit;
// - if there is no current state timestamp (old group metadata schema) and retention period has passed
// since the last commit timestamp, expire the offset
// 如果消费者组状态是 Empty,就传入组变更为 Empty 状态的时间
getExpiredOffsets(
commitRecordMetadataAndOffset => currentStateTimestamp
// 若该时间没有被记录,则使用提交位移消息本身的写入时间戳,来获取过期位移;
.getOrElse(commitRecordMetadataAndOffset.offsetAndMetadata.commitTimestamp)
)
case Some(ConsumerProtocol.PROTOCOL_TYPE) if subscribedTopics.isDefined =>
// consumers exist in the group =>
// - if the group is aware of the subscribed topics and retention period had passed since the
// the last commit timestamp, expire the offset. offset with pending offset commit are not
// expired
// 如果是普通的消费者组类型,且订阅主题信息已知,就传入提交位移消息本身的写入时间戳和订阅主题集合共同确定过期位移值;
getExpiredOffsets(
_.offsetAndMetadata.commitTimestamp,
subscribedTopics.get
)
case None =>
// protocolType is None => standalone (simple) consumer, that uses Kafka for offset storage only
// expire offsets with no pending offset commit that retention period has passed since their last commit
// 如果 protocolType 为 None,就表示,这个消费者组其实是一个 Standalone 消费者,依然是传入提交位移消息本身的写入时间戳,来决定过期位移值;
getExpiredOffsets(_.offsetAndMetadata.commitTimestamp)
// 如果消费者组的状态不符合刚刚说的这些情况,那就说明,没有过期位移值需要被移除。
case _ =>
Map()
}
if (expiredOffsets.nonEmpty)
debug(s"Expired offsets from group '$groupId': ${expiredOffsets.keySet}")
// 将过期位移对应的主题分区从offsets中移除
offsets --= expiredOffsets.keySet
// 返回主题分区对应的过期位移
expiredOffsets
}
分区分配策略管理方法
消费者组分区分配策略的管理,也就是字段 supportedProtocols 的管理。supportedProtocols 是分区分配策略的支持票数,这个票数在添加成员、移除成员时,会进行相应的更新。
消费者组每次 Rebalance 的时候,都要重新确认本次 Rebalance 结束之后,要使用哪个分区分配策略,因此,就需要特定的方法来对这些票数进行统计,把票数最多的那个策略作为新的策略。
GroupMetadata 类中定义了两个方法来做这件事情,分别是 candidateProtocols 和 selectProtocol 方法。
candidateProtocols 方法
candidateProtocols 方法找出组内所有成员都支持的分区分配策略集。
private def candidateProtocols = {
// get the set of protocols that are commonly supported by all members
// 获取组内成员数
val numMembers = members.size
// 找出支持票数=总成员数的策略,返回它们的名称
supportedProtocols.filter(_._2 == numMembers).map(_._1).toSet
}
支持票数等于总成员数的意思,等同于所有成员都支持该策略。
selectProtocol 方法
它的作用是选出消费者组的分区消费分配策略。
def selectProtocol: String = {
// 如果没有任何成员,自然无法确定选用哪个策略
if (members.isEmpty)
throw new IllegalStateException("Cannot select protocol for empty group")
// select the protocol for this group which is supported by all members
// 获取所有成员都支持的策略集合
val candidates = candidateProtocols
// let each member vote for one of the protocols and choose the one with the most votes
// 让每个成员投票,票数最多的那个策略当选
val votes: List[(String, Int)] = allMemberMetadata
.map(_.vote(candidates))
.groupBy(identity)
.mapValues(_.size)
.toList
// 成员支持列表中的策略是有顺序的。这就是说,[“策略 B”,“策略 A”]和[“策略 A”,“策略 B”]是不同的,成员会倾向于选择靠前的策略。
votes.maxBy(_._2)._1
}