Kafka日志模块(一):LogSegment

Kafka 日志对象由多个日志段对象组成,而每个日志段对象会在磁盘上创建一组文件,包括消息日志文件(.log)、位移索引文件(.index)、时间戳索引文件(.timeindex)以及已中止(Aborted)事务的索引文件(.txnindex)。LogSegment定义如下:

class LogSegment private[log] (val log: FileRecords, // log 文件对象
                               val lazyOffsetIndex: LazyIndex[OffsetIndex], // index 文件对象
                               val lazyTimeIndex: LazyIndex[TimeIndex], // timeindex 文件对象
                               val txnIndex: TransactionIndex,
                               val baseOffset: Long, // 当前日志分片文件中第一条消息的 offset 值
                               val indexIntervalBytes: Int, // 索引项之间间隔的最小字节数,对应 index.interval.bytes 配置
                               val rollJitterMs: Long,
                               // log.segment.ms,以时间为维度切分segment。
                               //那配置了这个参数之后如果有很多很多分区,然后因为这个参数是全局的,因此同一时刻需要做很多文件的切分,
                               // 这磁盘IO就顶不住了啊,因此需要设置个rollJitterMs,来岔开它们
                               val time: Time) extends Logging { 

  /** 当前 LogSegment 的创建时间 */
  private var created = time.milliseconds
  
  /** 自上次添加索引项后,在 log 文件中累计加入的消息字节数 */
  private var bytesSinceLastIndexEntry = 0
  
  // roll 一个新的segments的时间戳
  @volatile private var rollingBasedTimestamp: Option[Long] = None
  
  /** 已追加消息的最大时间戳 */
  @volatile private var _maxTimestampSoFar: Option[Long] = None
  
    /* log文件的大小 */
  def size: Int = log.sizeInBytes()
  
  // 判断是否需要roll一个新的segments段
  def shouldRoll(rollParams: RollParams): Boolean = {
    val reachedRollMs = timeWaitedForRoll(rollParams.now, rollParams.maxTimestampInMessages) > rollParams.maxSegmentMs - rollJitterMs

    size > rollParams.maxSegmentBytes - rollParams.messagesSize || // 当前 activeSegment 在追加本次消息之后,长度超过 LogSegment 允许的最大值
      (size > 0 && reachedRollMs) || // 当前 activeSegment 的存活时间超过了允许的最大时间
      offsetIndex.isFull || timeIndex.isFull || // 索引文件满了
      // canConvertToRelativeOffset 判断新append的message是否能放进去,判断是否能转化为相对offset
      // 即判断val relativeOffset = rollParams.maxOffsetInMessages - baseOffset
      //     (relativeOffset < 0 || relativeOffset > Int.MaxValue)
      !canConvertToRelativeOffset(rollParams.maxOffsetInMessages)
  }
  
  
  /**
   * The time this segment has waited to be rolled.
    * 判断此segment是否需要rolled
    * 1. 如果此segment的第一个消息的时间戳存在,就用当前的新的batch的时间戳,减去此segment第一条消息的的时间戳判断是否已经超过segments.ms
    * 2. 如果此segments的第一个消息的时间戳不存在,就用系统时间与此segment创建的时间差判断。
   */
  def timeWaitedForRoll(now: Long, messageTimestamp: Long) : Long = {
    // Load the timestamp of the first message into memory
    // loadFirstBatchTimestamp如果此segment的第一个消息的时间戳存在,rollingBasedTimestamp置为此时间戳。
    // 如果不存在,rollingBasedTimestamp本身就是此segment创建的时间,不用操作。
    loadFirstBatchTimestamp()
    rollingBasedTimestamp match {
      case Some(t) if t >= 0 => messageTimestamp - t
      case _ => now - created
    }
  }
  
  }

FileRecords  

一个日志段包含消息日志文件、位移索引文件、时间戳索引文件、已中止事务索引文件等, FileRecords 就是实际保存 Kafka 消息的对象,封装了真正的java.io.File对象。baseOffset是每个日志段对象保存自己的起始位移,即.log文件的命名,每个 LogSegment 对象实例一旦被创建,它的起始位移就是固定的了,不能再被更改。

