kafka源碼解析教程(一)

消息建模很重要。

比如理解下Kafka底層日誌文件00000000000000012345.log的命名由來。

Kafka日誌結構

Kafka日誌在磁盤上的層級關係

Kafka日誌對象由多個日誌段對象組成,而每個日誌段對象會在磁盤上創建一組文件,包括

  • 消息日誌文件(.log)
  • 位移索引文件(.index)
  • 時間戳索引文件(.timeindex)
  • 已中止(Aborted)事務的索引文件(.txnindex)
    使用Kafka事務纔會被創建出來。

圖中的一串數字0是該日誌段的起始位移值(Base Offset),即該日誌段中所存的第一條消息的位移值

一個Kafka主題一般有很多分區,每個分區對應一個Log對象,在物理磁盤上則對應於一個子目錄。
比如雙分區的主題test-topic,那麼,Kafka在磁盤上會創建兩個子目錄:test-topic-0和test-topic-1。
在服務器端,這就是兩個Log對象。每個子目錄下存在多組日誌段,也就是多組.log、.index、.timeindex文件組合,只不過文件名不同,因爲每個日誌段的起始位移不同。日誌段代碼解析閱讀日誌段源碼是很有必要的,因爲日誌段是Kafka保存消息的最小載體。也就是說,消息是保存在日誌段中的。然而,官網對於日誌段的描述少得可憐,以至於很多人對於這麼重要的概念都知之甚少。但是,不熟悉日誌段的話,如果在生產環境出現相應的問題,我們是沒有辦法快速找到解決方案的。我跟你分享一個真實案例。我們公司之前碰到過一個問題,當時,大面積日誌段同時間切分,導致瞬時打滿磁盤I/O帶寬。對此,所有人都束手無策,最終只能求助於日誌段源碼。最後,我們在LogSegment的shouldRoll方法中找到了解決方案:設置Broker端參數log.roll.jitter.ms值大於0,即通過給日誌段切分執行時間加一個擾動值的方式,來避免大量日誌段在同一時刻執行切分動作,從而顯著降低磁盤I/O。後來在覆盤的時候,我們一致認爲,閱讀LogSegment源碼是非常正確的決定。否則,單純查看官網對該參數的說明,我們不一定能夠了解它的真實作用。那,log.roll.jitter.ms參數的具體作用是啥呢?下面咱們說日誌段的時候,我會給你詳細解釋下。那話不多說,現在我們就來看一下日誌段源碼。我會重點給你講一下日誌段類聲明、append方法、read方法和recover方法。你首先要知道的是,日誌段源碼位於 Kafka 的 core 工程下,具體文件位置是 core/src/main/scala/kafka/log/LogSegment.scala。實際上,所有日誌結構部分的源碼都在 core 的 kafka.log 包下。該文件下定義了三個 Scala 對象:LogSegment class;LogSegment object;LogFlushStats object。LogFlushStats 結尾有個 Stats,它是做統計用的,主要負責爲日誌落盤進行計時。我們主要關心的是 LogSegment class 和 object。在 Scala 語言裏,在一個源代碼文件中同時定義相同名字的 class 和 object 的用法被稱爲伴生(Companion)。Class 對象被稱爲伴生類,它和 Java 中的類是一樣的;而 Object 對象是一個單例對象,用於保存一些靜態變量或靜態方法。如果用 Java 來做類比的話,我們必須要編寫兩個類才能實現,這兩個類也就是LogSegment 和 LogSegmentUtils。在 Scala 中,你直接使用伴生就可以了。對了,值得一提的是,Kafka 中的源碼註釋寫得非常詳細。我不打算把註釋也貼出來,但我特別推薦你要讀一讀源碼中的註釋。比如,今天我們要學習的日誌段文件開頭的一大段註釋寫得就非常精彩。我截一個片段讓你感受下:A segment of the log. Each segment has two components: a log and an index. The log is a FileRecords containing the actual messages. The index is an OffsetIndex that maps from logical offsets to physical file positions. Each segment has a base offset which is an offset <= the least offset of any message in this segment and > any offset in any previous segment.這段文字清楚地說明了每個日誌段由兩個核心組件構成:日誌和索引。當然,這裏的索引泛指廣義的索引文件。另外,這段註釋還給出了一個重要的事實:每個日誌段都有一個起始位移值(Base Offset),而該位移值是此日誌段所有消息中最小的位移值,同時,該值卻又比前面任何日誌段中消息的位移值都大。看完這個註釋,我們就能夠快速地瞭解起始位移值在日誌段中的作用了。日誌段類聲明下面,我分批次給出比較關鍵的代碼片段,並對其進行解釋。首先,我們看下 LogSegment 的定義:class LogSegment private[log] (val log: FileRecords,
val lazyOffsetIndex: LazyIndex[OffsetIndex],
val lazyTimeIndex: LazyIndex[TimeIndex],
val txnIndex: TransactionIndex,
val baseOffset: Long,
val indexIntervalBytes: Int,
val rollJitterMs: Long,
val time: Time) extends Logging { … }
就像我前面說的,一個日誌段包含消息日誌文件、位移索引文件、時間戳索引文件、已中止事務索引文件等。這裏的 FileRecords 就是實際保存 Kafka 消息的對象。專欄後面我將專門討論 Kafka 是如何保存具體消息的,也就是 FileRecords 及其家族的實現方式。同時,我還會給你介紹一下社區在持久化消息這塊是怎麼演進的,你一定不要錯過那部分的內容。下面的 lazyOffsetIndex、lazyTimeIndex 和 txnIndex 分別對應於剛纔所說的 3 個索引文件。不過,在實現方式上,前兩種使用了延遲初始化的原理,降低了初始化時間成本。後面我們在談到索引的時候再詳細說。每個日誌段對象保存自己的起始位移 baseOffset——這是非常重要的屬性!事實上,你在磁盤上看到的文件名就是baseOffset的值。每個LogSegment對象實例一旦被創建,它的起始位移就是固定的了,不能再被更改。indexIntervalBytes 值其實就是 Broker 端參數 log.index.interval.bytes 值,它控制了日誌段對象新增索引項的頻率。默認情況下,日誌段至少新寫入 4KB 的消息數據纔會新增一條索引項。而 rollJitterMs 是日誌段對象新增倒計時的“擾動值”。因爲目前 Broker 端日誌段新增倒計時是全局設置,這就是說,在未來的某個時刻可能同時創建多個日誌段對象,這將極大地增加物理磁盤 I/O 壓力。有了 rollJitterMs 值的干擾,每個新增日誌段在創建時會彼此岔開一小段時間,這樣可以緩解物理磁盤的 I/O 負載瓶頸。至於最後的 time 參數,它就是用於統計計時的一個實現類,在 Kafka 源碼中普遍出現,我就不詳細展開講了。下面我來說一些重要的方法。對於一個日誌段而言,最重要的方法就是寫入消息和讀取消息了,它們分別對應着源碼中的 append 方法和 read 方法。另外,recover方法同樣很關鍵,它是Broker重啓後恢復日誌段的操作邏輯。append方法我們先來看append 方法,瞭解下寫入消息的具體操作。def append(largestOffset: Long,
largestTimestamp: Long,
shallowOffsetOfMaxTimestamp: Long,
records: MemoryRecords): Unit = {
if (records.sizeInBytes > 0) {
trace(s"Inserting ${records.sizeInBytes} bytes at end offset $largestOffset at position ${log.sizeInBytes} " +
s"with largest timestamp $largestTimestamp at shallow offset $shallowOffsetOfMaxTimestamp")
val physicalPosition = log.sizeInBytes()
if (physicalPosition == 0)
rollingBasedTimestamp = Some(largestTimestamp)

  ensureOffsetInRange(largestOffset)

  // append the messages
  val appendedBytes = log.append(records)
  trace(s"Appended $appendedBytes to ${log.file} at end offset $largestOffset")
  // Update the in memory max timestamp and corresponding offset.
  if (largestTimestamp > maxTimestampSoFar) {
    maxTimestampSoFar = largestTimestamp
    offsetOfMaxTimestampSoFar = shallowOffsetOfMaxTimestamp
  }
  // append an entry to the index (if needed)
  if (bytesSinceLastIndexEntry > indexIntervalBytes) {
    offsetIndex.append(largestOffset, physicalPosition)
    timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar)
    bytesSinceLastIndexEntry = 0
  }
  bytesSinceLastIndexEntry += records.sizeInBytes
}

}
append 方法接收 4 個參數,分別表示待寫入消息批次中消息的最大位移值、最大時間戳、最大時間戳對應消息的位移以及真正要寫入的消息集合。下面這張圖展示了 append 方法的完整執行流程:第一步:在源碼中,首先調用 log.sizeInBytes 方法判斷該日誌段是否爲空,如果是空的話, Kafka 需要記錄要寫入消息集合的最大時間戳,並將其作爲後面新增日誌段倒計時的依據。第二步:代碼調用 ensureOffsetInRange 方法確保輸入參數最大位移值是合法的。那怎麼判斷是不是合法呢?標準就是看它與日誌段起始位移的差值是否在整數範圍內,即 largestOffset - baseOffset的值是不是介於 [0,Int.MAXVALUE] 之間。在極個別的情況下,這個差值可能會越界,這時,append 方法就會拋出異常,阻止後續的消息寫入。一旦你碰到這個問題,你需要做的是升級你的 Kafka 版本,因爲這是由已知的 Bug 導致的。第三步:待這些做完之後,append 方法調用 FileRecords 的 append 方法執行真正的寫入。前面說過了,專欄後面我們會詳細介紹 FileRecords 類。這裏你只需要知道它的工作是將內存中的消息對象寫入到操作系統的頁緩存就可以了。第四步:再下一步,就是更新日誌段的最大時間戳以及最大時間戳所屬消息的位移值屬性。每個日誌段都要保存當前最大時間戳信息和所屬消息的位移信息。還記得 Broker 端提供定期刪除日誌的功能嗎?比如我只想保留最近 7 天的日誌,沒錯,當前最大時間戳這個值就是判斷的依據;而最大時間戳對應的消息的位移值則用於時間戳索引項。雖然後面我會詳細介紹,這裏我還是稍微提一下:時間戳索引項保存時間戳與消息位移的對應關係。在這步操作中,Kafka會更新並保存這組對應關係。第五步:append 方法的最後一步就是更新索引項和寫入的字節數了。我在前面說過,日誌段每寫入 4KB 數據就要寫入一個索引項。當已寫入字節數超過了 4KB 之後,append 方法會調用索引對象的 append 方法新增索引項,同時清空已寫入字節數,以備下次重新累積計算。read 方法好了,append 方法我就解釋完了。下面我們來看read方法,瞭解下讀取日誌段的具體操作。def read(startOffset: Long,
maxSize: Int,
maxPosition: Long = size,
minOneMessage: Boolean = false): FetchDataInfo = {
if (maxSize < 0)
throw new IllegalArgumentException(s"Invalid max size $maxSize for log read from segment $log")

val startOffsetAndSize = translateOffset(startOffset)

// if the start position is already off the end of the log, return null
if (startOffsetAndSize == null)
  return null

val startPosition = startOffsetAndSize.position
val offsetMetadata = LogOffsetMetadata(startOffset, this.baseOffset, startPosition)

val adjustedMaxSize =
  if (minOneMessage) math.max(maxSize, startOffsetAndSize.size)
  else maxSize

// return a log segment but with zero size in the case below
if (adjustedMaxSize == 0)
  return FetchDataInfo(offsetMetadata, MemoryRecords.EMPTY)

// calculate the length of the message set to read based on whether or not they gave us a maxOffset
val fetchSize: Int = min((maxPosition - startPosition).toInt, adjustedMaxSize)

FetchDataInfo(offsetMetadata, log.slice(startPosition, fetchSize),
  firstEntryIncomplete = adjustedMaxSize < startOffsetAndSize.size)

}
read 方法接收 4 個輸入參數。startOffset:要讀取的第一條消息的位移;maxSize:能讀取的最大字節數;maxPosition :能讀到的最大文件位置;minOneMessage:是否允許在消息體過大時至少返回第一條消息。前3個參數的含義很好理解,我重點說下第 4 個。當這個參數爲 true 時,即使出現消息體字節數超過了 maxSize 的情形,read 方法依然能返回至少一條消息。引入這個參數主要是爲了確保不出現消費餓死的情況。下圖展示了 read 方法的完整執行邏輯:邏輯很簡單,我們一步步來看下。第一步是調用 translateOffset 方法定位要讀取的起始文件位置 (startPosition)。輸入參數 startOffset 僅僅是位移值,Kafka 需要根據索引信息找到對應的物理文件位置才能開始讀取消息。待確定了讀取起始位置,日誌段代碼需要根據這部分信息以及 maxSize 和 maxPosition 參數共同計算要讀取的總字節數。舉個例子,假設 maxSize=100,maxPosition=300,startPosition=250,那麼 read 方法只能讀取 50 字節,因爲 maxPosition - startPosition = 50。我們把它和maxSize參數相比較,其中的最小值就是最終能夠讀取的總字節數。最後一步是調用 FileRecords 的 slice 方法,從指定位置讀取指定大小的消息集合。recover 方法除了append 和read 方法,LogSegment 還有一個重要的方法需要我們關注,它就是 recover方法,用於恢復日誌段。下面的代碼是 recover 方法源碼。什麼是恢復日誌段呢?其實就是說, Broker 在啓動時會從磁盤上加載所有日誌段信息到內存中,並創建相應的 LogSegment 對象實例。在這個過程中,它需要執行一系列的操作。def recover(producerStateManager: ProducerStateManager, leaderEpochCache: Option[LeaderEpochFileCache] = None): Int = {
offsetIndex.reset()
timeIndex.reset()
txnIndex.reset()
var validBytes = 0
var lastIndexEntry = 0
maxTimestampSoFar = RecordBatch.NO_TIMESTAMP
try {
for (batch <- log.batches.asScala) {
batch.ensureValid()
ensureOffsetInRange(batch.lastOffset)

    // The max timestamp is exposed at the batch level, so no need to iterate the records
    if (batch.maxTimestamp > maxTimestampSoFar) {
      maxTimestampSoFar = batch.maxTimestamp
      offsetOfMaxTimestampSoFar = batch.lastOffset
    }

    // Build offset index
    if (validBytes - lastIndexEntry > indexIntervalBytes) {
      offsetIndex.append(batch.lastOffset, validBytes)
      timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar)
      lastIndexEntry = validBytes
    }
    validBytes += batch.sizeInBytes()

    if (batch.magic >= RecordBatch.MAGIC_VALUE_V2) {
      leaderEpochCache.foreach { cache =>
        if (batch.partitionLeaderEpoch > 0 && cache.latestEpoch.forall(batch.partitionLeaderEpoch > _))
          cache.assign(batch.partitionLeaderEpoch, batch.baseOffset)
      }
      updateProducerState(producerStateManager, batch)
    }
  }
} catch {
  case e@ (_: CorruptRecordException | _: InvalidRecordException) =>
    warn("Found invalid messages in log segment %s at byte offset %d: %s. %s"
      .format(log.file.getAbsolutePath, validBytes, e.getMessage, e.getCause))
}
val truncated = log.sizeInBytes - validBytes
if (truncated > 0)
  debug(s"Truncated $truncated invalid bytes at the end of segment ${log.file.getAbsoluteFile} during recovery")

