kafka的日志模块log源码解析

一、背景

作为一个分布式的消息队列,kafka的日志模块主要用来存储、读取消息,它为kafka集群的高可用、高性能提供了基础。下面结合kafka的源码分析一下日志模块的设计思路。

二、日志格式

kafka的以topic为单位组织消息,为了提高系统的吞吐率,将一个topic分为N个partition。以partition为单位接收、消费消息。一个partition有多个分片,主分片接收生成者发送的消息,同时副分片从主分片出拉取消息,消费者也从主分片出消费消息。其工作原理如下图。

图1-kafka消息流转过程

kafka集群中的每个broker中都会创建对应的日志文件来存储partition中的数据,每个partition都会有一个Log对象来进行管理。Log的定义如下:

class Log(val dir: File,
          @volatile var config: LogConfig,
          @volatile var recoveryPoint: Long = 0L,
          scheduler: Scheduler,
          time: Time = SystemTime) extends Logging with KafkaMetricsGroup {
  
       //segments是一个map结构,记录了这个Log下的所有的LogSegment
        private val segments: ConcurrentNavigableMap[java.lang.Long, LogSegment] = new ConcurrentSkipListMap[java.lang.Long, LogSegment]
       ......
  
        //topicAndPartition保存了topic和partition
        val topicAndPartition: TopicAndPartition = Log.parseTopicPartitionName(name)
        ......
}

Log中的topicAndPartition表示这个log对象对应的topic和partition,segments记录了这个log所有的LogSegment。LogSegment是Log的下一级单位,由于一个partition中的消息会非常多,所以系统不会把这些消息记录到一个文件里面,会将一个partition消息存放在多个文件,partition是分段的,每个段叫LogSegment,包括了一个数据文件和一个索引文件,下图是某个partition目录下的文件

partition

kafka是顺序写入,所以这些LogSegment是随着数据的写入而依次产生的。代码如下:

