五、Kafka日志存储

目录

  • 1. 文件目录布局
  • 2. 日志格式的演变
    • 2.1 v0版本
    • 2.2 v1版本
    • 2.3 消息压缩
    • 2.4 字段变长
    • 2.5 v2版本
  • 3. 日志索引
    • 3.1 偏移量索引
    • 3.2 时间戳索引
  • 4. 日志清理
    • 4.1 日志删除
    • 4.2 日志压缩

1. 文件目录布局

Kafka消息是以主题为单位进行归类的,各个主题逻辑上相互独立。一个主题包含多个分区。消息在发送的时候根据分区规则追加到指定分区,分区中每条消息都会分配一个序列号,也就是偏移量offset。

不考虑多副本的情况,一个分区对应一个日志(Log)。为了防止Log过大,Kafka引入了日志分段(LogSegment)的概念,将Log切成多个LogSegment,相当于一个大文件平均拆分成了一个小文件,这样利于消息的维护和清理。实际上,Log和LogSegment也不是纯粹物理意义上的概念,Log物理上只以文件夹的形式存储,每个LogSegment对应磁盘上一个日志文件和两个索引文件,以及其他文件(.txnindex为后缀的事务索引文件)。

五、Kafka日志存储_第1张图片
可以查看自己的主题文件夹-

在这里插入图片描述
向Log里面追加消息时是顺序写入的,只有最后一个LogSegment才能执行写入操作,在此之前的所有LogSegment都是不能写入消息的。为了方便描述,我们将最后一个LogSegment称为“activeSegment”,表示当前活跃的日志分段。随着消息写入,activeSegment满足一定条件时,就需要创建新的activeSegment,之后的消息将写入新的activeSegment。

为了方便消息的检索,每个LogSegment中的日志文件(“.log"结尾)都有两个对应的索引文件:偏移量索引文件(”.index"结尾)和时间戳索引文件(".timeindex"结尾)。每个LogSegment都有一个基准偏移量baseOffset,用来表示当前LogSegment第一条消息的offset。偏移量是一个64位的长整形数字,日志文件和两个索引文件都是根据基准偏移量(baseOffset)命名的,名称固定为20位数字,没有达到用0填充。

五、Kafka日志存储_第2张图片
第二个LogSegment第一条消息偏移量为133,同时也说明第一个LogSegment共有133条消息(偏移量0 - 132)。

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

第一次启动Kafka服务器的时候,默认根目录下会创建5个文件。

五、Kafka日志存储_第3张图片
消费位移是保存在Kafka内部主题__consumer_offsets中的,初始情况这个主题并不存在,当第一次有消费者消费消息时会自动创建这个主题。

某一时刻,Kafka文件目录布局如下。每一个根目录都会包含最基本的4个检查点文件(xxx-checkpoint)和meta.properties。创建主题的时候,如果当前broker中不止配置了一个根目录,那么会挑选分区最少的那个根目录完成本次创建任务。

五、Kafka日志存储_第4张图片

2. 日志格式的演变

随着kafka的迅猛发展,kafka消息格式不断升级改进,从0.8.x到2.0.0,消息格式历经了三个版本:v0、v1、v2。

如果消息格式设计不够精炼,功能和性能都会大打折扣。如有冗余字段,增加不必要的占用空间,进而使存储开销变大、网络传输开销变大,是Kafka性能下降。如果缺少字段,对内部而言,影响日志保存、切分策略,对外部而言,影响消息审计、端到端的延迟、大数据应用等功能扩展。

2.1 v0版本

年代久远,就不错深入探讨了。
五、Kafka日志存储_第5张图片

2.2 v1版本

五、Kafka日志存储_第6张图片

2.3 消息压缩

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

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

注意事项:压缩率是压缩后的大小与压缩前的对比。如:100MB的文件压缩后是90MB,压缩率为90 / 100 * 100% = 90%,压缩率越小,压缩效果越好。

消息压缩后是将整个消息集进行压缩作为内部消息(inner message),内存消息整体作为外层(wrapper message)的value。如下图

压缩有外层消息的key为null,value是多条压缩消息。当生产者创建压缩消息的时候,对内部压缩消息设置的offset从0开始为每个内部消息分配offset。
五、Kafka日志存储_第7张图片
五、Kafka日志存储_第8张图片

