kafka06-日志存储

参考文献:
1、《深入理解Kafka-核心设计与实践原理(201901)》(第5章 日志存储)

1、文件目录布局

  • 不考虑多副本的情况,一个分区对应一个日志(Log)。
  • 日志分段(LogSegment)是为了防止Log过大,将Log切分为多个LogSegment,相当于一个巨型文件被平均分配为多个相对较小的文件,这样也便于消息的维护和清理。
    • Log在物理上只以文件夹的形式存储。
    • 每个LogSegment对应于磁盘上的一个日志文件和两个索引文件,以及可能的其他文件(比如以".txnindex"为后缀的事务索引文件)。
  • 主题、分区、副本和Log、LogSegment之间的关系

  • 创建一个4个分区,2个副本的主题topic-log。并向主题中发送一定量的消息,某一时刻topic-log-0目录中的布局如下所示。
128 ~]# kafka-topics.sh --zookeeper localhost:2181 --create --topic topic-log --partitions 4 --replication-factor 2

128 ~]# ll /tmp/kafka/log/topic-log-0
-rw-r--r--. 1 root root 1046     11月 19 16:41 00000000000000000000.index
-rw-r--r--. 1 root root 5111     11月 19 16:41 00000000000000000000.log
-rw-r--r--. 1 root root 1596     11月 19 16:41 00000000000000000000.timeindex
-rw-r--r--. 1 root root 944      11月 19 16:45 00000000000000000133.index
-rw-r--r--. 1 root root 4085     11月 19 16:45 00000000000000000133.log
-rw-r--r--. 1 root root 1416     11月 19 16:45 00000000000000000133.timeindex
-rw-r--r--. 1 root root 10485760 11月 19 16:49 00000000000000000251.index
-rw-r--r--. 1 root root 3869     11月 19 16:49 00000000000000000251.log
-rw-r--r--. 1 root root 10485756 11月 19 16:49 00000000000000000251.timeindex
  • 为了便于消息的检索,每个LogSegment中的日志文件(以".log"为文件后缀)都有对应的两个索引文件:
    • 偏移量索引文件(以".index"为文件后缀)
    • 时间戳索引文件(以".timeindex"为文件后缀)
  • 每个LogSegment都有一个基准偏移量baseOffset,用来表示当前LogSegment中第一条消息的offset。偏移量是一个64位的长整型数。
  • 日志文件和两个索引文件都是根据基准偏移量(baseOffst)命名的,名称固定为20位数字,没有达到的位数则用0填充。
    • 比如第1个LogSegment的日志文件名是00000000000000000000.log,因此它的基准偏移量是0。
    • 比如第2个LogSegment的日志文件名是00000000000000000133.log,因此它的基准偏移量是133,也说明了该LogSegment中的第一条消息的偏移量为133,同时可以反映出第一个LogSegment中共有133条消息(偏移量从0至132的消息)。
  • 向Log中追加消息时是顺序写入的,只有最后一个LogSegment才能执行写入操作,在此之前所有的LogSegment都不能写入数据。
    • 为了方便描述,我们将最后一个LogSegment称为"activeSegment" ,即表示当前活跃的日志分段
    • 随着消息的不断写入,当activeSegment满足一定的条件时,就需要创建新的activeSegment,之后追加的消息将写入新的activeSegment。
  • 注意每个LogSegment中不只包含".log"、".index"、".timeindex"这3种文件,还可能包含"deleted"、".cleaned"、".swap"等临时文件,以及可能的".snapshot"、".txnindex"、"leader-epoch-checkpoint"等文件。
  • 在某一时刻,Kafka中的文件目录布局如图所示。
    • 每一个根目录都会包含最基本的4个检查点文件(xxx-checkpoint)和meta.properties文件。
    • 初始情况下主题__consumer_offisets并不存在,当第一次有消费者消费消息时会自动创建这个主题。
    • 在创建主题的时候,如果当前broker中不止配置了一个根目录,那么会挑选分区数最少的那个根目录来完成本次创建任务。

2、日志格式的演变

  • Kafka的消息格式有3个版本:
    • v0版本:在Kafka 0.10.0之前使用
    • v1版本:Kafka从0.10.0到0.11.0版本之前使用(比v0版本就多了一个timestamp字段)
    • v2版本:Kafka从0.11.0版本开始所使用(比v0和v1的版本而言变化很大)
  • 每个分区由内部的每一条消息组成,如果消息格式设计得不够精炼,那么其功能和性能都会大打折扣。
    • 如果有冗余字段,势必会不必要地增加分区的占用空间,进而不仅使存储的开销变大、网络传输的开销变大,也会使Kafka的性能下降。
    • 如果缺少字段,比如在最初的Kafka消息版本中没有timestamp字段,对内部而言,其影响了日志保存、切分策略,对外部而言,其影响了消息审计、端到端延迟、大数据应用等功能的扩展。

