Kafka日志模块(八):索引文件管理

在 Kafka 源码中,跟索引相关的源码文件有 5 个。

  • AbstractIndex.scala:它定义了最顶层的抽象类,这个类封装了所有索引类型的公共操作。
  • LazyIndex.scala:上层的 LazyIndex 仅仅是包装了一个 AbstractIndex 的实现类,用于延迟加载。它定义了 AbstractIndex 上的一个包装类,实现索引项延迟加载。这个类主要是为了提高性能。
  • OffsetIndex.scala:继承了AbstractIndex。定义位移索引,保存“< 位移值,文件磁盘物理位置 >”对。
  • TimeIndex.scala:继承了AbstractIndex。定义时间戳索引,保存“< 时间戳,位移值 >”对。
  • TransactionIndex.scala:继承了AbstractIndex。定义事务索引,为已中止事务(Aborted Transcation)保存重要的元数据信息。只有启用 Kafka 事务后,这个索引才有可能出现。

AbstractIndex 代码结构

abstract class AbstractIndex(
                  @volatile var file: File, // 每个索引对象在磁盘上都对应了一个索引文件。
                  val baseOffset: Long, // 索引对象对应日志段对象的起始位移值
                  val maxIndexSize: Int = -1, // 它控制索引文件的最大长度
                  val writable: Boolean
                ) extends Closeable {
protected def entrySize: Int // 抽象方法 entrySize 来表示不同索引项的大小
// 对于 OffsetIndex 而言,该值就是 8;对于 TimeIndex 而言,该值是 12

        Kafka 中的索引底层的实现原理是内存映射文件,即 Java 中的 MappedByteBuffer。
        使用内存映射文件的主要优势在于,它有很高的 I/O 性能,特别是对于索引这样的小文件来说,由于文件内存被直接映射到一段虚拟内存上,访问内存映射文件的速度要快于普通的读写文件速度。
        在很多操作系统中(比如 Linux),这段映射的内存区域实际上就是内核的页缓存(Page Cache)。这就意味着,里面的数据不需要重复拷贝到用户态空间,避免了很多不必要的时间、空间消耗。

 

        在 AbstractIndex 中,这个 MappedByteBuffer 就是名为 mmap 的变量。下面是这个 mmap 的主要流程。

  @volatile
  protected var mmap: MappedByteBuffer = {
    // 第1步:创建索引文件
    val newlyCreated = file.createNewFile()
    // 第2步:以writable指定的方式(读写方式或只读方式)打开索引文件
    val raf = if (writable) new RandomAccessFile(file, "rw") else new RandomAccessFile(file, "r")
    try {
      /* pre-allocate the file if necessary */
      if(newlyCreated) {
        if(maxIndexSize < entrySize)
          // 预设的索引文件大小不能太小,如果连一个索引项都保存不了,直接抛出异常
          throw new IllegalArgumentException("Invalid max index size: " + maxIndexSize)
        // 第3步:设置索引文件长度,roundDownToExactMultiple计算的是不超过maxIndexSize的最大整数倍entrySize
        // 比如maxIndexSize=1234567,entrySize=8,那么调整后的文件长度为1234560
        raf.setLength(roundDownToExactMultiple(maxIndexSize, entrySize))
      }

      /* memory-map the file */
      // 第4步:更新索引长度字段_length
      _length = raf.length()
      // 第5步:创建MappedByteBuffer对象
      val idx = {
        if (writable)
          raf.getChannel.map(FileChannel.MapMode.READ_WRITE, 0, _length)
        else
          raf.getChannel.map(FileChannel.MapMode.READ_ONLY, 0, _length)
      }
      /* set the position in the index for the next entry */
      // 第6步:如果是新创建的索引文件,将MappedByteBuffer对象的当前位置置成0
      // 如果索引文件已存在,将MappedByteBuffer对象的当前位置设置成最后一个索引项所在的位置
      if(newlyCreated)
        idx.position(0)
      else
        // if this is a pre-existing index, assume it is valid and set position to last entry
        idx.position(roundDownToExactMultiple(idx.limit(), entrySize))
      idx
      // 第7步:返回创建的MappedByteBuffer对象
    } finally {
      CoreUtils.swallow(raf.close(), AbstractIndex)
    }
  }

如果我们要计算索引对象中当前有多少个索引项,那么只需要执行下列计算即可

protected var _entries: Int = mmap.position() / entrySize

如果我们要计算索引文件最多能容纳多少个索引项,只要定义下面的变量就行了

private[this] var _maxEntries: Int = mmap.limit() / entrySize

写入索引项

OffsetIndex 的 append 方法,用于向索引文件中写入新索引项

  def append(offset: Long, position: Int): Unit = {
    inLock(lock) {
      // 第1步:判断索引文件未写满
      require(!isFull, "Attempt to append to a full index (size = " + _entries + ").")
      // 第2步:必须满足以下条件之一才允许写入索引项:
      // 条件1:当前索引文件为空
      // 条件2:要写入的位移大于当前所有已写入的索引项的位移——Kafka规定索引项中的位移值必须是单调增加的
      if (_entries == 0 || offset > _lastOffset) {
        trace(s"Adding index entry $offset => $position to ${file.getAbsolutePath}")
        mmap.putInt(relativeOffset(offset)) // 第3步A:向mmap中写入相对位移值
        mmap.putInt(position) // 第3步B:向mmap中写入物理位置信息
        // 第4步:更新其他元数据统计信息,如当前索引项计数器_entries和当前索引项最新位移值_lastOffset
        _entries += 1
        _lastOffset = offset
        // 第5步:执行校验。写入的索引项格式必须符合要求,即索引项个数*单个索引项占用字节数匹配当前文件物理大小,否则说明文件已损坏
        require(_entries * entrySize == mmap.position(), entries + " entries but file position in index is " + mmap.position() + ".")
      } else {
        throw new InvalidOffsetException(s"Attempt to append an offset ($offset) to position $entries no larger than" +
          s" the last offset appended (${_lastOffset}) to ${file.getAbsolutePath}.")
      }
    }
  }

查找索引项
AbstractIndex 定义了抽象方法 parseEntry 用于查找给定的索引项

protected def parseEntry(buffer: ByteBuffer, n: Int): IndexEntry

         这里的“n”表示要查找给定 ByteBuffer 中保存的第 n 个索引项(在 Kafka 中也称第 n 个槽)。IndexEntry 是源码定义的一个接口,里面有两个方法:indexKey 和 indexValue,分别返回不同类型索引的 <Key,Value> 对。

        OffsetIndex 实现 parseEntry 的逻辑如下

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

        OffsetPosition 是实现 IndexEntry 的实现类,Key 就是之前说的位移值,而 Value 就是物理磁盘位置值。所以,这里你能看到代码调用了 relativeOffset(buffer, n) + baseOffset 计算出绝对位移值,之后调用 physical(buffer, n) 计算物理磁盘位置,最后将它们封装到一起作为一个独立的索引项返回。

二分查找算法

        Kafka 索引应用二分查找算法快速定位待查找索引项位置,之后调用 parseEntry 来读取索引项
改进版的二分查找策略,也就是缓存友好的搜索算法。总体的思路是,设置分割线,代码将所有索引项分成两个部分:热区(Warm Area)和冷区(Cold Area),然后分别在这两个区域内执行二分查找算法。
        这个改进版算法提供了一个重要的保证:它能保证那些经常需要被访问的 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)
  }

