从源码看Kafka的日志及索引的实现原理

近期对Kafka源码进行了学习,对Kafka的日志模块有了更深入的了解,日志模块是Kafka最重要的模块之一,是其实现高性能消息处理的基础。因此对这部分内容进行了整理,在此做一个分享,主要包括日志和索引的结构,消息格式,以及核心的读、写逻辑。

基于Kafka官方代码仓库3.0版本分支:https://github.com/apache/kafka/tree/3.0

日志结构

在Kafka服务端,一个分区副本对应一个日志(Log),一个日志会分配成多个日志分段(LogSegment),Log在物理上以文件夹形式存储,而LogSegment对应磁盘上的一个日志文件和2个索引文件及其他文件。

以下图为例,每个LogSegment对应文件名中序号相同的一组文件。

核心类

Log

一个Log对象对应磁盘上的一个日志文件夹,管理着下属所有日志段,并封装了各类offset和状态的更新逻辑。

class Log(@volatile private var _dir: File,
          @volatile var config: LogConfig,
          val segments: LogSegments,
          @volatile var logStartOffset: Long,
          @volatile var recoveryPoint: Long,
          @volatile var nextOffsetMetadata: LogOffsetMetadata,
          scheduler: Scheduler,
          brokerTopicStats: BrokerTopicStats,
          val time: Time,
          val producerIdExpirationCheckIntervalMs: Int,
          val topicPartition: TopicPartition,
          @volatile var leaderEpochCache: Option[LeaderEpochFileCache],
          val producerStateManager: ProducerStateManager,
          logDirFailureChannel: LogDirFailureChannel,
          @volatile private var _topicId: Option[Uuid],
          val keepPartitionMetadataFile: Boolean) extends Logging with KafkaMetricsGroup {
  ...
}

关键属性:

  • _dir:日志文件所在的文件夹路径,是一个File类对象

  • segments:保存了分区日志下的所有日志段信息,LogSegments类内部实际是一个Map<Long, LogSegment>结构

  • 特殊offset:

    • logStartOffset:日志当前存储的最早位移

    • nextOffsetMetadata:这个也就是LEO(Log End Offset),标识下一条待写入消息的位移值

    • highWatermarkMetadata:分区日志高水位值 HW,消费者只能拉取这个offset之前的消息(不含HW)

另外,在Log伴生对象中,还声明了一系列的常量,主要是各种类型文件的后缀名:

object Log {
  val LogFileSuffix = ".log"
  val IndexFileSuffix = ".index"
  val TimeIndexFileSuffix = ".timeindex"
  val ProducerSnapshotFileSuffix = ".snapshot"
  val TxnIndexFileSuffix = ".txnindex"
  val DeletedFileSuffix = ".deleted"
  val CleanedFileSuffix = ".cleaned"
  val SwapFileSuffix = ".swap"
  val CleanShutdownFile = ".kafka_cleanshutdown"
  val DeleteDirSuffix = "-delete"
  val FutureDirSuffix = "-future"
  ...
}

LogSegment

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 {
  def offsetIndex: OffsetIndex = lazyOffsetIndex.get
  def timeIndex: TimeIndex = lazyTimeIndex.get
  ...
}

关键属性:

  • log:消息日志对象,FileRecords类封装了日志文件相关操作

  • lazyOffsetIndex:位移索引对象,对OffsetIndex进行了延迟初始化封装

  • lazyTimeIndex:时间戳索引对象,对TimeIndex进行了延迟初始化封装

  • txnIndex:已终止事务索引,与事务消息相关

  • baseOffset:日志段的起始位移值,也就是我们在磁盘上看到的文件名中的序号,表示当前日志段内存储的第一条消息(和索引)的位移值

  • indexIntervalBytes:控制每写入多少消息才会新增一条索引

索引

所有索引类型都继承了AbstractIndex:

AbstractIndex类声明

abstract class AbstractIndex(@volatile private var _file: File, 
                             val baseOffset: Long, 
                             val maxIndexSize: Int = -1,
                             val writable: Boolean) extends Closeable {
  @volatile
  private var _length: Long = _
  protected def entrySize: Int
  protected def _warmEntries: Int = 8192 / entrySize
  @volatile
  protected var mmap: MappedByteBuffer = {...}
  ...
}