1、v0版本和v1版本

  • Kafka消息格式的第一个版本通常称为v0版本(在0.8.x版之前,Kafka还使用过一个更古老的消息格式,不过不需要了解这个版本的消息格式)。
  • Kafka从0.10.0版本到0.11.0版本之前所使用的消息格式版本为v1,比v0版本就多了一个timestamp字段,表示消息的时间戳。
  • v0和v1版本的消息格式
    • 图1中左边的"RECORD"部分就是v0版本的消息格式,大多数人会把图1中左边的整体(即包括offset和message size字段)都看作消息,因为每个RECORD(v0和v1版)必定对应一个offset和message size
      • 每条消息都有一个offset用来标志它在分区中的偏移量,这个offset是逻辑值,而非实际物理偏移值。
      • message size表示消息的大小。
      • 这两者在一起被称为日志头部(LOG OVERHEAD),固定为12B。
    • LOG_OVERHEAD和RECORD一起用来描述一条消息。
      • 在讲述具体消息格式时会偏向于将单纯的RECORD看作消息,而在其他地方则偏向于将LOG OVERHEAD和RECORD的整体看作消息,读者需要留意其中的区别。
    • 消息集(图1的右边部分,Message Set)中包含一条或多条消息,消息集不仅是存储于磁盘及在网络上传输(Produce & Fetch)的基本形式,而且是Kafka中压缩的基本单元

  • 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)消息。
  • v1版本的消息格式中个字段的释义:(没有解释的和v0的含义相同)
    • crc32(4B):
    • magic(1B):消息格式版本号,此版本的magic值为1。
    • timestamp:表示消息的时间戳(v0中没有)。
      • timestamp类型由broker端参数log.message.timestamp.type来配置,默认值为CreateTime,即采用生产者创建消息时的时间戳。
      • 如果在创建ProducerRecord时没有显式指定消息的时间戳,那么Kafkaproducer也会在发送这条消息前自动添加上。
    • attributes(1B):消息的属性。总共占1个字节。
      • 低3位和v0版本的一样。
      • 第4位(bit):0表示timestamp类型为CreateTime,1表示timestamp类型为LogAppendTime。
      • 其余位保留。
    • key length(4B):
    • key:
    • value length 4B):
    • value:
  • 破损消息
    • v0版本中一个消息的最小长度(RECORD_OVERHEAD_vo)为crc32 + magic + attributes +key length + value length = 4B + 1B + 1B +4B +4B = 14B,v0版本中一条消息的最小长度为14B。
    • v1版本的消息的最小长度(RECORD_OVERHEAD_V1 )要比v0版本的大8个字节,即22B。
    • 如果消息的长度小于最小长度,那么这就是一条破损的消息而不被接收

2、消息压缩

  • Kafka的压缩方式是将多条消息一起进行压缩,这样可以保证较好的压缩效果(因为一条消息通常不会太大,压缩效果不太好)。
  • 在一般情况下,生产者发送的压缩数据在broker中也是保持压缩状态进行存储的,消费者从broker获取的也是压缩的消息,消费者在处理消息之前才会解压消息,这样保持了端到端的压缩
  • Kafka日志中使用哪种压缩方式是通过参数compression.type来配置的。
    • 默认值为"producer",表示保留生产者使用的压缩方式。
    • 这个参数还可以配置为"gzip"、"snappy"、"lz4",分别对应GZIP、SNAPPY、LZ4这3种压缩算法。
    • 如果参数compression.type配置为"uncompressed",则表示不压缩。
  • 消息压缩的过程
    • 压缩消息时是将整个消息集进行压缩作为内层消息(inner message),内层消息整体作为外层消息(wrapper message)的value,其结构如图1所示。(Record表示的是从crc32到value的消息格式)
    • 压缩后的外层消息中的key为null,所以图1的左半部分没有画出key字段,value字段中保存的是多条压缩消息(内层消息)。
    • 当生产者创建压缩消息的时候,对内部压缩消息设置的offset从0开始为每个内部消息分配offset,参考图2右半部分。
      • 其实每个从生产者发出的消息集中的消息offset都是从0开始的,当然这个offset不能直接存储在日志文件中,对offset的转换是在服务端进行的,客户端不需要做这个工作。
      • 外层消息保存了内层消息中最后一条消息的绝对位移(absolute offset),绝对位移是相对于整个分区而言的。
      • 参考图2,对于未压缩的情形,图2右半部分内层消息中最后一条的offset理应是1030,但被压缩之后就变成了5,而这个1030被赋予给了外层的offset。
    • 当消费者消费这个消息集的时候,首先解压缩整个消息集,然后找到内层消息中最后一条消息的inner offset,根据如下公式找到内层消息中最后一条消息前面的消息的absolute offiset(RO表示Relative Offset, IO表示Inner Offset,而AO表示Absolute Offset):
      • RO = IO_of_a_message - IO_of_the_last_message(相对offset = 内层消息A的offset - 最后一个内层消息的offset)
      • AO = AO_Of_Last_Inner_Message + RO (消息A的绝对offset = 最后一个内层消息的绝对offset + 相对offset)

  • 对于压缩的情形,外层消息的timestamp设置为:
    • 如果timestamp类型是CreateTime,那么设置的是内层消息中最大的时间戳。
    • 如果timestamp类型是LogAppendTime,那么设置的是Kafka服务器当前的时间戳。
    • 内层消息的timestamp设置为:
      • 如果外层消息的timestamp类型是CreateTime,那么设置的是生产者创建消息时的时间戳。
      • 如果外层消息的timestamp类型是LogAppendTime,那么所有内层消息的时间戳都会被忽略。
  • 对attributes字段而言,它的timestamp位只在外层消息中设置,内层消息中的timestamp类型一直都是CreateTime.

3、v2版本

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

1、变长整型(Varints)和ZigZag编码

  • Varints是使用一个或多个字节来序列化整数的一种方法。数值越小,其占用的字节数就越少。
  • 为了使编码更加高效,Varints使用了ZigZag的编码方式。
  • ZigZzag编码以一种锯齿形(zig-zags)的方式来回穿梭正负整数,将带符号整数映射为无符号整数,这样可以使绝对值较小的负数仍然享有较小的Varints编码值,比如-1编码为1、1编码为、-2编码为3。

  • 不过需要注意的是,Varints并非一直会节省空间,一个int32最长会占用5个字节(大于默认的4个字节),一个int64最长会占用10个字节(大于默认的8个字节)。

