系列文章目录

https://zhuanlan.zhihu.com/p/367683572


一. 业务模型

在上一篇文章中,我们分析了生产者的原理。下一步我们来分析下提交上来的消息在Server端时如何存储的。

1.1 概念梳理

Kafka用Topic将数据划分成内聚性较强的子集,Topic内部又划分成多个Partition。不过这两个都是逻辑概念,真正存储文件的是Partition所对应的一个或多个Replica,即副本。在存储层有个概念和副本一一对应——Log。为了防止Log过大,增加消息过期和数据检索的成本,Log又会按一定大小划分成"段",即LogSegment。用一张图汇总这些概念间的关系:

1.2 文件分析

1.2.1 数据目录

Kafkap配置文件(server.properties)中有一个配置项——log.dir,其指定了kafka数据文件存放位置。为了研究数据目录的结构,我们先创建一个Topic(lao-zhang-tou-topic)

kafka-console-producer.sh --topic lao-zhang-tou-topic --bootstrap-server localhost:9092

然后向其中写几条消息

kafka-console-producer.sh --topic lao-zhang-tou-topic --bootstrap-server localhost:9092
{"message":"This is the first message"}
{"message":"This is the sencond message"}

接下来我们来看看log.dir指定目录下存放了那些文件

该目录下文件分3类:

  1. 数据文件夹

    如截图中的lao-zhang-tou-topic-0

  2. checkpoint文件

    • cleaner-offset-checkpoint
    • log-start-offset-checkpoint
    • recovery-point-offset-checkpoint
    • replication-offset-checkpoint
  3. 配置文件

    meta.properties

第2、3类文件后续文章会详细分析,本文主要关注截图中lao-zhang-tou-topic-0目录。



实际上,该目录对应上文提到的Log概念,命名规则为 ${Topic}-${PartitionIndex}。该目录下,名称相同的.log文件、.index文件、.timeindex文件构成了一个LogSegment。例如图中的 00000000000000000000.log、00000000000000000000.index、00000000000000000000.timeindex 三个文件。其中.log是数据文件,用于存储消息数据;.index和.timeindex是在.log基础上建立起来的索引文件。

1.2.2 .log文件

log文件将消息数据依次排开进行存储



每个Message内部分为"数据头"(LOG_OVERHEAD)和"数据体"(Record)两部分



其中,LOG_OVERHEAD包含两个字段:

  1. offset:每条数据的逻辑偏移量,按插入顺序分别为0、1、2... ... N;每个消息的offset在Partition内部是唯一的;
  2. size:数据体(RECORD)部分的长度;

RECORD内部格式如下:



其中,

  • crc32:校验码,用于验证数据完整性;

  • magic:消息格式的版本号;v0=0,v1=1;本文讲v1格式;

  • timestamp:时间戳,具体业务含义依attributes的值而定;

  • attributes:属性值;其 8bits 的含义如下

  • keyLength:key值的长度;

  • key:消息数据对应的key;

  • valueLength:value值的长度;

  • value:消息体,承载业务信息;

1.2.3 .index和.timeindex文件

.index文件是依offset建立其的稀疏索引,可减少通过offset查找消息时的遍历数据量。.index文件的每个索引条目占8 bytes,有两个字段:relativeOffset 和 position(各占4 bytes)。也就是消息offset到其在文件中偏移量的一个映射。那有人要问了,索引项中保存的明明是一个叫relativeOffset的东西,为什么说是offset到偏移量的映射呢?其实,准确的来讲,relativeOffset指的的相对偏移量,是对LogSegment基准offset而言的。我们注意到,一个LogSegment内的.log文件、.index文件、和.index文件除后缀外的名称都是相同的。其实这个名称就是该LogSegment的基准offset,即LogSegment内保存的第一条消息对应的offset。baseOffset + relativeOffset即可得到offset,所以称索引项是offset到物理偏移量的映射。

不是所有的消息都对应.index文件内的一个条目。Kafka会每隔一定量的消息才会在.index建立索引条目,间隔大小由"log.index.interval.bytes"配置指定。.index文件布局示意图如下:



