日志清理(四)

日志清理

Kafka 将消息存储在磁盘中,为了控制磁盘占用空间的不断增加就需要对消息做一定的清理操作。Kafka 中每一个分区副本都对应一个 Log,而 Log 又可以分为多个日志分段,这样也便于日志的清理操作。Kafka 提供了两种日志清理策略:

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

我们可以通过 broker 端参数 log.cleanup.policy 来设置日志清理策略,此参数的默认值为“delete”,即采用日志删除的清理策略。如果要采用日志压缩的清理策略,就需要将 log.cleanup.policy 设置为“compact”,并且还需要将 log.cleaner.enable (默认值为 true)设定为 true。通过将 log.cleanup.policy 参数设置为“delete,compact”,还可以同时支持日志删除和日志压缩两种策略。

日志清理的粒度可以控制到主题级别,比如与 log.cleanup.policy 对应的主题级别的参数为 cleanup.policy,为了简化说明,本节只采用 broker 端参数做陈述,topic 级别的参数可以查看《图解Kafka之实战指南》的相关章节

日志删除

在 Kafka 的日志管理器中会有一个专门的日志删除任务来周期性地检测和删除不符合保留条件的日志分段文件,这个周期可以通过 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天。

5-13

 

查找过期的日志分段文件,并不是简单地根据日志分段的最近修改时间 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。

5-14

 

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

3. 基于日志起始偏移量

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

5-15

 

基于日志起始偏移量的保留策略的判断依据是某日志分段的下一个日志分段的起始偏移量 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。

收集完可删除的日志分段的文件集合之后的删除操作同基于日志大小的保留策略和基于时间的保留策略相同,这里不再赘述。

日志压缩

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

 

有很多中文资料会把 Log Compaction 翻译为“日志压缩”,笔者认为不够妥当,压缩应该是指 Compression,在 Kafka 中消息可以采用 gzip、Snappy、LZ4 等压缩方式进行压缩,如果把 Log Compaction 翻译为日志压缩,容易让人和消息压缩(Message Compression)产生关联,其实是两个不同的概念。英文“Compaction”可以直译为“压紧、压实”,如果这里将 Log Compaction 直译为“日志压紧”或“日志压实”又未免太过生硬。考虑到“日志压缩”的说法已经广为用户接受,笔者这里勉强接受此种说法,不过文中尽量直接使用英文 Log Compaction 来表示日志压缩。读者在遇到类似“压缩”的字眼之时需格外注意这个压缩具体是指日志压缩(Log Compaction)还是指消息压缩(Message Compression)。

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

Kafka 中的 Log Compaction 可以类比于 Redis 中的 RDB 的持久化模式。试想一下,如果一个系统使用 Kafka 来保存状态,那么每次有状态变更都会将其写入 Kafka。在某一时刻此系统异常崩溃,进而在恢复时通过读取 Kafka 中的消息来恢复其应有的状态,那么此系统关心的是它原本的最新状态而不是历史时刻中的每一个状态。如果 Kafka 的日志保存策略是日志删除(Log Deletion),那么系统势必要一股脑地读取 Kafka 中的所有数据来进行恢复,如果日志保存策略是 Log Compaction,那么可以减少数据的加载量进而加快系统的恢复速度。Log Compaction 在某些应用场景下可以简化技术栈,提高系统整体的质量。

我们知道可以通过配置 log.dir 或 log.dirs 参数来设置 Kafka 日志的存放目录,而每一个日志目录下都有一个名为“cleaner-offset-checkpoint”的文件,这个文件就是清理检查点文件,用来记录每个主题的每个分区中已清理的偏移量。

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

5-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 的哈希值和最后出现的 offset 都保存在 SkimpyOffsetMap 中,映射模型如下图所示。第二次遍历会检查每个消息是否符合保留条件,如果符合就保留下来,否则就会被清理。假设一条消息的 offset 为 O1,这条消息的 key 在 SkimpyOffsetMap 中对应的 offset 为 O2,如果 O1 大于等于 O2 即满足保留条件。

5-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

所以墓碑消息的保留条件为(可以对照往上第二张图中的 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)。同一个组的多个日志分段清理过后,只会生成一个新的日志分段。

5-19

 

如上图所示,假设所有的参数配置都为默认值,在 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 操作才算完成。

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

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值