2、v2版本的消息格式

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

 

  • v2版本消息(Record)的关键字段,可以看到内部字段大量采用了Varints,这样Kafka可以根据具体的值来确定需要几个字节来保存。v2版本的消息格式去掉了crc字段,另外增加了length(消息总长度)、timestamp delta(时间戳增量)、offset delta(位移增量)和headers信息,并且attributes字段被弃用了(key、key length、value、value length字段同v0和v1版本的一样,这里不再赘述)。
    • length:消息总长度。
    • attributes:弃用,但还是在消息格式中占据1B的大小,以备未来的格式扩展。
    • timestamp delta:时间戳增量。通常一个timestamp需要占用8个字节,如果像这里一样保存与RecordBatch的起始时间戳的差值,则可以进一步节省占用的字节数。
    • offset delta:位移增量。保存与RecordBatch起始位移的差值,可以节省占用的字节数。
    • key length:
    • key:
    • value length:
    • value:
    • headers count:headers的个数。
    • headers:这个字段用来支持应用级别的扩展,而不需要像v0和v1版本一样不得不将一些应用级别的属性值嵌入消息体。Header的格式如图最右部分所示,包含key和value,一个Record里面可以包含0至多个Header。
  • v2版本消息集(RecordBatch)的关键字段,参考图最左部分:
    • first offset:表示当前RecordBatch的起始位移。
    • length:计算从partition leader epoch字段开始到末尾的长度。
    • partition leader epoch:分区leader纪元,可以看作分区leader的版本号或更新次数。
    • magic:消息格式的版本号,对v2版本而言,magic等于2。
    • crc32:crc32校验值。
    • attributes:消息属性,注意这里占用了两个字节。
      • 低3位表示压缩格式,可以参考v0和v1。
      • 第4位表示时间戳类型。
      • 第5位表示此RecordBatch是否处于事务中,0表示非事务,1表示事务。
      • 第6位表示是否是控制消息(ControlBatch),0表示非控制消息,1表示是控制消息,控制消息用来支持事务功能。
    • last offset delta:RecordBatch中最后一个Record的offset与first offset的差值。主要被broker用来确保RecordBaich中Record组装的正确性。
    • first timestamp:RecordBatch中第一条Record的时间戳。
    • max timestamp:RecordBatch中最大的时间戳,一般情况下是指最后一个Record的时间戳,和last offset delta的作用一样,用来确保消息组装的正确性。
    • producer id:PID,用来支持幂等和事务。
    • producer epoch:和producer id一样,用来支持幂等和事务。
    • first sequence:和producer id一样,用来支持幂等和事务。
    • records count:RecordBatch中消息的个数。
    • records:被压缩的消息。

4、查看内容日志

  • kafka-dump-log.sh
    • --files <String: file1, file2, ...>:要转储的数据和索引日志文件列表,用逗号分隔。(必须)
    • --print-data-log:如果设置,转储数据日志时打印消息内容。如果指定了任何解码器选项,则自动设置。
#在Kafka 2.0.0之前使用
128 ~]# kafka-run-class.sh kafka.tools.DumpLogSegments --files
128 ~]# kafka-run-class.sh kafka.tools.DumpLogSegments --files /tmp/kafka/log/topic-log-3/00000000000000000000.log --print-data-log

#从Kafka 2.0.0开始使用
128 ~]# kafka-dump-log.sh --files /tmp/kafka/log/topic-log-0/00000000000000000000.log
128 ~]# kafka-dump-log.sh --files /tmp/kafka/log/topic-log-0/00000000000000000000.log --print-data-log

3、日志索引

  • 每个日志分段文件有两个索引文件,主要用来提高查找消息的效率:
    • 偏移量索引文件用来建立消息偏移量(offset)到物理地址之间的映射关系,方便快速定位消息所在的物理文件位置。
    • 时间戳索引文件则根据指定的时间戳(timestamp)来查找对应的偏移量信息。
  • Kafka中的索引文件以稀疏索引(sparse index)的方式构造消息的索引,它并不保证每个消息在索引文件中都有对应的索引项。每当写入一定量(由broker端参数log.index.interval.bytes指定,默认值为4096,即4KB)的消息时,偏移量索引文件和时间戳索引文件分别增加一个偏移量索引项和时间戳索引项,增大或减小log.index.interval.bytes的值,对应地可以增加或缩小索引项的密度。
  • 稀疏索引通过MappedByteBuffer将索引文件映射到内存中,以加快索引的查询速度。
    • 偏移量索引文件中的偏移量是单调递增的,查询指定偏移量时,使用二分查找法来快速定位偏移量的位置,如果指定的偏移量不在索引文件中,则会返回小于指定偏移量的最大偏移量
    • 时间戳索引文件中的时间戳也保持严格的单调递增,查询指定时间戳时,也根据二分查找法来查找不大于该时间戳的最大偏移量,至于要找到对应的物理文件位置还需要根据偏移量索引文件来进行再次定位
  • 稀疏索引的方式是在磁盘空间、内存空间、查找时间等多方面之间的一个折中。
  • 对非当前活跃的日志分段而言,其对应的索引文件内容已经固定而不需要再写入索引项,所以会被设定为只读。
  • 对当前活跃的日志分段(activeSegment)而言,索引文件还会追加更多的索引项,所以被设定为可读写。
  • Kafka在创建索引文件的时候会为其预分配log.index.size.max.bytes大小的空间,注意这一点与日志分段文件不同,只有当索引文件进行切分的时候,Kafka才会把该索引文件裁剪到实际的数据大小。也就是说,与当前活跃的日志分段对应的索引文件的大小固定为log.index.size.max.bytes,而其余日志分段对应的索引文件的大小为实际的占用空间
  • Kafka强制要求索引文件大小必须是索引项大小的整数倍.
    • 对偏移量索引文件而言,必须为8的整数倍。如果broker端参数log.index.size.max.bytes配置为67,那么Kafka在内部会将其转换为64,即不大于67,并且满足为8的整数倍的条件。
    • 对偏时间戳索引文件而言,必须为12的整数倍。同样假设broker端参数log.index.size.max.bytes配置内67,那么对应于时间戳索引文件,Kafka在内部会将其转换为60。

