kafka consumer rebalance

[TOC]

寫在前面

前陣子遇到的rebalance的問題比較多,發現自己對rebalance的理解也僅僅是浮于表面,也就是網上和書上講的宏觀層面上的什么FIND_COORDINATOR -> JOIN -> SYNC -> STABLE這一套,但是服務端究竟是如何運轉的呢?rebalance為什么會突然劣化導致停頓幾分鐘呢?依舊不甚了解,網上能查到的信息也都寥寥無幾基本上也都是宏觀上的幾個階段流轉,所以決定自己重新去看一下這部分的源碼,并記錄下來。

rebalance本身是一個比較復雜的過程,各位要是對源碼分析看不進去,我會在源碼分析之前給出一個過程概述,可能會更好理解一點。源碼這個東西,在博客上看,真是看來就忘,還是要自己打開IDE導入源碼自己分析才能加深印象,當自己無法理解的時候,可以到博客里面搜一下相關的注釋,看下別人的思路和理解,這是我個人認為看源碼比較友好的方式。

二、什么是rebalance?

中文直譯,就是重平衡。
是什么去重平衡呢?消費組內的消費者成員去重平衡。

為什么需要重平衡呢?因為消費組內成員的故障轉移和動態分區分配。

消費組內成員的故障轉移:當一個消費組內有三個消費者A,B,C,分別消費分區:a,b,c

A -> a
B -> b
C -> c

此時如果A消費者出了點問題,那么就意味著a分區沒有消費者進行消費了,那這肯定不行,那么就通過rebalance去將a分區分配給其他還存活著的消費者客戶端,rebalance后可能得到的消費策略:

A -> a (GG)
B -> b,a
C -> c

這就是消費組內成員的故障轉移,就是某個消費者客戶端出問題之后把它原本消費的分區通過REBALNACE分配給其他存活的消費者客戶端。

動態分區分配:當某個topic的分區數變化,對于消費組而言可消費的分區數變化了,因此就需要rebalance去重新進行動態分區分配,舉個栗子,原本某topic只有3個分區,我現在擴成了10個分區,那么不就意味著多了7個分區沒有消費者消費嗎?這顯然是不行的,因此就需要rebalance過程去進行分區分配,讓現有的消費者去把這10個分區全部消費到。

三、rebalance是怎么觸發的?

這個其實在上面一小節已經提到的差不多了,在這個小節再做一點補充和總結。
觸發條件:

  • 消費組內成員變化:下線/上線/故障被踢出。
  • 消費的分區數變化:topic被刪了,topic分區數增加了。
  • coordinator節點出問題了:因為消費組的元數據信息都是在coordinator節點的,因此coordinator節點出問題也會觸發rebalance去找一個新的coordinator節點。怎么找呢?顯然就是走一遍FIND_COORDINATOR請求嘛,然后找到負載最低的那個節點問一下,我的新的coordinator在哪兒呀?然后得到答案之后讓消費者客戶端去連新的coordinator節點。

四、rebalance的宏觀過程

整個rebalance的過程,是一個狀態機流轉的過程,整體過程示意圖如下:圖源:https://www.cnblogs.com/huxi2b/p/6815797.html

image.png

其實上面這個狀態機流轉過程在明白原理的情況下,已經非常清晰了,但是如果沒看過源碼的,依舊不知道為什么是這么流轉的,什么情況下狀態是Empty呢,什么狀態下是Stable呢?什么時候Empty狀態會轉換為PreparingRebalance狀態呢?

下面我就根據請求順序來看下整個狀態的流轉過程:
image.png

需要說明的一點是,上面請求的狀態CompletingRebalance其實就對應上面的AwaitingSync狀態。

讓我們根據這個請求順序圖來解釋一下各個狀態是如何流轉的:
Empty(Empty):當一個Group是新創建的,或者內部沒有成員時,狀態就是Empty。我們假設有一個新的消費組,這個消費組的第一個成員發送FIND_COORDINATOR請求的時候,也就是開啟了Rebalacne的第一個階段。

PreparingRebalance(JOIN):當完成FIND_COORDINATOR請求后,對應的客戶端就能找到自己的coordinator節點是哪個,然后緊接著就會發送JOIN_GROUP請求,當coordinator收到這個請求后,就會把狀態由Empty變更為PreparingRebalance,意味著準備要開始rebalance了

CompletingRebalance(SYNC):當所有的成員都完成JOIN_GROUP請求的發送之后,或者rebalance過程超時后,對應的PreparingRebalance階段就會結束,進而進入CompletingRebalance狀態。

Stabe(Stable):在進入CompletingRebalance狀態的時候呢,服務端會返回所有JOIN_GROUP請求對應的響應,然后客戶端收到響應之后立刻就發送SYNC_GROUP請求,服務端在收到leader發送的SNYC_GROUP請求后,就會轉換為Stable狀態,意味著整個rebalance過程已經結束了。

上面整個過程,就是我們經常能在一些博客里面看到,其實里面有很多細節,例如這些請求都帶有哪些關鍵數據,到底是哪個階段導致rebalance過程會劣化到幾分鐘?為什么要分為這么多階段?