其实每个生产者发出的消息集的消息offset都是从0开始的,当然这个offset不能直接存储在日志文件中,offset的转换是在服务端进行的,客户端不需要关心。外层消息保存了内层消息中最后一条的绝对位移(absolute offset),绝对位移是相对整个分区而言。未压缩的情况,上图右边内层消息的最后一条的offset理应是1030,但是压缩后变成了5,而这个1030被赋予了外层的offset。当消费者消费这个消息集的时候,首先解压整个消息集,然后找到内层消息中最后一条消息的inner offset,根据以下公式找到内层消息中最后一条消息前面消息的absolute offset(RO表示Relative offset,IO表示Inner Offset,而AO表示Absolute Offset)。

RO = IO_of _a_message - IO_of the latest_message
AO = AO_Of_Last_Inner_Message + RO

注意这里的RO是前面的消息相对最后一条消息的IO而言的,所以其值小于等于0,0表示最后一条消息在自身。

压缩消息,英文是compress message,Kafka中还有一个compact message,常常被直接译为压缩消息,需要注意两者的区别。compact message是针对日志清理策略而言的(cleanup.policy = compact),是指日志压缩(Log Compaction)后的消息。这里的压缩消息单指compress message,即采用GZIP、LZ4等压缩工具压缩的消息。

2.4 字段变长

,有机会再补充。

2.5 v2版本

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

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

  • length:消息的总长度
  • attributes:弃用,但还是在消息格式中占据1B的大小,以备未来的扩展。
  • timestamp delta:时间戳增量。通常一个 timestamp 需要占用 8 个字节,如果像这里一样保存与 RecordBatch 的起始时间戳差值,则可以进一步省略占用字节数。
  • offset delta:位移增量。保存于 RecordBatch 起始位移的差值,节省占用字节数。
  • headers:用来支持应用级别的扩展,包含 key 和 value,一个 Record 里面可以包含 0 或者多个 Header。

v2消息,还多了以下字段。

  • first offset:当前 RecordBatch 的其实位移。
  • length:计算从 partition leader epoch 字段开始到末尾的长度。
  • partition leader epoch:分区 ledaer 纪元,可以看做分区 leader 的版本号或者更新次数。
  • magic:消息格式的版本号,对 v2 而言,magic 等于 2。
  • attributes:消息属性,注意这里占用了两个字节。低 3 位表示压缩格式;第 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 一样,用来支持幂等和事务。
  • records count:RecordBatch 中 Record 的个数。

3. 日志索引

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

Kafka中的索引文件以稀疏索引(sparse index)的方式构造消息索引。当写入一定量(borker 断log.index.interval.bytes指定,默认为 4KB)的消息时,偏移量索引文件和时间戳索引文件分别增加一个偏移量项和时间戳项,增大或者减小 log.index.interval.bytes 的值,可以增加或者减小索引项密度。

稀疏索引通过 MappedByteBuffer 将索引文件映射到内存中。稀疏索引的方式是在磁盘空间、内存空间、查找时间等多个方面的一个折中。

日志分段切分包含以下几个条件,满足其一即可。

  • 当日志分段文件的大小超过了 broker 端参数 log.segment.bytes 配置的值,默认 1GB。
  • 当日志分段中消息的最大时间戳与当前系统的时间戳差值大于 log.roll.ms 或者 log.roll.hours 配置的值。同时配置以 log.roll.ms 为准,默认只配置了 log.roll.hours,7天。
  • 偏移量索引文件或者时间戳索引文件达到 broker 端参数 log.index.size.max.bytes 配置,默认 10MB。
  • 追加的消息的偏移量与当前日志分段的偏移量之间的差值大于 Integer.MAX_VALUE,即要追加的消息的偏移量不能转换为相对偏移量(offset - baseOffset > Integer.MAX_VALUE)。

非活跃日志分段,索引文件内容已固定而不再需要写入索引,会被设置为只读。而对于当前活跃日志分段而言,索引文件还会追加更多的索引项,设定为可读可写。在索引文件切分的时候,Kafka 会关闭正在写入的索引文件并设置为只读,同时可读可写的模式创建新的索引文件,大小由 broker 端的 log.index.size.max.bytes 配置。Kafka 在创建索引的时候会为其分配 log.index.size.max.bytes 大小的空间,在索引文件切分的时候,才会把索引文件裁剪到数据数据大小。当前活跃的日志文件对应的索引文件固定大小 log.index.size.max.bytes,其余日志分段对应的索引文件大小占用实际空间。

3.1 偏移量索引

偏移量索引项的格式如下图,每个索引项 8 个字节,分为两部分

  • relativeOffset:相对偏移量,表示消息相对 baseOffset 的偏移量,占用 4 个字节,当前索引文件的文件名即为 baseOffset 的值。
  • position:物理地址,也就是消息在日志分段文件中对应的物理位置,占用 4 个字节。

