在 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() + ".")
}
}
}