讓我們帶著問題繼續往下把,這些狀態機流轉的名字太長了,后面我會用上文中括號內的簡寫代表對應的階段。

五、rebalance的微觀過程概覽

image.png

讓我們來回答上個小節后面提出的幾個比較細節的問題:

這些請求都帶有哪些關鍵數據?
在FIND_COORDINATOR請求的時候,會帶上自己的group.id值,這個值是用來計算它的coordinator到底在哪兒的,對應的計算方法就是:coordinatorId=groupId.hash % 50 這個算出來是個數字,代表著具體的分區,哪個topic的分區呢?顯然是__consumer_offsets了。

在JOIN_GROUP請求的時候,是沒帶什么關鍵參數的,但是在響應的時候會挑選一個客戶端作為leader,然后在響應中告訴它被選為了leader并且把消費組元數據信息發給它,然后讓該客戶端去進行分區分配。
在SYNC_GROUP請求的時候,leader就會帶上它根據具體的策略已經分配好的分區分配方案,服務端收到后就更新到元數據里面去,然后其余的consumer客戶端只要一發送SYNC請求過來就告訴它要消費哪些分區,然后讓它自己去消費就ok了。

到底是哪個階段導致rebalance過程會劣化到幾分鐘?
我圖中特意將JOIN階段標位紅色,就是讓這個階段顯得顯眼一些,沒錯就是這個階段會導致rebalance整個過程耗時劣化到幾分鐘。

具體的原因就是JOIN階段會等待原先組內存活的成員發送JOIN_GROUP請求過來,如果原先組內的成員因為業務處理一直沒有發送JOIN_GROUP請求過來,服務端就會一直等待,直到超時。這個超時時間就是max.poll.interval.ms的值,默認是5分鐘,因此這種情況下rebalance的耗時就會劣化到5分鐘,導致所有消費者都無法進行正常消費,影響非常大。

為什么要分為這么多階段?
這個主要是設計上的考慮,整個過程設計的還是非常優雅的,第一次連上的情況下需要三次請求,正常運行的consumer去進行rebalance只需要兩次請求,因為它原先就知道自己的coordinator在哪兒,因此就不需要FIND_COORDINATOR請求了,除非是它的coordinator宕機了

回答完這些問題,是不是對整個rebalance過程理解加深一些了呢?其實還有很多細節沒有涉及到,例如consumer客戶端什么時候會進入rebalance狀態?服務端是如何等待原先消費組內的成員發送JOIN_GROUP請求的呢?這些問題就只能一步步看源碼了。

六、JOIN階段源碼分析

從這段函數我們知道,如果加入一個新的消費組,服務端收到第一個JOIN請求的時候會創建group,這個group的初始狀態為Empty

// 如果group都還不存在,就有了memberId,則認為是非法請求,直接拒絕。
      groupManager.getGroup(groupId) match {
        case None =>
          // 這里group都還不存在的情況下,memberId自然是空的
          if (memberId != JoinGroupRequest.UNKNOWN_MEMBER_ID) {
            responseCallback(joinError(memberId, Errors.UNKNOWN_MEMBER_ID))
          } else {
            // 初始狀態是EMPTY
            val group = groupManager.addGroup(new GroupMetadata(groupId, initialState = Empty))
            // 執行具體的加組操作
            doJoinGroup(group, memberId, clientId, clientHost, rebalanceTimeoutMs, sessionTimeoutMs, protocolType, protocols, responseCallback)
          }

        case Some(group) =>
          doJoinGroup(group, memberId, clientId, clientHost, rebalanceTimeoutMs, sessionTimeoutMs, protocolType, protocols, responseCallback)
      }

讓我們進入doJoinGroup函數,看下里面的核心邏輯:

    case Empty | Stable =>
            // 初始狀態是EMPTY,添加member并且執行rebalance
            if (memberId == JoinGroupRequest.UNKNOWN_MEMBER_ID) {
              // if the member id is unknown, register the member to the group
              addMemberAndRebalance(rebalanceTimeoutMs, sessionTimeoutMs, clientId, clientHost, protocolType, protocols, group, responseCallback)
            } else {
            // ...
              } else {
              //...
              }



  private def addMemberAndRebalance(rebalanceTimeoutMs: Int,
                                    sessionTimeoutMs: Int,
                                    clientId: String,
                                    clientHost: String,
                                    protocolType: String,
                                    protocols: List[(String, Array[Byte])],
                                    group: GroupMetadata,
                                    callback: JoinCallback) = {
    // 根據clientID初始化memberID
    val memberId = clientId + "-" + group.generateMemberIdSuffix
    // 封裝一個member對象
    val member = new MemberMetadata(memberId, group.groupId, clientId, clientHost, rebalanceTimeoutMs,
      sessionTimeoutMs, protocolType, protocols)
    member.awaitingJoinCallback = callback
    // update the newMemberAdded flag to indicate that the join group can be further delayed
    if (group.is(PreparingRebalance) && group.generationId == 0)
      group.newMemberAdded = true
    // 增加成員到group中
    group.add(member)
    maybePrepareRebalance(group)
    member
  }



  def add(member: MemberMetadata) {
    if (members.isEmpty)
      this.protocolType = Some(member.protocolType)

    assert(groupId == member.groupId)
    assert(this.protocolType.orNull == member.protocolType)
    assert(supportsProtocols(member.protocols))
    // coordinator選舉leader很簡單,就第一個發送join_group請求的那個member
    if (leaderId.isEmpty)
      leaderId = Some(member.memberId)
    members.put(member.memberId, member)
  }