/*产生LogSegment的函数
* 入参messagesSize:即将写入的消息的大小
* 出参LogSegment:该消息需要写入的LogSegment 
private def maybeRoll(messagesSize: Int): LogSegment = {
    //当前写入的LogSegment
    val segment = activeSegment
    //rollJitterMs 是日志段新增扰动值
    if (segment.size > config.segmentSize - messagesSize ||
        segment.size > 0 && time.milliseconds - segment.created > config.segmentMs - segment.rollJitterMs ||
        segment.index.isFull) {
      debug("Rolling new log segment in %s (log_size = %d/%d, index_size = %d/%d, age_ms = %d/%d)."
            .format(name,
                    segment.size,
                    config.segmentSize,
                    segment.index.entries,
                    segment.index.maxEntries,
                    time.milliseconds - segment.created,
                    config.segmentMs - segment.rollJitterMs))
      roll()
    } else {
      segment
    }
  }

每当写入消息时,会判断是否需要生成新的LogSegment(当前的LogSegment的大小+即将写入消息的大小是否大于规定的大小,或者当前时间-创建时间大于规定的时间间隔,或者索引文件已满)。如果需要就会创建LogSegment,生成新的文件,否则就写入到原先的文件。LogSegment的定义如下:

class LogSegment(val log: FileMessageSet, //实际保存消息的对象
                 val index: OffsetIndex,   //位移索引
                 val baseOffset: Long,     //起始位移
                 val indexIntervalBytes: Int,  //多少字节插入一个索引
                 val rollJitterMs: Long,  日志段新增扰动值
                 time: Time) extends Logging {
......
}

从上面的定义可以看出,LogSegment中即包含了实际的保存消息的对象,又包含了一个索引对象。索引文件和日志文件的关系如下:

三、Kafka消息的写入

kafka中的broker在接收到写入的请求时,会先根据topic和partition确定需要写入的Partition对象,然后找到这个partition对象对应的log对象,执行Log对象的append方法来写入消息。append函数的代码如下:

def append(messages: ByteBufferMessageSet, assignOffsets: Boolean = true): LogAppendInfo = {  //将消息添加到分片尾
    val appendInfo = analyzeAndValidateMessageSet(messages) //这里验证messages信息和创建logappendinfo.
    
    // if we have any valid messages, append them to the log
    if(appendInfo.shallowCount == 0)  //如果消息为空的话,就直接返回信息.
      return appendInfo
      
    // trim any invalid bytes or partial messages before appending it to the on-disk log
    var validMessages = trimInvalidBytes(messages, appendInfo)  //这个函数将消息里多余的部分截掉.

    try {
      // they are valid, insert them in the log
      lock synchronized {
        appendInfo.firstOffset = nextOffsetMetadata.messageOffset  //这里开始分配offset值.即上最后一个分片的最后一个offset值.

        if(assignOffsets) {
          // assign offsets to the message set
          val offset = new AtomicLong(nextOffsetMetadata.messageOffset) //创建新的offset
          try {
            validMessages = validMessages.assignOffsets(offset, appendInfo.codec) //使用ByteBufferMessageSet类中的分配方法分配offset值.
          } catch {
            case e: IOException => throw new KafkaException("Error in validating messages while appending to log '%s'".format(name), e)
          }
          appendInfo.lastOffset = offset.get - 1 //因为offset被assignOffsets方法累加过.所以最后要减1.
        } else {
          // we are taking the offsets we are given
          if(!appendInfo.offsetsMonotonic || appendInfo.firstOffset < nextOffsetMetadata.messageOffset)
            throw new IllegalArgumentException("Out of order offsets found in " + messages)
        }

        // re-validate message sizes since after re-compression some may exceed the limit
        for(messageAndOffset <- validMessages.shallowIterator) {
          if(MessageSet.entrySize(messageAndOffset.message) > config.maxMessageSize) { //这里判断每一个消息是否大于配置的消息最大长度.
            // we record the original message set size instead of trimmed size
            // to be consistent with pre-compression bytesRejectedRate recording
            BrokerTopicStats.getBrokerTopicStats(topicAndPartition.topic).bytesRejectedRate.mark(messages.sizeInBytes)
            BrokerTopicStats.getBrokerAllTopicsStats.bytesRejectedRate.mark(messages.sizeInBytes)
            throw new MessageSizeTooLargeException("Message size is %d bytes which exceeds the maximum configured message size of %d."
              .format(MessageSet.entrySize(messageAndOffset.message), config.maxMessageSize))
          }
        }

        // check messages set size may be exceed config.segmentSize
        if(validMessages.sizeInBytes > config.segmentSize) {  //判断要写入的消息集大小是否超过配置的分片大小.
          throw new MessageSetSizeTooLargeException("Message set size is %d bytes which exceeds the maximum configured segment size of %d."
            .format(validMessages.sizeInBytes, config.segmentSize))
        }


        // maybe roll the log if this segment is full
        val segment = maybeRoll(validMessages.sizeInBytes)  //这里是判断是否需要滚动分片.

        // now append to the log
        segment.append(appendInfo.firstOffset, validMessages)  //这里真正调用LogSegment对象写入消息.

        // increment the log end offset
        updateLogEndOffset(appendInfo.lastOffset + 1) //更新lastoffset.

        trace("Appended message set to log %s with first offset: %d, next offset: %d, and messages: %s"
                .format(this.name, appendInfo.firstOffset, nextOffsetMetadata.messageOffset, validMessages))

        if(unflushedMessages >= config.flush)  //判断是否需要刷新到磁盘
          flush()

        appendInfo
      }
    } catch {
      case e: IOException => throw new KafkaStorageException("I/O exception in append to log '%s'".format(name), e)
    }
  }

append函数的流程如下:

     1、验证messages信息和创建logappendinfo,将多余的部分剪裁掉。

     2、分配offset值,将最新的位移值nextOffsetMetadata.messageOffset设置为当前messages的第一个消息的offset,由于messages中含有N个消息,所以会滚动分配每个消息的position和offset(函数assignOffsets)

     3、判断要写入的消息集大小是否超过配置的分片大小,超过则抛出异常

     4、这里是判断是否需要滚动分片,即判断当前的分片LogSegment是否适合写入当前message是,如果适合就用当前分片;否则就创建新的LogSegment。

     5、调用LogSegment的append方法来真正写入messages。

     6、更新lastoffset.、判断是否需要刷新磁盘。

从上面的分析中可以看出,主要是LogSegment来执行写入的操作。下面看一下LogSegment的append的主要流程:

def append(offset: Long, messages: ByteBufferMessageSet) {
    if (messages.sizeInBytes > 0) {
      trace("Inserting %d bytes at offset %d at position %d".format(messages.sizeInBytes, offset, log.sizeInBytes()))
      // append an entry to the index (if needed)
      //如果超过索引的间隔,写入索引文件
      if(bytesSinceLastIndexEntry > indexIntervalBytes) {
        index.append(offset, log.sizeInBytes())
        this.bytesSinceLastIndexEntry = 0
      }
      // append the messages
      //写入数据
      log.append(messages)
      //更新bytesSinceLastIndexEntry
      this.bytesSinceLastIndexEntry += messages.sizeInBytes
    }
  }

可以看出,主要是通过log的append方法来写入的,这里的log不是前面提到的Log对象,这里的log是一个FileMessageSet对象,FileMessageSet对象的代码如下:

class FileMessageSet private[kafka](@volatile var file: File, 
                                    private[log] val channel: FileChannel, 
                                    private[log] val start: Int,
                                    private[log] val end: Int,
                                    isSlice: Boolean) extends MessageSet with Logging {
/* the size of the message set in bytes */
  private val _size = 
    if(isSlice)
      new AtomicInteger(end - start) // don't check the file size if this is just a slice view
    else
      new AtomicInteger(math.min(channel.size().toInt, end) - start)

  /* if this is not a slice, update the file pointer to the end of the file */
  if (!isSlice)
    /* set the file position to the last byte in the file */
    channel.position(channel.size)
