摘要:上一篇 Log.append 六步流程中,第四步"更新稀疏索引"只提了一句。这篇把它彻底拆开:Kafka 每个 LogSegment 有 .index 和 .timeindex 两个稀疏索引,都通过 MappedByteBuffer 内存映射读写。为什么写 .log 用 FileChannel、写 .index 却用 mmap?每条索引只有 8 或 12 字节,用相对 offset 省一半空间,每写 4KB 数据才追加一条,查找时直接在映射内存上做下界二分。本篇从 OffsetIndex 类结构、append 真实源码、lookup 二分实现、TimeIndex 特殊性到 mmap 生命周期与真实坑,逐层拆解。
关键词:Kafka 稀疏索引、OffsetIndex、TimeIndex、MappedByteBuffer、内存映射、mmap、二分查找、LogSegment
从一个反直觉的设计切入
读 Kafka 源码时很容易注意到一个矛盾:写 .log 数据文件用FileChannel.write,写 .index 索引文件用MappedByteBuffer(mmap)。同样是往磁盘写文件,为什么两套机制?
答案在两种写入的特征差异:.log 是大文件顺序写,每次 append 写一批消息几 KB 到几 MB,FileChannel 一次系统调用搞定;.index 是小文件随机写,每次只写 8 字节固定条目,按_entries × 8精确位置追加,如果用 FileChannel,每次都要position()+write(ByteBuffer.allocate(8))两次系统调用,开销远大于写入本身。
mmap 把文件映射到用户态内存后,mmap.putInt()就是直接写内存地址,没有系统调用、没有缓冲区分配。而且索引文件天生就小(每段预分配 10MB,实际通常几 MB),完全放得进内存映射。
01 OffsetIndex 类结构与 mmap 内存布局
每个 LogSegment 目录下三个文件,以 baseOffset 命名(20 位补零):.log 数据文件(最大 1GB,FileChannel 顺序写)、.index 偏移索引(offset → position,mmap)、.timeindex 时间索引(timestamp → offset,mmap)。两个索引在段创建时通过FileChannel.map(READ_WRITE, 0, maxIndexSize)映射到用户态内存。
OffsetIndex 核心字段:
classOffsetIndex(valfile:File,valbaseOffset:Long,valmaxIndexSize:Int){privatevalmmap:MappedByteBuffer=...// 文件内存映射privateval_entries=newAtomicInteger(0)// 已写入条目数privatevalmaxEntries=maxIndexSize/8// 最大条目数}_entries用 AtomicInteger——虽然 append 全程持 segmentLock,但 lookup 和 append 可能在不同线程,需要可见性保证。maxEntries = maxIndexSize / 8是硬上限,超了抛 “Index is full”。
mmap 内存中条目紧密排列无填充:OffsetIndex 每条 8 字节(4B 相对 offset + 4B position),TimeIndex 每条 12 字节(8B timestamp + 4B 相对 offset)。写入位置用_entries × 8精确计算,无需文件指针。
02 append 写入:真实源码与调用链
在LogSegment.append内部,每写一批消息累加bytesSinceLastIndexEntry,达到index.interval.bytes(默认 4096)才写一条索引:
if(bytesSinceLastIndexEntry>=indexIntervalBytes){valrelativeOffset=(targetOffset-baseOffset).toInt offsetIndex.append(relativeOffset,physicalPosition)timeIndex.append(maxTimestamp,relativeOffset)bytesSinceLastIndexEntry=0}两个细节:relativeOffset是 Int 不是 Long——用相对 offset 后段内消息数不超 2^31,4 字节够用,比存 8 字节绝对 offset 省一半空间。physicalPosition是消息在 .log 文件中的起始字节位置,来自写入前的channel.position(),查找时直接 seek。
OffsetIndex.append 真实实现:
defappend(offset:Int,position:Int):Unit={require(!isFull,"Attempt to append to a full index")if(_entries.get>0)require(offset>lastOffset,"Offsets not increasing in index")mmap.position(_entries.get*8)mmap.putInt(offset)mmap.putInt(position)_entries.incrementAndGet()}三行关键逻辑:require(offset > lastOffset)保证 offset 严格单调递增——二分查找的前提,乱序直接抛异常;mmap.position(_entries.get * 8)用条目数 × 8 精确定位;连续两次putInt写内存,无系统调用。
TimeIndex.append 写 12 字节:putLong(maxTimestamp) + putInt(relativeOffset)。timestamp 取 RecordBatch 中的最大 timestamp,因为批次内消息时间可能不同,取最大值保证查找时定位到的条目一定在目标时间之前。
03 lookup 查找:mmap 内存上的下界二分
查找核心是在 mmap 内存上做下界二分(lowerBound)——找最大的 ≤ 目标值的条目,而非精确匹配。完整调用链:segments 中floorEntry定位段 → 计算相对 offset → mmap 内存上二分 → 取出 position →channel.position(position)顺序扫描直到找到目标。
二分核心实现:
deflookup(targetOffset:Int):OffsetPosition={varlo=0;varhi=entries-1while(lo<hi){valmid=(lo+hi+1)>>1// 上中位数,避免死循环valoff=mmap.getInt(mid*8)// 直接读内存,无系统调用if(off<=targetOffset)lo=midelsehi=mid-1}newOffsetPosition(baseOffset+mmap.getInt(lo*8),mmap.getInt(lo*8+4))}三个实现细节:mid = (lo + hi + 1) >> 1用上中位数——因为lo = mid(不是 mid+1),用下中位数在 lo 和 hi 相邻时会死循环;mmap.getInt(mid * 8)直接从映射内存读 4 字节,无系统调用、无缓冲区分配、无内核拷贝,几万条索引的段二分只需 ~15 次内存读,微秒级完成;下界而非精确匹配——索引稀疏,目标 offset 大概率不在索引中,找 floor 后顺序扫描,最多扫 4KB。
04 TimeIndex 的三个特殊设计
TimeIndex 共用 mmap 机制,但 timestamp 处理有三个独特设计:
① timestamp 取批次最大值。append 时写的是 RecordBatch 中的最大 timestamp,不是每条消息的 timestamp。批次内消息时间可能不同(Producer 端设置),取最大值保证查找时间点 T 时,定位到的条目对应的 offset 一定 ≤ T 所在的消息。
② timestamp 不要求严格单调。OffsetIndex.append 有require(offset > lastOffset)校验,但 TimeIndex 没有——因为 timestamp 由 Producer 端设置,可能乱序(时钟回拨、重试消息)。查找时用下界二分即可,不要求严格递增。
③ 查找时需要二次定位。TimeIndex 只能定位到 offset,还需 OffsetIndex 二次定位到 position,再读消息验证 timestamp。因为 TimeIndex 稀疏且 timestamp 取批次最大值,定位到的 offset 可能早于目标时间,需顺序扫描到第一条 timestamp ≥ 目标的消息。
05 mmap 生命周期:预分配、写入、截断、刷盘
四阶段闭环,每步有具体源码操作:
① 预分配 + mmap(段创建时)。roll 出新段时创建空索引文件,RandomAccessFile.setLength(maxIndexSize)预分配 10MB,再channel.map(READ_WRITE, 0, maxIndexSize)映射整个文件。mmap 一旦创建大小不可变,必须在 map 前扩到最大,否则mmap.putInt超文件大小抛BufferOverflowException。
② 追加写入(append 过程中)。按 4KB 间隔追加条目,直接操作 mmap 内存地址,纯内存操作无系统调用,写后立即可读。此阶段磁盘文件仍是 10MB(预分配大小)。
③ 封闭截断(段滚动时)。channel.truncate(actualSize)截断到实际大小(entries × 8),释放预分配多余空间——这就是磁盘上 .index 通常只有几 MB 而非 10MB 的原因。截断后旧 mmap 仍映射原 10MB,但封闭段只查不写,旧 mmap 可正常读(截断部分读出 0),关闭时统一回收。
④ 刷盘 force + 关闭(后台或关闭时)。默认不主动 force,靠 OS 回写。LogManager 后台 flush 任务或 Broker 关闭时mmap.force()落盘,然后channel.close(),mmap 由 GC 回收(无显式 unmap API)。
06 mmap 的三个真实坑
① force() 是全量 fsync,开销大。mmap.force()对整个 10MB 映射区域执行 msync/fsync,即使只写了几十字节。每次 append 都 force 性能会崩,所以默认不主动 force,靠 OS 回写,只在后台和关闭时调用——和 .log 策略一致,用副本机制兜底持久性。
② unmap 依赖 GC,段删除后内存不立即释放。MappedByteBuffer 无显式 unmap 方法(JDK 内部有Unsafe.invokeCleaner但 Kafka 未用),段删除后 mmap 可能还占内存直到 GC。高吞吐下段滚动频繁时可能短暂内存上涨,这也是segment.bytes不宜设太小的原因之一。
③ 截断后旧 mmap 映射仍有效。段封闭时channel.truncate()缩小文件,但已存在的 MappedByteBuffer 仍映射原 10MB,继续写超截断位置会抛 IOException。Kafka 做法:封闭段后只查不写,旧 mmap 正常读,关闭时统一回收;重新打开已封闭段索引时创建新对象重新 mmap 截断后的大小。
写在最后
Kafka 稀疏索引的 mmap 写入,核心是一个朴素判断:大文件顺序写用 FileChannel,小文件随机写用 mmap,各取所长。8 字节固定条目、相对 offset 省一半空间、4KB 间隔稀疏策略、预分配+截断生命周期、mmap 内存上的下界二分——每个设计都有具体源码支撑和明确权衡。理解了索引的写入和查找,Log.append 完整流程就通了:写数据文件持久化消息,写索引快速定位消息,一主一辅共同支撑高吞吐写入和低延迟查找。