上面的代碼翻譯一下很簡單,就是新來了一個member,封裝一下,添加到這個group中,需要說一下的就是當組狀態是Empty的情況下,誰先連上誰就是leader。緊接著就準備rebalance:

 private def maybePrepareRebalance(group: GroupMetadata) {
    group.inLock {
      if (group.canRebalance)
        prepareRebalance(group)
    }
  }



  // 這里是傳入PreparingRebalance狀態,然后獲取到一個SET
  // 翻譯一下:就是只有這個SET(Stable, CompletingRebalance, Empty)里面的狀態,才能開啟rebalance
  def canRebalance = GroupMetadata.validPreviousStates(PreparingRebalance).contains(state)

  private val validPreviousStates: Map[GroupState, Set[GroupState]] =
    Map(Dead -> Set(Stable, PreparingRebalance, CompletingRebalance, Empty, Dead),
      CompletingRebalance -> Set(PreparingRebalance),
      Stable -> Set(CompletingRebalance),
      PreparingRebalance -> Set(Stable, CompletingRebalance, Empty),
      Empty -> Set(PreparingRebalance))



  private def prepareRebalance(group: GroupMetadata) {
    // if any members are awaiting sync, cancel their request and have them rejoin
    if (group.is(CompletingRebalance))
      resetAndPropagateAssignmentError(group, Errors.REBALANCE_IN_PROGRESS)

    val delayedRebalance = if (group.is(Empty))
      new InitialDelayedJoin(this,
        joinPurgatory,
        group,
        groupConfig.groupInitialRebalanceDelayMs,// 默認3000ms,即3s
        groupConfig.groupInitialRebalanceDelayMs,
        max(group.rebalanceTimeoutMs - groupConfig.groupInitialRebalanceDelayMs, 0))
    else
      new DelayedJoin(this, group, group.rebalanceTimeoutMs)// 這里這個超時時間是客戶端的poll間隔,默認5分鐘
    // 狀態機轉換:EMPTY -> PreparingRebalance
    group.transitionTo(PreparingRebalance)
    // rebalance開始標志日志
    info(s"Preparing to rebalance group ${group.groupId} with old generation ${group.generationId} " +
      s"(${Topic.GROUP_METADATA_TOPIC_NAME}-${partitionFor(group.groupId)})")
    // 加入時間輪
    val groupKey = GroupKey(group.groupId)
    joinPurgatory.tryCompleteElseWatch(delayedRebalance, Seq(groupKey))
  }

上面這段代碼有兩個關鍵點,一個是判斷當前能否進入rebalance過程,可以看到只有(Stable, CompletingRebalance, Empty)里面的狀態,才能開啟rebalance,而最開始來到第一個member的時候,組的狀態是Empty顯然是能進來的,但是近來之后就給轉換為了PreparingRebalance狀態,那么后續的member發送JOIN請求過來之后就進不來了,就只能設置個回調后一直等。

那么要等到什么時候呢?第二段代碼寫的很清楚就是等待延時任務超時,這個延時任務創建是根據當前狀態來判斷的,如果是Empty就創建一個InitialDelayedJoin延時任務,超時時間是3s;如果不是Empty就創建一個DelayedJoin,超時時間默認是5min。看,源碼出真知,這就是JOIN階段等待member的代碼實現。

這里需要補充一下,為什么Empty的狀態下要等待3s呢?這其實是一個優化,主要就是優化多消費者同時連入的情況。舉個栗子,10個消費者都能在3s內啟動然后練上,如果你等著3s時間那么一次rebalance過程就搞定了,如果你不等,那么就意味著來一個就又要開啟一次rebalance,一共要進行10次rebalance,這個耗時就比較長了。具體的細節可以查看:https://www.cnblogs.com/huxi2b/p/6815797.html

另外就是,為什么狀態不是Empty的時候就延時5分鐘呢?這個其實上面就回答了,要等待原來消費組內在線的消費者發送JOIN請求,這個也是rebalance過程耗時劣化的主要原因。

接下來我們看看這兩個延時任務,在超時的時候分別都會做些啥,首先是InitialDelayedJoin:

/**
  * Delayed rebalance operation that is added to the purgatory when a group is transitioning from
  * Empty to PreparingRebalance
  *
  * When onComplete is triggered we check if any new members have been added and if there is still time remaining
  * before the rebalance timeout. If both are true we then schedule a further delay. Otherwise we complete the
  * rebalance.
  */