.timeindex文件和.index原理相同,只不过其IndexEntry的两个字段分别为timestamp(8 bytes)和relativeOffset(4 bytes)。用于减少以时间戳查找消息时遍历元素数量。

1.3 顺序IO

对于我们常用的机械硬盘,其读取数据分3步:

  1. 寻道;
  2. 寻找扇区;
  3. 读取数据;

前两个,即寻找数据位置的过程为机械运动。我们常说硬盘比内存慢,主要原因是这两个过程在拖后腿。不过,硬盘比内存慢是绝对的吗?其实不然,如果我们能通过顺序读写减少寻找数据位置时读写磁头的移动距离,硬盘的速度还是相当可观的。一般来讲,IO速度层面,内存顺序IO > 磁盘顺序IO > 内存随机IO > 磁盘随机IO。

Kafka在顺序IO上的设计分两方面看:

  1. LogSegment创建时,一口气申请LogSegment最大size的磁盘空间,这样一个文件内部尽可能分布在一个连续的磁盘空间内;
  2. .log文件也好,.index和.timeindex也罢,在设计上都是只追加写入,不做更新操作,这样避免了随机IO的场景;

Kafka是公认的高性能消息中间件,顺序IO在这里占了很大一部分因素。

不知道大家有没有听过这样一个说法:Kafka集群能承载的Partition数量有上限。很大一部分原因是Partition数量太多会抹杀掉Kafka顺序IO设计带来的优势,相当于自废武功。Why?因为不同Partition在磁盘上的存储位置可不保证连续,当以不同Partition为读写目标并发地向Kafka发送请求时,Server端近似于随机IO。

1.4 端到端压缩

一条压缩消息从生产者处发出后,其在消费者处才会被解压。Kafka Server端不会尝试解析消息体,直接原样存储,省掉了Server段压缩&解压缩的成本,这也是Kafka性能喜人的原因之一。

二. 源码结构

2.1 核心类

2.1.1 核心类之间的关系

Kafka消息存储涉及的核心类有:

  • ReplicaManager
  • Partition
  • Replica
  • Log
  • LogSegment
  • OffsetIndex
  • TimeIndex
  • MemoryRecords
  • FileRecords

它们之间的关系如下图:

2.1.1 数据传递对象

Kafka消息存储的基本单位不是"一条消息",而是"一批消息"。在生产者文章中提到过,Producer针对每个Partition会攒一批消息,经过压缩后发到Server端。Server端会将对应Partition下的这一"批"消息作为一个整体进行管理。所以在Server端,一个"Record"表示"一批消息",而数据传递对象"XXXRecords"则可以表示一批或多批消息。

MemoryRecords所表示的消息数据存储于内存。比如Server端从接到生产者消息到将消息存入磁盘的过程就用MemoryRecords来传递数据,因为这期间消息需要暂存于内存,且没有磁盘数据与之对应。MemoryRecords核心属性有两个:

属性名 类型 说明
buffer ByteBuffer 存储消息数据
batches Iterable<MutableRecordBatch> 迭代器;用于以批为单位遍历buffer所存储的数据

FileRecords所表示的消息数据存储于磁盘文件。比如从磁盘读出消息返回给消费者的过程就用FileRecords来传递数据。其核心属性如下:

属性名 类型 说明
file File 消息数据所存储的文件
channel FileChannel 文件所对应的FileChannel
start int 本FileRecords所表示的数据在文件中的起始偏移量
end int 本FileRecords所表示的数据在文件中的结束偏移量
size AtomicInteger 本FileRecords所表示的数据的字节数

2.1.2 ReplicaManager

ReplicaManager负责管理本节点存储的所有副本。这个类的属性真的巨多。不过不要慌,对于消息存储原理这块,我们只需要关注下面这一个属性就可以,其他和请求处理以及副本复制相关的属性我们放到后边对应章节慢慢分析。

属性名 类型 说明
allPartitions Pool[TopicPartition, Partition] 存储Partition对象,可根据TopicPartition类将其检索出来

