kafka源码阅读【3-日志段】

1、什么是日志段

在kafka中,所有的消息都是落盘保存在日志中,然而如果一个topic只保存在一份文件里面的话,这份文件会非常大,为了避免这种情况kafka对日志进行分段处理,每一个日志段底下有有各自类型的文件,比如:日志文件(.log)、位移索引文件(.index)、时间戳索引文件(.timeindex)事务的索引文件(.txnindex)等等。日志和日志段之间的关系如下所示:
在这里插入图片描述
在kafka中一个topic底下会有1~多个partition,kafka会按照每个partition的维度创建相应的目录,日志文件(.log)、位移索引文件(.index)、时间戳索引文件(.timeindex)事务的索引文件(.txnindex)等等都会放在这个目录底下,日志段 LogSegment 封装了对这些文件的操作。在kafka中日志对于的功能封装在Log.scala文件底下,而日志段作为日志的成员,在LogSegment.scala文件底下。

2、LogSegment日志段源码

日志段作为kafka保存消息最小的载体,通过日子段kafka能对消息进行写入等操作。下面来看一下日志段的实现源码吧

2.1、LogSegment定义

@nonthreadsafe
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

在LogSegment中,基本成员属性有以下几个

  • log: FileRecords 封装了对文件操作方式的对象,在FileRecords中封装了FileChannel和File,其中FileChannel就是用来实现零拷贝技术的Java API对象。
  • lazyOffsetIndex: LazyIndex[OffsetIndex] 对应.index文件,使用了延迟初始化方式,为了降低了初始化时间成本
  • lazyTimeIndex: LazyIndex[TimeIndex] 对应.timeindex文件,同样适应延迟初始化方式
  • txnIndex: TransactionIndex 对应.txnindex文件
  • baseOffset: Long 当前日志段的最小位移值
  • indexIntervalBytes: Int 对应Broker 端参数 log.index.interval.bytes 值,用于控制日志段对象新增索引项的频率,间隔多少个字节创建一个索引
  • rollJitterMs: Long 时间扰动值,在创建日志端对象的时候通过扰动值避免同一时刻创建大量文件
  • time: Time kafka封装的时间工具,用于获取系统时间等操作