public class FileRecords extends AbstractRecords implements Closeable {
    /** 标识是否为日志文件分片 */
    private final boolean isSlice;
    private final int start;
    /** 分片的结束位置 */
    private final int end;

    private final Iterable<FileLogInputStream.FileChannelRecordBatch> batches;

    // mutable state
    /** 如果是分片则表示分片的大小(end - start),如果不是分片则表示整个日志文件的大小 */
    private final AtomicInteger size;
    /** 读写对应的日志文件的通道 */
    private final FileChannel channel;
    /** 日志文件对象 */
    private volatile File file;

    /**
     * The {@code FileRecords.open} methods should be used instead of this constructor whenever possible.
     * The constructor is visible for tests.
     */
    // FileRecords 类用于描述和管理日志(分片)文件数据,对应一个 log 文件,其字段定义如下:
    FileRecords(File file,
                FileChannel channel,
                int start,
                int end,
                boolean isSlice) throws IOException {
                }

append方法

append方法写消息的具体操作。

  /**
   * Append the given messages starting with the given offset. Add
   * an entry to the index if needed.
   * 从给定的起始offset加消息。如需要的话添加index文件
   * 线程不安全类,默认调用此方法前加了锁
   *
   * @param 待写入消息的最大位移值
   * @param 待写入消息的最大时间戳
   * @param 最大时间戳对应消息的位移
   * @param 待写入的实际消息集合
   * @return 返回消息写入log文件的物理位置。
   * @throws LogSegmentOffsetOverflowException if the largest offset causes index offset overflow
   */
  @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")
      // 获取当前要写入的log日志段文件大小
      val physicalPosition = log.sizeInBytes()
      // 判断要写入的日志段log文件是否是空的,如果是空的,就一定要记录写入消息集合的最大时间戳,并将其作为后面新增日志段倒计时的依据
      if (physicalPosition == 0)
        rollingBasedTimestamp = Some(largestTimestamp)
      // 判断(largestOffset - baseOffset < 0 || largestOffset - baseOffset > Int.MaxValue)
      ensureOffsetInRange(largestOffset)

      // append the messages
      // 正式写入log文件,具体在FileRecords类中,在之后接受Log类时会分析log.append,将内存中的消息对象写入到操作系统的页缓存
      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)
      // 更新索引项和写入的字节数,志段每写入 4KB 数据就要写入一个索引项。
      // 当已写入字节数超过了 4KB 之后,append 方法会调用索引对象的 append 方法新增索引项,同时清空已写入字节数,以备下次重新累积计算。
      if (bytesSinceLastIndexEntry > indexIntervalBytes) {
        offsetIndex.append(largestOffset, physicalPosition)
        timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar)
        bytesSinceLastIndexEntry = 0
      }
      bytesSinceLastIndexEntry += records.sizeInBytes
    }
  }

read方法

read方法读取log中的数据,分为三步:
1. 查找索引确定读取物理文件位置
2. 计算要读取的总字节数
3. 读取消息

  /**
   * Read a message set from this segment beginning with the first offset >= startOffset. The message set will include
   * no more than maxSize bytes and will end before maxOffset if a maxOffset is specified.
   * 从第一个比入参startOffset的值大的offset值开始读取,读取的最大大小为maxSize,最大条数为maxOffset
   * @param startOffset 要读取的第一条消息的位移
   * @param maxSize 能读取的最大字节数
   * @param maxPosition 能读到的最大文件位置
   * @param minOneMessage 是否允许在消息体过大时至少返回第一条消息。
   *
   * @return The fetched data and the offset metadata of the first message whose offset is >= startOffset,
   *         or null if the startOffset is larger than the largest offset in this log
    *         返回对象包括:消息集合、第一条offset值大于入参startOffset的消息的metadata对象。
    *         如果入参startOffset大于log文件的最大offset,返回空。
   */
  @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")

    // 调用 translateOffset 方法定位要读取的起始文件位置
    // startOffset 仅仅是位移值,Kafka 需要根据索引信息找到对应的物理文件位置才能开始读取消息,调用translateOffset方法获取
    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
    // offsetMetadata对象,里面主要成员为:消息offset、消息所在的segment的baseOffset、所在的segment的物理位置。
    val offsetMetadata = LogOffsetMetadata(startOffset, this.baseOffset, startPosition)
    // 根据minOneMessage参数,调整此次读取的最大size
    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
    // 计算fetch的最大size
    val fetchSize: Int = min((maxPosition - startPosition).toInt, adjustedMaxSize)

    // log.slize方法从指定位置读取指定大小的消息集合。
    // firstEntryIncomplete为是否整条消息都读完,可能是maxSize小于第一条消息的大小,
    FetchDataInfo(offsetMetadata, log.slice(startPosition, fetchSize),
      firstEntryIncomplete = adjustedMaxSize < startOffsetAndSize.size)
  }