2.1.3 Partition

Partition对象负责维护本分区下的所有副本,其核心属性如下:

属性名 类型 说明
allReplicasMap Pool[Int, Replica] 本分区下的所有副本。其中,key为BrokerId,value为Replica对象
leaderReplicaIdOpt Option[Int] Leader副本所在节点的BrokerId
localBrokerId Int 本节点对应的BrokerId

2.1.4 Replica

Replica负责维护Log对象。Replica是业务模型层面"副本"的表示,Log是数据存储层面的"副本"。Replica核心属性如下:

属性名 类型 说明
log Option[Log] Replica对应的Log对象
topicPartition TopicPartition 标识该副本所属"分区"
brokerId Int 该副本所在的BrokerId
highWatermarkMetadata LogOffsetMetadata 高水位(后续章节会详细分析)
logEndOffsetMetadata LogOffsetMetadata 该副本中现存最大的Offset(后续章节会详细分析)

2.1.5 Log

Log负责维护副本下的LogSegment,其核心属性如下:

属性名 类型 说明
dir File Log对应的目录,即存储LogSegment的文件夹
segments ConcurrentSkipListMap[java.lang.Long, LogSegment] LogSegment集合,其中key为对应LogSegment的起始offset

2.1.6 LogSegment

LogSegment则实实在在维护消息数据,其核心属性如下:

属性名 类型 说明
log FileRecords 本日志段的消息数据
baseOffset Long 本日志段的起始offset
maxSegmentBytes Int 本日志段的最大字节数;
超过后就需要新建一个LogSegment;
maxSegmentMs Long 日志段也可以根据时间来滚动;
比如待插入消息和日志段第一个消息间隔超过一定时间后,需要开个新的日志段;
maxSegmentMs便是所指定的间隔大小(segment.ms 配置项);
rollJitterMs Long 为避免当前节点上所有LogSegment同时滚动的情况,需要在maxSegmentMs基础上减去一个随机数值;
rollJitterMs便是这个随机扰动(segment.jitter.ms 配置项指定该随机数的最大值)
offsetIndex OffsetIndex 偏移量索引,下文分析
timeIndex TimeIndex 时间索引,下文分析

2.1.7 OffsetIndex和TimeIndex

首先两个索引都继承于AbstractIndex,那么他们就有一批共同的核心属性:

属性名 类型 说明
file File 对应的索引文件
mmap MappedByteBuffer 索引文件的内存映射
maxIndexSize Int 索引文件的最大字节数,
由 segment.index.bytes 配置项指定
baseOffset Long 所在日志段的起始offset

实际上,这些属性已足够表达当前的索引逻辑,OffsetIndex和TimeIndex均未再额外自定义属性。

2.2 消息写入流程

消息写入流程时序图如下:



需要提一点,这里不是为了让诸君将这一串流程视为整体记入脑海。面向对象的代码仍然要从面向对象的角度去理解。所以这里重要的是各个类各自内部的逻辑,这有助于进一步明确类所扮演的角色。

2.2.1 ReplicaManager.appendRecords

def appendRecords(timeout: Long,
requiredAcks: Short,
internalTopicsAllowed: Boolean,
isFromClient: Boolean,
entriesPerPartition: Map[TopicPartition, MemoryRecords],// 各Partition上待插入的消息数据
responseCallback: Map[TopicPartition, PartitionResponse] => Unit,
delayedProduceLock: Option[Lock] = None,
recordConversionStatsCallback: Map[TopicPartition, RecordConversionStats] => Unit = _ => ()) {
... ...
val localProduceResults = appendToLocalLog(internalTopicsAllowed = internalTopicsAllowed,
isFromClient = isFromClient, entriesPerPartition, requiredAcks)
... ... } private def appendToLocalLog(internalTopicsAllowed: Boolean,
isFromClient: Boolean,
entriesPerPartition: Map[TopicPartition, MemoryRecords],
requiredAcks: Short): Map[TopicPartition, LogAppendResult] = {
... ...
// step1 reject appending to internal topics if it is not allowed
if (Topic.isInternal(topicPartition.topic) && !internalTopicsAllowed) {
(topicPartition, LogAppendResult(
LogAppendInfo.UnknownLogAppendInfo,
Some(new InvalidTopicException(s"Cannot append to internal topic ${topicPartition.topic}"))))
} else {
try {
//step2 若本Broker节点不承载对应partition的主副本, 这步会抛异常
val (partition, _) = getPartitionAndLeaderReplicaIfLocal(topicPartition)
//step3 将消息写入对应Partition主副本, 并唤醒相关的等待操作(比如, 消费等待)
val info = partition.appendRecordsToLeader(records, isFromClient, requiredAcks)
... ...
}
}
}
}

