Kafka消息文件目录

421 阅读16分钟

Kafka消息文件目录

首先回顾一下kafka最最基础的架构图

为了方便删除、防止 Log 过大等,Kafka引入了日志分段(LogSegment)的概念,将Log切分为多个LogSegment,相当于一个巨型文件被平均分配为多个相对较小的文件,这样也便于消息的维护和清理。

又为了方便查找,每个LogSegment 对应于磁盘上的一个日志文件和两个索引文件,以及可能的其他文件(比如以“.txnindex”为后缀的事务索引文件)。

举个例子,假设有一个名为topic-log的主题,此主题中具有 4 个分区,且broker数量也是4个,那么每一台机在实际物理存储上表现为topic-log-0、topic-log-1、topic-log-2、topic-log-3这4个文件夹,每个文件夹里面都是有相应的log文件。

当我们向Log 中写入消息,我们将最后一个 LogSegment 称为activeSegment,只有最后一个LogSegment 才能执行写入操作,在此之前所有的LogSegment 都不能写入数据。

为了便于消息的检索,每个LogSegment中的日志文件(以“.log”为文件后缀)都有对应的两个索引文件:偏移量索引文件(以“.index”为文件后缀)和时间戳索引文件(以“.timeindex”为文件后缀)。

每个 LogSegment 都有一个基准偏移量 baseOffset,用来表示当前 LogSegment中第一条消息的offset。偏移量是一个64位的长整型数,日志文件和两个索引文件都是根据基准偏移量(baseOffset)命名的,名称固定为20位数字,没有达到的位数则用0填充。比如第一个LogSegment的基准偏移量为0,对应的日志文件为00000000000000000000.log

举例说明,向主题topic-log中发送一定量的消息,某一时刻topic-log-0目录中的布局如下所示。

image-20200805172712521

示例中第2个LogSegment对应的基准位移是133,也说明了该LogSegment中的第一条消息的偏移量为133,同时可以反映出第一个LogSegment中共有133条消息(偏移量从0至132的消息)。

注意每个LogSegment中不只包含“.log”“.index”“.timeindex”这3种文件,还可能包含“.deleted”“.cleaned”“.swap”等临时文件,以及可能的“.snapshot”“.txnindex”“leader-epoch-checkpoint”等文件。

文件格式的进化

从0.8.x版本开始到现在的2.0.0版本,Kafka的消息格式也经历了3个版本:v0版本、v1版本和v2版本。

vo ---> v1 ---> v2

v0

  • crc32(4B):crc32校验值。校验范围为magic至value之间。
  • magic(1B):消息格式版本号,此版本的magic值为0。
  • attributes(1B):消息的属性。总共占1个字节,低3位表示压缩类型:0表示NONE、1表示GZIP、2表示SNAPPY、3表示LZ4(LZ4自Kafka 0.9.x引入),其余位保留。
  • key length(4B):表示消息的key的长度。如果为-1,则表示没有设置key,即key=null。
  • key:可选,如果没有key则无此字段。
  • value length(4B):实际消息体的长度。如果为-1,则表示消息为空。
  • value:消息体。可以为空,比如墓碑(tombstone)消息。

因为每个RECORD(v0和v1版)必定对应一个offsetmessage size。每条消息都有一个offset 用来标志它在分区中的偏移量,这个offset是逻辑值,而非实际物理偏移值,message size表示消息的大小,这两者在一起被称为日志头部(LOG_OVERHEAD),固定为12B。

v0版本中一个消息的最小长度(RECORD_OVERHEAD_V0)为

crc32+magic+attributes+key length+value length=4B+1B+1B+4B+4B=14B。也就是说,v0版本中一条消息的最小长度为14B,如果小于这个值,那么这就是一条破损的消息而不被接收。

v1

Kafka从0.10.0版本开始到0.11.0版本之前所使用的消息格式版本为v1,比v0版本就多了一个timestamp字段,表示消息的时间戳

attributes第4个位(bit)也被利用了起来:0表示timestamp类型为CreateTime,而1表示timestamp类型为LogAppendTime,其他位保留。timestamp类型由broker端参数log.message.timestamp.type来配置,默认值为CreateTime,即采用生产者创建消息时的时间戳。