关键属性:

  • file:索引文件

  • baseOffset:起始位移值,也对应LogSegment中的起始位移

  • maxIndexSize:索引文件最大长度

  • _length:记录当前索引文件长度

  • mmap:内存映射文件(MappedByteBuffer)

    • 索引底层实现,通过内存映射实现高性能I/O

  • entrySize:每个索引项的大小,各个实现类不同

    • AbstractIndex定义了索引项为Entry结构,比如OffsetIndex的索引项是<位移值, 物理位置>

  • _warmEntries:热区索引项范围,这个主要用在索引二分查找中的冷热分区优化,下文关于索引查找会讲

OffsetIndex

位移索引,对应文件后缀 .index,每个索引项(Entry)占8个字节,包含:

  • relativaOffset(int):相对位移,表示消息相对于baseOffset的位移值

  • position(int):消息在日志分段文件中的物理位置

在磁盘内的存储结构为:

TimeIndex

时间戳索引,对应文件后缀 .timeindex,每个索引项(Entry)占12个字节,包含:

  • timestamp(long):消息时间戳

  • relativeOffset(int):时间戳对应消息的相对位移值

在磁盘内的存储结构为:

关键链路

写日志

写入日志有两种方式:

  1. Producer向Leader副本所在Broker发起写入请求:

KafkaApis.handleProduceRequest()
|-- ReplicaManager.appendToLocalLog()
|---- Partition.appendRecordsToLeader()
|------ Log.appendAsLeader()
|-------- Log.append()
  1. Follower副本通过Fetcher线程向Leader副本拉取消息后写入:

AbstractFetcherManager.addFetcherForPartitions()
|-- ReplicaFetcherThread.processPartitionData()
|---- Partition.appendRecordsToFollowerOrFutureReplica()
|------ Log.appendAsFollower()
|-------- Log.append()

这两条链路最终都是调用到了Log.append()方法,其主要逻辑是:

在Log对象内,append()方法主要是做了一些校验,消息和索引写入是在LogSegment对象内完成的,对应LogSegment.append()方法,其流程是:

  • LogSegment写入消息是通过 FileRecords.append() 完成的,实际就是向FileChannel写入ByteBuffer。

    • FileRecords.append()最后接收的是MemoryRecords对象,这个对象实际上是对一组 RecordBatch 集合数据的包装,其内部包含ByteBuffer和RecordBatch的迭代器,也就是说日志的写入是以 RecordBatch 为最小单位的,而RecordBatch在下文会详细介绍。

  • 索引的更新则调用了 OffsetIndex.append() 和 TimeIndex.maybeAppend() 两个方法,分别进行唯一索引和时间戳索引的写入。索引项的写入,实际就是往mmap(内存映射文件)写数据,去掉一些校验逻辑,最终实际上就是执行下面这段代码:

  • 位移索引的写入:

  mmap.putInt(relativeOffset(offset))	// 写入相对位移值
  mmap.putInt(position)	// 写入物理位置
  _entries += 1
  _lastOffset = offset
  • 时间戳索引的写入:

  mmap.putLong(timestamp)	// 写入时间戳
  mmap.putInt(relativeOffset(offset))	// 写入相对位移值
  _entries += 1
  _lastEntry = TimestampOffset(timestamp, offset)

读取日志

KafkaServer收到拉取消息的请求的处理链路:

KafkaApis.handleFetchRequest()
|-- ReplicaManager.fetchMessages()
|---- Partition.readRecords()
|------ Log.read()
|-------- LogSegment.read()

最终会调到Log.read() 及 LogSegment.read()

这个过程中,Log首先需要根据请求参数中的startOffset来确定要读取的消息在哪一个日志段,这里是直接通过Log保存的segments,借助ConcurrentSkipListMap.floorEntry()方法实现。

然后在LogSegment内的逻辑,先是通过查找索引文件,来将参数offset转换为一个具体的物理位置。最后包装一个FileRecords对象返回,内部包含File对象、物理位置范围、以及一个RecordBatch迭代器(用于消息记录的遍历)。

查找索引

从文件中读取索引项数据是通过索引类实现的parseEntry()方法完成的,比如 OffsetIndex.parseEntry() 方法,通过relativeOffset()和physical()读取相对位移值和物理位置,实际上就是从文件buffer中读取两个相邻的int值:

  override protected def parseEntry(buffer: ByteBuffer, n: Int): OffsetPosition = {
    OffsetPosition(baseOffset + relativeOffset(buffer, n), physical(buffer, n))
  }

  private def relativeOffset(buffer: ByteBuffer, n: Int): Int = buffer.getInt(n * entrySize)

  private def physical(buffer: ByteBuffer, n: Int): Int = buffer.getInt(n * entrySize + 4)

