一、背景
作为一个分布式的消息队列,kafka的日志模块主要用来存储、读取消息,它为kafka集群的高可用、高性能提供了基础。下面结合kafka的源码分析一下日志模块的设计思路。
二、日志格式
kafka的以topic为单位组织消息,为了提高系统的吞吐率,将一个topic分为N个partition。以partition为单位接收、消费消息。一个partition有多个分片,主分片接收生成者发送的消息,同时副分片从主分片出拉取消息,消费者也从主分片出消费消息。其工作原理如下图。
图1-kafka消息流转过程
kafka集群中的每个broker中都会创建对应的日志文件来存储partition中的数据,每个partition都会有一个Log对象来进行管理。Log的定义如下:
class Log(val dir: File,
@volatile var config: LogConfig,
@volatile var recoveryPoint: Long = 0L,
scheduler: Scheduler,
time: Time = SystemTime) extends Logging with KafkaMetricsGroup {
//segments是一个map结构,记录了这个Log下的所有的LogSegment
private val segments: ConcurrentNavigableMap[java.lang.Long, LogSegment] = new ConcurrentSkipListMap[java.lang.Long, LogSegment]
......
//topicAndPartition保存了topic和partition
val topicAndPartition: TopicAndPartition = Log.parseTopicPartitionName(name)
......
}
Log中的topicAndPartition表示这个log对象对应的topic和partition,segments记录了这个log所有的LogSegment。LogSegment是Log的下一级单位,由于一个partition中的消息会非常多,所以系统不会把这些消息记录到一个文件里面,会将一个partition消息存放在多个文件,partition是分段的,每个段叫LogSegment,包括了一个数据文件和一个索引文件,下图是某个partition目录下的文件
kafka是顺序写入,所以这些LogSegment是随着数据的写入而依次产生的。代码如下:
/*产生LogSegment的函数
* 入参messagesSize:即将写入的消息的大小
* 出参LogSegment:该消息需要写入的LogSegment
private def maybeRoll(messagesSize: Int): LogSegment = {
//当前写入的LogSegment
val segment = activeSegment
//rollJitterMs 是日志段新增扰动值
if (segment.size > config.segmentSize - messagesSize ||
segment.size > 0 && time.milliseconds - segment.created > config.segmentMs - segment.rollJitterMs ||
segment.index.isFull) {
debug("Rolling new log segment in %s (log_size = %d/%d, index_size = %d/%d, age_ms = %d/%d)."
.format(name,
segment.size,
config.segmentSize,
segment.index.entries,
segment.index.maxEntries,
time.milliseconds - segment.created,
config.segmentMs - segment.rollJitterMs))
roll()
} else {
segment
}
}
每当写入消息时,会判断是否需要生成新的LogSegment(当前的LogSegment的大小+即将写入消息的大小是否大于规定的大小,或者当前时间-创建时间大于规定的时间间隔,或者索引文件已满)。如果需要就会创建LogSegment,生成新的文件,否则就写入到原先的文件。LogSegment的定义如下:
class LogSegment(val log: FileMessageSet, //实际保存消息的对象
val index: OffsetIndex, //位移索引
val baseOffset: Long, //起始位移
val indexIntervalBytes: Int, //多少字节插入一个索引
val rollJitterMs: Long, 日志段新增扰动值
time: Time) extends Logging {
......
}
从上面的定义可以看出,LogSegment中即包含了实际的保存消息的对象,又包含了一个索引对象。索引文件和日志文件的关系如下:
三、Kafka消息的写入
kafka中的broker在接收到写入的请求时,会先根据topic和partition确定需要写入的Partition对象,然后找到这个partition对象对应的log对象,执行Log对象的append方法来写入消息。append函数的代码如下:
def append(messages: ByteBufferMessageSet, assignOffsets: Boolean = true): LogAppendInfo = { //将消息添加到分片尾
val appendInfo = analyzeAndValidateMessageSet(messages) //这里验证messages信息和创建logappendinfo.
// if we have any valid messages, append them to the log
if(appendInfo.shallowCount == 0) //如果消息为空的话,就直接返回信息.
return appendInfo
// trim any invalid bytes or partial messages before appending it to the on-disk log
var validMessages = trimInvalidBytes(messages, appendInfo) //这个函数将消息里多余的部分截掉.
try {
// they are valid, insert them in the log
lock synchronized {
appendInfo.firstOffset = nextOffsetMetadata.messageOffset //这里开始分配offset值.即上最后一个分片的最后一个offset值.
if(assignOffsets) {
// assign offsets to the message set
val offset = new AtomicLong(nextOffsetMetadata.messageOffset) //创建新的offset
try {
validMessages = validMessages.assignOffsets(offset, appendInfo.codec) //使用ByteBufferMessageSet类中的分配方法分配offset值.
} catch {
case e: IOException => throw new KafkaException("Error in validating messages while appending to log '%s'".format(name), e)
}
appendInfo.lastOffset = offset.get - 1 //因为offset被assignOffsets方法累加过.所以最后要减1.
} else {
// we are taking the offsets we are given
if(!appendInfo.offsetsMonotonic || appendInfo.firstOffset < nextOffsetMetadata.messageOffset)
throw new IllegalArgumentException("Out of order offsets found in " + messages)
}
// re-validate message sizes since after re-compression some may exceed the limit
for(messageAndOffset <- validMessages.shallowIterator) {
if(MessageSet.entrySize(messageAndOffset.message) > config.maxMessageSize) { //这里判断每一个消息是否大于配置的消息最大长度.
// we record the original message set size instead of trimmed size
// to be consistent with pre-compression bytesRejectedRate recording
BrokerTopicStats.getBrokerTopicStats(topicAndPartition.topic).bytesRejectedRate.mark(messages.sizeInBytes)
BrokerTopicStats.getBrokerAllTopicsStats.bytesRejectedRate.mark(messages.sizeInBytes)
throw new MessageSizeTooLargeException("Message size is %d bytes which exceeds the maximum configured message size of %d."
.format(MessageSet.entrySize(messageAndOffset.message), config.maxMessageSize))
}
}
// check messages set size may be exceed config.segmentSize
if(validMessages.sizeInBytes > config.segmentSize) { //判断要写入的消息集大小是否超过配置的分片大小.
throw new MessageSetSizeTooLargeException("Message set size is %d bytes which exceeds the maximum configured segment size of %d."
.format(validMessages.sizeInBytes, config.segmentSize))
}
// maybe roll the log if this segment is full
val segment = maybeRoll(validMessages.sizeInBytes) //这里是判断是否需要滚动分片.
// now append to the log
segment.append(appendInfo.firstOffset, validMessages) //这里真正调用LogSegment对象写入消息.
// increment the log end offset
updateLogEndOffset(appendInfo.lastOffset + 1) //更新lastoffset.
trace("Appended message set to log %s with first offset: %d, next offset: %d, and messages: %s"
.format(this.name, appendInfo.firstOffset, nextOffsetMetadata.messageOffset, validMessages))
if(unflushedMessages >= config.flush) //判断是否需要刷新到磁盘
flush()
appendInfo
}
} catch {
case e: IOException => throw new KafkaStorageException("I/O exception in append to log '%s'".format(name), e)
}
}
append函数的流程如下:
1、验证messages信息和创建logappendinfo,将多余的部分剪裁掉。
2、分配offset值,将最新的位移值nextOffsetMetadata.messageOffset设置为当前messages的第一个消息的offset,由于messages中含有N个消息,所以会滚动分配每个消息的position和offset(函数assignOffsets)
3、判断要写入的消息集大小是否超过配置的分片大小,超过则抛出异常
4、这里是判断是否需要滚动分片,即判断当前的分片LogSegment是否适合写入当前message是,如果适合就用当前分片;否则就创建新的LogSegment。
5、调用LogSegment的append方法来真正写入messages。
6、更新lastoffset.、判断是否需要刷新磁盘。
从上面的分析中可以看出,主要是LogSegment来执行写入的操作。下面看一下LogSegment的append的主要流程:
def append(offset: Long, messages: ByteBufferMessageSet) {
if (messages.sizeInBytes > 0) {
trace("Inserting %d bytes at offset %d at position %d".format(messages.sizeInBytes, offset, log.sizeInBytes()))
// append an entry to the index (if needed)
//如果超过索引的间隔,写入索引文件
if(bytesSinceLastIndexEntry > indexIntervalBytes) {
index.append(offset, log.sizeInBytes())
this.bytesSinceLastIndexEntry = 0
}
// append the messages
//写入数据
log.append(messages)
//更新bytesSinceLastIndexEntry
this.bytesSinceLastIndexEntry += messages.sizeInBytes
}
}
可以看出,主要是通过log的append方法来写入的,这里的log不是前面提到的Log对象,这里的log是一个FileMessageSet对象,FileMessageSet对象的代码如下:
class FileMessageSet private[kafka](@volatile var file: File,
private[log] val channel: FileChannel,
private[log] val start: Int,
private[log] val end: Int,
isSlice: Boolean) extends MessageSet with Logging {
/* the size of the message set in bytes */
private val _size =
if(isSlice)
new AtomicInteger(end - start) // don't check the file size if this is just a slice view
else
new AtomicInteger(math.min(channel.size().toInt, end) - start)
/* if this is not a slice, update the file pointer to the end of the file */
if (!isSlice)
/* set the file position to the last byte in the file */
channel.position(channel.size)
......
def append(messages: ByteBufferMessageSet) {
val written = messages.writeTo(channel, 0, messages.sizeInBytes)
_size.getAndAdd(written)
}
......
}
FileMessageSet 核心字段
file: 指向磁盘上日志文件
channel:FileChannel类型,用于读写对应的日志文件
start:FileMessageSet对象除了表示一个完整的日志文件,还可以表示日志文件的分片,start表示分片的开始位置
end:表示分片的结束位置
isSlice:表示当前FileMessageSet是否为日志文件的分片
_size:FileMessageSet大小,单位是字节,如果是分片则表示分片大小
从上面可以看出,append主要是将messages写入到FileChannel中,并且更新_size的大小。到这里,写入流程就结束了。但是现在messages只写入到内存中,还需要定时将调用函数flush将内存中的消息写入到磁盘,这个主要是LogManager控制的,这里不作介绍。
四、kafka消息的读取
kafka消息的读取,会调用Log对象中的read方法来进行。下面来看一下Log的read方法。
def read(startOffset: Long, maxLength: Int, maxOffset: Option[Long] = None): FetchDataInfo = {
val next = nextOffsetMetadata.messageOffset
//如果是最新的offset,则无数据读取
if(startOffset == next)
return FetchDataInfo(nextOffsetMetadata, MessageSet.Empty)
//根据startOffset定位位于哪个LogSegment
var entry = segments.floorEntry(startOffset)
//异常判断,如果startOffset大于当前最大的偏移量或者没有找到具体的LogSegment,则抛出异常
if(startOffset > next || entry == null)
throw new OffsetOutOfRangeException("Request for offset %d but we only have log segments in the range %d to %d.".format(startOffset, segments.firstKey, next))
//调用LogSegment的read方法读取具体的数据
while(entry != null) {
val fetchInfo = entry.getValue.read(startOffset, maxOffset, maxLength)
if(fetchInfo == null) {
entry = segments.higherEntry(entry.getKey)
} else {
return fetchInfo
}
}
从上面的代码可以看出,主要分为两个步骤:
1、根据startOffset定位位于哪个LogSegment,Log中包含多个LogSegment,这些LogSegment保存在segments,segments的定义如下:
private val segments: ConcurrentNavigableMap[java.lang.Long, LogSegment] = new ConcurrentSkipListMap[java.lang.Long, LogSegment]
segments是一个ConcurrentSkipListMap,它的内部将key组织成一个跳跃表,segments的key就是LogSegment的baseOffset,即它的第一个消息的offset。 通过segments.floorEntry(startOffset)就能找到不大于当前startOffset的最大的baseOffset对应的entry,然后调用LogSegment的read方法读取对应的消息。LogSegment的read方法如下:
def read(startOffset: Long, maxOffset: Option[Long], maxSize: Int): FetchDataInfo = {
if(maxSize < 0)
throw new IllegalArgumentException("Invalid max size for log read (%d)".format(maxSize))
val logSize = log.sizeInBytes // this may change, need to save a consistent copy
val startPosition = translateOffset(startOffset) //获取对应offset的读取点位置.
// if the start position is already off the end of the log, return null
if(startPosition == null) //没有读取点位置则返回空
return null
val offsetMetadata = new LogOffsetMetadata(startOffset, this.baseOffset, startPosition.position) //定义offsetMetadata
// if the size is zero, still return a log segment but with zero size
if(maxSize == 0) //最大读取尺寸是0的话.返回空消息.
return FetchDataInfo(offsetMetadata, MessageSet.Empty)
// calculate the length of the message set to read based on whether or not they gave us a maxOffset
val length = //计算最大读取的消息总长度.
maxOffset match {
case None => //未设置maxoffset则使用maxsize.
// no max offset, just use the max size they gave unmolested
maxSize
case Some(offset) => { //如果设置了Maxoffset,则计算对应的消息长度.
// there is a max offset, translate it to a file position and use that to calculate the max read size
if(offset < startOffset) //maxoffset小于startoffset则返回异常
throw new IllegalArgumentException("Attempt to read with a maximum offset (%d) less than the start offset (%d).".format(offset, startOffset))
val mapping = translateOffset(offset, startPosition.position) //获取相对maxoffset读取点.
val endPosition =
if(mapping == null)
logSize // the max offset is off the end of the log, use the end of the file
else
mapping.position
min(endPosition - startPosition.position, maxSize) //用maxoffset读取点减去开始的读取点.获取需要读取的数据长度.如果长度比maxsize大则返回maxsize
}
}
FetchDataInfo(offsetMetadata, log.read(startPosition.position, length)) //使用FileMessageSet.read读取相应长度的数据返回FetchDataInfo的封装对象.
}
读取函数通过映射offset到读取长度.来读取多个offset.
private[log] def translateOffset(offset: Long, startingFilePosition: Int = 0): OffsetPosition = { //用来将offset映射到读取指针位置的函数.
val mapping = index.lookup(offset) //通过查找index获取对应的指针对象.
log.searchFor(offset, max(mapping.position, startingFilePosition)) //通过FileMessageSet获取对应的指针位置.
}
最终还是通过调用FileMessageSet的read方法来进行读取。FileMessageSet的read方法如下:
def read(position: Int, size: Int): FileMessageSet = {
if(position < 0)
throw new IllegalArgumentException("Invalid position: " + position)
if(size < 0)
throw new IllegalArgumentException("Invalid size: " + size)
new FileMessageSet(file,
channel,
start = this.start + position,
end = math.min(this.start + position + size, sizeInBytes()))
}
最终返回一个日志分片,然后组装成一个FetchDataInfo对象给请求端。自此,kafka的消息读取流程结束。