五、Kafka日志存储_第10张图片
消息偏移量(offset)占用 8 个字节,也可以成为绝对偏移量。索引项中没有直接使用绝对偏移量而改为只占用 4 个字节的绝对偏移量(relativeOffset = offset - baseOffset),这样就可以减少索引文件占用空间。再者,追加的消息的偏移量与当前日志分段的偏移量差大于 Integer.MAX_VALUE。如果大于 Integer.MAX_VALUE,那么 relativeOffset 就不能用 4 个字节表示了。

我们可以使用 kafka-dump-log.sh 脚本来解析 .index 文件,还包括 .timeindex、.snapshot、.txnindex 等文件。

> bin/kafka-dump-log.sh --files kafka-logs/hello-0/00000000000000000000.index

Dumping kafka-logs\hello-0\00000000000000000000.index
offset: 0 position: 0

我这里是因为消息比较小。

对照下图做进一步的解释。
五、Kafka日志存储_第11张图片
这里我们需要找偏移量为 23 的消息,怎么做?首先通过二分法在偏移量索引文件中找到不大于 23 的最大索引项,即[22, 656],然后从日志分段文件中的物理位置 656 开始顺序查找偏移量为 23 的消息。

上面是最简单的一种情况。如下图,要查找偏移量为 268 的消息,应该怎么做?

五、Kafka日志存储_第12张图片
步骤一:找对对应的日志分段,Kafka 的每个日志对象中使用了 ConcreteSkipListMap 来保存各个日志分段,每个日志分段的 baseOffset 作为 key,这样可以根据指定偏移量来快读定位消息所在的日志分段。这里找到的是 baseOffset = 251 的日志分段。
步骤二:计算相对偏移量,relative = 268 - 251 = 17
步骤三:在对应的索引文件中找到不大于 17 的索引项
步骤四:根据索引项中的 position 定位到具体的日志分段文件位置开始查找目标消息。

注意:Kafka 强制要求索引文件大小必须是索引项的整数倍,对偏移量索引文件而言,必须为 8 的整数倍。

3.2 时间戳索引

时间戳索引项格式如下

五、Kafka日志存储_第13张图片

每个索引项占用 12 个字节

  • timestamp:当前日志分段的最大时间戳
  • offset:时间戳对应的消息的相对偏移量

时间戳索引文件包含多个时间戳项,每个追加的时间戳索引项的 timestamp 必须大于之前追加的所有项的 timestamp,否则不予追加。broker 端参数 log.message.timestamp.type 设置为 LogAppendTime,那么消息的时间戳必定能够单调递增;如果是 CreateTime 类型则无法保证。生产者可以自己指定时间戳,即使生产者控制时间自动生成也无法保证单调递增,好比两个生产者时钟不一样。

时间戳索引文件的大小必须是索引项大小(12B)的整数倍。