v1 版本的消息的最小长度(RECORD_OVERHEAD_V1)要比 v0 版本的大 8 个字节,即22B。

v2

v2版本中消息集称为Record Batch,而不是先前的Message Set,其内部也包含了一条或多条消息,消息的格式参见图的中部和右部。在消息压缩的情形下,Record Batch Header部分(参见图左部,从first offset到records count字段)是不被压缩的,而被压缩的是records字段中的所有内容。生产者客户端中的ProducerBatch对应这里的RecordBatch,而ProducerRecord对应这里的Record。

image-20200805222239001

先讲述消息格式Record的关键字段,可以看到内部字段大量采用了Varints,这样Kafka可以根据具体的值来确定需要几个字节来保存。v2版本的消息格式去掉了crc字段,另外增加了length(消息总长度)、timestamp delta(时间戳增量)、offset delta(位移增量)和headers信息,并且attributes字段被弃用了

v2版本对消息集(RecordBatch)做了彻底的修改,参考图最左部分,除了刚刚提及的crc字段,还多了如下字段。

  • first offset:表示当前RecordBatch的起始位移。
  • length:计算从partition leader epoch字段开始到末尾的长度。
  • partition leader epoch:分区leader纪元,可以看作分区leader的版本号或更新次数,
  • magic:消息格式的版本号,对v2版本而言,magic等于2。
  • attributes:消息属性,注意这里占用了两个字节。低3位表示压缩格式,可以参考v0和v1;第4位表示时间戳类型;第5位表示此RecordBatch是否处于事务中,0表示非事务,1表示事务。第6位表示是否是控制消息(ControlBatch),0表示非控制消息,而1表示是控制消息,控制消息用来支持事务功能,
  • last offset delta: RecordBatch中最后一个Record的offset与first offset的差值。主要被broker用来确保RecordBatch中Record组装的正确性。
  • first timestamp: RecordBatch中第一条Record的时间戳。
  • max timestamp: RecordBatch 中最大的时间戳,一般情况下是指最后一个 Record的时间戳,和last offset delta的作用一样,用来确保消息组装的正确性。
  • producer id: PID,用来支持幂等和事务
  • producer epoch:和producer id一样,用来支持幂等和事务
  • first sequence:和 producer id、producer epoch 一样,用来支持幂等和事务
  • records count: RecordBatch中Record的个数。

实际消息分析

为了验证这个格式的正确性,我们往某个分区中一次性发送6 条key为“key”、value为“value”的消息,相应的日志内容如下:

逐条分析

image-20200805224845173 image-20200805224857315

在2.0.0版本的 Kafka 中创建一个分区数和副本因子数都为 1的主题,名称为“msg_format_v2”。然后同样插入1条key="key"、value="value"的消息,可以看到消息大小占用为73,比起v0,v1都是要大的。

但如果我们连续向主题msg_format_v2中再发送10条value长度为6、key为null的消息

本来应该占用740B大小的空间,实际上只占用了191B,在v0版本中这10条消息需要占用320B的空间大小,而v1版本则需要占用400B的空间大小,这样看来v2版本又节省了很多空间,因为它将多个消息(Record)打包存放到单个RecordBatch中,又通过Varints编码极大地节省了空间。有兴趣的读者可以自行测试一下在大批量消息的情况下,v2版本和其他版本消息占用大小的对比,比如往主题msg_format_v0和msg_format_v2中各自发送100万条1KB的消息。v2版本的消息不仅提供了更多的功能,比如事务、幂等性等,某些情况下还减少了消息的空间占用,总体性能提升很大。

消息压缩

常见的压缩算法是数据量越大压缩效果越好,一条消息通常不会太大,这就导致压缩效果并不是太好。而Kafka实现的压缩方式是将多条消息一起进行压缩,这样可以保证较好的压缩效果。在一般情况下,生产者发送的压缩数据在broker中也是保持压缩状态进行存储的,消费者从服务端获取的也是压缩的消息,消费者在处理消息之前才会解压消息,这样保持了端到端的压缩。

Kafka 日志中使用哪种压缩方式是通过参数 compression.type 来配置的,默认值为“producer”,表示保留生产者使用的压缩方式。这个参数还可以配置为“gzip”“snappy”“lz4”,分别对应 GZIP、SNAPPY、LZ4 这 3 种压缩算法。如果参数 compression.type 配置为“uncompressed”,则表示不压缩。