下面简单介绍一下translateOffset,这个是根据索引信息找到对应的物理文件位置才开始读取消息,查询是基于二分查找发。

  private[log] def translateOffset(offset: Long, startingFilePosition: Int = 0): LogOffsetPosition = {
    // 基于二分查找获取小于等于参数 offset 的最大 offset,返回 offset 与对应的物理地址
    val mapping = offsetIndex.lookup(offset)
    // 查找对应的物理地址 position,找到map中对应的batch,后面会用到这个batch的起始位置
    log.searchForOffsetWithSize(offset, max(mapping.position, startingFilePosition))
  }
  def lookup(targetOffset: Long): OffsetPosition = {
    maybeLock(lock) {
      // 使用私有变量复制出整个索引映射区
      val idx = mmap.duplicate
      // largestLowerBoundSlotFor方法底层使用了改进版的二分查找算法寻找对应的槽
      val slot = largestLowerBoundSlotFor(idx, targetOffset, IndexSearchType.KEY)
      // 如果没找到,返回一个空的位置,即物理文件位置从0开始,表示从头读日志文件
      // 否则返回slot槽对应的索引项
      if(slot == -1)
        OffsetPosition(baseOffset, 0)
      else
        parseEntry(idx, slot)
    }
  }
  protected def largestLowerBoundSlotFor(idx: ByteBuffer, target: Long, searchEntity: IndexSearchEntity): Int =
    indexSlotRangeFor(idx, target, searchEntity)._1

这个是改进的二分查找发,把搜索的区域分成了冷区和热区,然后有条件地在不同区域执行普通的二分查找算法
这个改进版算法提供了一个重要的保证:它能保证那些经常需要被访问的 Page 组合是固定的。

  private def indexSlotRangeFor(idx: ByteBuffer, target: Long, searchEntity: IndexSearchEntity): (Int, Int) = {
    // check if the index is empty
    // 第1步:如果索引为空,直接返回<-1,-1>对
    if(_entries == 0)
      return (-1, -1)

    // 封装原版的二分查找算法
    def binarySearch(begin: Int, end: Int) : (Int, Int) = {
      // binary search for the entry
      var lo = begin
      var hi = end
      while(lo < hi) {
        val mid = ceil(hi/2.0 + lo/2.0).toInt
        val found = parseEntry(idx, mid)
        val compareResult = compareIndexEntry(found, target, searchEntity)
        if(compareResult > 0)
          hi = mid - 1
        else if(compareResult < 0)
          lo = mid
        else
          return (mid, mid)
      }
      (lo, if (lo == _entries - 1) -1 else lo + 1)
    }

    // 第3步:确认热区首个索引项位于哪个槽。_warmEntries就是所谓的分割线,目前固定为8192字节处
    // 如果是OffsetIndex,_warmEntries = 8192 / 8 = 1024,即第1024个槽
    // 如果是TimeIndex,_warmEntries = 8192 / 12 = 682,即第682个槽
    val firstHotEntry = Math.max(0, _entries - 1 - _warmEntries)
    // check if the target offset is in the warm section of the index
    // 第4步:判断target位移值在热区还是冷区
    if(compareIndexEntry(parseEntry(idx, firstHotEntry), target, searchEntity) < 0) {
      // 如果在热区,搜索热区
      return binarySearch(firstHotEntry, _entries - 1)
    }

    // check if the target offset is smaller than the least offset
    // 第5步:确保target位移值不能小于当前最小位移值
    if(compareIndexEntry(parseEntry(idx, 0), target, searchEntity) > 0)
      return (-1, 0)

    // 第6步:如果在冷区,搜索冷区
    binarySearch(0, firstHotEntry)
  }