1、偏移量索引

  • 偏移量索引项的格式如图所示。每个索引项占用8个字节,分为两个部分。
    • relativeofet:相对偏移量,表示消息相对于baseOffset的偏移量,占用4个字节,当前索引文件的文件名即为baseOffset的值。
    • position:物理地址,也就是消息在日志分段文件中对应的物理位置,占用4个字节。

  • 消息的偏移量(offset)占用8个字节,也可以称为绝对偏移量。索引项中没有直接使用绝对偏移量而改为只占用4个字节相对偏移量(relativeOffset = offset - baseOfset),这样可以减小索引文件占用的空间。举个例子,一个日志分段的baseOffset为32,那么其文件名就是00000000000000000032.log,offset为35的消息在索引文件中的relativeOffset的值为35-32=3。
  • 如果要查找偏移量为23的消息:
    • 首先通过二分法在偏移量索引文件中找到不大于23的最大索引项,即[22,65]。
    • 然后从日志分段文件中的物理位置656开始顺序查找偏移量为23的消息。

  • 如果要查找偏移量为268的消息:
    • 首先定位到baseOffset为251的日志分段。
    • 然后计算相对偏移量relativeOffset=268-251=17,之后再在对应的索引文件中找到不大于17的索引项。
    • 最后根据索引项中的position定位到具体的日志分段文件位置开始查找目标消息。
  • 那么又是如何查找baseOffset为251的日志分段的呢?
    • 这里并不是顺序查找,而是用了跳跃表的结构。Kafka的每个日志对象中使用了ConcurrentSkipListMap来保存各个日志分段,每个日志分段的baseOffset作为key,这样可以根据指定偏移量来快速定位到消息所在的日志分段。

2、时间戳索引

  • 时间戳索引项的格式如图所示。每个索引项占用12个字节,分为两个部分。
    • timestamp:当前日志分段最大的时间戳。
    • relativeOffset:时间戳所对应的消息的相对偏移量。

  • 时间戳索引文件中包含若干时间戳索引项,每个追加的时间戳索引项中的timestamp必须大于之前追加的索引项的timestamp,否则不予追加。
    • 如果broker端参数log.message.timestamp.type设置为LogAppendTime,那么消息的时间戳必定能够保持单调递增。
    • 如果是CreateTime类型,则无法保证时间戳能够单调递增。
    • 生产者可以使用类似ProducerRecord(String topic,Integerpartition,Long timestamp,K key,V value)的方法来指定时间戳的值。即使生产者客户端采用自动,插入的时间戳也无法保证时间戳能够单调递增。
    • 如果两个不同时钟的生产者同时往一个分区中插入消息,那么也会造成当前分区的时间戳乱序。
  • 每当写入一定量的消息时,就会在偏移量索引文件和时间戳索引文件中分别增加一个偏移量索引项和时间戳索引项。两个文件增加索引项的操作是同时进行的,但并不意味着偏移量索引中的relativeOffset和时间戳索引项中的relativeOffst是同一个值
  • 如果要查找指定时间戳targetTimeStamp=1526384718288开始的消息,首先是找到不小于指定时间戳的日志分段。这里就无法使用跳跃表来快速定位到相应的日志分段了,需要分以下几个步骤来完成。
    • 步骤1:将targetTimeStamp和每个日志分段中的最大时间戳largestTimeStamp逐一对比,直到找到不小于targetTimeStamp的largestTimeStamp所对应的日志分段。日志分段中的largestTimeStamp的计算是先查询该日志分段所对应的时间戳索引文件,找到最后一条索引项,若最后一条索引项的时间戳字段值大于0,则取其值,否则取该日志分段的最近修改时间。
    • 步骤2:找到相应的日志分段之后,在时间戳索引文件中使用二分查找算法查找到不大于targetrimeStamp的最大索引项,即[1526384718283,28],如此便找到了一个相对偏移量28。
    • 步骤3:在偏移量索引文件中使用二分算法查找到不大于28的最大索引项,即[26,838]。
    • 步骤4:从步骤1中找到日志分段文件中的838的物理位置开始查找不小于targetTimeStamp的消息。

3、切分日志文件

  • 切分日志文件,满足以下条件中的一个就会进行:
    • (1)当前日志分段文件的大小超过了broker端参数log.segment.bytes配置的值。log.segment.bytes参数的默认值为1073741824,即1GB。(根据日志文件大小,切分日志文件)
    • (2)当前日志分段中消息的最大时间戳与当前系统的时间戳的差值大于log.roll.ms或log.roll.hours参数配置的值。如果同时配置了log.roll.ms和log.roll.hours参数,那么log.roll.ms的优先级高。默认情况下,只配置了log.roll.hours参数,其值为168即7天。(根据日志文件时间,切分日志文件)
    • (3)偏移量索引文件或时间戳索引文件的大小达到broker端参数log.index.size.max.bytes配置的值。log.index.size.max.bytes默认值为10485760,即10MB。(根据索引文件大小,切分日志文件)
    • (4)追加的消息的偏移量与当前日志分段的偏移量之间的差值大于Integer.MAXVALUE,即要追加的消息的偏移量不能转变为相对偏移量(offset- baseOffset > Integer.MAX VALUE)

4、日志清理

  • Kafka提供了两种日志清理策略。
    • 日志删除(Log Retention):按照一定的保留策略直接删除不符合条件的日志分段。
    • 日志压缩(Log Compaction):针对每个消息的key进行整合,对于有相同key的不同value值,只保留最后一个版本。
  • 可以通过broker端参数log.cleanup.policy来设置日志清理策略:
    • 日志删除:log.cleanup.policy="delete",默认配置
    • 日志压缩:log.cleanup.policy="compact",并且还需要将log.cleaner.enable(默认值为true)设定为true。
    • 同时支持日志删除和日志压缩两种策略:log.cleanup.policy="delete,compact"
  • 日志清理的粒度可以控制到主题级别,比如与log.cleanup.policy对应的主题级别的参数为cleanup.policy。

1、日志删除

  • 在Kaka的日志管理器中有一个专门的日志删除任务来周期性地检测和删除不符合保留条件的日志分段文件,这个周期可以通过broker端参数log.retention.check.interval.ms来配置,默认值为300000,即5分钟。
  • 当前日志分段的保留策略有3种:
    • 基于时间的保留策略。
    • 基于日志大小的保留策略。
    • 基于日志起始偏移量的保留策略。

