Kafka 日志对象由多个日志段对象组成,而每个日志段对象会在磁盘上创建一组文件,包括消息日志文件(.log)、位移索引文件(.index)、时间戳索引文件(.timeindex)以及已中止(Aborted)事务的索引文件(.txnindex)。LogSegment定义如下:
class LogSegment private[log] (val log: FileRecords, // log 文件对象
val lazyOffsetIndex: LazyIndex[OffsetIndex], // index 文件对象
val lazyTimeIndex: LazyIndex[TimeIndex], // timeindex 文件对象
val txnIndex: TransactionIndex,
val baseOffset: Long, // 当前日志分片文件中第一条消息的 offset 值
val indexIntervalBytes: Int, // 索引项之间间隔的最小字节数,对应 index.interval.bytes 配置
val rollJitterMs: Long,
// log.segment.ms,以时间为维度切分segment。
//那配置了这个参数之后如果有很多很多分区,然后因为这个参数是全局的,因此同一时刻需要做很多文件的切分,
// 这磁盘IO就顶不住了啊,因此需要设置个rollJitterMs,来岔开它们
val time: Time) extends Logging {
/** 当前 LogSegment 的创建时间 */
private var created = time.milliseconds
/** 自上次添加索引项后,在 log 文件中累计加入的消息字节数 */
private var bytesSinceLastIndexEntry = 0
// roll 一个新的segments的时间戳
@volatile private var rollingBasedTimestamp: Option[Long] = None
/** 已追加消息的最大时间戳 */
@volatile private var _maxTimestampSoFar: Option[Long] = None
/* log文件的大小 */
def size: Int = log.sizeInBytes()
// 判断是否需要roll一个新的segments段
def shouldRoll(rollParams: RollParams): Boolean = {
val reachedRollMs = timeWaitedForRoll(rollParams.now, rollParams.maxTimestampInMessages) > rollParams.maxSegmentMs - rollJitterMs
size > rollParams.maxSegmentBytes - rollParams.messagesSize || // 当前 activeSegment 在追加本次消息之后,长度超过 LogSegment 允许的最大值
(size > 0 && reachedRollMs) || // 当前 activeSegment 的存活时间超过了允许的最大时间
offsetIndex.isFull || timeIndex.isFull || // 索引文件满了
// canConvertToRelativeOffset 判断新append的message是否能放进去,判断是否能转化为相对offset
// 即判断val relativeOffset = rollParams.maxOffsetInMessages - baseOffset
// (relativeOffset < 0 || relativeOffset > Int.MaxValue)
!canConvertToRelativeOffset(rollParams.maxOffsetInMessages)
}
/**
* The time this segment has waited to be rolled.
* 判断此segment是否需要rolled
* 1. 如果此segment的第一个消息的时间戳存在,就用当前的新的batch的时间戳,减去此segment第一条消息的的时间戳判断是否已经超过segments.ms
* 2. 如果此segments的第一个消息的时间戳不存在,就用系统时间与此segment创建的时间差判断。
*/
def timeWaitedForRoll(now: Long, messageTimestamp: Long) : Long = {
// Load the timestamp of the first message into memory
// loadFirstBatchTimestamp如果此segment的第一个消息的时间戳存在,rollingBasedTimestamp置为此时间戳。
// 如果不存在,rollingBasedTimestamp本身就是此segment创建的时间,不用操作。
loadFirstBatchTimestamp()
rollingBasedTimestamp match {
case Some(t) if t >= 0 => messageTimestamp - t
case _ => now - created
}
}
}
FileRecords
一个日志段包含消息日志文件、位移索引文件、时间戳索引文件、已中止事务索引文件等, FileRecords 就是实际保存 Kafka 消息的对象,封装了真正的java.io.File对象。baseOffset是每个日志段对象保存自己的起始位移,即.log文件的命名,每个 LogSegment 对象实例一旦被创建,它的起始位移就是固定的了,不能再被更改。
public class FileRecords extends AbstractRecords implements Closeable {
/** 标识是否为日志文件分片 */
private final boolean isSlice;
private final int start;
/** 分片的结束位置 */
private final int end;
private final Iterable<FileLogInputStream.FileChannelRecordBatch> batches;
// mutable state
/** 如果是分片则表示分片的大小(end - start),如果不是分片则表示整个日志文件的大小 */
private final AtomicInteger size;
/** 读写对应的日志文件的通道 */
private final FileChannel channel;
/** 日志文件对象 */
private volatile File file;
/**
* The {@code FileRecords.open} methods should be used instead of this constructor whenever possible.
* The constructor is visible for tests.
*/
// FileRecords 类用于描述和管理日志(分片)文件数据,对应一个 log 文件,其字段定义如下:
FileRecords(File file,
FileChannel channel,
int start,
int end,
boolean isSlice) throws IOException {
}
append方法
append方法写消息的具体操作。
/**
* Append the given messages starting with the given offset. Add
* an entry to the index if needed.
* 从给定的起始offset加消息。如需要的话添加index文件
* 线程不安全类,默认调用此方法前加了锁
*
* @param 待写入消息的最大位移值
* @param 待写入消息的最大时间戳
* @param 最大时间戳对应消息的位移
* @param 待写入的实际消息集合
* @return 返回消息写入log文件的物理位置。
* @throws LogSegmentOffsetOverflowException if the largest offset causes index offset overflow
*/
@nonthreadsafe
def append(largestOffset: Long,
largestTimestamp: Long,
shallowOffsetOfMaxTimestamp: Long,
records: MemoryRecords): Unit = {
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")
// 获取当前要写入的log日志段文件大小
val physicalPosition = log.sizeInBytes()
// 判断要写入的日志段log文件是否是空的,如果是空的,就一定要记录写入消息集合的最大时间戳,并将其作为后面新增日志段倒计时的依据
if (physicalPosition == 0)
rollingBasedTimestamp = Some(largestTimestamp)
// 判断(largestOffset - baseOffset < 0 || largestOffset - baseOffset > Int.MaxValue)
ensureOffsetInRange(largestOffset)
// append the messages
// 正式写入log文件,具体在FileRecords类中,在之后接受Log类时会分析log.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
}
// append an entry to the index (if needed)
// 更新索引项和写入的字节数,志段每写入 4KB 数据就要写入一个索引项。
// 当已写入字节数超过了 4KB 之后,append 方法会调用索引对象的 append 方法新增索引项,同时清空已写入字节数,以备下次重新累积计算。
if (bytesSinceLastIndexEntry > indexIntervalBytes) {
offsetIndex.append(largestOffset, physicalPosition)
timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar)
bytesSinceLastIndexEntry = 0
}
bytesSinceLastIndexEntry += records.sizeInBytes
}
}
read方法
read方法读取log中的数据,分为三步:
1. 查找索引确定读取物理文件位置
2. 计算要读取的总字节数
3. 读取消息
/**
* Read a message set from this segment beginning with the first offset >= startOffset. The message set will include
* no more than maxSize bytes and will end before maxOffset if a maxOffset is specified.
* 从第一个比入参startOffset的值大的offset值开始读取,读取的最大大小为maxSize,最大条数为maxOffset
* @param startOffset 要读取的第一条消息的位移
* @param maxSize 能读取的最大字节数
* @param maxPosition 能读到的最大文件位置
* @param minOneMessage 是否允许在消息体过大时至少返回第一条消息。
*
* @return The fetched data and the offset metadata of the first message whose offset is >= startOffset,
* or null if the startOffset is larger than the largest offset in this log
* 返回对象包括:消息集合、第一条offset值大于入参startOffset的消息的metadata对象。
* 如果入参startOffset大于log文件的最大offset,返回空。
*/
@threadsafe
def read(startOffset: Long,
maxSize: Int,
maxPosition: Long = size,
minOneMessage: Boolean = false): FetchDataInfo = {
if (maxSize < 0)
throw new IllegalArgumentException(s"Invalid max size $maxSize for log read from segment $log")
// 调用 translateOffset 方法定位要读取的起始文件位置
// startOffset 仅仅是位移值,Kafka 需要根据索引信息找到对应的物理文件位置才能开始读取消息,调用translateOffset方法获取
val startOffsetAndSize = translateOffset(startOffset)
// if the start position is already off the end of the log, return null
if (startOffsetAndSize == null)
return null
val startPosition = startOffsetAndSize.position
// offsetMetadata对象,里面主要成员为:消息offset、消息所在的segment的baseOffset、所在的segment的物理位置。
val offsetMetadata = LogOffsetMetadata(startOffset, this.baseOffset, startPosition)
// 根据minOneMessage参数,调整此次读取的最大size
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)
// calculate the length of the message set to read based on whether or not they gave us a maxOffset
// 计算fetch的最大size
val fetchSize: Int = min((maxPosition - startPosition).toInt, adjustedMaxSize)
// log.slize方法从指定位置读取指定大小的消息集合。
// firstEntryIncomplete为是否整条消息都读完,可能是maxSize小于第一条消息的大小,
FetchDataInfo(offsetMetadata, log.slice(startPosition, fetchSize),
firstEntryIncomplete = adjustedMaxSize < startOffsetAndSize.size)
}
下面简单介绍一下translateOffset,这个是根据索引信息找到对应的物理文件位置才开始读取消息,查询是基于二分查找发。
private[log] def translateOffset(offset: Long, startingFilePosition: Int = 0): LogOffsetPosition = {
// 基于二分查找获取小于等于参数 offset 的最大 offset,返回 offset 与对应的物理地址
val mapping = offsetIndex.lookup(offset)
// 查找对应的物理地址 position,找到map中对应的batch,后面会用到这个batch的起始位置
log.searchForOffsetWithSize(offset, max(mapping.position, startingFilePosition))
}
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)
}
}
protected def largestLowerBoundSlotFor(idx: ByteBuffer, target: Long, searchEntity: IndexSearchEntity): Int =
indexSlotRangeFor(idx, target, searchEntity)._1
这个是改进的二分查找发,把搜索的区域分成了冷区和热区,然后有条件地在不同区域执行普通的二分查找算法
这个改进版算法提供了一个重要的保证:它能保证那些经常需要被访问的 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)
}
public LogOffsetPosition searchForOffsetWithSize(long targetOffset, int startingPosition) {
for (FileChannelRecordBatch batch : batchesFrom(startingPosition)) {
long offset = batch.lastOffset();
if (offset >= targetOffset)
// 找到第一个batch含有targetOffset的,传入的offset值后面没有用到,主要是用batch.position,代表这个index对应的块的起始位置。
return new LogOffsetPosition(offset, batch.position(), batch.sizeInBytes());
}
return null;
}
recover 方法
恢复日志段,Broker 在启动时会从磁盘上加载所有日志段信息到内存中,并创建相应的 LogSegment 对象实例。
1. 情况索引文件
2. 遍历日志段中所有的消息集合:
A. 检验消息集合
B. 保存最大时间戳和所属消息位移
C. 更新索引项
D. 更新总消息字节数
E. 更细leaderEpoche
3. 执行消息索引文件截断。
/**
* Run recovery on the given segment. This will rebuild the index from the log file and lop off any invalid bytes
* from the end of the log and index.
*
* @param producerStateManager Producer state corresponding to the segment's base offset. This is needed to recover
* the transaction index.
* @param leaderEpochCache Optionally a cache for updating the leader epoch during recovery.
* @return The number of bytes truncated from the log
* @throws LogSegmentOffsetOverflowException if the log segment contains an offset that causes the index offset to overflow
*/
@nonthreadsafe
def recover(producerStateManager: ProducerStateManager, leaderEpochCache: Option[LeaderEpochFileCache] = None): Int = {
// 情况索引文件
offsetIndex.reset()
timeIndex.reset()
txnIndex.reset()
var validBytes = 0
var lastIndexEntry = 0
maxTimestampSoFar = RecordBatch.NO_TIMESTAMP
try {
// 遍历每个log中的每个batches
for (batch <- log.batches.asScala) {
// 该集合中的消息必须要符合 Kafka 定义的二进制格式
// 消息头字段大小必须保证 >= 14
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
}
// Build offset index
// 建立索引项,如果当前的所有读取的byte 减去 上次建索引文件的bytes 大于 indexIntervalBytes(默认配置),建立新索引
if (validBytes - lastIndexEntry > indexIntervalBytes) {
offsetIndex.append(batch.lastOffset, validBytes)
timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar)
lastIndexEntry = validBytes
}
validBytes += batch.sizeInBytes()
// 1. magic是消息格式版本,总有3个版本。V2是最新版本
// 2. leader epoch是controller分配给分区leader副本的版本号。
// 每个消息批次都要有对应的leader epoch。Kafka会记录每个分区leader不同epoch对应的首条消息的位移。
// 比如leader epoch=0时producer写入了100条消息,那么cache会记录<0, 0>,之后leader变更,epoch增加到1,之后producer又写入了200条消息,那么cache会记录<1, 100>。
// epoch主要用于做日志截断时保证一致性用的,单纯依赖HW值可能出现各种不一致的情况。这是社区对于HW值的一个修正机制
if (batch.magic >= RecordBatch.MAGIC_VALUE_V2) {
leaderEpochCache.foreach { cache =>
if (batch.partitionLeaderEpoch > 0 && cache.latestEpoch.forall(batch.partitionLeaderEpoch > _))
cache.assign(batch.partitionLeaderEpoch, batch.baseOffset)
}
updateProducerState(producerStateManager, batch)
}
}
} catch {
case e@ (_: CorruptRecordException | _: InvalidRecordException) =>
warn("Found invalid messages in log segment %s at byte offset %d: %s. %s"
.format(log.file.getAbsolutePath, validBytes, e.getMessage, e.getCause))
}
// Kafka 会将日志段当前总字节数(直接从.log文件获取),和刚刚累加的已读取字节数进行比较
// 如果发现前者比后者大,说明日志段写入了一些非法消息,需要执行截断操作
// 日志文件写入了消息的部分字节然后broker宕机。磁盘是块设备,它可不能保证消息的全部字节要么全部写入,要么全都不写入。
// 因此Kafka必须有机制应对这种情况,即校验+truncate。
// ps:truncate是强行截取log文件的指定大小,从末尾处直接截取。如果是因为网络抖动导致中间某些字节丢失或写入错误字节,
// 会出现消息集合CRC校验值发生变更,这个检查不是在log这一层级执行的,而是在底层的消息集合或消息批次这个level执行的。
val truncated = log.sizeInBytes - validBytes
if (truncated > 0)
debug(s"Truncated $truncated invalid bytes at the end of segment ${log.file.getAbsoluteFile} during recovery")
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.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar, skipFullCheck = true)
timeIndex.trimToValidSize()
truncated
}