public LogOffsetPosition searchForOffsetWithSize(long targetOffset, int startingPosition) {
    for (FileChannelRecordBatch batch : batchesFrom(startingPosition)) {
        long offset = batch.lastOffset();
        if (offset >= targetOffset)
            // 找到第一个batch含有targetOffset的,传入的offset值后面没有用到,主要是用batch.position,代表这个index对应的块的起始位置。
            return new LogOffsetPosition(offset, batch.position(), batch.sizeInBytes());
    }
    return null;
}

recover 方法

恢复日志段,Broker 在启动时会从磁盘上加载所有日志段信息到内存中,并创建相应的 LogSegment 对象实例。
1. 情况索引文件
2. 遍历日志段中所有的消息集合:
   A. 检验消息集合
   B. 保存最大时间戳和所属消息位移
   C. 更新索引项
   D. 更新总消息字节数
   E. 更细leaderEpoche
3. 执行消息索引文件截断。

  /**
   * Run recovery on the given segment. This will rebuild the index from the log file and lop off any invalid bytes
   * from the end of the log and index.
   *
   * @param producerStateManager Producer state corresponding to the segment's base offset. This is needed to recover
   *                             the transaction index.
   * @param leaderEpochCache Optionally a cache for updating the leader epoch during recovery.
   * @return The number of bytes truncated from the log
   * @throws LogSegmentOffsetOverflowException if the log segment contains an offset that causes the index offset to overflow
   */
  @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 {
      // 遍历每个log中的每个batches
      for (batch <- log.batches.asScala) {
        // 该集合中的消息必须要符合 Kafka 定义的二进制格式
        // 消息头字段大小必须保证 >= 14
        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
        // 建立索引项,如果当前的所有读取的byte 减去 上次建索引文件的bytes 大于 indexIntervalBytes(默认配置),建立新索引
        if (validBytes - lastIndexEntry > indexIntervalBytes) {
          offsetIndex.append(batch.lastOffset, validBytes)
          timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar)
          lastIndexEntry = validBytes
        }
        validBytes += batch.sizeInBytes()

        // 1. magic是消息格式版本,总有3个版本。V2是最新版本
        // 2. leader epoch是controller分配给分区leader副本的版本号。
        //    每个消息批次都要有对应的leader epoch。Kafka会记录每个分区leader不同epoch对应的首条消息的位移。
        //    比如leader epoch=0时producer写入了100条消息,那么cache会记录<0, 0>,之后leader变更,epoch增加到1,之后producer又写入了200条消息,那么cache会记录<1, 100>。
        //    epoch主要用于做日志截断时保证一致性用的,单纯依赖HW值可能出现各种不一致的情况。这是社区对于HW值的一个修正机制
        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))
    }
    // Kafka 会将日志段当前总字节数(直接从.log文件获取),和刚刚累加的已读取字节数进行比较
    // 如果发现前者比后者大,说明日志段写入了一些非法消息,需要执行截断操作
    // 日志文件写入了消息的部分字节然后broker宕机。磁盘是块设备,它可不能保证消息的全部字节要么全部写入,要么全都不写入。
    // 因此Kafka必须有机制应对这种情况,即校验+truncate。
    // ps:truncate是强行截取log文件的指定大小,从末尾处直接截取。如果是因为网络抖动导致中间某些字节丢失或写入错误字节,
    // 会出现消息集合CRC校验值发生变更,这个检查不是在log这一层级执行的,而是在底层的消息集合或消息批次这个level执行的。
    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
  }

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值