2.1 Kafka工作流程
Kafka 存储的消息是以 topic 进行分类的,生产者生产消息,消费者消费消息,都是面向 topic的。
topic 是逻辑上的概念,而 partition 是物理上的概念,每个 partition 对应于一个日志目录,该目录下包含一批log文件,该 log 文件中存储的就是 Producer 生产的数据。Producer 生产的数据会被不断追加到该log 文件末端,且每条数据都有自己的 offset。消费者组中的每个消费者,都会实时记录自己消费到了哪个 offset,以便出错恢复时,从上次的位置继续消费。
2.2 Kafka 保存消息机制
2.2.1 存储方式
kafka日志文件的存储目录可以通过配置文件config/server.properties
中的配置项log.dirs
确定。
生产者生产的消息会不断追加到 log 文件末尾,为了防止 log 文件过大导致数据定位效率低下,Kafka 采取了分片和索引机制,一个片段的默认大小为1GB,可以在config/server.properties
配置文件中修改segment大小。Kafka将每个 partition 分为多个 segment。
kafka把主题都存储在log.dirs
目录下,每个topic分成一个或多个patition,每个patition物理上对应一个文件夹,该文件夹的命名规则为:topic 名称+分区序号。分区内(文件夹)内的日志文件分为多个Segment,且每个Log Segment包含三个主要文件
log.dirs
目录下主题test,有3个分区
test-0
test-1
test-2
分区目录下具体文件如下所示
00000000000000000000.index
00000000000000000000.log
00000000000000000000.timeindex
00000000000000000128.index
00000000000000000128.log
00000000000000000128.timeindex
xxx.log:消息内容主文件,记录消息内容信息
xxx.index:偏移量索引文件,记录偏移量到消息位置的映射
xxx.timeindex:时间戳索引文件,记录时间戳到偏移量的映射
注意:分区目录下还包含两类文件,xxx.snapshot
对幂等型或者事务型producer所生成的快照文件;xxx.leader-epoch-checkpoint
保存了每一任leader开始写入消息时的offset,该文件会定时更新。
每个Log Segment都有一个基准偏移量baseOffset, 用来表示当前Log Segment中第一条消息的偏移量,日志文件和两个索引文件都是根据baseOffset命名的,文件名称固定20位整数,不够用0填充。例如,以第二个分段00000000000000000128.log为例,00000000000000000128.log表示当前Log Segment中第一条消息偏移量为128,同时可以推断出第一个分段中的偏移量为0 - 127
2.2.2 日志格式
2.2.2.1.日志索引
日志索引有两个:偏移量索引和时间戳索引。索引文件建立偏移量(offset)/时间戳(timestamp)到物理地址之间的映射关系,方便快速定位消息所在的物理文件的位置;
kafka中使用稀疏索引的方式构造消息索引,所以索引中不保证每个消息在索引中都有对应物理地址的映射;索引记录的方式由配置参数log.index.interval.bytes
指定,默认4KB。当kafka写入消息大小大于4KB时,偏移量索引文件和时间戳索引文件分别增加一个索引项,记录此刻偏移量/时间戳与消息物理地址的映射,修改此配置,可以改变索引文件中索引项的密度
2.2.2.2.日志分段的条件
- 当前日志分段文件的大小超过broker端参数
log.segment.bytes
配置值,默认1G - 当前日志分段文件中消息的最大时间戳和当前系统的时间戳差值大于
log.roll.ms
或log.roll.hours
参数配置(ms优先级更高,同时出现以ms配置值为准),默认配置后者为168,即7天 - 索引文件大小超过broker端参数
log.index.size.max.bytes
,默认10M - 追加消息的偏移量与当前日志分段的BaseOffset之间差值大于
Integer.MAX_VALUE
2.2.2.3.偏移量索引(.index文件)
偏移量索引即记录偏移量(offset)到消息物理位置的映射。每个索引项大小为8个字节,分为两部分:
- relative offset:相对偏移量,即消息的绝对偏移量和基础偏移量BaseOffset的差值,占4个字节,offset本身为8个字节,这里保存相对位移可以降低索引文件的大小,上述日志分段条件4是受相对偏移量影响,最多只能保存4B差值的偏移量
- position:物理地址,即消息在日志分段文件中对应的物理地址,占4个字节
以00000000000000000536.index为例,使用kafka自带命令kafka-dump-log可以解析日志文件
Dumping 00000000000000000536.index
offset: 588 position: 4160
offset: 640 position: 8320
offset: 692 position: 12480
offset: 744 position: 16640
offset: 796 position: 20800
offset: 848 position: 24960
offset: 900 position: 29120
offset: 952 position: 33280
offset: 1004 position: 37440
offset: 1056 position: 41600
索引查找方式
kafka在查找某偏移量时使用偏移量索引,假设现在需要查找partition = 0, offset = 745的消息,其过程如下
- 第一步,确定日志分段,先从跳表中确定offset = 745在00000000000000000536分段中,kafka使用跳跃表缓存每个日志分段的BaseOffset,确定分段会先经过跳表查询
- 第二步,确定偏移量索引项,通过二分查找,确认offset = 745在744 - 796之间,kafka根据offset = 744对应的物理位置再去日志文件中查找,kafka会在索引文件中找到不大于指定偏移量的最大偏移量对应的物理位置,去日志文件中顺序查找
- 第三步,确定消息位置:到日志文件中后,从offset:744 position: 16640开始顺序查找,知道找到offset = 745的消息
2.2.2.4.时间戳索引(.timeindex)
时间戳索引记录时间戳(timestamp)到相对偏移量(relativeOffset)之间的映射。每个索引项大小为12个字节,包含两个部分:
- 时间戳timestamp:当前日志分段最大的时间戳(新增时间戳索引项的那个时刻),日志分段中最大的时间戳(相当于那个时刻的快照数据),大小为8个字节
- 相对偏移量relativeOffset: 该时间戳对应的相对偏移量
即时间戳索引是以偏移量索引为前提的,在时间戳中确定偏移量后,必须再从偏移量索引中才能知道消息的具体位置。
2.2.3 Log Segement文件
查看segement文件00000000000000000000.log
1.0.1版本命令
bin/kafka-run-class.sh kafka.tools.DumpLogSegments --files 00000000000000000000.log --deep-iteration --print-data-log
–deep-iteration查看日志文件中每一条信息,不加此选项为查看批消息(不显示具体每条日志内容)。
文件内容如下
- offset:相对于该分区的记录偏移量,指的是第几条记录,比如0代表第一条记录。
- position:表示该记录相对于当前片段文件的偏移量。
- CreateTime:记录创建的时间。
- isvalid:记录是否有效。
- keysize:表示key的长度。
- valuesize:表示value的长度
- magic:表示本次发布kafka服务程序协议版本号。
- compresscodec:压缩工具。
- producerId:生产者ID(用于幂等机制)。
- sequence:消息的序列号(用于幂等机制)。
- payload:表示具体的消息
2.7.1版本
bin/kafka-run-class.sh kafka.tools.DumpLogSegments --files logs/test3-2/00000000000000000000.log --print-data-log
选项--print-data-log
查看具体消息内容,不加此选项,为查看批消息。
批消息如下所示
baseOffset: 0 lastOffset: 1 count: 2 baseSequence: -1 lastSequence: -1 producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 0 isTransactional: false isControl: false position: 0 CreateTime: 1653893608415 size: 90 magic: 2 compresscodec: NONE crc: 789477047 isvalid: true
一条批消息(Record Batch)日志记录,包含多条的实际数据
具体消息如下所示
baseOffset: 0 lastOffset: 1 count: 2 baseSequence: -1 lastSequence: -1 producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 0 isTransactional: false isControl: false position: 0 CreateTime: 1653893608415 size: 90 magic: 2 compresscodec: NONE crc: 789477047 isvalid: true
| offset: 0 CreateTime: 1653893607501 keysize: -1 valuesize: 7 sequence: -1 headerKeys: [] payload: fdsfsdf
| offset: 1 CreateTime: 1653893608415 keysize: -1 valuesize: 7 sequence: -1 headerKeys: [] payload: sdfasdf
注意:kafka2.x版本新增了kafka-dump-log.sh
脚本,该脚本实质也是调用kafka-run-class.sh
脚本。
2.2.4 Kafka高效存储原因
kafka使用磁盘存储消息数据,一般情况下认为磁盘的读写速率较低。kafka在磁盘存储的前提下,可以保持高吞吐,主要原因在以下三个方面
2.2.4.1.顺序写
日志存储是基于日志文件的追加,所以在单个日志分段上日志文件的写入是顺序写入;而磁盘顺序写入的效率比内存随机写入效率更高,所以在写入效率上kafka依然可以保持高效
2.2.4.2.页缓存
页缓存是操作系统实现的磁盘缓存,使用页缓存可以减少对磁盘的I/O操作。当一个进程准备去读磁盘文件时,会首先查看页缓存是否存在,如果存在则直接读取数据并返回,否则再从磁盘获取数据,并更新到页缓存中;同样在数据写入磁盘时,也会查看对应的数据页是否在页缓存中,如果有则直接更新页缓存,没有则从磁盘中读取该页,然后写入页缓存;被写入的数据页就变成了脏页,操作系统会定时将脏页同步到磁盘中
kafka没有在进程中管理缓存,而是将缓存行为完全交给操作系统,一方面可以简化进程的处理逻辑,提高处理效率,同时可以不用考虑进程重启后的缓存丢失;另一方面,可以利用操作系统页缓存的特性,提高磁盘读写效率。
- 生产者通过pwrite()方法,把消息写入page_cache
- 消费者通过sendfile()方法,通过零拷贝的方式把消息从page cache传输到broker的Socker buffer
所以,如果生产者和消费者的速率相差不多,那么写入和读取几乎可以通过页缓存完成,进行极少的磁盘操作,可以达到很高的吞吐量;
2.2.5 零拷贝
零拷贝是指将数据从磁盘文件中直接复制到网卡设备中,而无需经过应用程序。零拷贝技术可以减少文件被拷贝的次数,同时不用在用户态和内核态之间转换,提高文件传输效率。
假设将磁盘文件A通过网络传输给用户,在使用零拷贝前其过程如下:
- 用户发起read()系统调用,将磁盘文件A复制到内核空间的Read Buffer中
- 同时CPU控制将Read Buffer数据从内核态复制到用户态的进程内存中
- 进程发起write()系统调用,将用户态进程内存数据复制到内核态socket buffer中
- 内核态数据从Socket Buffer中复制到网卡设备,准备发送
可以看到,在使用零拷贝之前,文件经过四次复制,并两次经过用户态和内核态的切换;而文件进入用户态后基本上没有任何处理,就被直接复制到内核buffer中,可见经过用户空间的这两次复制是多余的,因此,在使用零拷贝后:
零拷贝通过DMA(Direct Memory Access)将文件复制到内核空间的Read Buffer中,随后不经过用户空间,直接复制到网卡待发送。经过零拷贝后,文件的准备过程减少了两次文件复制,并不经过用户态和内核态的转换,提高了文件发送效率。
2.2.6 日志清理
kafka日志存储在磁盘中,随着时间推移日志文件必然越来越大,kafka提供两种方式清理日志文件:
- 日志删除:按照某种策略,删除不符合条件的日志分段
- 日志压缩/瘦身:针对key对日志重新梳理,相同key的消息只保留最后一条,这里不是对日志文件直接进行压缩保存,而是从逻辑上删除日志文件的内容
broker参数log.cleanup.policy
可设置日志清理策略,delete表示日志删除,compact为日志压缩,默认前者
2.2.6.1.日志删除
基于时间:如果日志分段中最大时间戳和当前时间的差值大于设定的保留时间,则分段需要清除。保留时间可以通过配置确定:
log.retention.ms/minutes/hours
三个参数可指定保留时间,ms优先级最高,默认hours = 168,即保留7天- 基于日志大小:如果分区内日志文件总大小(非单个日志分段文件大小)超过阈值,则清理最老的日志分段,
log.retention.bytes
设置总大小阈值,默认为-1,表示可以无穷大 - 基于日志起始偏移量:当日志分段的偏移量小于日志起始偏移量logStartOffset时,日志分段被删除,logStartOffset可以被管理员修改,从而指定删除某些分段
注意:因为Kafka读取特定消息的时间复杂度与Log Segment文件大小无关,所以这里删除过期文件与提高 Kafka 性能无关。
2.2.6.2.日志压缩/瘦身
日志压缩针对所有的历史消息,根据key对消息进行合并,保留相同key的最后一条消息
日志压缩适用于某些场景,必须适用kafka记录某些状态,这种情况不用记录过程,保留最终状态是可行的;但不是所有场景都适用,比如相同key的消息通过不同的字段划分不同的行为,这时合并消息就不是理想的方案了
2.2.7 offset维护
由于 consumer 在消费过程中可能会出现断电宕机等故障,consumer 恢复后,需要从故障前的位置继续消费,所以 consumer 需要实时记录自己消费到了哪个 offset,以便故障恢复后继续消费。
注意: producer不在zk中注册,消费者在zk中注册。
Kafka 0.9 版本之前,consumer 默认将 offset 保存在 Zookeeper 中,从 0.9 版本开始,consumer 默认将 offset 保存在 Kafka 内置的 topic 中,该 topic 为__consumer_offsets
。
查看offset
- 修改配置文件
consumer.properties
exclude.internal.topics=false
- 读取 offset
0.11.0.0 之前版本
bin/kafka-console-consumer.sh --topic __consumer_offsets --
zookeeper hadoop102:2181 --formatter
"kafka.coordinator.GroupMetadataManager\$OffsetsMessageFormatter"
--consumer.config config/consumer.properties --from-beginning
0.11.0.0 之后版本(含)
bin/kafka-console-consumer.sh --topic __consumer_offsets --
zookeeper hadoop102:2181 --formatter
"kafka.coordinator.group.GroupMetadataManager\$OffsetsMessageForm
atter" --consumer.config config/consumer.properties --from-beginning
2.3 Kafka 生产者
2.3.1 写入数据方式
Kafka Producer负责向Kafka写入数据。采用推(push)模式将消息发布到broker,每条消息都被追加(append)到分区(patition)中,属于顺序写磁盘(顺序写磁盘效率比随机写内存要高,保障kafka吞吐率)。
2.3.2 分区(Partition)策略
消息发送时都被发送到一个topic,其本质就是一个目录,而topic是由一些Partition Log(分区日志)组成,其组织结构如下图所示
每个Partition中的消息都是有序的,生产的消息被不断追加到Partition log,其中的每一个消息都被赋予了一个唯一的offset值。
分区原因
- 方便在集群中扩展,每个Partition可以通过调整数量以适应它所在的机器,而一个topic又可以有多个Partition组成,因此整个集群就可以适应任意大小的数据;
- 提高并发,以Partition为单位读写。
分区原则
- 指定了patition,则直接使用;
- 未指定patition,但指定key,将 key 的 hash 值与 topic 的 partition 数进行取余得到 partition 值;
- patition和key都未指定,第一次调用时随机生成一个整数(后面每次调用在这个整数上自增),将这个值与 topic 可用的 partition 总数取余得到 partition 值,也就是常说的 round-robin 算法。
DefaultPartitioner类
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
int numPartitions = partitions.size();
if (keyBytes == null)