1、基于时间

  • 日志删除任务会检查当前日志文件中是否有保留时间超过设定的阈值(retentionMs)来寻找可删除的日志分段文件集合(deletablesegments ),如图所示。
    • retentionMs可以通过broker端参数log.retention.hours、log.retention.minutes和log.retention.ms来配置,其中log.retention.ms的优先级最高,log.retention.minutes次之,log.retention.hours最低。
    • 默认情况下只配置了log.retention.hours参数,其值为168,故默认情况下日志分段文件的保留时间为7天

 

  • 查找过期的日志分段文件,并不是简单地根据日志分段的最近修改时间lastModifiedTime来计算的,而是根据日志分段中最大的时间戳largestTimeStamp来计算的。
    • 因为日志分段的lastModifiedTime可以被有意或无意地修改,比如执行了touch操作,或者分区副本进行了重新分配, lastModifiedTime并不能真实地反映出日志分段在磁盘的保留时间。
    • 要获取日志分段中的最大时间戳largestTimeStamp的值,首先要查询该日志分段所对应的时间戳索引文件,查找时间戳索引文件中最后一条索引项,若最后一条索引项的时间戳字段值大于0,则取其值,否则才设置为最近修改时间lastModifiedTime。
  • 若待删除的日志分段的总数等于该日志文件中所有的日志分段的数量,那么说明所有的日志分段都已过期,但该日志文件中还要有一个日志分段用于接收消息的写入,即必须要保证有一个活跃的日志分段activeSegment,在此种情况下,会先切分出一个新的日志分段作为activeSegment,然后执行删除操作
  • 删除日志分段时
    • 首先会从Log对象中所维护日志分段的跳跃表中移除待删除的日志分段,以保证没有线程对这些日志分段进行读取操作。
    • 然后将日志分段所对应的所有文件添加上".deleted"的后缀(当然也包括对应的索引文件) 。
    • 最后交由一个以"delete-file"命名的延迟任务来删除这些以"deleted"为后缀的文件,这个任务的延迟执行时间可以通过file.delete.delay.ms参数来调配,此参数的默认值为60000,即1分钟。

2、基于日志大小

  • 日志删除任务会检查当前日志的大小是否超过设定的阈值(retentionSize)来寻找可删除的日志分段的文件集合(deletableSegments), 如图所示。
    • retentionSize可以通过broker端参数log.retention.bytes来配置,默认值为-1,表示无穷大。
    • 注意log.retention.bytes配置的是Log中所有日志文件的总大小,而不是单个日志分段(确切地说应该为.log日志文件)的大小。
    • 单个日志分段的大小由broker端参数log.segment.bytes来限制,默认值为1073741824,即1GB。

  • 查找可删除的日志分段文件
    • 首先计算日志文件的总大小size和retentionsize的差值diff,即计算需要删除的日志总大小。
    • 然后从日志文件中的第一个日志分段开始进行查找可删除的日志分段的文件集合deletablesegments,查找出deletableSegments之后就执行删除操作。
  • 删除操作和基于时间的保留策略的删除操作相同。

3、基于日志起始偏移量

  • 一般情况下,日志文件的起始偏移量logStartOffset等于第一个日志分段的baseOffset,但这并不是绝对的,logStartOffiset的值可以通过DeleteRecordsRequest请求(比如使用KafkaAdminClient的deleteRecords()方法、使用kafka-delete-records.sh脚本)、日志的清理和截断等操作进行修改。
  • 基于日志起始偏移量的保留策略的判断依据是某日志分段的下一个日志分段的起始偏移量baseOffset是否小于等于logStartOffset,若是,则可以删除此日志分段。如图所示,假设logStartOffiset等于25,日志分段1的起始偏移量为0,日志分段2的起始偏移量为11,日志分段3的起始偏移量为23,通过如下动作收集可删除的日志分段的文件集合deletableSegments:
    • (1)从头开始遍历每个日志分段,日志分段1的下一个日志分段的起始偏移量为11,小于logStartOffset的大小,将日志分段1加入deletablesegments。
    • (2)日志分段2的下一个日志偏移量的起始偏移量为23,也小于logStartOffset的大小,将日志分段2页加入deletableSegments。
    • (3)日志分段3的下一个日志偏移量大于logStartOffset,故从日志分段3(包含)开始的所有日志分段都不会加入deletableSegments。

  • 删除操作和基于时间的保留策略的删除操作相同。

2、日志压缩

  • 日志压缩的过程:
    • (1)选择哪些日志文件进行日志压缩操作
      • activeSegment不参与Log Compaction(日志压缩)。(log.cleaner.min.compaction.log.ms参数可以增加不参与的日志分段)
    • (2)进行日志压缩操作的条件
      • 达到最小污浊率时,才会进行日志压缩
    • (3)将要进行日志压缩的文件进行分组
    • (4)筛选要保留的消息
  • Log Compaction(日志压缩)对于有相同key的不同value值,只保留最后一个版本,如图所示。
    • 如果应用只关心key对应的最新value值,则可以开启Kafka的日志清理功能,Kafka会定期将相同key的消息进行合并,只保留最新的value值。
    • 读者在遇到“压缩”之时需格外注意这个压缩是指日志压缩(LogCompaction),还是指消息压缩(Message Compression),因为这是两种完全不同的行为。 

  • Log Compaction执行前后,日志分段中的每条消息的偏移量和写入时的偏移量保持一致。Log Compaction会生成新的日志分段文件,日志分段中每条消息的物理位置会重新按照新文件来组织。Log Compaction执行过后的偏移量不再是连续的,不过这并不影响日志的查询。
  • 如果一个系统使用Kafka来保存状态,那么每次有状态变更都会将其写入Kafka。在某一时刻此系统异常崩溃,进而在恢复时通过读取Kafka中的消息来恢复其应有的状态,那么此系统关心的是它原本的最新状态而不是历史时刻中的每一个状态。
    • 如果Kafka的日志保存策略是日志删除(Log Deletion),那么系统势必要一股脑地读取Kafka中的所有数据来进行恢复。
    • 如果日志保存策略是Log Compaction,那么可以减少数据的加载量进而加快系统的恢复速度。Log Compaction在某些应用场景下可以简化技术栈,提高系统整体的质量。
  • Kafka中用于保存消费者消费位移的主题__consumer_offsets使用的就是Log Compaction策略。