当消息压缩时是将整个消息集进行压缩作为内层消息(inner message),内层消息整体作为外层(wrapper message)的 value,其结构如图

image-20200805214535282 image-20200805214548783

压缩后的外层消息(wrapper message)中的key为null,所以图左半部分没有画出key字段,value字段中保存的是多条压缩消息(inner message,内层消息),其中Record表示的是从 crc32 到value 的消息格式。当生产者创建压缩消息的时候,对内部压缩消息设置的offset从0开始为每个内部消息分配offset

其实每个从生产者发出的消息集中的消息offset都是从0开始的,当然这个offset不能直接存储在日志文件中,对 offset 的转换是在服务端进行的,客户端不需要做这个工作。外层消息保存了内层消息中最后一条消息的绝对位移(absolute offset),绝对位移是相对于整个分区而言的。参考图5-6,对于未压缩的情形,图右内层消息中最后一条的offset理应是1030,但被压缩之后就变成了5,而这个1030被赋予给了外层的offset。当消费者消费这个消息集的时候,首先解压缩整个消息集,然后找到内层消息中最后一条消息的inner offset

压缩消息,英文是compress message

Kafka中还有一个compact message,常常被人们直译成压缩消息,需要注意两者的区别。compact message是针对日志清理策略而言的(cleanup.policy=compact),是指日志压缩(Log Compaction)后的消息

在讲述v1版本的消息时,我们了解到v1版本比v0版的消息多了一个timestamp字段。

对于压缩的情形,timestamp的设置如下

  • 外层消息的timestamp设置为:

    如果timestamp类型是CreateTime,那么设置的是内层消息中最大的时间戳。

    如果timestamp类型是LogAppendTime,那么设置的是Kafka服务器当前的时间戳。

  • 内层消息的timestamp设置为:

    如果外层消息的timestamp类型是CreateTime,那么设置的是生产者创建消息时的时间戳。·

    如果外层消息的timestamp类型是LogAppendTime,那么所有内层消息的时间戳都会被忽略。

**对于压缩的情形,**对 attributes 字段而言,它的 timestamp 位只在外层消息中设置,内层消息中的timestamp类型一直都是CreateTime。

变长字段

Kafka从0.11.0版本开始所使用的消息格式版本为v2,这个版本的消息相比v0和v1的版本而言改动很大,同时还参考了Protocol Buffer[1]而引入了变长整型(Varints)和ZigZag编码。

Varints

Varints是使用一个或多个字节来序列化整数的一种方法,Varints中的每个字节都有一个位于最高位的msb位(most significant bit),除最后一个字节外,其余msb位都设置为1,最后一个字节的msb位为0。

回顾Kafka v0和v1版本的消息格式,如果消息本身没有key,那么keylength字段为-1,int类型的需要4个字节来保存,而如果采用Varints来编码则只需要1个字节。根据Varints的规则可以推导出0~63之间的数字占1个字节,64~8191之间的数字占2个字节,8192~1048575之间的数字占3个字节。而Kafka broker端配置message.max.bytes的默认大小为1000012 (Varints编码占3个字节),如果消息格式中与长度有关的字段采用Varints的编码,那么绝大多数情况下都会节省空间,而v2版本的消息格式也正是这样做的。

细说日志

复习下上边的图

正常使用中,我们一般只需要关注.log .index .timeindex三个文件

.log 是消息存储文件,另外两个索引文件是用于查找某个offset下的消息

日志索引

每个日志分段文件对应了两个索引文件,主要用来提高查找消息的效率。

  • .index : 偏移量索引文件用来建立消息偏移量(offset)到物理地址之间的映射关系,方便快速定位消息所在的物理文件位置;
  • .timeindex : 时间戳索引文件则根据指定的时间戳(timestamp)来查找对应的偏移量信息。

  • 偏移量索引文件而言,必须为8的整数倍,如果broker端参数log.index.size.max.bytes配置为67,那么Kafka在内部会将其转换为64
  • 与偏移量索引文件相似,时间戳索引文件大小必须是索引项大小(12B)的整数倍

