一、场景分析
Kafka服务端的网络模块基本已经分析完了,在KafkaApis处理生产消息类型请求的最后,通过调用ReplicaManager.appendRecords方法,将数据写入了本地存储系统。从这篇开始,将分析Kafka服务端的存储模块,包括涉及到的各种组件、存储流程和一些核心概念等。
在分析之前,首先了解一下服务端存储模块的大致结构:- 所有的Partition由ReplicaManager组件管理
- 一个Partition对应多个Replica副本,分布在不同的节点上(这几个节点上每个节点一个副本)
- 每个分区对应一个Log日志对象,用来管理日志相关的操作。
- Log对象由LogManager管理,而Logmanager由ReplicaManager管理
- 一个Log日志又划分成多个日志段LogSegment,日志段是真正进行数据读写的对象,每个日志段包含一组文件:.log数据文件、.index偏移量索引文件和.timeindex时间戳索引文件等。
这篇先来分析最基本的LogSegment日志段是如何进行数据读写和日志段恢复的。
二、图示说明 1.写数据流程:2.读数据流程:
class LogSegment private[log] (val log: FileRecords,//实际存储消息的对象 val lazyOffsetIndex: LazyIndex[OffsetIndex],//位移索引文件 val lazyTimeIndex: LazyIndex[TimeIndex],//时间戳索引文件 val txnIndex: TransactionIndex,//已终止事务索引文件 val baseOffset: Long,//日志段起始偏移量 val indexIntervalBytes: Int,//每写入多少字节的数据,就创建一个索引。由Broker 端参数 log.index.interval.bytes 控制 // 默认情况下,日志段至少新写入 4KB 的消息数据才会新增一条索引项 val rollJitterMs: Long,//扰动值,设置大于0,可以避免同一时刻服务端生成多个日志段对象给磁盘IO带来的压力 val time: Time) extends Logging {
其中,各个参数的含义如下:
- log:实际存储消息的对象
- lazyOffsetIndex:偏移量索引
- lazyTimeIndex:时间戳索引
- txnIndex:已终止事务索引
- baseOffset:日志段文件的起始偏移量
- indexIntervalBytes:每写入多少字节的数据,就创建一条索引。默认4KB
- rollJitterMs:扰动值,设置大于0,可以避免同一时刻服务端生成多个日志段对象给磁盘IO带来的压力
def append(largestOffset: Long,//待写入消息集合的最大偏移量 largestTimestamp: Long,//待写入消息集合待最大时间戳 shallowOffsetOfMaxTimestamp: Long,//最大时间戳对应的数据偏移量(一般来说,最大时间戳对应的偏移量就是最大偏移量,但由于 //时间戳可以在producer端任意指定,所以可能导致两者不一致 records: MemoryRecords//待写入待消息集合 ): Unit = { //小于等于0说明所有消息已经写完,直接结束append if (records.sizeInBytes > 0) { trace(s"Inserting ${records.sizeInBytes} bytes at end offset $largestOffset at position ${log.sizeInBytes} " + s"with largest timestamp $largestTimestamp at shallow offset $shallowOffsetOfMaxTimestamp") //获取物理地址 val physicalPosition = log.sizeInBytes() if (physicalPosition == 0) //更新用于日志段切分的时间戳 rollingBasedTimestamp = Some(largestTimestamp) //检查消息集合中最大的偏移量是否可以转为相对偏移量 //即 largestOffset - baseOffset 的值是不是介于 [0,Int.MAXVALUE] 之间。 //在极个别的情况下,这个差值可能会越界,这时,append 方法就会抛出异常,阻止后续的消息写入。 ensureOffsetInRange(largestOffset) //调用 FileRecords 的 append 方法执行真正的写入,返回写入的字节数 val appendedBytes = log.append(records) trace(s"Appended $appendedBytes to ${log.file} at end offset $largestOffset") // Update the in memory max timestamp and corresponding offset. if (largestTimestamp > maxTimestampSoFar) { // 更新日志段的最大时间戳以及最大时间戳所属消息的偏移量 maxTimestampSoFar = largestTimestamp offsetOfMaxTimestampSoFar = shallowOffsetOfMaxTimestamp } // 如果需要,在索引文件中增加索引值;默认当日志写入 4KB 的数据是要写入一个索引项 if (bytesSinceLastIndexEntry > indexIntervalBytes) { //增加索引项 offsetIndex.append(largestOffset, physicalPosition) timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar) //bytesSinceLastIndexEntry归零,重新计算 bytesSinceLastIndexEntry = 0 } //统计从上次增加索引项后写入的消息字节 bytesSinceLastIndexEntry += records.sizeInBytes }}
a. 判断待写入消息的字节数是否大于0,如果小于等于0,说明消息已经写完,直接结束append方法:
records.sizeInBytes > 0
b. 判断日志段是否为空,如果为空,更新用于日志段切分的时间戳
//获取日志段追加数据的起始物理地址val physicalPosition = log.sizeInBytes()//如果物理地址为0,说明日志段为空if (physicalPosition == 0) //更新用于日志段切分的时间戳 rollingBasedTimestamp = Some(largestTimestamp)
c. 检查消息集合中最大的偏移量是否合法:为了节省存储空间,存储的是消息的实际偏移量(8个字节)和起始偏移量(baseOffset)的差值,即相对偏移量(4个字节)。
这里的判断依据是:计算出的相对偏移量介于[0,Int.MaxValue],如果超出这个范围,相对偏移量就无法用4个字节存储了。
ensureOffsetInRange(largestOffset)
d. 调用FileRecords的append方法执行真正的数据写入:
val appendedBytes = log.append(records)
e. 更新日志段的最大时间戳以及对应消息的偏移量
if (largestTimestamp > maxTimestampSoFar) { //更新日志段的最大时间戳以及最大时间戳所属消息的偏移量 maxTimestampSoFar = largestTimestamp offsetOfMaxTimestampSoFar = shallowOffsetOfMaxTimestamp}
f. 如果累计写入的字节数超过indexIntervalBytes(默认4KB),则在索引文件中增加索引项:
if (bytesSinceLastIndexEntry > indexIntervalBytes) { //增加索引项 offsetIndex.append(largestOffset, physicalPosition) timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar) //bytesSinceLastIndexEntry归零,重新计算 bytesSinceLastIndexEntry = 0}
g. 统计从上次增加索引项后写入的字节数
//统计从上次增加索引项后写入的消息字节bytesSinceLastIndexEntry += records.sizeInBytes
3. 读数据的方法:read
def read(startOffset: Long, //起始偏移量 maxOffset: Option[Long], //读取的最大偏移量 maxSize: Int,//读取的最大字节数 maxPosition: Long = size,//能读到读最大文件位置 minOneMessage: Boolean = false//是否允许在消息体过大时至少返回第一条消息。为 true 时,即使出现消息体 // 字节数超过了 maxSize 的情形,read 方法依然能返回至少一条消息。引入这个参数主要是为了确保不出现消费饿死的情况。 // 消费饿死:如果每条消息的大小都超过了maxSize且该参数为false,那么就一直读不到数据 ): FetchDataInfo = { if (maxSize < 0) throw new IllegalArgumentException(s"Invalid max size $maxSize for log read from segment $log") //获取日志段的字节数 val logSize = log.sizeInBytes //TODO 步骤一:获取LogOffsetPosition对象,找到开始偏移量对应数据的物理位置 val startOffsetAndSize = translateOffset(startOffset) //如果起始偏移量已经超出了日志段最大偏移量,返回null if (startOffsetAndSize == null) return null //获取起始物理位置 val startPosition = startOffsetAndSize.position //构建LogOffsetMetadata对象 val offsetMetadata = new LogOffsetMetadata(startOffset, this.baseOffset, startPosition) //允许读取的最大字节数 val adjustedMaxSize = if (minOneMessage) math.max(maxSize, startOffsetAndSize.size) else maxSize // return a log segment but with zero size in the case below if (adjustedMaxSize == 0) return FetchDataInfo(offsetMetadata, MemoryRecords.EMPTY) //TODO 步骤二:计算可以获取的消息的字节数。 val fetchSize: Int = maxOffset match { //如果没有指定读取的最大偏移量 case None => //读取的字节:(能读到的最大位置-起始位置)和允许读取的最大字节数的较小值 min((maxPosition - startPosition).toInt, adjustedMaxSize) //如果指定了读取的最大偏移量 case Some(offset) => //如果读取的最大偏移量小于起始偏移量,则返回一个空的FetchDataInfo对象 if (offset < startOffset) return FetchDataInfo(offsetMetadata, MemoryRecords.EMPTY, firstEntryIncomplete = false) val mapping = translateOffset(offset, startPosition) //如果读取的最大偏移量超过了日志段的最大偏移量,意味着可以读取到日志段的所有数据 //否则,读取的最后位置就是maxOffset对应的物理位置 val endPosition = if (mapping == null) logSize else mapping.position //可以读取的字节总数:先取能够读取到的最大文件位置和读取结束位置的较小值,然后计算和起始位置的差值,再比较结果和允许读取的最大字节数,取较小值 min(min(maxPosition, endPosition) - startPosition, adjustedMaxSize).toInt } //TODO 步骤三:调用FileRecoreds.slice方法读取数据 FetchDataInfo(offsetMetadata, log.slice(startPosition, fetchSize), firstEntryIncomplete = adjustedMaxSize < startOffsetAndSize.size)}
首先,看一下方法的几个参数:
- startOffset:读取消息的起始偏移量
- maxOffset:读取消息的最大偏移量
- maxSize:读取的最大字节数
- maxPosition:可以读取到的最大文件位置
- minOneMessage:是否允许在消息体过大时,至少读取一条消息。为 true 时,即使出现消息体字节数超过了 maxSize 的情形,read 方法依然能返回至少一条消息。引入这个参数主要是为了确保不出现消费饿死的情况。
消费饿死:如果每条消息的大小都超过了maxSize且该参数为false,那么就一直读不到数据
a. 获取当前日志段的字节总数
//获取日志段的字节数val logSize = log.sizeInBytes
b. 找到读取的起始偏移量对应的物理位置,如果指定的起始偏移量已经超出了日志段的最大偏移量,则读不到数据,直接返回null
//获取LogOffsetPosition对象,该对象就是起始偏移量对应的消息对象,包含了消息的偏移量,物理位置和消息大小val startOffsetAndSize = translateOffset(startOffset)//如果起始偏移量已经超出了日志段最大偏移量,返回nullif (startOffsetAndSize == null) return null
c. 调整允许读取的最大字节数,主要看minOneMessage参数是否为默认的false:如果是,则允许读取的最大字节数就是给定值;否则,就是给定值和第一条消息大小的较大值
//允许读取的最大字节数val adjustedMaxSize = if (minOneMessage) math.max(maxSize, startOffsetAndSize.size) else maxSize
d. 判断是否指定了读取的最大偏移量:
- 没有指定:读取的字节数=(能读到的最大位置-起始位置)和允许读取的最大字节数的较小值
- 如果指定了:
- 如果指定读取的最大偏移量比起始偏移量还小,则返回一个空的FetchDataInfo对象
- 否则,获取可以读取的最后位置:
- 如果指定读取的最大偏移量超过了日志段的最大偏移量,那么说明可以读到日志段结尾
- 否则,只可以读取到指定的最大偏移量对应的物理位置
- 计算可以读取的字节数:先取能够读取到的最大文件位置和读取结束位置的较小值,然后计算和起始位置的差值,再比较结果和允许读取的最大字节数,取较小值
最后计算可以读取的字节数比较绕,可以看下面的图:
val fetchSize: Int = maxOffset match { //如果没有指定读取的最大偏移量 case None => //读取的字节:(能读到的最大位置-起始位置)和允许读取的最大字节数的较小值 min((maxPosition - startPosition).toInt, adjustedMaxSize) //如果指定了读取的最大偏移量 case Some(offset) => //如果读取的最大偏移量小于起始偏移量,则返回一个空的FetchDataInfo对象 if (offset < startOffset) return FetchDataInfo(offsetMetadata, MemoryRecords.EMPTY, firstEntryIncomplete = false) val mapping = translateOffset(offset, startPosition) //如果读取的最大偏移量超过了日志段的最大偏移量,意味着可以读取到日志段的所有数据 //否则,读取的最后位置就是maxOffset对应的物理位置 val endPosition = if (mapping == null) logSize else mapping.position //可以读取的字节总数:先取能够读取到的最大文件位置和读取结束位置的较小值,然后计算和起始位置的差值,再比较结果和允许读取的最大字节数,取较小值 min(min(maxPosition, endPosition) - startPosition, adjustedMaxSize).toInt}
e. 调用FileRecords.slice方法从指定位置读取指定大小的数据:
log.slice(startPosition, fetchSize)
f. 封装FetchDataInfo对象返回
FetchDataInfo(offsetMetadata, log.slice(startPosition, fetchSize), firstEntryIncomplete = adjustedMaxSize < startOffsetAndSize.size)
4. 恢复日志段的方法:recover
该方法用来在Broker启动时,加载所有的日志段信息到内存,构建对应的LogSegment对象def recover(producerStateManager: ProducerStateManager, leaderEpochCache: Option[LeaderEpochFileCache] = None): Int = { //TODO 步骤一:清空所有索引文件 offsetIndex.reset()//清空偏移量索引文件 timeIndex.reset()//清空时间戳索引文件 txnIndex.reset()//清空已中止事务索引文件 //初始化读取的合法字节数 var validBytes = 0 //初始化最后创建索引时读取的字节数,当validBytes - lastIndexEntry > indexIntervalBytes(默认4KB)时构建一个索引 var lastIndexEntry = 0 maxTimestampSoFar = RecordBatch.NO_TIMESTAMP //TODO 步骤二:遍历日志段中所有消息集合,统计日志段字节数 try { for (batch //验证消息批次合法性 batch.ensureValid() //验证消息批次中消息的偏移量是否合法 ensureOffsetInRange(batch.lastOffset) // The max timestamp is exposed at the batch level, so no need to iterate the records //更新最大时间戳和对应的偏移量 if (batch.maxTimestamp > maxTimestampSoFar) { maxTimestampSoFar = batch.maxTimestamp offsetOfMaxTimestampSoFar = batch.lastOffset } //重建索引 if (validBytes - lastIndexEntry > indexIntervalBytes) { offsetIndex.append(batch.lastOffset, validBytes) timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar) //创建索引后将最后创建索引读取的字节数更新为当前读取的字节总数 lastIndexEntry = validBytes } //统计读取的字节总数 validBytes += batch.sizeInBytes() if (batch.magic >= RecordBatch.MAGIC_VALUE_V2) { leaderEpochCache.foreach { cache => if (batch.partitionLeaderEpoch > 0 && cache.latestEpoch.forall(batch.partitionLeaderEpoch > _)) //更新Leader Epoch 缓存 cache.assign(batch.partitionLeaderEpoch, batch.baseOffset) } //更新事务型 Producer 的状态 updateProducerState(producerStateManager, batch) } } } catch { case e: CorruptRecordException => warn("Found invalid messages in log segment %s at byte offset %d: %s." .format(log.file.getAbsolutePath, validBytes, e.getMessage)) } //日志段的总字节数-读取的合法字节数,如果 >0 说明有部分非法消息,需要按照合法字节数对日志进行截断操作 val truncated = log.sizeInBytes - validBytes if (truncated > 0) debug(s"Truncated $truncated invalid bytes at the end of segment ${log.file.getAbsoluteFile} during recovery") //TODO 步骤三:执行日志截断 log.truncateTo(validBytes) offsetIndex.trimToValidSize() // A normally closed segment always appends the biggest timestamp ever seen into log segment, we do this as well. //timeIndex文件中添加最大时间戳和对应的消息的偏移量 timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar, skipFullCheck = true) timeIndex.trimToValidSize() truncated}
a. 清空日志段中所有的索引文件:
//TODO 步骤一:清空所有索引文件offsetIndex.reset()//清空偏移量索引文件timeIndex.reset()//清空时间戳索引文件txnIndex.reset()//清空已中止事务索引文件
b. 遍历日志段中的所有消息批次,统计读取的字节数
b1. 验证消息批次的合法性
//验证消息批次合法性batch.ensureValid()//验证消息批次中消息的偏移量是否合法ensureOffsetInRange(batch.lastOffset)
b2. 更新最大时间戳和对应消息的偏移量
//更新最大时间戳和对应的偏移量if (batch.maxTimestamp > maxTimestampSoFar) { maxTimestampSoFar = batch.maxTimestamp offsetOfMaxTimestampSoFar = batch.lastOffset}
b3. 重建索引
//重建索引if (validBytes - lastIndexEntry > indexIntervalBytes) { //新增索引项 offsetIndex.append(batch.lastOffset, validBytes) timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar) //创建索引后将最后创建索引读取的字节数更新为当前读取的字节总数 lastIndexEntry = validBytes}
b4. 更新读取的字节总数
//统计读取的字节总数validBytes += batch.sizeInBytes()
b5. 更新leader epoch缓存和事务型Producer的状态
if (batch.magic >= RecordBatch.MAGIC_VALUE_V2) { leaderEpochCache.foreach { cache => if (batch.partitionLeaderEpoch > 0 && cache.latestEpoch.forall(batch.partitionLeaderEpoch > _)) //更新Leader Epoch 缓存 cache.assign(batch.partitionLeaderEpoch, batch.baseOffset) } //更新事务型 Producer 的状态 updateProducerState(producerStateManager, batch)}
c. 计算非法字节数
//日志段的总字节数-读取的合法字节数,如果 >0 说明有部分非法消息,需要按照合法字节数对日志进行截断操作val truncated = log.sizeInBytes - validBytes
d. 如果有非法字节,执行日志截断
//TODO 步骤三:执行日志截断log.truncateTo(validBytes)
truncateTo方法:就是根据给定值调整FileChannel的大小
public int truncateTo(int targetSize) throws IOException { int originalSize = sizeInBytes(); if (targetSize > originalSize || targetSize < 0) throw new KafkaException("Attempt to truncate log segment " + file + " to " + targetSize + " bytes failed, " + " size of this log segment is " + originalSize + " bytes."); if (targetSize < (int) channel.size()) { //调整FileChannel的大小 channel.truncate(targetSize); //重设size的值 size.set(targetSize); } return originalSize - targetSize;}
e. 裁剪索引文件
//裁剪偏移量索引文件offsetIndex.trimToValidSize()//timeIndex文件中添加最大时间戳和对应的消息的偏移量timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar, skipFullCheck = true)//裁剪时间戳索引文件timeIndex.trimToValidSize()
总结:
这一篇,主要分析了LogSegment的定义,以及日志段的几个基本操作
- append:写数据的方法。由于保存的是相对偏移量,所以计算出的相对偏移量值必须介于 [0,Int.MaxValue] 之间。这个方法重点关注添加索引项的时机:即累计写入的字节数大于 indexIntervalBytes(默认4KB)
- read:读数据的方法。重点关注startOffset、maxOffset、maxPosition和maxSize 这几个参数是如何共同影响读取的字节数的。
- recover:恢复日志段的方法。Broker启动的过程中会调用该方法读取日志段文件。如果一台Broker上保存了大量的日志段对象,就可能导致Broker启动很慢。