文章目录
- 1、什么是日志段
- 2、LogSegment日志段源码
- 2.1、LogSegment定义
- 2.2、 append方法
- 2.2、 read方法
- 2.3、 recover方法
- 2.3.1、 truncateTo文件截取
- 2.4、 flush落盘
- 3、总结
1、什么是日志段
在kafka中,所有的消息都是落盘保存在日志中,然而如果一个topic只保存在一份文件里面的话,这份文件会非常大,为了避免这种情况kafka对日志进行分段处理,每一个日志段底下有有各自类型的文件,比如:日志文件(.log)、位移索引文件(.index)、时间戳索引文件(.timeindex)事务的索引文件(.txnindex)等等。日志和日志段之间的关系如下所示:
在kafka中一个topic底下会有1~多个partition,kafka会按照每个partition的维度创建相应的目录,日志文件(.log)、位移索引文件(.index)、时间戳索引文件(.timeindex)事务的索引文件(.txnindex)等等都会放在这个目录底下,日志段 LogSegment 封装了对这些文件的操作。在kafka中日志对于的功能封装在Log.scala
文件底下,而日志段作为日志的成员,在LogSegment.scala
文件底下。
2、LogSegment日志段源码
日志段作为kafka保存消息最小的载体,通过日子段kafka能对消息进行写入等操作。下面来看一下日志段的实现源码吧
2.1、LogSegment定义
@nonthreadsafe
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
在LogSegment中,基本成员属性有以下几个
- log: FileRecords 封装了对文件操作方式的对象,在FileRecords中封装了FileChannel和File,其中FileChannel就是用来实现零拷贝技术的Java API对象。
- lazyOffsetIndex: LazyIndex[OffsetIndex] 对应.index文件,使用了延迟初始化方式,为了降低了初始化时间成本
- lazyTimeIndex: LazyIndex[TimeIndex] 对应.timeindex文件,同样适应延迟初始化方式
- txnIndex: TransactionIndex 对应.txnindex文件
- baseOffset: Long 当前日志段的最小位移值
- indexIntervalBytes: Int 对应Broker 端参数 log.index.interval.bytes 值,用于控制日志段对象新增索引项的频率,间隔多少个字节创建一个索引
- rollJitterMs: Long 时间扰动值,在创建日志端对象的时候通过扰动值避免同一时刻创建大量文件
- time: Time kafka封装的时间工具,用于获取系统时间等操作
此外LogSegment 的class中还定义了许多方法,这里主要介绍一下比较重要的方法:
def append(largestOffset: Long,
largestTimestamp: Long,
shallowOffsetOfMaxTimestamp: Long,
records: MemoryRecords): Unit = {
def read(startOffset: Long,
maxSize: Int,
maxPosition: Long = size,
minOneMessage: Boolean = false): FetchDataInfo = {
def recover(producerStateManager: ProducerStateManager,
leaderEpochCache: Option[LeaderEpochFileCache] = None): Int = {
def truncateTo(offset: Long): Int = {
分别是append
,read
,recover
,和truncateTo
2.2、 append方法
append作为消息写入的主要方法,其代码实现如下:
@nonthreadsafe
def append(largestOffset: Long, //当前日志段最大的offset位移值
largestTimestamp: Long, //当前日志段最大的日志时间戳
shallowOffsetOfMaxTimestamp: Long, //当前日志段最大的日志时间戳对于的消息offset
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")
val physicalPosition = log.sizeInBytes()
if (physicalPosition == 0)
rollingBasedTimestamp = Some(largestTimestamp)
//确保消息位移值没有越界
ensureOffsetInRange(largestOffset)
//这里的log 就是当前日志段的FileRecords,这里将消息写入文件中
// append the messages
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
}
//判断是否需要添加一个索引节点,通过log.index.interval.bytes值判断
// append an entry to the index (if needed)
if (bytesSinceLastIndexEntry > indexIntervalBytes) {
offsetIndex.append(largestOffset, physicalPosition)
timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar)
bytesSinceLastIndexEntry = 0
}
bytesSinceLastIndexEntry += records.sizeInBytes
}
}
append
方法的执行流程如下所示;
- 从上面代码看出
append
方法不是线程安全的。其流程是,首先消息是保存在内存中MemoryRecords
。在写入文件之前判断MemoryRecords
不为空,并且确保新写入的消息没有越界确保在[0,Int.MaxValue]
之间。判断条件都通过后,然后调用FileRecords
将消息追加进去,通过JDK提供的FileChannel
从而能实现
2.2、 read方法
read的方法用于消息的获取,其实现如下所示
@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")
//确定需要查找消息的物理位置
val startOffsetAndSize = translateOffset(startOffset)
// if the start position is already off the end of the log, return null
//如果当前的位置是当前日志段的末尾,则返回null
if (startOffsetAndSize == null)
return null
val startPosition = startOffsetAndSize.position
val offsetMetadata = 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)
// calculate the length of the message set to read based on whether or not they gave us a maxOffset
//计算本次能获取到的消息数量
val fetchSize: Int = min((maxPosition - startPosition).toInt, adjustedMaxSize)
FetchDataInfo(offsetMetadata, log.slice(startPosition, fetchSize),
firstEntryIncomplete = adjustedMaxSize < startOffsetAndSize.size)
}
方法是线程安全的使用@threadsafe
修饰,上示代码流程如下所示
- 1、 调用
translateOffset
将offset转换为实际物理位置,内部是调用lookup
方法,而在looup
中使用了基于冷热区的二分查找法(后面的文章会详细介绍)。 - 2、 若当前有设置最小允许返回一条消息,则计算出不超过当前消息上限
maxSize
的消息总大小adjustedMaxSize
,(最少返回一条消息的设置是为了防止消费者饥饿)。 - 3、 根据计算出来本次的消息大小,调用
log.slice()
方法获取实际数据。 - 4、 将获取到的数据封装成
FetchDataInfo
。
在translateOffset
方法中,转换为实际的物理地址的时候使用到了mmap技术,而在查找中使用了二分查找法 (关于二分查找,在后面的文章还会进一步说明)
@threadsafe
private[log] def translateOffset(offset: Long, startingFilePosition: Int = 0): LogOffsetPosition = {
val mapping = offsetIndex.lookup(offset)
log.searchForOffsetWithSize(offset, max(mapping.position, startingFilePosition))
}
.............................................................................
/**
*
*/
def lookup(targetOffset: Long): OffsetPosition = {
maybeLock(lock) {
//拷贝一份mmap映射,这样有别的地方修改的时候对其是可见
val idx = mmap.duplicate
//查找最大的小于或等于给定目标键或值的存储项的槽(内部调用二分查找)
val slot = largestLowerBoundSlotFor(idx, targetOffset, IndexSearchType.KEY)
if(slot == -1)
OffsetPosition(baseOffset, 0)
else
parseEntry(idx, slot)
}
}
- 这里还有细节需要注意,
read
方法里面的FetchDataInfo
中的Records
就是本次获取到的消息体,而这些消息是通过FileChannel
去获取的,而FileChannel
正是JDK中实现了零拷贝技术的JAVA API,最终消息封装在FileRecords
的batches
里面,代码如下所示:
//创建对象并获取消息
FetchDataInfo(offsetMetadata, log.slice(startPosition, fetchSize),
firstEntryIncomplete = adjustedMaxSize < startOffsetAndSize.size)
/**
* 实际获取数据
*/
public FileRecords slice(int position, int size) throws IOException {
// Cache current size in case concurrent write changes it
int currentSizeInBytes = sizeInBytes();
if (position < 0)
throw new IllegalArgumentException("Invalid position: " + position + " in read from " + this);
if (position > currentSizeInBytes - start)
throw new IllegalArgumentException("Slice from position " + position + " exceeds end position of " + this);
if (size < 0)
throw new IllegalArgumentException("Invalid size: " + size + " in read from " + this);
int end = this.start + position + size;
// handle integer overflow or if end is beyond the end of the file
if (end < 0 || end > start + currentSizeInBytes)
end = start + currentSizeInBytes;
//创建FileRecords,并调用 FileChannel 获取数据
return new FileRecords(file, channel, this.start + position, end, true);
}
-
FileRecords
的创建
FileRecords(File file, //当前日志段文件
FileChannel channel,//当前文件对于的FileChannel对象
int start,//开始位置
int end,//结束位置
boolean isSlice) throws IOException {//是否切片
this.file = file;
this.channel = channel;
this.start = start;
this.end = end;
this.isSlice = isSlice;
this.size = new AtomicInteger();
if (isSlice) {
// don't check the file size if this is just a slice view
size.set(end - start);
} else {
if (channel.size() > Integer.MAX_VALUE)
throw new KafkaException("The size of segment " + file + " (" + channel.size() +
") is larger than the maximum allowed segment size of " + Integer.MAX_VALUE);
int limit = Math.min((int) channel.size(), end);
size.set(limit - start);
// if this is not a slice, update the file pointer to the end of the file
// set the file position to the last byte in the file
channel.position(limit);
}
batches = batchesFrom(start);
}
-
batches
的获取
//batches定义
private final Iterable<FileLogInputStream.FileChannelRecordBatch> batches;
//批量获取
public Iterable<FileChannelRecordBatch> batchesFrom(final int start) {
return () -> batchIterator(start);
}
private AbstractIterator<FileChannelRecordBatch> batchIterator(int start) {
final int end;
if (isSlice)
end = this.end;
else
end = this.sizeInBytes();
FileLogInputStream inputStream = new FileLogInputStream(this, start, end);
return new RecordBatchIterator<>(inputStream);
}
2.3、 recover方法
- 在前面介绍完,
read
和append
方法后,还有一个方法有必要介绍一下,就是recover
方法。recover
方法与read
和append
同样重要,recover
用于在Broker的时候加载和创建日志段对象,只有Broker创建完日志段对象之后,才能操作消息进出文件,recover
实现代码如下所示:
@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 {
//遍历所有日志段对象
for (batch <- log.batches.asScala) {
batch.ensureValid()
//确保offset没有越界
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
//添加位移索引和时间索引
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 > _))
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))
}
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
}
- 调用
recover
方法后,先初始化所有索引文件对象为空,之后会开始遍历日志段中的所有消息集合或消息批次(RecordBatch),确保其是合法的:1、魔数值合法
2、位移值没有越界
。然后添加索引项,时间索引根据实际情况判断是否添加,位移索引添加一个索引项。
2.3.1、 truncateTo文件截取
- 遍历结束后,判断当前总字节数(
originalSize
)和刚刚累计读取到的字节数(validBytes
),判断是否需要截取
,比如上次宕机导致脏数据写入等等 :根据当前累计读取到的数据大小去截取文件
/**
* 截断文件
*/
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()) {
channel.truncate(targetSize);
size.set(targetSize);
}
return originalSize - targetSize;
}
2.4、 flush落盘
LogSegment落盘相对比较简单,代码如下所示:
@threadsafe
def flush(): Unit = {
LogFlushStats.logFlushTimer.time {
log.flush()
offsetIndex.flush()
timeIndex.flush()
txnIndex.flush()
}
}
//耗时监控
object LogFlushStats extends KafkaMetricsGroup {
val logFlushTimer = new KafkaTimer(newTimer("LogFlushRateAndTimeMs", TimeUnit.MILLISECONDS, TimeUnit.SECONDS))
}
- 落盘操作本身是线程安全的,在日志段中落盘会相应保存
log文件
,index文件
,timeindex文件
,事务index文件
,同时还会记录本次落盘的耗时用于监控使用:可以通过搜索关键字LogFlushRateAndTimeMs
,来查看落盘耗时监控kafka性能。
3、总结
本篇文章的重点基本上就是上图所示,通过日志段对象操作实际文件,体现很好的面对对象的设计思想。同时kafka日志段是kafka很重要的一个组件,无论是消息获取还是写入,都离不开日志段,kafka通过日志分段的方式能很好的支持大文件,同时日志段的索引也使得日志端能高效的操作消息,同时阅读源码中发现,kafka非常善于使用mmap
技术和使用基于FileChannel
接口使用的文件操作,这样可以很好的使用零拷贝技术,mmap
(一种内存映射文件的方法),能使其操作大文件的时候更加快速高效。