Kafka 中的索引文件以稀疏索引(sparse index)的方式构造消息的索引,说白了就是隔n条消息(下图中我们假设跨度为8,实际kafka中跨度并不是固定条数,而是取决于消息累积字节数大小)存一条索引数据。这样做比每一条消息都建索引,查找起来会慢,但是也极大的节省了存储空间。

消息累积字节数大小

每当写入一定量(由 broker 端参数log.index.interval.bytes指定,默认值为4096,即4KB)的消息时,偏移量索引文件和时间戳索引文件分别增加一个偏移量索引项和时间戳索引项,增大或减小log.index.interval.bytes的值,对应地可以增加或缩小索引项的密度。

日志分段

日志段有当前日志段和过往日志段。Kafka在进行日志分段时,会开辟一个新的文件。触发日志分段主要有以下条件:

  • 当前日志段日志文件大小超过了log.segment.bytes配置的大小
  • 当前日志段中消息的最大时间戳与系统的时间戳差值超过了log.roll.ms配置的毫秒值
  • 当前日志段中消息的最大时间戳与当前系统的时间戳差值超过log.roll.hours配置的小时值,优先级比log.roll.ms低
  • 当前日志段中索引文件与时间戳索引文件超过了log.index.size.max.bytes配置的大小
  • 追加的消息的偏移量与当前日志段中的之间的偏移量差值大于Interger.MAX_VALUE,意思就是因为要追加的消息偏移量不能转换为相对偏移量。原因在于在偏移量索引文件中,消息基于baseoffset的偏移量使用4个字节来表示。

注意,索引文件在做分段的时候首先会固定好索引文件的大小(log.index.size.max.bytes),在新的分段的时候对前一个分段的索引文件进行裁剪,文件的大小才代表实际的数据大小。

就是说,Kafka 在创建索引文件的时候会为其预分配log.index.size.max.bytes 大小的空间,注意这一点与日志分段文件不同,只有当索引文件进行切分的时候,Kafka 才会把该索引文件裁剪到实际的数据大小

日志查找

稀疏索引在查找中的表现是通过二分查找法来查找不大于该查找偏移量的最大偏移量。至于要找到对应的物理文件位置还需要根据偏移量索引文件来进行再次定位。

稀疏索引的方式是在磁盘空间、内存空间、查找时间三者上边的折中。

偏移量索引文件查找

如果要查找偏移量为268的消息,首先Kafka 的每个日志对象中使用了ConcurrentSkipListMap来保存各个日志分段,每个日志分段的baseOffset作为key,这样可以根据指定偏移量来快速定位到baseOffset为251的日志分段,然后计算相对偏移量relativeOffset=268-251=17,之后再在对应的索引文件中通过二分查找找到不大于17的索引项,最后根据索引项中的position定位到具体的日志分段文件位置开始顺序查找目标消息。

总的走法是 : 跳表 -> 二分查找(.index) -> 顺序查找(.log)

时间戳索引查找

时间戳索引查找的前提:

​ 时间戳索引文件中包含若干时间戳索引项,每个追加的时间戳索引项中的timestamp 必须大于之前追加的索引项的 timestamp,否则不予追加。如果broker 端参数 log.message.timestamp.type设置为LogAppendTime,那么消息的时间戳必定能够保持单调递增;相反,如果是 CreateTime 类型则无法保证。

查找步骤:

  1. 将targetTimeStamp和每个日志分段中的最大时间戳largestTimeStamp逐一对比,直到找到不小于 targetTimeStamp 的 largestTimeStamp 所对应的日志分段。日志分段中的largestTimeStamp的计算是先查询该日志分段所对应的时间戳索引文件,找到最后一条索引项,若最后一条索引项的时间戳字段值大于0,则取其值,否则取该日志分段的最近修改时间。
  2. 找到相应的日志分段之后,在时间戳索引文件中使用二分查找算法查找到不大于targetTimeStamp的最大索引项,即[1526384718283,28],如此便找到了一个相对偏移量28。
  3. 在偏移量索引文件中使用二分算法查找到不大于28的最大索引项,即[26,838]。步骤4:从步骤1中找到日志分段文件中的838的物理位置开始查找不小于targetTimeStamp的消息。

这里没有使用跳跃表来快速定位到相应的日志分段