截断操作

        除了 append 方法,索引还有一个常见的操作:截断操作(Truncation)。截断操作是指,将索引文件内容直接裁剪掉一部分。比如,OffsetIndex 索引文件中当前保存了 100 个索引项,我想只保留最开始的 40 个索引项。源码定义了 truncateToEntries 方法来实现这个需求:

private def truncateToEntries(entries: Int): Unit = {
  inLock(lock) {
    _entries = entries
    mmap.position(_entries * entrySize)
    _lastOffset = lastEntry.offset
    debug(s"Truncated index ${file.getAbsolutePath} to $entries entries;" +
      s" position is now ${mmap.position()} and last offset is now ${_lastOffset}")
  }
}

以上是AbstractIndex的字段与方法,下面介绍kafka的两种重要索引项,位移索引项和时间戳索引

位移索引项

        位移索引也就是所谓的 OffsetIndex,OffsetIndex 被用来快速定位消息所在的物理文件位置,一个方法执行对应的查询逻辑是 lookup。

  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)
    }
  }

OffsetIndex 定义了专门的方法,用于将一个 Long 型的位移值转换成相对位移

def relativeOffset(offset: Long): Int = {
    val relativeOffset = toRelative(offset)
    if (relativeOffset.isEmpty)
      // 如果无法转换成功(比如差值超过了整型表示范围),则抛出异常
      throw new IndexOffsetOverflowException(s"Integer overflow for offset: $offset (${file.getAbsoluteFile})")
    relativeOffset.get
}