private[group] class InitialDelayedJoin(coordinator: GroupCoordinator,
                                        purgatory: DelayedOperationPurgatory[DelayedJoin],
                                        group: GroupMetadata,
                                        configuredRebalanceDelay: Int,
                                        delayMs: Int,
                                        remainingMs: Int) extends DelayedJoin(coordinator, group, delayMs) {

  // 這里寫死是false,是為了在tryComplete的時候不被完成
  override def tryComplete(): Boolean = false

  override def onComplete(): Unit = {
    // 延時任務處理
    group.inLock  {
      // newMemberAdded是后面有新的member加進來就會是true
      // remainingMs第一次創建該延時任務的時候就是3s。
      // 所以這個條件在第一次的時候都是成立的
      if (group.newMemberAdded && remainingMs != 0) {
        group.newMemberAdded = false
        val delay = min(configuredRebalanceDelay, remainingMs)
        // 最新計算的remaining恒等于0,其實本質上就是3-3=0,
        // 所以哪怕這里是新創建了一個InitialDelayedJoin,這個任務的超時時間就是下一刻
        // 這么寫的目的其實就是相當于去完成這個延時任務
        val remaining = max(remainingMs - delayMs, 0)
        purgatory.tryCompleteElseWatch(new InitialDelayedJoin(coordinator,
          purgatory,
          group,
          configuredRebalanceDelay,
          delay,
          remaining
        ), Seq(GroupKey(group.groupId)))
      } else
        // 如果沒有新的member加入,直接調用父類的函數
        // 完成JOIN階段
        super.onComplete()
    }
  }
}

大意我都寫在注釋里面了,其實就是等待3s,然后完了之后調用父類的函數完成整個JOIN階段,不過不聯系上下文去看,還是挺費勁的,對了看這個需要對時間輪源碼有了解

接著看下DelayedJoin超時后會干嘛:

/**
 * Delayed rebalance operations that are added to the purgatory when group is preparing for rebalance
 *
 * Whenever a join-group request is received, check if all known group members have requested
 * to re-join the group; if yes, complete this operation to proceed rebalance.
 *
 * When the operation has expired, any known members that have not requested to re-join
 * the group are marked as failed, and complete this operation to proceed rebalance with
 * the rest of the group.
 */
private[group] class DelayedJoin(coordinator: GroupCoordinator,
                                 group: GroupMetadata,
                                 rebalanceTimeout: Long) extends DelayedOperation(rebalanceTimeout, Some(group.lock)) {

  override def tryComplete(): Boolean = coordinator.tryCompleteJoin(group, forceComplete _)
  override def onExpiration() = coordinator.onExpireJoin()
  override def onComplete() = coordinator.onCompleteJoin(group)
}

  // 超時之后啥也沒干,哈哈,因為確實不用做啥,置空就好了
  // 核心是onComplete函數和tryComplete函數
  def onExpireJoin() {
    // TODO: add metrics for restabilize timeouts
  }



  def tryCompleteJoin(group: GroupMetadata, forceComplete: () => Boolean) = {
    group.inLock {
      if (group.notYetRejoinedMembers.isEmpty)
        forceComplete()
      else false
    }
  }
  def notYetRejoinedMembers = members.values.filter(_.awaitingJoinCallback == null).toList
  
  def forceComplete(): Boolean = {
    if (completed.compareAndSet(false, true)) {
      // cancel the timeout timer
      cancel()
      onComplete()
      true
    } else {
      false
    }
  }



  def onCompleteJoin(group: GroupMetadata) {
    group.inLock {
      // remove any members who haven‘t joined the group yet
      // 如果組內成員依舊沒能連上,那么就刪除它,接收當前JOIN階段
      group.notYetRejoinedMembers.foreach { failedMember =>
        group.remove(failedMember.memberId)
        // TODO: cut the socket connection to the client
      }

      if (!group.is(Dead)) {
        // 狀態機流轉 : preparingRebalancing -> CompletingRebalance
        group.initNextGeneration()
        if (group.is(Empty)) {
          info(s"Group ${group.groupId} with generation ${group.generationId} is now empty " +
            s"(${Topic.GROUP_METADATA_TOPIC_NAME}-${partitionFor(group.groupId)})")

          groupManager.storeGroup(group, Map.empty, error => {
            if (error != Errors.NONE) {
              // we failed to write the empty group metadata. If the broker fails before another rebalance,
              // the previous generation written to the log will become active again (and most likely timeout).
              // This should be safe since there are no active members in an empty generation, so we just warn.
              warn(s"Failed to write empty metadata for group ${group.groupId}: ${error.message}")
            }
          })
        } else {
          // JOIN階段標志結束日志
          info(s"Stabilized group ${group.groupId} generation ${group.generationId} " +
            s"(${Topic.GROUP_METADATA_TOPIC_NAME}-${partitionFor(group.groupId)})")

          // trigger the awaiting join group response callback for all the members after rebalancing
          for (member <- group.allMemberMetadata) {
            assert(member.awaitingJoinCallback != null)
            val joinResult = JoinGroupResult(
              // 如果是leader 就返回member列表及其元數據信息
              members = if (group.isLeader(member.memberId)) {
                group.currentMemberMetadata
              } else {
                Map.empty
              },
              memberId = member.memberId,
              generationId = group.generationId,
              subProtocol = group.protocolOrNull,
              leaderId = group.leaderOrNull,
              error = Errors.NONE)

            member.awaitingJoinCallback(joinResult)
            member.awaitingJoinCallback = null
            completeAndScheduleNextHeartbeatExpiration(group, member)
          }
        }
      }
    }
  }