1、选择哪些日志文件进行日志压缩操作

  • 每一个日志目录下都有一个名为"cleaner-offset-checkpoint"的清理检查点文件,用来记录每个主题的每个分区中已清理的偏移量。
    • 通过清理检查点文件可以将Log分成两个部分,如图所示。
      • 通过检查点cleaner checkpoint来划分出一个已经清理过的clean部分和一个还未清理过的dirty部分
    • 在日志清理的同时,客户端也可以读取日志中的消息。
      • dirty部分的消息偏移量是逐一递增的,而clean部分的消息偏移量是断续的,如果客户端总能赶上dirty部分,那么它就能读取日志的所有消息,反之就不可能读到全部的消息。

  • dirty部分(如图所示):起始偏移量是firstDirtyofset(与cleaner checkpoint相等),截止偏移量是firstUncleanableOfset,整个dirty部分的偏移量范围为[firstDirtyOffset, firstUncleanableOfiset),注意这里是左闭右开区间
    • 为了避免当前活跃的日志分段activeSegment成为热点文件,activeSegment不会参与Log Compaction的执行
    • 同时Kafka支持通过参数log.cleaner.min.compaction.log.ms(默认值为0)来配置消息在被清理前的最小保留时间默认情况下firstUncleanableOffset等于activeSegment的baseOffset
  • 注意Log Compaction是针对key的,所以在使用时应注意每个消息的key值不为null

2、进行日志压缩操作的条件

  • 每个broker会启动log.cleaner.thread(默认值为1)个日志清理线程负责执行清理任务,这些线程会选择“污浊率”最高的日志文件进行清理。
    • 用cleanBytes表示clean部分的日志占用大小,dirtyBytes表示dirty部分的日志占用大小,那么这个日志的污浊率(dirtyRatio)为:
dirtyRatio = dirtyBytes / (cleanBytes + dirtyBytes)
    • 为了防止日志不必要的频繁清理操作,Kafka还使用了参数log.cleaner.min.cleanable.ratio(默认值为0.5)来限定可进行清理操作的最小污浊率。

3、筛选要保留的消息

  • Kafka中的每个日志清理线程会使用一个名为"SkimpyOffsetMap"的对象来构建key与offset的映射关系的哈希表。
  • 日志清理需要遍历两次日志文件
    • 第一次遍历把每个key的哈希值和最后出现的offset都保存在SkimpyOffsetMap中,映射模型如图所示。
    • 第二次遍历会检查每个消息是否符合保留条件,如果符合就保留下来,否则就会被清理。
      • 假设一条消息的offset为O1,这条消息的key在SkimpyOffsetMap中对应的offset为O2,如果O1大于等于O2,即满足保留条件。

  • 默认情况下,SkimpyOffsetMap使用MDS来计算key的哈希值,占用空间大小为16B,根据这个哈希值来从SkimpyOfisetMap中找到对应的槽位,如果发生冲突则用线性探测法处理。
    • 为了防止哈希冲突过于频繁,也可以通过broker端参数log.cleaner.io.buffer.load.factor(默认值为0.9)来调整负载因子。
    • 偏移量占用空间大小为8B,故一个映射项占用大小为24B,每个日志清理线程的SkimpyOffsetMap的内存占用大小为log.cleanerdedupe.buffer.size / log.cleaner.thread,默认值为=128MB/1 = 128MB。所以默认情况下SkimpyOffsetMap可以保存128MB x 0.9  / 24B = 5033164个key的记录。
      • 假设每条消息的大小为1KB,那么这个SkimpyOffsetMap可以用来映射4.8GB的日志文件,如果有重复的key,那么这个数值还会增大,整体上来说,SkimpyOffsetMap极大地节省了内存空间且非常高效。
  • 如果遇到两个不同的key但哈希值相同的情况,那么其中一个key所对应的消息就会丢失。虽然MDS摘要算法的冲突概率非常小,但根据墨菲定律,任何一个事件,只要具有大于0的概率,就不能假设它不会发生,所以在使用Log Compaction策略时要注意这一点。 
  • Log Compaction会保留key相应的最新value值,那么当需要删除一个key时怎么办?
    • Kafka提供了一个墓碑消息(tombstone)的概念,如果一条消息的key不为null,但是其value为null,那么此消息就是墓碑消息
  • 日志清理线程发现墓碑消息时会先进行常规的清理,并保留墓碑消息一段时间
    • 墓碑消息的保留条件是当前墓碑消息所在的日志分段的最近修改时间lastModifiedTime大于deleteHorizonMs。
    • 这个deleteHlorizonMs的计算方式为clean部分中最后一个日志分段的最近修改时间减去保留阈值deleteRetionMs(通过broker端参数log.cleaner.delete.retention.ms配置,默认值为86400000,即24小时)的大小,即
deleteHorizonMs = clean部分中最后一个LogSegment的lastModifiedTime - deleteRetionMs