relativeOffset 方法调用了父类的 toRelative 方法执行真正的转换。

private def toRelative(offset: Long): Option[Int] = {
  val relativeOffset = offset - baseOffset
  if (relativeOffset < 0 || relativeOffset > Int.MaxValue)
    None
  else
    Some(relativeOffset.toInt)
}

时间戳索引

        与 OffsetIndex 不同的是,TimeIndex 保存的是 < 时间戳,相对位移值 > 对。时间戳需要一个长整型来保存,相对位移值使用 Integer 来保存。因此,TimeIndex 单个索引项需要占用 12 个字节。这也揭示了一个重要的事实:在保存同等数量索引项的基础上,TimeIndex 会比 OffsetIndex 占用更多的磁盘空间。

  def maybeAppend(timestamp: Long, offset: Long, skipFullCheck: Boolean = false): Unit = {
    inLock(lock) {
      if (!skipFullCheck)
      // 如果索引文件已写满,抛出异常
        require(!isFull, "Attempt to append to a full time index (size = " + _entries + ").")
      // We do not throw exception when the offset equals to the offset of last entry. That means we are trying
      // to insert the same time index entry as the last entry.
      // If the timestamp index entry to be inserted is the same as the last entry, we simply ignore the insertion
      // because that could happen in the following two scenarios:
      // 1. A log segment is closed.
      // 2. LogSegment.onBecomeInactiveSegment() is called when an active log segment is rolled.
      // 确保索引单调增加性
      if (_entries != 0 && offset < lastEntry.offset)
        throw new InvalidOffsetException(s"Attempt to append an offset ($offset) to slot ${_entries} no larger than" +
          s" the last offset appended (${lastEntry.offset}) to ${file.getAbsolutePath}.")
      // 确保时间戳的单调增加性
      if (_entries != 0 && timestamp < lastEntry.timestamp)
        throw new IllegalStateException(s"Attempt to append a timestamp ($timestamp) to slot ${_entries} no larger" +
          s" than the last timestamp appended (${lastEntry.timestamp}) to ${file.getAbsolutePath}.")
      // We only append to the time index when the timestamp is greater than the last inserted timestamp.
      // If all the messages are in message format v0, the timestamp will always be NoTimestamp. In that case, the time
      // index will be empty.
      if (timestamp > lastEntry.timestamp) {
        trace(s"Adding index entry $timestamp => $offset to ${file.getAbsolutePath}.")
        mmap.putLong(timestamp) // 向mmap写入时间戳
        mmap.putInt(relativeOffset(offset)) // 向mmap写入相对位移值
        _entries += 1 // 更新索引项个数
        _lastEntry = TimestampOffset(timestamp, offset) // 更新当前最新的索引项
        require(_entries * entrySize == mmap.position(), _entries + " entries but file position in index is " + mmap.position() + ".")
      }
    }
  }

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值