appendRecords直接调用appendToLocalLog,后者才是真正实行逻辑的方法。ReplicaManager的逻辑基本分三步走:

  1. 检查目标Topic是否为Kafka内部Topic,若是的话根据配置决定是否允许写入;
  2. 获取对应的Partition对象;
  3. 调用Partition.appendRecordsToLeader写入消息数据;

2.2.2 Partition.appendRecordsToLeader

接下来看看Partition内部的逻辑

def appendRecordsToLeader(records: MemoryRecords, isFromClient: Boolean, requiredAcks: Int = 0): LogAppendInfo = {
val (info, leaderHWIncremented) = inReadLock(leaderIsrUpdateLock) {
leaderReplicaIfLocal match {
//step1 判断Leader副本是否在当前节点
case Some(leaderReplica) =>
//step2 获取Log对象
val log = leaderReplica.log.get
... ...
//step3 调用Log对象方法写入数据
val info = log.appendAsLeader(records, leaderEpoch = this.leaderEpoch, isFromClient)
... ... // 若本节点不是目标Partition的Leader副本, 抛异常
case None =>
throw new NotLeaderForPartitionException("Leader not local for partition %s on broker %d"
.format(topicPartition, localBrokerId))
}
}
... ...
}

这里的逻辑也分3步走:

  1. 判断Leader副本是否在当前节点;
  2. 获取Log对象;
  3. 调用Log对象的appendAsLeader方法写入数据;

这里我们额外看下第1步的原理。leaderReplicaIfLocal是个方法

def leaderReplicaIfLocal: Option[Replica] =
leaderReplicaIdOpt.filter(_ == localBrokerId).flatMap(getReplica) def getReplica(replicaId: Int = localBrokerId): Option[Replica] = Option(allReplicasMap.get(replicaId))

其核心思想是那本节点BrokerId和Leader副本所在节点的BrokerId作比较,若相等,则返回对应的Replica对象。

2.2.3 Log.appendAsLeader

def appendAsLeader(records: MemoryRecords, leaderEpoch: Int, isFromClient: Boolean = true): LogAppendInfo = {
append(records, isFromClient, assignOffsets = true, leaderEpoch)
} private def append(records: MemoryRecords, isFromClient: Boolean, assignOffsets: Boolean, leaderEpoch: Int): LogAppendInfo = {
... ...
// maybe roll the log if this segment is full
val segment = maybeRoll(validRecords.sizeInBytes, appendInfo)
... ...
// 将消息插入segment
segment.append(largestOffset = appendInfo.lastOffset,
largestTimestamp = appendInfo.maxTimestamp,
shallowOffsetOfMaxTimestamp = appendInfo.offsetOfMaxTimestamp,
records = validRecords)
... ...
}

appendAsLeader方法直接调用append方法,后者两步走:

  1. 判断是否需要创建一个新的LogSegment,并返回最新的LogSegment;
  2. 调用LogSegment.append方法写入数据;

这里我们再额外关注下第1步的判断标准。主要还是根据LogSegment.shouldRoll方法的返回值来作决策:

def shouldRoll(messagesSize: Int, maxTimestampInMessages: Long, maxOffsetInMessages: Long, now: Long): Boolean = {
val reachedRollMs = timeWaitedForRoll(now, maxTimestampInMessages) > maxSegmentMs - rollJitterMs size > maxSegmentBytes - messagesSize ||
(size > 0 && reachedRollMs) ||
offsetIndex.isFull || timeIndex.isFull || !canConvertToRelativeOffset(maxOffsetInMessages)
}