4、将要进行日志压缩的文件进行分组

  • Log Compaction执行过后的日志分段的大小会比原先的日志分段的要小,为了防止出现太多的小文件, Kafka在实际清理过程中并不对单个的日志分段进行单独清理,而是将日志文件中offset从0至firstUncleanableOffset的所有日志分段进行分组,每个日志分段只属于一组。
  • 分组策略为:按照日志分段的顺序遍历,每组中日志分段的占用空间大小之和不超过segmentsize(可以通过broker端参数log.segment.bytes设置,默认值为1GB) ,且对应的索引文件占用大小之和不超过maxIndexSize (可以通过broker端参数log.index.interval.bytes设置,默认值为10MB) 。
  • 同一个组的多个日志分段清理过后,只会生成一个新的日志分段
  • 执行日志压缩的过程如图所示:
    • 假设所有的参数配置都为默认值,在Log Compaction之前checkpoint的初始值为0。
    • 执行第一次Log Compaction之后,每个非活跃的日志分段的大小都有所缩减,checkpoint的值也有所变化。
    • 执行第二次Log Compaction时会组队成[0.4GB, 0.4GB]、[0.3GB, 0.7GB]、[0.3GB]、[1GB]这4个分组,并且从第二次Log Compaction开始还会涉及墓碑消息的清除。
    • 第三次Log Compaction过后的情形可参考图的尾部。
      • Log Compaction过程中会将每个日志分组中需要保留的消息复制到一个以".clean"为后缀的临时文件中,此临时文件以当前日志分组中第一个日志分段的文件名命名,例如00000000000000000000.log.clean。
      • 然后Log Compaction过后将".clean"的文件修改为".swap"后缀的文件,例如00000000000000000000.log.swap。
      • 最后删除原本的日志文件,才把文件的".swap"后缀去掉。
      • 整个过程中的索引文件的变换也是如此,至此一个完整Log Compaction操作才算完成。

5、磁盘存储

  • Katka使用磁盘来存储和缓存消息。
    • 从文件系统层面分析,Kafka操作的都是普通文件,并没有依赖于特定的文件系统,但是依然推荐使用EXT4或XFS,尤其是对XFS而言,它通常有更好的性能,这种性能的提升主要影响的是Kalka的写入性能。
  • Kafka实现高吞吐的重要因素
    • Kafka使用消息顺序追加(顺序写入磁盘)
    • Kafka使用页缓存(文件系统并依赖于页缓存)
    • Katka使用零拷贝
  • Kafka依赖于文件系统(更底层地来说就是磁盘)来存储和缓存消息
  • 如图所示,磁盘处于一个比较尴尬的位置,这不禁让我们怀疑Kafka采用这种持久化形式能否提供有竞争力的性能。然而,事实上磁盘可以比我们预想的要快,也可能比我们预想的要慢,这完全取决于我们如何使用它。

  • 在传统的消息中间件RabbitMQ中,是使用内存作为默认的存储介质,而磁盘作为备选介质,以此实现高吞吐和低延迟的特性。
  • 有关测试结果表明,一个由6块7200r/min的RAID-5阵列组成的磁盘簇的线性(顺序)写入速度可以达到600MB/s,而随机写入速度只有100KB/s,两者性能相差6000倍。操作系统可以针对线性读写做深层次的优化,比如预读(read-ahead,提前将一个比较大的磁盘块读入内存)和后写(write behind,将很多小的逻辑写操作合并起来组成一个大的物理写操作)技术。
  • 顺序写盘的速度不仅比随机写盘的速度快,而且也比随机写内存的速度快,如图所示。

  • Kafka在设计时采用了文件追加的方式来写入消息,即只能在日志文件的尾部追加新的消息,并且也不允许修改已写入的消息,这种方式属于典型的顺序写盘的操作,所以就算Kafka使用磁盘作为存储介质,它所能承载的吞吐量也不容小觑。

1、页缓存

  • 页缓存是操作系统实现的一种主要的磁盘缓存,以此用来减少对磁盘I/O的操作。具体来说,就是把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问。
    • 当一个进程准备读取磁盘上的文件内容时,操作系统会先查看待读取的数据所在的页(page)是否在页缓存(pagecache)中,如果存在(命中)则直接返回数据,从而避免了对物理磁盘的1/0操作;如果没有命中,则操作系统会向磁盘发起读取请求并将读取的数据页存入页缓存,之后再将数据返回给进程。
    • 如果一个进程需要将数据写入磁盘,那么操作系统也会检测数据对应的页是否在页缓存中,如果不存在,则会先在页缓存中添加相应的页,最后将数据写入对应的页。被修改过后的页也就变成了脏页,操作系统会在合适的时间把脏页中的数据写入磁盘,以保持数据的一致性。
  • Linux操作系统中和脏页有关的参数:
    • vm.dirty_background_ratio参数用来指定当脏页数量达到系统内存的百分之多少之后就会触发pdflush/flush/kdmflush等后台回写进程的运行来处理脏页,一般设置为小于10的值即可,但不建议设置为0。
    • vm.dirty_ratio参数,它用来指定当脏页数量达到系统内存的百分之多少之后就不得不开始对脏页进行处理,在此过程中,新的IO请求会被阻挡直至所有脏页被冲刷到磁盘中。
    • 脏页还有vm.dirty_expire_centisecs、vm.dirty_writeback.centisecs等参数。
  • 对一个进程而言,它会在进程内部缓存处理所需的数据,然而这些数据有可能还缓存在操作系统的页缓存中,因此同一份数据有可能被缓存了两次。并且,除非使用Direct I/O的方式,否则页缓存很难被禁止。此外,用过Java的人一般都知道两点事实:对象的内存开销非常大,通常会是真实数据大小的几倍甚至更多,空间使用率低下;Java的垃圾回收会随着堆内数据的增多而变得越来越慢。
  • 基于这些因素,使用文件系统并依赖于页缓存的做法明显要优于维护一个进程内缓存或其他结构,至少我们可以省去了一份进程内部的缓存消耗,同时还可以通过结构紧凑的字节码来替代使用对象的方式以节省更多的空间。如此,我们可以在32GB的机器上使用28GB至30GB的内存而不用担心GC所带来的性能问题。此外,即使Kafka服务重启,页缓存还是会保持有效,然而进程内的缓存却需要重建。这样也极大地简化了代码逻辑,因为维护页缓存和文件之间的一致性交由操作系统来负责,这样会比进程内维护更加安全有效。
  • Kafka中大量使用了页缓存,这是Kafka实现高吞吐的重要因素之一。
  • 消息都是先被写入页缓存,然后由操作系统负责具体的刷盘任务的。Kaka提供了同步刷盘间断性强制刷盘(fsync)的功能,这些功能可以通过log.flush.interval.messages、log.flush.interval.ms等参数来控制。
    • log.flush.interval.messages参数是每当producer写入多少条消息时,刷数据到磁盘。
    • log.flush.interval.ms参数是每间隔多长时间,刷数据到磁盘。
    • 同步刷盘可以提高消息的可靠性,防止由于机器掉电等异常造成处于页缓存而没有及时写入磁盘的消息丢失。不过笔者并不建议这么做,刷盘任务就应交由操作系统去调配,消息的可靠性应该由多副本机制来保障,而不是由同步刷盘这种严重影响性能的行为来保障。
  • Linux系统会使用磁盘的一部分作为swap分区,这样可以进行进程的调度:把当前非活跃的进程调入swap分区,以此把内存空出来让给活跃的进程。
    • 对大量使用系统页缓存的Kafka而言,应当尽量避免这种内存的交换,否则会对它各方面的性能产生很大的负面影响。可以通过修改vm.swappiness参数(Linux系统参数)来进行调节。
    • vm.swappiness参数的上限为100,它表示积极地使用swap分区,并把内存上的数据及时地搬运到swap分区中;vm.swappiness参数的下限为0,表示在任何情况下都不要发生交换(vm.swappiness=0的含义在不同版本的Linux内核中不太相同,这里采用的是变更后的最新解释),这样一来,当内存耗尽时会根据一定的规则突然中止某些进程。笔者建议将这个参数的值设置为1,这样保留了swap的机制而又最大限度地限制了它对Kafka性能的影响。

