kafka日誌對象(一)—— Log Segment

kafka的高吞吐量持久性是一大亮點,內部的日誌操作是如何呢,研究明白了一定豁然開朗,至少我有種恍然大明白的感覺。

kafka的日誌結構

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

包括消息日誌文件(.log)、位移索引文件(.index)、時間戳索引文件(.timeindex)以及已中止事務的索引文件(.txnindex)。當然,如果你沒有使用 Kafka 事務,已中止事務的索引文件是不會被創建出來的。圖中的一串數字 0 是該日誌段的起始位移值(Base Offset),也就是該日誌段中所存的第一條消息的位移值。

一般情況下,一個 Kafka 主題(topic)有很多分區,每個分區就對應一個 Log 對象,在物理磁盤上則對應於一個子目錄。比如你創建了一個雙分區的主題 test-topic,那麼,Kafka 在磁盤上會創建兩個子目錄:test-topic-0 和 test-topic-1。而在服務器端,這就是兩個 Log 對象。每個子目錄下存在多組日誌段,也就是多組.log、.index、.timeindex 文件組合,只不過文件名不同,因爲每個日誌段的起始位移不同。

我們使用 Kafka Eagle 做監控對象,當穿建了一個只有一個分區的topic時:

如果創建了有三個分區的topic時就是這樣的:

日誌段代碼解析

日誌段源碼位於 Kafka 的 core 工程下,具體文件位置是 core/src/main/scala/kafka/log/LogSegment.scala。實際上,所有日誌結構部分的源碼都在 core 的 kafka.log 包下。

該文件下定義了三個 Scala 對象:

  • LogSegment class;
  • LogSegment object;
  • LogFlushStats object。LogFlushStats 結尾有個 Stats,它是做統計用的,主要負責爲日誌落盤進行計時,這裏不做分析。

Object 對象是一個單例對象,用於保存一些靜態變量或靜態方法。

日誌段類聲明

看下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 { … }

1. 這裏的 FileRecords、lazyOffsetIndex、lazyTimeIndex 和 txnIndex 分別對應剛纔所說的 Kafka 消息的對象和3 個索引文件。而同時lazyOffsetIndex、lazyTimeIndex都使用了使用了延遲初始化的原理,降低了初始化時間成本。

2. 每個日誌段對象保存自己的起始位移 baseOffset。事實上,你在磁盤上看到的文件名就是 baseOffset 的值。每個 LogSegment 對象實例一旦被創建,它的起始位移就是固定的了,不能再被更改。

3. indexIntervalBytes 值其實就是 Broker 端參數 log.index.interval.bytes 值,它控制了日誌段對象新增索引項的頻率。默認情況下,日誌段至少新寫入 4KB 的消息數據纔會新增一條索引項。

4. 而 rollJitterMs 是日誌段對象新增倒計時的“擾動值”。因爲目前 Broker 端日誌段新增倒計時是全局設置,這就是說,可能在某個時刻同時創建多個日誌段對象,這將極大地增加物理磁盤 I/O 壓力。有了 rollJitterMs 值的干擾,每個新增日誌段在創建時會彼此岔開一小段時間,這樣可以緩解物理磁盤的 I/O 負載瓶頸。

日誌段操作方法 —— 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 個參數,分別表示:

  • largestOffset  待寫入消息批次中消息的最大位移值
  • largestTimestamp  最大時間戳
  • shallowOffsetOfMaxTimestamp  最大時間戳對應消息的位移
  • records  真正要寫入的消息集合

append方法整體流程:

第一步:

在源碼中,首先調用 records.sizeInBytes 方法判斷該日誌段是否爲空,如果是空的話, Kafka 需要記錄要寫入消息集合的最大時間戳,並將其作爲後面新增日誌段倒計時的依據。

第二步:

代碼調用 ensureOffsetInRange 方法確保輸入參數最大位移值是合法的。那怎麼判斷是不是合法呢?標準就是看它與日誌段起始位移的差值是否在整數範圍內,即 largestOffset - baseOffset 的值是不是介於 [0,Int.MAXVALUE] 之間。在極個別的情況下,這個差值可能會越界,這時,append 方法就會拋出異常,阻止後續的消息寫入。

第三步:

這些做完之後,append 方法調用 FileRecords 的 append 方法執行真正的寫入。將內存中的消息對象寫入到操作系統的頁緩存。

第四步:

更新日誌段的最大時間戳以及最大時間戳所屬消息的位移值屬性。每個日誌段都要保存當前最大時間戳信息和所屬消息的位移信息。

到這裏結合實際開發想到一個事情。Broker 端提供定期刪除日誌的功能,比如因爲磁盤有限,我只想保留最近 7 天的日誌,當前最大時間戳這個值就是判斷的依據;而最大時間戳對應的消息的位移值則用於時間戳索引項。時間戳索引項保存時間戳與消息位移的對應關係。在這步操作中,Kafka 會更新並保存這組對應關係。

第五步:

append 方法的最後一步就是更新索引項和寫入的字節數了。默認情況下日誌段每寫入 4KB 數據就要寫入一個索引項。當已寫入字節數超過了 4KB 之後,append 方法會調用索引對象的 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 方法依然能返回至少一條消息。這個參數主要是爲了確保不出現消費餓死的情況。

第一步

調用 translateOffset 方法定位要讀取的起始文件位置 (startPosition)。輸入參數 startOffset 僅僅是位移值,Kafka 需要根據索引信息找到對應的物理文件位置才能開始讀取消息。

待確定了讀取起始位置,日誌段代碼需要根據這部分信息以及 maxSize 和 maxPosition 參數共同計算要讀取的總字節數。舉個例子,假設 maxSize=100,maxPosition=300,startPosition=250,那麼 read 方法只能讀取 50 字節,因爲 maxPosition - startPosition = 50。我們把它和 maxSize 參數相比較,其中的最小值就是最終能夠讀取的總字節數。

最後一步

調用 FileRecords 的 slice 方法,從指定位置讀取指定大小的消息集合。

日誌段操作方法 —— recover 方法

顧名思義,恢復日誌段。什麼是恢復日誌段呢?其實就是說, Broker 在啓動時會從磁盤上加載所有日誌段信息到內存中,並創建相應的 LogSegment 對象實例。在這個過程中,它需要執行一系列的操作。所以也就明白了,每次Broker重啓時很費勁就是因爲這個方法要加載很多日誌段文件。

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 還必須相應地調整索引文件的大小。把這些都做完之後,日誌段恢復的操作也就結束了。

到此,日誌段的代碼分析完畢。那麼日誌對象是如何加載日誌段對象並且如何操作的呢? 請聽下回分解。

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