log.truncateTo(validBytes)
offsetIndex.trimToValidSize()
// A normally closed segment always appends the biggest timestamp ever seen into log segment, we do this as well.
timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar, skipFullCheck = true)
timeIndex.trimToValidSize()
truncated

}
我依然使用一張圖來說明 recover 的處理邏輯:recover 開始時,代碼依次調用索引對象的 reset 方法清空所有的索引文件,之後會開始遍歷日誌段中的所有消息集合或消息批次(RecordBatch)。對於讀取到的每個消息集合,日誌段必須要確保它們是合法的,這主要體現在兩個方面:該集合中的消息必須要符合 Kafka 定義的二進制格式;該集合中最後一條消息的位移值不能越界,即它與日誌段起始位移的差值必須是一個正整數值。校驗完消息集合之後,代碼會更新遍歷過程中觀測到的最大時間戳以及所屬消息的位移值。同樣,這兩個數據用於後續構建索引項。再之後就是不斷累加當前已讀取的消息字節數,並根據該值有條件地寫入索引項。最後是更新事務型Producer的狀態以及Leader Epoch緩存。不過,這兩個並不是理解Kafka日誌結構所必需的組件,因此,我們可以忽略它們。遍歷執行完成後,Kafka 會將日誌段當前總字節數和剛剛累加的已讀取字節數進行比較,如果發現前者比後者大,說明日誌段寫入了一些非法消息,需要執行截斷操作,將日誌段大小調整回合法的數值。同時, Kafka 還必須相應地調整索引文件的大小。把這些都做完之後,日誌段恢復的操作也就宣告結束了。總結今天,我們對Kafka日誌段源碼進行了重點的分析,包括日誌段的append方法、read方法和recover方法。append方法:我重點分析了源碼是如何寫入消息到日誌段的。你要重點關注一下寫操作過程中更新索引的時機是如何設定的。read方法:我重點分析了源碼底層讀取消息的完整流程。你要關注下Kafka計算待讀取消息字節數的邏輯,也就是maxSize、maxPosition和startOffset是如何共同影響read方法的。recover方法:這個操作會讀取日誌段文件,然後重建索引文件。再強調一下,這個操作在執行過程中要讀取日誌段文件。因此,如果你的環境上有很多日誌段文件,你又發現Broker重啓很慢,那你現在就知道了,這是因爲Kafka在執行recover的過程中需要讀取大量的磁盤文件導致的。你看,這就是我們讀取源碼的收穫。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章