查找offset对应的物理位置的入口是 OffsetIndex.lookup(),而核心代码逻辑在 AbstractIndex.indexSlotRangeFor() 方法中:

private def indexSlotRangeFor(idx: ByteBuffer, target: Long, searchEntity: IndexSearchType): (Int, Int) = {
    // 非空校验
    if(_entries == 0) return (-1, -1)

    // 定义二分查找函数
    def binarySearch(begin: Int, end: Int) : (Int, Int) = {
      var lo = begin
      var hi = end
      while(lo < hi) {
        val mid = (lo + hi + 1) >>> 1
        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)
    }

    // 判断查找的offset是否在[热区],优先从[热区]查找
    val firstHotEntry = Math.max(0, _entries - 1 - _warmEntries)
    if(compareIndexEntry(parseEntry(idx, firstHotEntry), target, searchEntity) < 0) {
      return binarySearch(firstHotEntry, _entries - 1)
    }

    // 位移值范围校验
    if(compareIndexEntry(parseEntry(idx, 0), target, searchEntity) > 0)
    return (-1, 0)

    // 从[冷区]查找
    binarySearch(0, firstHotEntry)
}

这段代码里面的查找算法,就是常见的二分查找,但在此基础上Kafka对查找范围进行了冷热分区,用来针对页缓存的读取进行优化。

缓存友好优化

缓存文件的读写是使用MappedByteBuffer,底层是使用了页缓存PageCache,而操作系统使用LRU机制管理页缓存,同时由于Kafka写入索引文件是文件末尾追加写入,因此几乎所有索引查询都集中在尾部,如果在文件全文进行二分查找可能会碰到不在页缓存中的索引,导致缺页中断,阻塞等待从磁盘加载没有被缓存到page cache的数据。

Kafka通过对索引文件进行冷热分区,在AbstractIndex类中定义热区的分界线warmEntries值是8192,标识索引文件末尾的8KB为热区,保证查询最热那部分数据所遍历的Page永远是固定的。

这个分界线的值为什么是8192,源码中针对warmEntries属性有很详细的描述,翻译过来大概是:

  1. 这个值足够小,通常处理器缓存页大小会大于4096,那么8192能够保证页数小于等3,保证用于热区查找的页面都能命中缓存

  2. 这个值足够大,可以保证大多数同步查找都在暖区。使用默认卡夫卡设置,8KB索引对应于大约4MB(偏移索引)或2.7MB(时间索引)的日志消息。

另外,源码的注释内还提出了未来可能的改进方向:通过后端线程定时去加载热区。

消息存储格式

Kafka会将多条消息一起打包封装为 RecordBatch 对象,从生产者发送,到 broker 保存消息到日志文件,到消费者从服务端拉取消息,这个过程中,消息都是保持打包状态的,直到消费者处理前才会解压。

RecordBatch 和 Record 的默认实现类分别是 DefaultRecordBatch 和 DefaultRecord,其存储结构如下图所示:

参考官方文档中的说明

消息打包

将消息数据打包为 RecordBatch 的过程是在 Producer 端完成的,消息数据的写入可以看下这两个方法:

  • 单条消息数据的写入:DefaultRecord.writeTo()

  • 消息集合信息的写入:DefaultRecordBatch.writeHeader()

以DefaultRecord.writeTo()为例,可以对照上面的结构图来了解一条消息是如何写入文件中的:

public static int writeTo(DataOutputStream out, int offsetDelta, long timestampDelta,
                              ByteBuffer key, ByteBuffer value, Header[] headers) throws IOException {
    // 写入整体长度
    int sizeInBytes = sizeOfBodyInBytes(offsetDelta, timestampDelta, key, value, headers);
    ByteUtils.writeVarint(sizeInBytes, out);

    // 写入attributes,目前没有实际作用
    out.write(0);

    // 写入时间戳(相对值)
    ByteUtils.writeVarlong(timestampDelta, out);
    // 写入offset(相对值)
    ByteUtils.writeVarint(offsetDelta, out);

    // 写入keyLength和key
    int keySize = key.remaining();
    ByteUtils.writeVarint(keySize, out);
    Utils.writeTo(out, key, keySize);

    // 写入valueLength和value
    int valueSize = value.remaining();
    ByteUtils.writeVarint(valueSize, out);
    Utils.writeTo(out, value, valueSize);

    // 写入headerLength和header
    ByteUtils.writeVarint(headers.length, out);
    for (Header header : headers) { ... }

    // 返回长度
    return ByteUtils.sizeOfVarint(sizeInBytes) + sizeInBytes;
}

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值