Kafka源码阅读之:日志段 kafka.log.LogSegment

序言

笔者在平时工作中时时都在享受kafka的性能和稳定性,但始终没有机会真正了解kafka的设计思想和设计原理,最近报名了极客时间的一门课,开始坚持阅读kafka源代码。在此期间我也会持续更新自己梳理注释过的源代码。
鉴于本人的Scala代码能力有限,加上kafka的代码层次较深,在阅读源码的初期我会忽略一些kafka复杂设计的细节以及嵌套过深的方法调用,先从最核心的最小单元类开始,之后自己有了更全面的把握之后会有更全面的分析总结。
一点源码相关信息
本次源码阅读的是kafka的主分支,kafka主分支
ide采用Intellij idea,编译工具gradle版本6.3。

Log与LogSegment

kafa的源码中设计最丰富也最精妙的是server端或者说啥broker端的代码,而kafka和众多消息系统不同的地方在于kafka传输的消息是需要持久化的,这一特点在很大程度上造就kafka的容错性。
正如kafka官方所指出的:

As a result of taking storage seriously and allowing the clients to control their read position, you can think of Kafka as a kind of special purpose distributed filesystem dedicated to high-performance, low-latency commit log storage, replication, and propagation.

kafka从文件系统的角度来看是一个专注于提交日志存储、复制和增长的分布式文件系统。从某种程度上来说,kafka的日志部分上kafka所有上层系统的基石,也是最直接和系统相关的部分。掌握这一部分的源码能够很有效提高我们对于kafka存储功能的理解,对于kafka broker在文件系统上创建的文件的意义和作用,对于kafka的使用中可能出现的日志问题会有更清晰的解决思路。

首先来看一下Log对象,如下可见官网的截图:
image.png
kafka主题的一个分区对应一个Log对象,生产者会按顺序向指定的分区追加日志消息。如果我们只关注单个分区内消息的追加过程:
image.png
上图所示的是官网对于kafka log的抽象图,事实上真正的日志的管理远比示意图复杂,图中的一个个位移数值代表的也不是简单的一条消息,而是日志段,消息会追加入对应的日志段,而日志段之间的位移保持有序,因此消息之间的保持有序。
image.png
每一个分区中的消息以Log的形式存在,但Log并不是日志存储和操作的最底层单元,kafka消息最底层的单元的是日志段,即LogSegemnt, Log和LogSegement的关系如上图所示。

这里需要注意的是,kafka的日志段并不是一个物理概念,而是一个逻辑概念,一个日志段包括一个消息日志文件和若干索引文件组成,即一个.log和多个.xxxindex文件:
image.png
如图是笔者刚执行了kafka官方的quickstart之后的topic对应文件夹(因为新增的test主题设置为单分区,故只有一个分区文件夹,该文件夹下所有的文件对应一个Log)下的文件,因为笔者用生产者产生的消息条数很少,所以生成了一个日志段,故topic文件夹下只有一个对应日志段,所以只有一组对应的.log和.index文件。
leader-epoch文件涉及到的功能我们在源码阅读初期不会关注,故在此不做叙述。

LogSegment

现在我们进入源码阅读部分。kafka日志段相关的代码位于kafa.core.src.main.scala.kafka.log.LogSegment.scala。
该文件包含一个class LogSegment和两个Object(scala语法,单例对象):object LogSegmentobject LogFlushStats
本次我们主要关注class logSegment

1. 定义


/**
 * 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.
 */
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

这里目前我们只需要关注如下的一些参数。
类的定义直接明了地验证了上面所说的,一个日志段对应一个日志文化和一系列索引文件,其中log对应.log文件,而lazyOffsetIndex, lazyTimeIndex对应默认配置一定会存储的两种索引文件,偏移量索引和时间戳索引文件,有关这几种文件的具体内容会在后续的源码阅读系列文章中介绍。此处还有一个重要的参数是需要关注的,即baseOffset。官方注释对它的定义是