......
def append(messages: ByteBufferMessageSet) {
    val written = messages.writeTo(channel, 0, messages.sizeInBytes)
    _size.getAndAdd(written)
  }
......
}

FileMessageSet 核心字段

file: 指向磁盘上日志文件

channel:FileChannel类型,用于读写对应的日志文件

start:FileMessageSet对象除了表示一个完整的日志文件,还可以表示日志文件的分片,start表示分片的开始位置

end:表示分片的结束位置

isSlice:表示当前FileMessageSet是否为日志文件的分片

_size:FileMessageSet大小,单位是字节,如果是分片则表示分片大小

从上面可以看出,append主要是将messages写入到FileChannel中,并且更新_size的大小。到这里,写入流程就结束了。但是现在messages只写入到内存中,还需要定时将调用函数flush将内存中的消息写入到磁盘,这个主要是LogManager控制的,这里不作介绍。

四、kafka消息的读取

kafka消息的读取,会调用Log对象中的read方法来进行。下面来看一下Log的read方法。

def read(startOffset: Long, maxLength: Int, maxOffset: Option[Long] = None): FetchDataInfo = {

    val next = nextOffsetMetadata.messageOffset
   //如果是最新的offset,则无数据读取
    if(startOffset == next)
      return FetchDataInfo(nextOffsetMetadata, MessageSet.Empty)

    //根据startOffset定位位于哪个LogSegment
    var entry = segments.floorEntry(startOffset)
      
    //异常判断,如果startOffset大于当前最大的偏移量或者没有找到具体的LogSegment,则抛出异常
    if(startOffset > next || entry == null)
      throw new OffsetOutOfRangeException("Request for offset %d but we only have log segments in the range %d to %d.".format(startOffset, segments.firstKey, next))
    
    //调用LogSegment的read方法读取具体的数据
    while(entry != null) {
      val fetchInfo = entry.getValue.read(startOffset, maxOffset, maxLength)
      if(fetchInfo == null) {
        entry = segments.higherEntry(entry.getKey)
      } else {
        return fetchInfo
      }
    }

从上面的代码可以看出,主要分为两个步骤:

      1、根据startOffset定位位于哪个LogSegment,Log中包含多个LogSegment,这些LogSegment保存在segments,segments的定义如下:

 private val segments: ConcurrentNavigableMap[java.lang.Long, LogSegment] = new ConcurrentSkipListMap[java.lang.Long, LogSegment]

segments是一个ConcurrentSkipListMap,它的内部将key组织成一个跳跃表,segments的key就是LogSegment的baseOffset,即它的第一个消息的offset。 通过segments.floorEntry(startOffset)就能找到不大于当前startOffset的最大的baseOffset对应的entry,然后调用LogSegment的read方法读取对应的消息。LogSegment的read方法如下:

def read(startOffset: Long, maxOffset: Option[Long], maxSize: Int): FetchDataInfo = {
    if(maxSize < 0)
      throw new IllegalArgumentException("Invalid max size for log read (%d)".format(maxSize))

    val logSize = log.sizeInBytes // this may change, need to save a consistent copy
    val startPosition = translateOffset(startOffset)  //获取对应offset的读取点位置.

    // if the start position is already off the end of the log, return null
    if(startPosition == null) //没有读取点位置则返回空
      return null

    val offsetMetadata = new LogOffsetMetadata(startOffset, this.baseOffset, startPosition.position) //定义offsetMetadata

    // if the size is zero, still return a log segment but with zero size
    if(maxSize == 0)  //最大读取尺寸是0的话.返回空消息.
      return FetchDataInfo(offsetMetadata, MessageSet.Empty)

    // calculate the length of the message set to read based on whether or not they gave us a maxOffset
    val length =   //计算最大读取的消息总长度.
      maxOffset match {
        case None => //未设置maxoffset则使用maxsize.
          // no max offset, just use the max size they gave unmolested
          maxSize
        case Some(offset) => { //如果设置了Maxoffset,则计算对应的消息长度.
          // there is a max offset, translate it to a file position and use that to calculate the max read size
          if(offset < startOffset)  //maxoffset小于startoffset则返回异常
            throw new IllegalArgumentException("Attempt to read with a maximum offset (%d) less than the start offset (%d).".format(offset, startOffset))
          val mapping = translateOffset(offset, startPosition.position) //获取相对maxoffset读取点.
          val endPosition = 
            if(mapping == null)
              logSize // the max offset is off the end of the log, use the end of the file
            else
              mapping.position
          min(endPosition - startPosition.position, maxSize) //用maxoffset读取点减去开始的读取点.获取需要读取的数据长度.如果长度比maxsize大则返回maxsize
        }
      }
    FetchDataInfo(offsetMetadata, log.read(startPosition.position, length)) //使用FileMessageSet.read读取相应长度的数据返回FetchDataInfo的封装对象.
  }

读取函数通过映射offset到读取长度.来读取多个offset.

private[log] def translateOffset(offset: Long, startingFilePosition: Int = 0): OffsetPosition = {  //用来将offset映射到读取指针位置的函数.
    val mapping = index.lookup(offset) //通过查找index获取对应的指针对象.
    log.searchFor(offset, max(mapping.position, startingFilePosition)) //通过FileMessageSet获取对应的指针位置.
  }

最终还是通过调用FileMessageSet的read方法来进行读取。FileMessageSet的read方法如下:

def read(position: Int, size: Int): FileMessageSet = {
    if(position < 0)
      throw new IllegalArgumentException("Invalid position: " + position)
    if(size < 0)
      throw new IllegalArgumentException("Invalid size: " + size)
    new FileMessageSet(file,
                       channel,
                       start = this.start + position,
                       end = math.min(this.start + position + size, sizeInBytes()))
  }

最终返回一个日志分片,然后组装成一个FetchDataInfo对象给请求端。自此,kafka的消息读取流程结束。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值