近期对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):时间戳对应消息的相对位移值
在磁盘内的存储结构为:
关键链路
写日志
写入日志有两种方式:
- Producer向Leader副本所在Broker发起写入请求:
KafkaApis.handleProduceRequest()
|-- ReplicaManager.appendToLocalLog()
|---- Partition.appendRecordsToLeader()
|------ Log.appendAsLeader()
|-------- Log.append()
- 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属性有很详细的描述,翻译过来大概是:
- 这个值足够小,通常处理器缓存页大小会大于4096,那么8192能够保证页数小于等3,保证用于热区查找的页面都能命中缓存
- 这个值足够大,可以保证大多数同步查找都在暖区。使用默认卡夫卡设置,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;
}