日志清理
Kafka将消息存储在磁盘中,为了控制磁盘占用间的不断增加就需要对消息做一定的清理操作。Kafka 中每个分区副本都对应一个Log ,而Log又可以分为多个日志分段,这样也便于日志的清理操作。 Kafka 提供了两种日志清理策略。
- 日志删除(Log Retention):按照一定的保留策略直接删除不符合条件的日志分段。
- 日志压缩(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 端参数做陈述。
日志删除
在kafka 的日志管理器中会有一个专门的日志删除任务来周期性地检测和删除不符合保留条件的日志分段文件,这个周期可以通过 broke 端参数log.retention.check.interval.ms
来配置,默认值为 00000 ,即5分钟。当前日志分段的保留策略有3种: 基于时间的保留策略、基于日志大小的保留策略、基于日志起始偏移量的保留策略。
1、基于时间
日志删除任务会检查当前日志文件中是否有保留时间超过设定的阈值(retentionMs)来寻找可删除的日志分段文件集合( deletebleSegments ),如下图所示。 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.retention.bytes
来限制,默认值为1073741824,即 1GB。
基于日志大小的保留策略与基于时间的保留策略类似,首先计算日志文件的总大小 size和retentionSize差值diff,即计算需要删除的日志总大小,然后从日志文件中的第一个日志分段开始进行查找可删除的日志分段的文件集合 deletableSegments 。查找出deletableSegments之后就执行删除操作,这个删除操作和基于时间的保留策略的删除操作相同,这里不再赘述。
3、基于日志起始偏移量
一般情况下,日志文件的起始偏移logStartOffset等于第一个日志分段的baseOffset,但这并不是绝对的, logStartOffset的值可以通过DeleteRecordsRequest请求(比如使用KafkaAdminClient的deleteRecords()方法、使用kafka-delete-records.sh脚本)、日志的清理和截断等操作进行修改。
基于日志起始偏移量的保留策略的判断依据是某日志分段的下一个日志分段的起始偏移量baseOffset 是否小于等于 logStartOffset ,若是,则可以删除此日志分段。如下图所示,假设logStartOffset等于 25 ,日志分段1的起始偏移量为0,日志分段2的起始偏移量为 11,日志分的起始偏移量为23 ,通过如下动作收集可删除的日志分段的文件集合 deletableSegments:
- 从头开始遍历每个日志分段,日志分段1的下一个日志分段的起始偏移量11,小于logStartOffset 的大小,将日志分段1加入deletableSegments。
- 日志分段2的下一个日志偏移量的起始偏移量为23 ,也小于logStartOffset的大小,将日志分段2也加入 deletableSegments。
- 日志分段3的下一个日志偏移量在logStartOffset的右侧,故从日志分段3开始的所有日志分段都不会加入 deletableSegments。
收集完可删除的日志分段的文件集合之后的删除操作同基于日志大小的保留策略和基于时间的保留策略相同,这里不再赘述。
日志压缩
Kafka 中的 Log Compaction 是指在默认的日志删除( Log Retention )规则之外提供的一种清理过时数据的方式。如下图所示, Log Compaction 对于有相同 key 的不同 value 值,只保留最后一个版本。如果应用只关心 key 对应的最新 value 值,则可以开启 Kafka 的日志清理功能,Kafka 会定期将相同 key 的消息进行合井,只保留最新的 alue 值。
Log Compaction 执行前后,日志分段中的每条消息的偏移量和写入时的偏移量保持一致。Log Compaction 会生成新的日志分段文件,日志分段中每条消息的物理位置会重新按照新文件来组织。 Log Compaction 执行过后的偏移量不再是连续的,不过这并不影响日志的查询。
Kafka 中的 Log Compaction 可以类比于 Redis 中的 RDB 的持久化模式。试想一下,如果一个系统使用 Kafka 来保存状态,那么每次有状态变更都会将其写入 Kafka 在某一时刻此系统异常崩溃,进而在恢复时通过读取 Kafka 中的消息来恢复其应有的状态,那么此系统关心的是它原本的最新状态而不是历史时刻中的每一个状态 。如果 Kafka 的日志保存策略是日志删除(Log Deletion ),那么系统势必要一股脑地读取 Kafka 中的所有数据来进行恢复,如果日志保存策略是 Log Compaction ,那么可以减少数据的加载量进而加快系统的恢复速度。LogCompaction 在某些应用场景下可以简化技术找,提高系统整体的质量。
我们知道可以通过配置 log.dir
log.dirs
数来设置 Kafka 日志的存放目录,而每一个日志目录下都有一个名为“cleaner-offset-checkpoint ”的文件,这个文件就是清理检查点文件,用来记录每个主题的每个分区中己清理的偏移量。 通过清理检查点文件可以将 Log 分成两个部分,如下图所示,通过检查点 cleaner checkpoint来划分出一个己经清理过的 clean 部分和一个还未清理过的 dirty 部分,在日志清理的同时,客户端也可以读取日志中的消息。dirty部分的消息偏移量是逐一递增的,而 clean 部分的消息偏移量是断续的,如果客户端总能赶上dirty 部分,那么它就能读取日志的所有消息,反之就不可能读到全部的消息。
上图中的 firstDirtyOffset (与 cleaner checkpoint 等)表示 dirty 部分的起始偏移量,而firstUncleanableOffset为dirty 部分的截止偏移量,整个 dirty 部分的偏移量范围为
[firstDirtyOffset ,firstUncleanableOff] 注意这里是左闭右开区间。为了避免当前活跃的日志分段 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 部分的日志占用大小,dirtBytes 表示 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即满足保留条件。
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的lastModfiedTime > deleteHorizonMs
Log Compaction 行过后的日志分段的大小会比原先的日志分段的要小,为了防止出现太多的小文件, Kafka 在实际清理过程中并不对单个的日志分段进行单独清理, 而是将日志文件中offset从0到firstUncleanableOffset 的所有日志分段进行分组,每个日志分段只属于一组,
分组策略为:按照日志分段的顺序遍历,每组中日志分段的占用空间大小之和不超过 segmentSize(可以通过 broker端参数 log.segment bytes
设置,默认值为 IGB),且对应的索引文件占用大小之和不超过 maxIndexSize (可以通过 broker 端参数 log.index.interval.bytes
设置,默认值为 10MB )。同一个组的多个日志分段清理过后,只会生成一个新的日志分段。
如下图所示,假设所有的参数配置都为默认值,在 Log Compaction 之前 checkpoint 的初始值为0。执行第一次 Log Compaction 之后,每个非活跃的日志分段的大小都有所缩减,checkpoint 的值也有所变化。执行第二次 Log Compaction 会组队成[0.4GB,0.4G]、[3GB,0.7G]、[ 0.3G]、 [1GB]这4个分组,并且从第二次 Log Compaction 开始还会涉及墓碑消息的清除,同理,第三次 Log Compaction 过后的情形可参考下图的尾部。Log Compaction 过程中会将每个日志分组中需要保留的消息复制到一个以".clean"为后缀的临时文件中, 此临时文件以当前日志分组中第一个日志分段的文件名命名,例如 00000000000000000000.log.clean。LogCompaction 过后将"clean"的文件修改为"swap"后缀的文件,例如: 00000000000000000000.log.swap 。然后删除原本的日志文件,最后才把文件的".swap"后缀去掉。整个过程中的索引文件的变换也是如此,至此一个完整 Log Compaction 操作才算完成。