上面這一串代碼有幾個要點,首先,這個任務超時的時候是啥也不干的,為什么呢?這里要了解時間輪的機制,代碼也在上面,當一個任務超時的時候,時間輪強制執行對應任務的onComplete函數,然后執行onExpiration函數,其實onExpiration函數對于這個延時任務來說是沒有意義的,并不需要做什么,打日志都懶得打。
第二點就是這個任務onComplete什么時候會被調用呢?難道就只能等待5分鐘超時才能被調用嗎?那不是每一次rebalance都必須要等待5分鐘?當然不可能啦,這里就需要先看下tryComplete函數的內容,發現這個內容會去檢查還沒連上的member,如果發現到期了,就強制完成。那么我們看下這tryComplete是在哪兒被調用的?這里需要插入一點之前沒貼全的代碼,在doJoinGroup函數中的而最后一段:

if (group.is(PreparingRebalance))
      joinPurgatory.checkAndComplete(GroupKey(group.groupId))

這段代碼非常關鍵,當當前狀態是PreparingRebalance的時候,會嘗試去完成當前的延時任務,最終調用的代碼:

 private[server] def maybeTryComplete(): Boolean = {
    var retry = false
    var done = false
    do {
      if (lock.tryLock()) {
        try {
          tryCompletePending.set(false)
          done = tryComplete()
        } finally {
          lock.unlock()
        }
        // While we were holding the lock, another thread may have invoked `maybeTryComplete` and set
        // `tryCompletePending`. In this case we should retry.
        retry = tryCompletePending.get()
      } else {
        // Another thread is holding the lock. If `tryCompletePending` is already set and this thread failed to
        // acquire the lock, then the thread that is holding the lock is guaranteed to see the flag and retry.
        // Otherwise, we should set the flag and retry on this thread since the thread holding the lock may have
        // released the lock and returned by the time the flag is set.
        retry = !tryCompletePending.getAndSet(true)
      }
    } while (!isCompleted && retry)
    done
  }

就是上面的tryComplete函數,最終會調用到DelayedJoin中的tryComplete函數,什么意思呢?已經很明顯了,每來一個JOIN請求的時候,如果處于PreparingRebalance階段,都會去檢查一下group中原來的成員是否已經到齊了,到齊了就立刻結束JOIN階段往后走。看到這兒,回頭看下InitialDelayedJoin這個延時任務的tryComplete為什么就默認實現了個false呢?也明白了,就是初始化延時任務的時候不讓你嘗試完成,我就等3s,不需要你們來觸發我提前完成。

以上,我們就看完了整個服務端的JOIN請求處理過程,其實主要核心就是這兩個延時任務,如果不聯系上下文,不了解時間輪機制,看起來確實費勁。接下來就看下SYNC階段是如何處理的。

兩個角色

Consumer Group Co-ordinator
Group Leader

[圖片上傳失敗...(image-558ab0-1627916151967)]
rebalance過程有以下幾點

  1. rebalance本質上是一組協議。group與coordinator共同使用它來完成group的rebalance。
  2. consumer如何向coordinator證明自己還活著? 通過定時向coordinator發送Heartbeat請求。如果超過了設定的超時時間,那么coordinator就認為這個consumer已經掛了。
  3. 一旦coordinator認為某個consumer掛了,那么它就會開啟新一輪rebalance,并且在當前其他consumer的心跳response中添加“REBALANCE_IN_PROGRESS”,告訴其他consumer:不好意思各位,你們重新申請加入組吧!
  4. 所有成員都向coordinator發送JoinGroup請求,請求入組。一旦所有成員都發送了JoinGroup請求,coordinator選擇第一個發送JoinGroup請求的consumer擔任leader的角色,并將consumer group 信息和partition信息告訴group leader。
  5. leader負責分配消費方案(使用PartitionAssignor),即哪個consumer負責消費哪些topic的哪些partition。一旦完成分配,leader會將這個方案封裝進SyncGroup請求中發給coordinator,非leader也會發SyncGroup請求,只是內容為空。coordinator接收到分配方案之后會把方案塞進SyncGroup的response中發給各個consumer。

小結一下就是:coordinator負責決定leader,leader 負責分配方案,consumer group的分區分配方案是在客戶端執行的, 分配方案由coordinator 擴散。

Rebalance過程

rebalance的前提是coordinator已經確定了。
總體而言,rebalance分為2步:Join和Sync
1 Join, 顧名思義就是加入組。這一步中,所有成員都向coordinator發送JoinGroup請求,請求入組。一旦所有成員都發送了JoinGroup請求,coordinator會從中選擇一個consumer擔任leader的角色,并把組成員信息以及訂閱信息發給leader——注意leader和coordinator不是一個概念。leader負責消費分配方案的制定。

2 Sync,這一步leader開始分配消費方案,即哪個consumer負責消費哪些topic的哪些partition。一旦完成分配,leader會將這個方案封裝進SyncGroup請求中發給coordinator,非leader也會發SyncGroup請求,只是內容為空。coordinator接收到分配方案之后會把方案塞進SyncGroup的response中發給各個consumer。這樣組內的所有成員就都知道自己應該消費哪些分區了。