//@param baseOffset A lower bound on the offsets in this segment

baseOffset表示该日志段的消息位移的下限,即日志段中所有的消息的位移最小值为baseOffset,索引消息的位移值均大于等于baseOffset,现在我们再回想之前的分区文件夹的截图

image.png
如我们之前所说,文件夹里面只包含一个日志段,所有的文件属于一个日志段。而因为这个日志段是整个分区的第一个日志段,所以它的baseOffset为0,按照kafka的定义,一个日志段用baseOffset作为名字,将baseOffset补全为20位即为我们所见的.log和.index文件的文件名。由此也baseOffset的重要性也可见一斑。

indexIntervalBytes,即Broker 端参数 log.index.interval.bytes,它控制了索引文件的增加频率。
对于日志段而言,最重要的就是日志的读取和写入了,所以我们要重点理解日志段的appendread方法。另外,recover 方法同样很关键,它是 Broker 重启后恢复日志段的操作逻辑,这也是kafka高可用性和高容错性的保证之一。

2. 日志段写入消息

这里我们主要分析LogSegment的append()方法。

@nonthreadsafe
  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()//确认当前.log文件已经写到的位置
      if (physicalPosition == 0)//如果日志段为空
        rollingBasedTimestamp = Some(largestTimestamp)//更新用于日志段切分的时间戳

      ensureOffsetInRange(largestOffset)//确保要写入的消息集合中位移最大值大于等于要写入的日志段的baseOffset且小于等于Int的最大值。

      // 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使用。
    }
  }

append 方法接收 4 个参数,分别表示待写入消息批次中消息的最大位移值最大时间戳最大时间戳对应消息的位移以及真正要写入的消息集合

整个方法主要可以分为如下五步

第一步:在源码中,首先调用 log.sizeInBytes 方法判断该日志段是否为空,如果是空的话, Kafka 需要记录要写入消息集合的最大时间戳,并将其作为后面新增日志段倒计时的依据。

第二步:代码调用 ensureOffsetInRange 方法确保输入参数最大位移值是合法的,即检查largestOffset - baseOffset 的值是不是介于 [0,Int.MAXVALUE] 之间。

第三步:待这些做完之后,append 方法调用 FileRecords 的 append 方法执行真正的写入。前面说过了,专栏后面我们会详细介绍 FileRecords 类。这里你只需要知道它的工作是将内存中的消息对象写入到操作系统的页缓存就可以了。

第四步:再下一步,就是更新日志段的最大时间戳以及最大时间戳所属消息的位移值属性。每个日志段都要保存当前最大时间戳信息和所属消息的位移信息。最大时间戳对应的消息的位移值则用于时间戳索引项。位移索引则记录了消息位移和物理文件写入位置的对应关系。

第五步:append 方法的最后一步就是更新索引项和写入的字节数了。我在前面说过,日志段每写入 indexIntervalBytes量的数据就要写入一个索引项。当已写入字节数超过了 indexIntervalBytes的量就要写入一个索引项,append 方法会调用索引对象的 append 方法新增索引项,同时清空已写入字节数,以备下次重新累积计算。

2. 日志段读取消息

read方法将从当前日志段中读取消息集合,源码如下:

  @threadsafe
  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)//将搜索offsetIndex文件,获取符合条件的第一条消息的:1.位移值 2.消息大小 3.消息的物理文件位置
    //置于startOffsetAndSize容器中
    // if the start position is already off the end of the log, return null
    if (startOffsetAndSize == null)//如果该日志段没有符合条件的消息,返回空
      return null

    val startPosition = startOffsetAndSize.position//从startOffsetAndSize 获取消息物理位置
    val offsetMetadata = LogOffsetMetadata(startOffset, this.baseOffset, startPosition)//获取日志位移的元数据

    val adjustedMaxSize = //更新计划读取的最大字节数
      if (minOneMessage) math.max(maxSize, startOffsetAndSize.size)//情况一,至少一条设置为true,则需要读取的字节数= max(计划要读取字节数,第一条位移满足条件的消息的大小)
      else maxSize//情况二,至少一条设置为false,adjustedMaxSize直接等于maxSize

    // return a log segment but with zero size in the case below
    if (adjustedMaxSize == 0)//第一条大于初始位移值的消息的内容为空或者要去读取的字节数为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:是否允许在消息体过大时至少返回第一条消息。

