文章目录
1、什么是索引
kafka的定位是一个能够存储海量消息的消息引擎,从前面的日志段就能看出,kafka为了保存大量的数据会将消息按照offset分片到多个LogSegment里面,从而实现海量数据的存储能力。但是当数据量达到一定程度后由此而来的问题也产生的了:如果快速定位到某个offset的消息? 为了解决这个问题,kafka按照不同的需要设计了不同的索引类型,目前主要的索引有:
- 1、 基于offset的索引
.index
- 2、 基于时间的索引
.timeindex
- 3、 基于事务的索引
.txnindex
kafka中的索引文件都是以稀疏索引的方式构造消息索引,这样做的好处是可以缓存最近常访问的索引项不需要担心全量缓存索引带来的内存压力。所以每达到一定的量或者阈值的时候才会建立索引项。其中时间索引和基于offset的索引都是按照这种方式来创建的,其每当写入一定量的时候两个索引都会创建其特定的索引项,这个阈值由broker端的log.index.interval.bytes
控制,修改它的值能改变索引项的密度。此外索引本身就是日志段对象的一个成员属性,对索引的操作统一都封装在日志段对象LogSegment
中。
在kafka中,所有的索引相关代码都在/src/main/scala/kafka/log
包底下,主要有以下几种:
- 1、
AbstractIndex
,所有索引的抽象类。 - 2、
LazyIndex
索引更高一层的封装,用于索引文件的延迟加载。 - 3、
OffsetIndex
基于offset的索引,继承于AbstractIndex
,作为LazyIndex
的泛型使用,索引项结构:【offset,物理位置】。 - 4、
TimeIndex
基于时间的索引,继承于AbstractIndex
,作为LazyIndex
的泛型使用,索引项结构:【时间戳,物理位置】。 - 5、
TransactionIndex
基于事务的索引,继承于Logging
,保存事务相关的元数据。
以上几个索引的关系如下图所示:
2、AbstractIndex
2.1、冷热区二分查找
了解索引实现,首先就要了解他们的公共抽象类AbstractIndex
,AbstractIndex将OffsetIndex 和 TimeIndex 的公共内容提取出来,主要抽离了一些和mmap
有关的操作,还有基于entries
一些对象的计算逻辑。此外我们需要格外关注这个类的一个方法indexSlotRangeFor
,这个方法的定义如下:
private def indexSlotRangeFor(idx: ByteBuffer, target: Long, searchEntity: IndexSearchType): (Int, Int)
其入参主要有:
- 1、当前索引文件的
mmap
对象idx
; - 2、需要定位的offset
target
; - 3、需要搜索的对象类型
IndexSearchType
;
这个方法主要实现的功能就是,通过当前的索引文件mmap
和目标offset来找到最接近(小于)目标offset的那个索引项的对应索引的offset。其中内部使用了二分查找算法,如下所示:
def binarySearch(begin: Int, end: Int) : (Int, Int) = {
// binary search for the entry
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)
}
但是这个这个二分查找和常见的二分查找算法还是有区别的,其为了保证所索引文件能充分利用page cache,kafka对索引文件的mmap
进行了冷热区的区分。那么什么要区分冷热区呢?
如上图所示,当写入消息的时候由于操作系统开启着 page cache,最近写入的内容会保留在 page cache中。由于数据读取的时候,操作系统会优先查找 page cache,当 page cache 无法命中的时候才会进行磁盘IO,这样可以大大提升硬盘的性能,而冷热区就是把当前的消息划分为冷区和热区两块,热区的数据基本上大概率都是在 page cache 中。
那么划分冷热区的作用是? 由于线上大多情况都是消费者消费刚刚发送的数据,这些数据大概率都是热点数据还再page cache中,这时候消费者优先关注热区,如果热区找不到再去冷区查找,通过这种方式消费者在二分查找时候能充分利用page cache 并且免去不必要的磁盘IO,从而大大的提高性能。这种设计思路在多消费者组的情况下非常有效。
此外这里还有一个需要注意的地方,kafka默认把热区的大小设置为 8192字节
,那么为什么是8192字节
呢?官方是如下解释的:
We set N (_warmEntries) to 8192, because
1. This number is small enough to guarantee all the pages of the "warm" section is touched in every warm-section
lookup. So that, the entire warm section is really "warm".
When doing warm-section lookup, following 3 entries are always touched: indexEntry(end), indexEntry(end-N),
and indexEntry((end*2 -N)/2). If page size >= 4096, all the warm-section pages (3 or fewer) are touched, when we
touch those 3 entries. As of 2018, 4096 is the smallest page size for all the processors (x86-32, x86-64, MIPS,
SPARC, Power, ARM etc.).
2. This number is large enough to guarantee most of the in-sync lookups are in the warm-section. With default Kafka
settings, 8KB index corresponds to about 4MB (offset index) or 2.7MB (time index) log messages.
We can't set make N (_warmEntries) to be larger than 8192, as there is no simple way to guarantee all the "warm"
section pages are really warm (touched in every lookup) on a typical 4KB-page host.
In there future, we may use a backend thread to periodically touch the entire warm section. So that, we can
1) support larger warm section
2) make sure the warm section of low QPS topic-partitions are really warm.
其大致意思是: 由于当前常见硬件厂商架构如 (x86-32, x86-64, MIPS,SPARC, Power, ARM) 的 page size 都是默认都是4kb
,当大于4kb
的时候,一般来说也只有不超过三个或者更少数量的 page 真正属于 warm page
。折衷这些情况后设置为8kb
既能不浪费充分利用page cache,又可以避免出现大量数据虽然被划分在 warm page
中,但是其实际已经被清理的情况。同时 8kb
大概可以对应4MB
的 offset index 和2.7MB
的 time offset。
- 在下面作者也对未来的改进做出一些计划,很显然这个是临时方案:
In there future, we may use a backend thread to periodically touch the entire warm section. So that, we can
1) support larger warm section
2) make sure the warm section of low QPS topic-partitions are really warm.
意思是,未来可能回去实现一个后台线程,通过这个线程去触发一些 page 变成 warm page
,这样既可以让warm page
超过 8kb
也可以让一些低 QPS 的topic也能享受到 page cache 带来的好处。
3、OffsetIndex-位移索引
OffsetIndex
是基于AbstractIndex
的一种实现,其最本质的作用是保存了<offset,物理位置>
的关系,其中offset是使用Int类型存储的,在OffsetIndex
中,实际的offset是通过相对位移 + 索引基本offset 得出实际offset的:[当前索引文件baseffset] + [相对offset] = [实际offset]
。这样做的好处是在存储offset的时候每个索引项可以节约4字节
的空间。
3.1 lookup()
def lookup(targetOffset: Long): OffsetPosition = {
maybeLock(lock) {
val idx = mmap.duplicate
val slot = largestLowerBoundSlotFor(idx, targetOffset, IndexSearchType.KEY)
if(slot == -1)
OffsetPosition(baseOffset, 0)
else
parseEntry(idx, slot)
}
}
lookup
底层就是调用上面的二分查找算法去定位目标索引项的,通过目标offset查询到目标offset的索引项或者最接近且小于这个offset的索引项
3.2 physical()
private def physical(buffer: ByteBuffer, n: Int): Int = buffer.getInt(n * entrySize + 4)
physical
方法是用来计算实际数据物理位置的,入参 n
是当前索引文件的第n
个索引项,
3.3 append()
def append(offset: Long, position: Int): Unit = {
inLock(lock) {
require(!isFull, "Attempt to append to a full index (size = " + _entries + ").")
if (_entries == 0 || offset > _lastOffset) {
trace(s"Adding index entry $offset => $position to ${file.getAbsolutePath}")
//向mmap 写入相对位移值
mmap.putInt(relativeOffset(offset))
//向mmap 写入物理位置
mmap.putInt(position)
_entries += 1
_lastOffset = offset
require(_entries * entrySize == mmap.position(), s"$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}.")
}
}
}
append
方法是用来追加索引项,索引项保存后_entries
和 _lastOffset
会自增或者保存为当前offset。这里需要注意的是,为了防止写入的 offset比之前的offset小,这里做了强校验,如果出现写入的offset比_lastOffset
小则会抛出InvalidOffsetException
异常,毕竟kafka的offset是递增的。
3.4 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}")
}
}
truncateToEntries
这个方法的主要作用是用来截断不需要的索引项,通过mmap
的position
方法把不需要的部分截取掉。
4、TimeIndex-时间索引
TimeIndex
同样也是AbstractIndex
的一种实现,其保存的结构为<timestamp,相对位移>
。其中lookup
方法和truncateToEntries
方法的实现和OffsetIndex
大致是一样。这里需要注意的一点就是,TimeIndex
保存的不是物理位置,而是相对位移。其保存的物理结构如下所示:
4.1 maybeAppend()
与OffsetIndex
append
方法不同的是TimeIndex
是通过maybeAppend
追加索引项的,从名字可以看出,这个方法有可能追加也有可能不追加。因为只有当写入的时间戳和偏移量都大于最后一个写入的索引项的时间戳和偏移量的时候才会添加索引项,同样在写入的时候也会强校验当前写入的偏移量是否小于最后一个写入的偏移量,以便保证offset的递增性。
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.putInt(relativeOffset(offset))
_entries += 1
_lastEntry = TimestampOffset(timestamp, offset)
require(_entries * entrySize == mmap.position(), s"${_entries} entries but file position in index is ${mmap.position()}.")
}
}
}
5、总结
本篇文章介绍了kafka中各种索引的实现,以及其在索引查找上基于冷热区的特殊实现,kafka本身对文件的操作也是基于mmap(操作系统内存映射文件技术)
进行的,从而也能看出kafka对高性能的追求,从中我们也侧面了解到了一些有关操作系统page cache的知识点.