注意!! consumer group的分區分配方案是在客戶端執行的!Kafka將這個權利下放給客戶端主要是因為這樣做可以有更好的靈活性。比如這種機制下我可以實現類似于Hadoop那樣的機架感知(rack-aware)分配方案,即為consumer挑選同一個機架下的分區數據,減少網絡傳輸的開銷。Kafka默認為你提供了兩種分配策略:range和round-robin。由于這不是本文的重點,這里就不再詳細展開了,你只需要記住你可以覆蓋consumer的參數:partition.assignment.strategy來實現自己分配策略就好了。

consumer group狀態機
和很多kafka組件一樣,group也做了個狀態機來表明組狀態的流轉。coordinator根據這個狀態機會對consumer group做不同的處理,如下圖所示

[圖片上傳失敗...(image-e8410e-1627916151967)]
簡單說明下圖中的各個狀態:
Dead:組內已經沒有任何成員的最終狀態,組的元數據也已經被coordinator移除了。這種狀態響應各種請求都是一個response: UNKNOWN_MEMBER_ID
Empty:組內無成員,但是位移信息還沒有過期。這種狀態只能響應JoinGroup請求
PreparingRebalance:組準備開啟新的rebalance,等待成員加入
AwaitingSync:正在等待leader consumer將分配方案傳給各個成員
Stable:rebalance完成!可以開始消費了

GroupCoordinator joingroup源碼解析

kafka新版consumer所有的group管理工作在服務端都由GroupCoordinator這個新角色來處理,最近測試發現consumer在reblance過程中會有各種各樣的等待行為,于是研究下相關源碼,GroupCoordinator是broker服務端處理consumer各種group相關請求的管理類。本次源碼研究版本是0.10.2.0

首先貼一下huxihx在Kafka消費組(consumer group)畫過的一個流程圖

[圖片上傳失敗...(image-b224b6-1627916151967)]

這個圖以及下面的幾個流程圖非常清晰的表明了當一個consumer(無論是新初始化的實例還是各種情況重新reblance的已有客戶端)試圖加入一個group的第一步都是先發送一個JoinGoupRequest到Coordinator,這個請求里具體包含了什么信息可以從AbstractCoordinator這個類的源代碼找到