参考上图,如果要查询时间戳 targetTimestamp = 1526384718288 开始的消息,需要经过下面几个步骤
步骤一:将 targetTimestamp 和每个日志分段中最大的时间戳 largestTimestamp 逐一对比,直到找到不小于 targetTimestamp 的 largestTimestamp 对饮的日志分段。日志分段中 largestTimestamp 的计算是先查询该日志分段对应的时间戳索引文件,找到最后一条索引项,若最后一条索引项的时间戳字段值大于 0,则取其值,否则取该日志分段的最近修改时间。
步骤二:找到日志分段后,在时间戳索引文件中使用二分查找算法找到不大于 targetTimestamp 的最大索引项,即[[1526384718283, 28],这里就找到了一个相对偏移量 28。
步骤三:在偏移量索引文件中使用二分算法查找到不大于 28 的最大索引项,即[26, 838]。
步骤四:从步骤1找到的日志分段文件中的838的物理位置开始查找不小于 targetTimestamp 的消息。

4. 日志清理

Kafka 消息储存在磁盘中,为了控制磁盘占用空间的不断增加就需要对消息做一定的清理操作。Kafka 提供了两种日志清理策略。

  • 日志删除(Log Retention):按照一定的保留策略直接删除不符合条件的日志分段。
  • 日志压缩(Log Compaction):针对每个消息的 key 进行整合,针对有相同 key 的不同 value 值,只保留最后一个版本。

broker 端的 log.cleanup.policy 设置日志清理策略,默认值为 delete,即采用日志删除的清理策略。日志压缩,设置为 compact,并且需要将 log.cleaner.enable (默认 true)设置为 true。设置为 delete,compact,表示同时支持删除和压缩。日志清理的粒度可以控制到主题级别。

4.1 日志删除

Kafka 的日志管理器中会有一个专门的日志删除任务来周期性地检查和删除不符合保留条件的日志分段文件,通过broker 端参数 log.retention.check.interval.ms 来配置,默认 5 分钟。日志分段保留策略有三种:基于时间的保留策略、基于日志大小的保留策略和基于日志起始偏移量的保留策略。

  1. 基于时间

日志删除任务会检查当前日志文件中是否有保留时间超过设定的阈值(retentionMs)来寻找可以删除的日志分段集合(deletableSegments),如下图。retentionMs 可以通过 broker 端参数 log.retention.hourslog.retention.minuteslog.retention.ms 来配置,优先级为 log.retention.mslog.retention.minuteslog.retention.hours 依次降低。默认情况下日志分段文件保留时间为 7 天。
五、Kafka日志存储_第14张图片
查找过期的日志分段文件,并不是简单地根据日志分段的最近修改时间 lastModifiedTime 来计算的,而是根据日志分段中最大的时间戳 largestTimeStamp 来计算的。

若待删除的日志分段的总数等于该日志文件中所有的日志分段的数量,那么说明所有的日志分段都已过期,但该日志文件中还要有一个日志分段用于接收消息的写入,即必须要保证有一个活跃的日志分段 activeSegment,在此种情况下,会先切分出一个新的日志分段作为 activeSegment,然后执行删除操作。

删除日志分段时,首先会从 Log 对象中所维护日志分段的跳跃表中移除待删除的日志分段,以保证没有线程对这些日志分段进行读取操作。然后将日志分段所对应的所有文件添加上 “.deleted” 的后缀(当然也包括对应的索引文件)。最后交由一个以 “delete-file” 命名的延迟任务来删除这些以 “.deleted” 为后缀的文件,这个任务的延迟执行时间可以通过 file.delete.delay.ms 参数来调配,此参数的默认值为60000,即1分钟。

  1. 基于日志大小

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

五、Kafka日志存储_第15张图片
基于日志大小的保留策略与基于时间的保留策略类似,首先计算日志文件的总大小 size 和retentionSize 的差值 diff,即计算需要删除的日志总大小,然后从日志文件中的第一个日志分段开始进行查找可删除的日志分段的文件集合 deletableSegments。查找出 deletableSegments 之后就执行删除操作。

  1. 基于日志起始偏移量

一般情况下,日志文件的起始偏移量 logStartOffset 等于第一个日志分段的 baseOffset,但这并不是绝对的,logStartOffset 的值可以通过 DeleteRecordsRequest 请求(比如 KafkaAdminClient 的 deleteRecords() 方法、kafka-delete-records.sh 脚本)、日志清理和截断等操作进行修改。

基于日志起始偏移量的保留策略的判断依据是某日志分段的下一个日志分段的起始偏移量 baseOffset 是否小于等于 logStartOffset,若是,则可以删除此日志分段。如下图,假设 logStartOffset 等于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。

4.2 日志压缩

Kafka 中的 Log Compaction 是指在默认的日志删除(Log Retention)规则之外提供的一种清理数据的方式。如下图,Log Compaction 对于具有相同 key 的不同 value 值,只保留最后一个版本。如果应用只关心 key 对于的最新 value 值,则可以开启 Kafka 的日志清理功能,Kafka 会定期将相同 key 的消息进行合并,只保留最新的 value。

五、Kafka日志存储_第16张图片
Log Compaction 执行前后,日志分段中的每条消息的偏移量和写入时的偏移量保持一致。Log Compaction 会生成新的日志分段文件,日志分段中每条消息的物理位置会重新按照新文件来组织。Log Compaction 执行过后的偏移量不再是连续的,不过这并不影响日志的查询。

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

五、Kafka日志存储_第17张图片
上图中的 firstDirtyOffset (与 cleaner checkpoint 相等)表示 dirty 部分的起始偏移量,而firstUncleanableOffset 为 dirty 部分的截止偏移量,整个 dirty 部分的偏移量范围为[firstDirtyOffset, firstUncleanableOffset),注意这里是左闭右开区间。为了避免当前活跃的日志分段 activeSegment 成为热点文件,activeSegment 不会参与 Log Compaction 的执行。同时 Kafka 支持通过参数 log.cleaner.min.compaction.lag.ms(默认值为 0) 来配置消息在被清理前的最小保留时间,默认情况下 firstUncleanableOffset 等于 activeSegment 的 baseOffset。

注意 Log Compaction 是针对 key 的,所以在使用时应注意每个消息的 key 值不为 null。每个 broker 会启动 log.cleaner.thread(默认值为 1) 个日志清理线程负责执行清理任务,这些线程会选择“污浊率”最高的日志文件进行清理。用 cleanBytes 表示 clean 部分的日志占用大小,dirtyBytes 表示dirty部分的日志占用大小,那么这个日志的污浊率(dirtyRatio)为:

dirtyRatio = dirtyBytes / (cleanBytes + dirtyBytes)

为了防止日志不必要的频繁清理操作,Kafka 还使用了参数 log.cleaner.min.cleanable.ratio(默认值为0.5)来限定可进行清理操作的最小污浊率。Kafka 中用于保存消费者消费位移的主题 __consumer_offsets 使用的就是 Log Compaction 策略。

这里我们已经知道怎样选择合适的日志文件做清理操作,然而怎么对日志文件中消息的 key 进行筛选操作呢?Kafka 中的每个日志清理线程会使用一个名为 “SkimpyOffsetMap” 的对象来构建 key 与 offset 的映射关系的哈希表。日志清理需要遍历两次日志文件,第一次遍历把每个 key 的哈希值和最后出现的 offiset 都保存在 SkimpyOffsetMap 中,映射模型如下图所示。第二次遍历会检查每个消息是否符合保留条件,如果符合就保留下来,
否则就会被清理。假设一条消息的 offiset 为 O1,这条消息的 key 在 SkimpyOffsetMap 中对应的 offset 为 O2,如果 O1 大于等于 O2 即满足保留条件。

五、Kafka日志存储_第18张图片
默认情况下,SkimpyOffsetMap 使用 MD5 来计算 key 的哈希值,占用空间大小为 16B,根据这个哈希值来从 SkimpyOffsetMap 中找到对应的槽位,如果发生冲突则用线性探测法处理。为了防止哈希冲突过于频繁,也可以通过 broker 端参数 log.cleaner.io.buffer.load.factor(默认值为0.9) 来调整负载因子。偏移量占用空间大小为 8B,故一个映射项占用大小为 24B。每个日志清理线程的 SkimpyOffsetMap 的内存占用大小为 log.cleaner.dedupe.buffer.size / log.cleaner.thread,默认值为 = 128MB / 1 = 128MB。所以默认情况下 SkimpyOffsetMap 可以保存 128MB × 0.9 / 24B ≈ 5033164 个 key 的记录。假设每条消息的大小为 1KB,那么这个 SkimpyOffsetMap 可以用来映射 4.8GB 的日志文件,如果有重复的 key,那么这个数值还会增大,整体上来说,SkimpyOffsetMap 极大地节省了内存空间且非常高效。

题外话:“SkimpyOffsetMap”的取名也很有意思,“Skimpy” 可以直译为“不足的”,可以看出它最初的设计者也认为这种实现不够严谨。如果遇到两个不同的 key 但哈希值相同的情况,那么其中一个 key 所对应的消息就会丢失。虽然说 MD5 这类摘要算法的冲突概率非常小,但根据墨菲定律,任何一个事件,只要具有大于 0 的概率,就不能假设它不会发生,所以在使用 Log Compaction 策略时要注意这一点。

Log Compaction 会保留 key 相应的最新 value 值,那么当需要删除一个 key 时怎么办?Kafka 提供了一个墓碑消息 (tombstone) 的概念,如果一条消息的 key 不为 null,但是其 value 为 null,那么此消息就是墓碑消息。日志清理线程发现墓碑消息时会先进行常规的清理,并保留墓碑消息一段时间。墓碑消息的保留条件是当前墓碑消息所在的日志分段的最近修改时间 lastModifiedTime 大于 deleteHorizonMs。这个 deleteHorizonMs 的计算方式为 clean 部分中最后一个日志分段的最近修改时间减去保留阈值 deleteRetionMs(通过broker 端参数 log.cleaner.delete.retention.ms 配置,默认值为86400000,即24小时)的大小,即:

deleteHorizonMs = clean 部分中最后一个 LogSegment 的 lastModifiedTime - deleteRetionMs

墓碑消息的保留条件为:

所在 LogSegment 的 lastModifiedTime > deleteHorizonMs
	=> 所在 LogSegment 的 lastModifiedTime > clean 部分中最后一个 LogSegment 的 lastModifiedTime - deleteRetionMs
	=> 所在 LogSegment 的 lastModifiedTime + deleteRetionMs clean部分中最后一个 LogSegment 的lastModifiedTime

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 操作才算完成。

五、Kafka日志存储_第19张图片
以上是整个日志压缩(Log Compaction)过程的详解,读者需要注意将日志压缩和日志删除区分开,日志删除是指清除整个日志分段,而日志压缩是针对相同key的消息的合并清理。

你可能感兴趣的:(kafka,kafka,分布式,java)