2、磁盘I/O流程

  • 从编程角度而言,一般磁盘I/O的场景有以下四种,如图所示。
    • (1)用户调用标准C库进行1/0操作,数据流为:应用程序buffer --> C库标准IObuffer --> 文件系统页缓存 --> 通过具体文件系统到磁盘。
    • (2)用户调用文件1O,数据流为:应用程序buffer --> 文件系统页缓存 --> 通过具体文件系统到磁盘。
    • (3)用户打开文件时使用O_DIRECT,绕过页缓存直接读写磁盘。
    • (4)用户使用类似dd工具,并使用direct参数,绕过系统cache与文件系统直接写磁盘。

  • 发起IO请求的步骤可以表述为如下的内容(以最长链路为例):
    • 写操作:用户调用fwrite把数据写入C库标准IObuffer后就返回,即写操作通常是异步操作;数据写入C库标准IObuffer后,不会立即刷新到磁盘,会将多次小数据量相邻写操作先缓存起来合并,最终调用write函数一次性写入(或者将大块数据分解多次write调用)页缓存;数据到达页缓存后也不会立即刷新到磁盘,内核有pdflush线程在不停地检测脏页,判断是否要写回到磁盘,如果是则发起磁盘I/0请求。
    • 读操作:用户调用fread到C库标准IObuffer中读取数据,如果成功则返回,否则继续;到页缓存中读取数据,如果成功则返回,否则继续;发起IO请求,读取数据后缓存buffer和C库标准IObuffer并返回。可以看出,读操作是同步请求
    • IO请求处理:通用块层根据IO请求构造一个或多个bio结构并提交给调度层;调度器将bio结构进行排序和合并组织成队列且确保读写操作尽可能理想:将一个或多个进程的读操作合并到一起读,将一个或多个进程的写操作合并到一起写,尽可能变随机为顺序(因为随机读写比顺序读写要慢) ,读必须优先满足,而写也不能等太久
  • 针对不同的应用场景,I/O调度策略也会影响I/O的读写性能,目前Linux系统中的IO调度策略有4种,分别为NOOP、CFQ、DEADLINE和ANTICIPATORY,默认为CFQ。

3、零拷贝

  • Katka使用零拷贝(Zero-Copy)技术来进一步提升性能。
  • 零拷贝是指将数据直接从磁盘文件复制到网卡设备中,而不需要经由应用程序之手。零拷贝大大提高了应用程序的性能,减少了内核和用户模式之间的上下文切换。
    • 对Linux操作系统而言,零拷贝技术依赖于底层的sendfile()方法实现。
    • 对应于Java语言,FileChannal.transferTo()方法的底层实现就是sendfile()方法。

1、非零拷贝技术

  • 首先调用read()将静态内容(这里假设为文件A)读取到tmpbuf,然后调用write()将tmp buf,写入Socket,如图所示。
  • 在这个过程中,文件A经历了4次复制的过程:
    • (1)调用read()时,文件A中的内容被复制到了内核模式下的Read Buffer中。
    • (2)CPU控制将内核模式数据复制到用户模式下。
    • (3)调用write()时,将用户模式下的内容复制到内核模式下的Socket Buffer中。
    • (4)将内核模式下的Socket Buffer的数据复制到网卡设备中传送。

  • 从上面的过程可以看出,数据平白无故地从内核模式到用户模式“走了一圈”,浪费了2次复制过程:第一次是从内核模式复制到用户模式;第二次是从用户模式再复制回内核模式,即上面4次过程中的第2步和第3步。而且在上面的过程中,内核和用户模式的上下文的切换也是4次。

2、零拷贝技术

  • 如果采用了零拷贝技术,那么应用程序可以直接请求内核把磁盘中的数据传输给Socket,如图所示。

  • 零拷贝技术通过DMA(Direct Memory Access)技术将文件内容复制到内核模式下的ReadBuffer中。不过没有数据被复制到Socket Buffer,相反只有包含数据的位置和长度的信息的文件描述符被加到Socket Buffer中。DMA引擎直接将数据从内核模式中传递到网卡设备(协议引擎)。这里数据只经历了2次复制就从磁盘中传送出去了,并且上下文切换也变成了2次。零拷贝是针对内核模式而言的,数据在内核模式下实现了零拷贝。 
posted @ 2021-11-19 17:32  麦恒  阅读(141)  评论(0编辑  收藏  举报