/**
   * Join the group and return the assignment for the next generation. This function handles both
   * JoinGroup and SyncGroup, delegating to {@link #performAssignment(String, String, Map)} if
   * elected leader by the coordinator.
   * @return A request future which wraps the assignment returned from the group leader
   */
  private RequestFuture<ByteBuffer> sendJoinGroupRequest() {
      if (coordinatorUnknown())
          return RequestFuture.coordinatorNotAvailable();
 
      // send a join group request to the coordinator
      log.info("(Re-)joining group {}", groupId);
      JoinGroupRequest.Builder requestBuilder= new JoinGroupRequest.Builder(
              groupId,
              this.sessionTimeoutMs,
              this.generation.memberId,
              protocolType(),
              metadata()).setRebalanceTimeout(this.rebalanceTimeoutMs);
 
      log.debug("Sending JoinGroup ({}) to coordinator {}", requestBuilder,this.coordinator);
      return client.send(coordinator, requestBuilder)
              .compose(new JoinGroupResponseHandler());
 
 
 
private Generation generation= Generation.NO_GENERATION;
 
 
  protected staticclass Generation {
      public staticfinal Generation NO_GENERATION= new Generation(
              OffsetCommitRequest.DEFAULT_GENERATION_ID,
              JoinGroupRequest.UNKNOWN_MEMBER_ID,
              null);
 
      publicfinal int generationId;
      publicfinal String memberId;
      publicfinal String protocol;
 
      public Generation(int generationId, String memberId, String protocol) {
          this.generationId= generationId;
          this.memberId= memberId;
          this.protocol= protocol;
      }
  }  

上述可以看出sendJoinGroupRequest里面包含了groupid,sesseionTimeout,membeid,rebalancetimeout等幾個屬性,如果是新初始化的consumer程序generation屬性默認為NO_GENERATION,memberid就是JoinGroupRequest.UNKNOWN_MEMBER_ID

  然后是server處理sendJoinGroupRequest的代碼,請求被轉交到了GroupCoordinator類里的handleJoinGroup方法,該方法在校驗了部分參數和group狀態的合法性后將具體工作放到了doJoinGroup方法里。
private def doJoinGroup(group: GroupMetadata,
                          memberId: String,
                          clientId: String,
                          clientHost: String,
                          rebalanceTimeoutMs: Int,
                          sessionTimeoutMs: Int,
                          protocolType: String,
                          protocols: List[(String, Array[Byte])],
                          responseCallback: JoinCallback) {
    group synchronized {
      if (!group.is(Empty) && (group.protocolType != Some(protocolType) || !group.supportsProtocols(protocols.map(_._1).toSet))) {
        // if the new member does not support the group protocol, reject it
        responseCallback(joinError(memberId, Errors.INCONSISTENT_GROUP_PROTOCOL.code))
      }else if (memberId != JoinGroupRequest.UNKNOWN_MEMBER_ID && !group.has(memberId)) {
        // if the member trying to register with a un-recognized id, send the response to let
        // it reset its member id and retry
        responseCallback(joinError(memberId, Errors.UNKNOWN_MEMBER_ID.code))
      }else {
        group.currentStatematch {
          case Dead=>
            // if the group is marked as dead, it means some other thread has just removed the group
            // from the coordinator metadata; this is likely that the group has migrated to some other
            // coordinator OR the group is in a transient unstable phase. Let the member retry
            // joining without the specified member id,
            responseCallback(joinError(memberId, Errors.UNKNOWN_MEMBER_ID.code))
 
          case PreparingRebalance=>
            if (memberId== JoinGroupRequest.UNKNOWN_MEMBER_ID) {
              addMemberAndRebalance(rebalanceTimeoutMs, sessionTimeoutMs, clientId, clientHost, protocolType, protocols, group, responseCallback)
            }else {
              val member= group.get(memberId)
              updateMemberAndRebalance(group, member, protocols, responseCallback)
            }
 
          case AwaitingSync=>
            if (memberId== JoinGroupRequest.UNKNOWN_MEMBER_ID) {
              addMemberAndRebalance(rebalanceTimeoutMs, sessionTimeoutMs, clientId, clientHost, protocolType, protocols, group, responseCallback)
            }else {
              val member= group.get(memberId)
              if (member.matches(protocols)) {
                // member is joining with the same metadata (which could be because it failed to
                // receive the initial JoinGroup response), so just return current group information
                // for the current generation.
                responseCallback(JoinGroupResult(
                  members= if (memberId== group.leaderId) {
                    group.currentMemberMetadata
                  }else {
                    Map.empty
                  },
                  memberId= memberId,
                  generationId= group.generationId,
                  subProtocol= group.protocol,
                  leaderId= group.leaderId,
                  errorCode= Errors.NONE.code))
              }else {
                // member has changed metadata, so force a rebalance
                updateMemberAndRebalance(group, member, protocols, responseCallback)
              }
            }
 
          case Empty | Stable=>
            if (memberId== JoinGroupRequest.UNKNOWN_MEMBER_ID) {
              // if the member id is unknown, register the member to the group
              addMemberAndRebalance(rebalanceTimeoutMs, sessionTimeoutMs, clientId, clientHost, protocolType, protocols, group, responseCallback)
            }else {
              val member= group.get(memberId)
              if (memberId== group.leaderId || !member.matches(protocols)) {
                // force a rebalance if a member has changed metadata or if the leader sends JoinGroup.
                // The latter allows the leader to trigger rebalances for changes affecting assignment
                // which do not affect the member metadata (such as topic metadata changes for the consumer)
                updateMemberAndRebalance(group, member, protocols, responseCallback)
              }else {
                // for followers with no actual change to their metadata, just return group information
                // for the current generation which will allow them to issue SyncGroup
                responseCallback(JoinGroupResult(
                  members= Map.empty,
                  memberId= memberId,
                  generationId= group.generationId,
                  subProtocol= group.protocol,
                  leaderId= group.leaderId,
                  errorCode= Errors.NONE.code))
              }
            }
        }
 
        if (group.is(PreparingRebalance))
          joinPurgatory.checkAndComplete(GroupKey(group.groupId))
      }
    }
  }  

GroupMetadata對象是一個有PreparingRebalance,AwaitingSync,Stable,Dead,Empty幾種狀態的狀態機,在服務端用于表示當前管理group的狀態。

第一批consumer加入group

1 由上文可知,新初始化的consumer剛開始的memberid都是JoinGroupRequest.UNKNOWN_MEMBER_ID,所有新成員都進入addMemberAndRebalance方法初始化一個member對象并add進group列表內部,只有一個加入的member才能進入maybePrepareRebalance的同步代碼塊內調用prepareReblacne方法

private def addMemberAndRebalance(rebalanceTimeoutMs: Int,
                                  sessionTimeoutMs: Int,
                                  clientId: String,
                                  clientHost: String,
                                  protocolType: String,
                                  protocols: List[(String, Array[Byte])],
                                  group: GroupMetadata,
                                  callback: JoinCallback)= {
  // use the client-id with a random id suffix as the member-id
  val memberId= clientId +"-" + group.generateMemberIdSuffix
  val member= new MemberMetadata(memberId, group.groupId, clientId, clientHost, rebalanceTimeoutMs,
    sessionTimeoutMs, protocolType, protocols)
  member.awaitingJoinCallback= callback
  group.add(member)
  maybePrepareRebalance(group)
  member
}
 
private def maybePrepareRebalance(group: GroupMetadata) {
  group synchronized {
    if (group.canRebalance)
      prepareRebalance(group)
  }
}

prepareReblacne會把group的狀態由上述的empty轉變為PreparingRebalance,后續的客戶端會判斷PreparingRebalance同樣進入addMemberAndRebalance,這樣即使第一個member退出maybePrepareRebalance的synchronized代碼塊,剩余的member會發現group.canRebalacne返回的都是false直接略過

private def prepareRebalance(group: GroupMetadata) {
  // if any members are awaiting sync, cancel their request and have them rejoin
  if (group.is(AwaitingSync))
    resetAndPropagateAssignmentError(group, Errors.REBALANCE_IN_PROGRESS)
 
  group.transitionTo(PreparingRebalance)
  info("Preparing to restabilize group %s with old generation %s".format(group.groupId, group.generationId))
 
  val rebalanceTimeout= group.rebalanceTimeoutMs
  val delayedRebalance= new DelayedJoin(this, group, rebalanceTimeout)
  val groupKey= GroupKey(group.groupId)
  joinPurgatory.tryCompleteElseWatch(delayedRebalance, Seq(groupKey))
}

上述代碼里生成了一個DelayJoin,DelayJoin是kafka內部一種有超時時間的Timer.task的實現,會在兩種情況下根據情況執行對應操作,一是timeout超時,另一種是滿足某種條件后由程序主動運行并注銷定時任務,注意這里放的時間是rebalanceTimeout而不是sessiontimeout。

我們看一下joinPurgatory.tryCompleteElseWatch(delayedRebalance, Seq(groupKey))和joinPurgatory.checkAndComplete(GroupKey(group.groupId))這兩個方法的調用鏈路。

joinPurgatory.tryCompleteElseWatch->DelayedJoin.safeTryComplete->DelayedJoin.tryComplete->coordinator.tryCompleteJoin

joinPurgatory.checkAndComplete->DelayedOperation.checkAndComplete->DelayedJoin.safeTryComplete->DelayedJoin.tryComplete->coordinator.tryCompleteJoin

所以無論是第一個member結束prepareReblacne還是后續的member在doJoinGroup代碼的最后都是去調用一下coordinator.tryCompleteJoin這個方法嘗試完成joinGroup的等待

  def tryCompleteJoin(group: GroupMetadata, forceComplete: ()=> Boolean)= {
    group synchronized {
      if (group.notYetRejoinedMembers.isEmpty)
        forceComplete()
      else false
    }
  }
 
def notYetRejoinedMembers= members.values.filter(_.awaitingJoinCallback== null).toList

tryCompleteJoin的判斷邏輯非常簡單,GroupMetadata內部緩存的所有member都有對應的注冊連接上來(addMemberAndRebalance方法里的member.awaitingJoinCallback = callback會給member的awaitingJoinCallback賦予一個值,值為null的就是有之前的member沒有加入進來),如果notYetRejoinedMembers的列表為空,那么客戶端就齊了,可以進行reblance分配,如果一直不齊,那么會等到rebalanceTimeout過期后觸發強制reblance。

二 heartbeat和session timeout

在reblance過程中可以從下列源碼看到heartbeat的delay時間設置的是session.timeout,如果一個舊的consumer死掉后在這個時間內持續沒有心跳,那么服務端onMemberFailure會把group內對應的memberid刪除并重試一下joinPurgatory.checkAndComplete,如果前次刪除后notYetRejoinedMembers變為空后那么joingroup的等待也結束了。

/**
  * Complete existing DelayedHeartbeats for the given member and schedule the next one
  */
 private def completeAndScheduleNextHeartbeatExpiration(group: GroupMetadata, member: MemberMetadata) {
   // complete current heartbeat expectation
   member.latestHeartbeat= time.milliseconds()
   val memberKey= MemberKey(member.groupId, member.memberId)
   heartbeatPurgatory.checkAndComplete(memberKey)
 
   // reschedule the next heartbeat expiration deadline
   val newHeartbeatDeadline= member.latestHeartbeat + member.sessionTimeoutMs
   val delayedHeartbeat= new DelayedHeartbeat(this, group, member, newHeartbeatDeadline, member.sessionTimeoutMs)
   heartbeatPurgatory.tryCompleteElseWatch(delayedHeartbeat, Seq(memberKey))
 }
 
 
def onExpireHeartbeat(group: GroupMetadata, member: MemberMetadata, heartbeatDeadline: Long) {
   group synchronized {
     if (!shouldKeepMemberAlive(member, heartbeatDeadline))
       onMemberFailure(group, member)
   }
 }
 
 
 private def onMemberFailure(group: GroupMetadata, member: MemberMetadata) {
   trace("Member %s in group %s has failed".format(member.memberId, group.groupId))
   group.remove(member.memberId)
   group.currentStatematch {
     case Dead | Empty=>
     case Stable | AwaitingSync=> maybePrepareRebalance(group)
     case PreparingRebalance=> joinPurgatory.checkAndComplete(GroupKey(group.groupId))
   }
 }

結論,個人在測試過程中發現重啟consumer中會有的部分卡頓大部分應該是由于這個notYetRejoinedMembers的列表由于上一次的關掉的consumer的session沒有到期造成非空引起的等待。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,825評論 6 546
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,814評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,980評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,064評論 1 319
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,779評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,109評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,099評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,287評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,799評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,515評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,750評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,221評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,933評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,327評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,667評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,492評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,703評論 2 380

推薦閱讀更多精彩內容