Kafka的源码很清晰的,这方面值得点赞和学习。从shouldRoll的结果表达式我们可以看到,以下4类场景中,LogSegment需要向前滚动:

  1. 若接受新消息的写入,当前LogSegment将超过最大字节数限制;
  2. 若接受新消息的写入,当前LogSegment将超过最大时间跨度限制;
  3. 当前LogSegment对应的索引已无法写入新数据;
  4. 输入的offset不在当前LogSegment表示范围;

2.2.4 LogSegment.append

def append(largestOffset: Long,
largestTimestamp: Long,
shallowOffsetOfMaxTimestamp: Long,
records: MemoryRecords): Unit = {
// step1.1 判断输入消息大小
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")
// step1.2 校验offset
val physicalPosition = log.sizeInBytes()
if (physicalPosition == 0)
rollingBasedTimestamp = Some(largestTimestamp) ensureOffsetInRange(largestOffset) // step2 append the messages
val appendedBytes = log.append(records)
trace(s"Appended $appendedBytes to ${log.file} at end offset $largestOffset")
// step3 Update the in memory max timestamp and corresponding offset.
if (largestTimestamp > maxTimestampSoFar) {
maxTimestampSoFar = largestTimestamp
offsetOfMaxTimestamp = shallowOffsetOfMaxTimestamp
}
// step4 append an entry to the index (if needed)
if (bytesSinceLastIndexEntry > indexIntervalBytes) {
offsetIndex.append(largestOffset, physicalPosition)
timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestamp)
bytesSinceLastIndexEntry = 0
}
bytesSinceLastIndexEntry += records.sizeInBytes
}
}

LogSegment.append大体可以分为4步:

  1. 数据校验

    1. 校验输入消息大小;
    2. 校验offset;
  2. 写入数据(注意: 此步的log对象不是Log类的实例,而是FileRecords的实例);
  3. 更新统计数据;
  4. 处理索引;

三. 总结

本文从业务模型&源码角度分析了Kafka消息存储原理。才疏学浅,不一定很全面。

另外也可以在目录中找到同系列的其他文章:Kafka源码分析系列-目录(收藏关注不迷路)

欢迎诸君随时来交流。

Kafka源码分析(三) - Server端 - 消息存储的更多相关文章

  1. Apache Kafka源码分析 – Broker Server

    1. Kafka.scala 在Kafka的main入口中startup KafkaServerStartable, 而KafkaServerStartable这是对KafkaServer的封装 1: ...

  2. kafka源码分析之一server启动分析

    0. 关键概念 关键概念 Concepts Function Topic 用于划分Message的逻辑概念,一个Topic可以分布在多个Broker上. Partition 是Kafka中横向扩展和一 ...

  3. Hbase源码分析:server端RPC

    server端rpc包括master和RegionServer.接下来主要梳理一下,master和regionserver中有关rpc创建,启动以及处理的过程. 1,server rpc的初始化过程 ...

  4. 使用react全家桶制作博客后台管理系统 网站PWA升级 移动端常见问题处理 循序渐进学.Net Core Web Api开发系列【4】:前端访问WebApi [Abp 源码分析]四、模块配置 [Abp 源码分析]三、依赖注入

    使用react全家桶制作博客后台管理系统   前面的话 笔者在做一个完整的博客上线项目,包括前台.后台.后端接口和服务器配置.本文将详细介绍使用react全家桶制作的博客后台管理系统 概述 该项目是基 ...

  5. Kafka源码分析系列-目录(收藏不迷路)

    持续更新中,敬请关注! 目录 <Kafka源码分析>系列文章计划按"数据传递"的顺序写作,即:先分析生产者,其次分析Server端的数据处理,然后分析消费者,最后再补充 ...

  6. Kafka源码分析(一) - 概述

    系列文章目录 https://zhuanlan.zhihu.com/p/367683572 目录 系列文章目录 一. 实际问题 二. 什么是Kafka, 如何解决这些问题的 三. 基本原理 1. 基本 ...

  7. zookeeper源码分析之五服务端(集群leader)处理请求流程

    leader的实现类为LeaderZooKeeperServer,它间接继承自标准ZookeeperServer.它规定了请求到达leader时需要经历的路径: PrepRequestProcesso ...

  8. zookeeper源码分析之四服务端(单机)处理请求流程

    上文: zookeeper源码分析之一服务端启动过程 中,我们介绍了zookeeper服务器的启动过程,其中单机是ZookeeperServer启动,集群使用QuorumPeer启动,那么这次我们分析 ...

  9. tomcat源码分析(三)一次http请求的旅行-从Socket说起

    p { margin-bottom: 0.25cm; line-height: 120% } tomcat源码分析(三)一次http请求的旅行 在http请求旅行之前,我们先来准备下我们所需要的工具. ...