此外LogSegment 的class中还定义了许多方法,这里主要介绍一下比较重要的方法:

  def append(largestOffset: Long,
             largestTimestamp: Long,
             shallowOffsetOfMaxTimestamp: Long,
             records: MemoryRecords): Unit = {
             
  def read(startOffset: Long,
           maxSize: Int,
           maxPosition: Long = size,
           minOneMessage: Boolean = false): FetchDataInfo = {   
           
  def recover(producerStateManager: ProducerStateManager,
              leaderEpochCache: Option[LeaderEpochFileCache] = None): Int = { 

  def truncateTo(offset: Long): Int = {     

分别是appendreadrecover,和truncateTo

2.2、 append方法

append作为消息写入的主要方法,其代码实现如下:

  @nonthreadsafe
  def append(largestOffset: Long, //当前日志段最大的offset位移值
             largestTimestamp: Long, //当前日志段最大的日志时间戳
             shallowOffsetOfMaxTimestamp: Long,  //当前日志段最大的日志时间戳对于的消息offset
             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)

	  //这里的log 就是当前日志段的FileRecords,这里将消息写入文件中
      // 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
      }
      //判断是否需要添加一个索引节点,通过log.index.interval.bytes值判断
      // 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方法不是线程安全的。其流程是,首先消息是保存在内存中MemoryRecords。在写入文件之前判断MemoryRecords不为空,并且确保新写入的消息没有越界确保在[0,Int.MaxValue]之间。判断条件都通过后,然后调用FileRecords将消息追加进去,通过JDK提供的FileChannel从而能实现

2.2、 read方法

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)

    // if the start position is already off the end of the log, return null
    //如果当前的位置是当前日志段的末尾,则返回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)
  }

方法是线程安全的使用@threadsafe修饰,上示代码流程如下所示
在这里插入图片描述

  • 1、 调用translateOffset将offset转换为实际物理位置,内部是调用lookup方法,而在looup中使用了基于冷热区的二分查找法(后面的文章会详细介绍)。
  • 2、 若当前有设置最小允许返回一条消息,则计算出不超过当前消息上限maxSize的消息总大小adjustedMaxSize,(最少返回一条消息的设置是为了防止消费者饥饿)。
  • 3、 根据计算出来本次的消息大小,调用log.slice()方法获取实际数据。
  • 4、 将获取到的数据封装成FetchDataInfo

translateOffset方法中,转换为实际的物理地址的时候使用到了mmap技术,而在查找中使用了二分查找法 (关于二分查找,在后面的文章还会进一步说明)

  @threadsafe
  private[log] def translateOffset(offset: Long, startingFilePosition: Int = 0): LogOffsetPosition = {
    val mapping = offsetIndex.lookup(offset)
    log.searchForOffsetWithSize(offset, max(mapping.position, startingFilePosition))
  }
 .............................................................................
  /**
  	*
  	*/
  def lookup(targetOffset: Long): OffsetPosition = {
    maybeLock(lock) {
      //拷贝一份mmap映射,这样有别的地方修改的时候对其是可见
      val idx = mmap.duplicate
      //查找最大的小于或等于给定目标键或值的存储项的槽(内部调用二分查找)
      val slot = largestLowerBoundSlotFor(idx, targetOffset, IndexSearchType.KEY)
      if(slot == -1)
        OffsetPosition(baseOffset, 0)
      else
        parseEntry(idx, slot)
    }
  }
  • 这里还有细节需要注意,read方法里面的FetchDataInfo中的Records就是本次获取到的消息体,而这些消息是通过FileChannel去获取的,而FileChannel正是JDK中实现了零拷贝技术JAVA API,最终消息封装在FileRecordsbatches里面,代码如下所示:
//创建对象并获取消息
  FetchDataInfo(offsetMetadata, log.slice(startPosition, fetchSize),
      firstEntryIncomplete = adjustedMaxSize < startOffsetAndSize.size)
      
  /**
  	* 实际获取数据
    */
	public FileRecords slice(int position, int size) throws IOException {
	        // Cache current size in case concurrent write changes it
	        int currentSizeInBytes = sizeInBytes();


	        if (position < 0)
	            throw new IllegalArgumentException("Invalid position: " + position + " in read from " + this);
	        if (position > currentSizeInBytes - start)
	            throw new IllegalArgumentException("Slice from position " + position + " exceeds end position of " + this);
	        if (size < 0)
	            throw new IllegalArgumentException("Invalid size: " + size + " in read from " + this);
	
	        int end = this.start + position + size;
	        // handle integer overflow or if end is beyond the end of the file
	        if (end < 0 || end > start + currentSizeInBytes)
	            end = start + currentSizeInBytes;
	        //创建FileRecords,并调用 FileChannel 获取数据
	        return new FileRecords(file, channel, this.start + position, end, true);
	    }
  • FileRecords的创建
    FileRecords(File file,  	//当前日志段文件
                FileChannel channel,//当前文件对于的FileChannel对象
                int start,//开始位置
                int end,//结束位置
                boolean isSlice) throws IOException {//是否切片
        this.file = file;
        this.channel = channel;
        this.start = start;
        this.end = end;
        this.isSlice = isSlice;
        this.size = new AtomicInteger();

        if (isSlice) {
            // don't check the file size if this is just a slice view
            size.set(end - start);
        } else {
            if (channel.size() > Integer.MAX_VALUE)
                throw new KafkaException("The size of segment " + file + " (" + channel.size() +
                        ") is larger than the maximum allowed segment size of " + Integer.MAX_VALUE);

            int limit = Math.min((int) channel.size(), end);
            size.set(limit - start);

            // if this is not a slice, update the file pointer to the end of the file
            // set the file position to the last byte in the file
            channel.position(limit);
        }
        batches = batchesFrom(start);
    }
  • batches的获取
	//batches定义
 	private final Iterable<FileLogInputStream.FileChannelRecordBatch> batches;
	
	//批量获取
 	public Iterable<FileChannelRecordBatch> batchesFrom(final int start) {
        return () -> batchIterator(start);
    }
    
    private AbstractIterator<FileChannelRecordBatch> batchIterator(int start) {
        final int end;
        if (isSlice)
            end = this.end;
        else
            end = this.sizeInBytes();
        FileLogInputStream inputStream = new FileLogInputStream(this, start, end);
        return new RecordBatchIterator<>(inputStream);
    }

2.3、 recover方法

  • 在前面介绍完,readappend方法后,还有一个方法有必要介绍一下,就是recover方法。recover方法与readappend同样重要,recover用于在Broker的时候加载和创建日志段对象,只有Broker创建完日志段对象之后,才能操作消息进出文件,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()
        //确保offset没有越界
        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方法后,先初始化所有索引文件对象为空,之后会开始遍历日志段中的所有消息集合或消息批次(RecordBatch),确保其是合法的:1、魔数值合法 2、位移值没有越界。然后添加索引项,时间索引根据实际情况判断是否添加,位移索引添加一个索引项。

2.3.1、 truncateTo文件截取

  • 遍历结束后,判断当前总字节数(originalSize)和刚刚累计读取到的字节数(validBytes),判断是否需要截取,比如上次宕机导致脏数据写入等等 :根据当前累计读取到的数据大小去截取文件
	/**
	  * 截断文件
	  */
    public int truncateTo(int targetSize) throws IOException {
        int originalSize = sizeInBytes();
        if (targetSize > originalSize || targetSize < 0)
            throw new KafkaException("Attempt to truncate log segment " + file + " to " + targetSize + " bytes failed, " +
                    " size of this log segment is " + originalSize + " bytes.");
        if (targetSize < (int) channel.size()) {
            channel.truncate(targetSize);
            size.set(targetSize);
        }
        return originalSize - targetSize;
    }

2.4、 flush落盘

LogSegment落盘相对比较简单,代码如下所示:

  @threadsafe
  def flush(): Unit = {
    LogFlushStats.logFlushTimer.time {
      log.flush()
      offsetIndex.flush()
      timeIndex.flush()
      txnIndex.flush()
    }
  }

//耗时监控
object LogFlushStats extends KafkaMetricsGroup {
  val logFlushTimer = new KafkaTimer(newTimer("LogFlushRateAndTimeMs", TimeUnit.MILLISECONDS, TimeUnit.SECONDS))
}
  • 落盘操作本身是线程安全的,在日志段中落盘会相应保存log文件index文件timeindex文件事务index文件,同时还会记录本次落盘的耗时用于监控使用:可以通过搜索关键字LogFlushRateAndTimeMs,来查看落盘耗时监控kafka性能。

3、总结

在这里插入图片描述
本篇文章的重点基本上就是上图所示,通过日志段对象操作实际文件,体现很好的面对对象的设计思想。同时kafka日志段是kafka很重要的一个组件,无论是消息获取还是写入,都离不开日志段,kafka通过日志分段的方式能很好的支持大文件,同时日志段的索引也使得日志端能高效的操作消息,同时阅读源码中发现,kafka非常善于使用mmap技术和使用基于FileChannel接口使用的文件操作,这样可以很好的使用零拷贝技术,mmap(一种内存映射文件的方法),能使其操作大文件的时候更加快速高效。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值