其中第四个参数的含义需要额外解释一下,即minOneMessage。当这个参数为 true 时,即使出现消息体字节数超过了 maxSize 的情形,read 方法依然能返回至少一条消息。引入这个参数主要是为了确保不出现消费者需要读取的数据大于一个日志段的大小就始终消费不到数据的情况 。
日志读取主要分为三个步骤:

  1. 获取符合条件的消息的的物理位置、位移值、消息大小字节数
  2. 根据参数配置和实际的日志段情况,更新需要读取的字节数
  3. 利用log文件(FileRecords)的方法截取日志段内容,读取消息集合

3. 日志段恢复

还有一个方法也需要我们重点关注,那就是kafka日志段的恢复方法。kafka broker会有需要重启的时候,重启后的机子如何恢复到宕机前的状态是保证kafka高可用性的重点之一,而其中消息的恢复是最主要的内容之一,到底层的话,需要恢复的实际就是日志段。

日志段恢复所做的事情就是:Broker 在启动时会从磁盘上加载所有日志段信息到内存中,并创建相应的 LogSegment 对象实例。在这个过程中,它需要执行一系列的操作。因此我们需要学习了解 LogSegement的recover方法,方法的源码如下:

  @nonthreadsafe
  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()//检查消息集合内容是否符合kafka的二进制格式
        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) {//更新事务性消费者和leaderEpoch(暂且不清楚原理)
          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//开始执行消息截断,截除不合法的字节,为何会存在读取的字节数大于实际的.log文件中的消息本体,需要后续深入研究
    if (truncated > 0)
      debug(s"Truncated $truncated invalid bytes at the end of segment ${log.file.getAbsoluteFile} during recovery")

    log.truncateTo(validBytes)//.log文件即消息本体截断
    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方法接受两个参数,producerStateManagerleaderEpocheCache,前者和对应该日志段的baseOffset的事务性消费者状态管理有关,我们前面也说个这个基础位移值是一个日志段最重要的参数,而后者属于相对复杂的功能,笔者对于源码的系统理解还不足以说清楚LeaderEpoche这个机制,故这个话题暂且略过,但这个参数的忽略对我们理解recover方法并不会有很大的影响。
recover方法的处理流程可用如下流程图表示:
image.png

预告

本系列kafka源码解读下一期内容将会继续深挖消息日志的存储模块,分析kafka消息存储中的索引文件的作用以及Log(对应topic分区)和LogSegment的关系等内容。在本篇中我们只看到在某些场景下索引文件会需要增加和修改和清空,之后我们会看到它们具体起到了什么样的作用。

引用

Kafka源码解读
Apache Kafka

后记

Kafka官方的代码注释还是相对很完善的,但是英文的理解有时候需要下一些功夫,对于不太好理解的方法,一层层深入下去,去看到它的底层,返回类型是什么,对于理解源码组成部分的功能有很大的帮助。

同时我也感受到,所谓的编程语言的差异性很多时候真的不是阅读源码的最大障碍,在理解功能过程中学习语法,一个是实用性有了保证,二一个时间上也节省了不少,遇到不会的,技术社区问就好了,StackOverflow是个好地方,也相信日后的思否也会成为有这样技术沉淀和氛围的地方。我也会继续坚持,一是坚持自己的追求,二也希望为社区作出贡献,和大家一同进步提高。

阅读 607

推荐阅读

一个大数据程序员的夯实基础,积累进步的心得记录,内容会包括:常见大数据框架底层原理解析、java多线...

0 人关注
2 篇文章
专栏主页