随机推荐

  1. Portswigger web security academy:OS command injection

    Portswigger web security academy:OS command injection 目录 Portswigger web security academy:OS command ...

  2. 一句 Task.Result 就死锁, 这代码还怎么写?

    一:背景 1. 讲故事 前些天把 .NET 高级调试 方面的文章索引到 github 的过程中,发现了一个有意思的评论,详见 文章,截图如下: 大概就是说在 Winform 的主线程下执行 Task. ...

  3. js--吐血总结最近遇到的变态表单校验---element+原生+jq+easyUI(前端职业生涯见过的最烦的校验)

    最近写了无数各种形式的表单,记录下奇奇怪怪的校验规则~ 一:首先是element自带的rules校验规则: element作为常用框架,自带rules属性简单易懂,官方文档一目了然,不再赘述,应付常用 ...

  4. Python协程与JavaScript协程的对比

    前言 以前没怎么接触前端对JavaScript 的异步操作不了解,现在有了点了解一查,发现 python 和 JavaScript 的协程发展史简直就是一毛一样! 这里大致做下横向对比和总结,便于对这 ...

  5. 敏捷史话(十七):维基(Wiki)背后的灵感来源—— Ward Cunningham

    在软件开发领域, Ward Cunningham 有许多独到的见解与成就. 1949年,Ward Cunningham 出生于印第安纳州的密歇根市,并在莱克县的一个小镇中长大.怀揣着对计算机浓厚的兴趣 ...

  6. Python数模笔记-NetworkX(3)条件最短路径

    1.带有条件约束的最短路径问题 最短路径问题是图论中求两个顶点之间的最短路径问题,通常是求最短加权路径. 条件最短路径,指带有约束条件.限制条件的最短路径.例如,顶点约束,包括必经点或禁止点的限制:边 ...

  7. VSCode·备份&还原配置及拓展项

    阅文时长 | 0.54分钟 字数统计 | 924字符 主要内容 | 1.引言&背景 2.备份VSCode配置 3.还原VSCode配置 4.Syncing常用命令 5.声明与参考资料 『VSC ...

  8. stm32开发笔记(三):stm32系列的GPIO基本功能之输出驱动LED灯、输入按键KEY以及Demo

    前言   stm32系列是最常用的单片机之一,不同的版本对应除了引脚.外设.频率.容量等'不同之外,其开发的方法是一样的.  本章讲解使用GPIO引脚功能驱动LED灯和接收Key按钮输入.   STM ...

  9. [OS] 概述&学习资料

    计算机启动 启动自检 初始化启动 启动加载 内核装载 登录 中断 硬件中断 I/O设备 CPU Timer:时间片结束后,发中断给CPU Scheduler:将CPU合理分配任务使用 异常中断 内存: ...

  10. 《我常用的股票投资工具与网站》v2.0

    <我常用的股票投资工具与网站>v2.0 王大海 职业投资,抽空做一点分享. 661 人赞同了该文章 "少年你好,想不到你竟有如此因缘际会看到这里.我看你骨骼精奇,定是万中无一的交 ...