Kafka日志存储

日志存储

文件目录布局

分区的每个副本都会占用一定的文件描述符,系统的文件描述符数量是有限的,因此分区数不能太大。

在不考虑多副本的情况下,一个分区对应一个日志(Log)。 为了防止Log过大, Kafka又引入了日志分段(LogSegment)的概念, 将Log切分为多个LogSegment, 相当于一个巨型文件被平均分配为多个相对较小的文件, 这样也便于消息的维护和清理。 事实上, Log 和 LogSegnient也不是纯粹物理意义上的概念, Log在物理上只以文件夹的形式存储, 而每个 LogSegment对应于磁盘上的 一 个日志文件 和两个索引文件(偏移量索引文件和时间戳索引文件), 以及可能的其他文件(比如以".txnindex"为后缀的事务索引文件)。

由于一个broker会作为leader负责不同topic的多个partition,因此broker将多个partition的消息写到日志是随机写的,Rocketmq是磁盘顺序写的。
Log对应了 一 个命名形式为<topic>-<partition>的文件夹。 举个例子, 假设有 一 个名为"topic-log" 的主题 , 此主题中具有4 个分区, 那么在实际物理存储上表现为 "topic-log-0","topic-log-1", "topic-log-2","topic-log-3"这4个文件夹。向Log中追加 消息时是顺序写入的, 只有最后 一 个LogSegment才能执行写入操作。
为了便于消息的检索, 每个LogSegment中的日志文件 (以 " .log"为文件后缀)都有对应 的两个索引文件:偏移量 索引文件(以".index"为文件后缀)和时间戳索引文件(以" .timeindex" 为文件后缀) 。 每个LogSegment都有一个基准偏移量baseOffset, 用来表示当前LogSegment 中第一条消息的offset。 偏移量是 一 个64位的长整型数, 日志文件和两个索引文件都是根据 基 准偏移量(baseOffset)命名 的, 名称固定为20 位数字, 没有达到的位数则用 0 填充。 比如第一个LogSegment的基准偏移量为O, 对应的日志文件为00000000000000000000.log。

日志格式

忽略V0版本

V1版本

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

timestamp类型由broker 端参 数log.message.timestamp.type来配置, 默认值为CreateTime, 即采用生产者创建消息时的时间戳。 如果在创建 ProducerRecord 时没有显式指定消息的时间戳, 那么KafkaProducer 也会在发送这条消息前自动添加上。 下面是KafkaProducer中与此对应的一句关键代码:

long timestamp = record.timestamp () == null ? time.milliseconds() : record.timestamp();

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

消息压缩

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

V2版本

生产者客户端中的 ProducerBatch 对应这里的RecordBatch。

offset delta :位移增量。 保存与 RecordBatch 起始位移的差值 , 可以节省占用的字节数

根据 Varints 的 规则可 以推导出 0~63(2^6) 之间的数字占 1 个字节, 64~8191 (2^13)之间的数字占 2 个字节, 8192~ 1048575 之间的数字占 3 个字节。而 Kafka broker 端配置 message.max.bytes 的默认大小为 1000012 ( Varints 编码占 3 个字节) ,如果消息格式中与长度有关的字段采用 Varints 的编码,那么只需最多3字节就够了,比原来4字节的长度字节节约1个字节。不过需要注意的是, Varints 并非一直会节省空间,一个 int32 最长会占用 5 个字节(大于 默认的 4 个字节) , 一个 int64 最长会占用 10 个字节(大于默认的 8 个字节)。

对于 v1 版本的消息,如果用户指定的 timestamp 类型是 LogAppendTime 而不是 CreateTime ,那么消息从生产者进入 broker 后, timestamp 字段会被更新,此时消息的 crc 值将被重新计算,而此值在生产者中己经被计算过一次 。 在这些类似的情况下,一条消息从 生产者到消费者之间流动时, crc 的值是变动的,需要计算两次 crc 的值。而对于V2版本,一个RecordBatch中的多条消息,生产者和消费者只会计算一次crc。

V2版本相比V1版本的改进:
1、将公用的crc32,magic,attributes字段移动到最外面
2、使用timestamp delta和offset delta减少长度(外层放置first offset和first timestamp)
3、所有的整数字段使用varint进一步减少长度

对于单条消息,V2版本要比之前V1版本占用更多的空间,但当消息数量变多后,V2版本的消息相比V1版本将占用更少的空间。

日志中的索引

每个日志分段(Log Segment)文件对应了两个索引文件:
偏移量索引文件用来建立消息偏移量(offset)到物理地址之间的映射关系。
时间戳索引文件用来建立时间戳到偏移量之间的映射关系。

日志分段文件达到一定的条件时需要进行切分,其对应的索引文件也需要进行切分。对非当前活跃的日志分段而言,其对应的索引文件内容己经固定而不需要再写入索引项, 会被设定为只读 。而对当前活跃的日志分段 (Active Segment )而言,索引文件还会追加更多的索引项,所以被设定为可读写。

Kafka中的索引文件以稀疏索引( sparse index )的方式构造消息的索引,它并不保证每个 消息在索引文件中都有对应的索引项 。 每当写入一定量(由 broker 端参数 log.index.interval.bytes 指定,默认值为 4096 ,即4KB)的消息时,偏移量索引文件和时间戳索引文件分别增加一个偏移量索引项和时间戳索引项。

偏移量索引

偏移量索引项的格式如图 5-8 所示。每个索引项分为两个部分。 ( 1 ) relativeOffset:相对偏移量,表示消息相对于当前日志分段 baseOffset 的偏移量,当前索引文件的文件名包含 baseOffset 的值。 ( 2) position:物理地址,也就是消息在日志分段文件中对应的物理位置。

查找目标offset的消息的方式
(1)先找文件:在跳表(ConcurrentSkipListMap维护)中找到<= 目标offset的baseOffset最大的LogSegment。
(2)在文件中查找:通过二分查找,找到<= 目标offset的relativeOffset最大的索引项,从索引项的position位置开始往后查找。

时间戳索引

(1)timestamp :当前日志分段最大的时间戳。(2)relativeOffset:时间戳所对应的消息的相对偏移量。

每个追加的时间戳索引项中的 timestamp 必须大于之前追加的索引项的timestamp ,否则不予追加 。 如果 broker 端参数 log.message.timestamp.type 设置为 LogAppendTime, 那么消息的时间戳必定能够保持单调递增:相反, 如果是 CreateTime(默认) 类型则无法保证 。如果两个不同时钟的生产者同时往一个分区中插入消息, 那么就会造成当前分区的时间戳乱序。时间戳乱序会导致现在的方法查找不准确

两个索引文件增加索引项的操作是同时进行的, 但并不意味着偏移量索引中的 relativeOffset 和时间戳索引项中的 relativeOffset 是同一个值 。

查找目标timestamp的消息的方式
(1)先找文件:由于日志文件和两个索引文件都是根据基准偏移量(baseOffset)命名的,文件名中不包含时间戳信息,因此只能使用顺序查找:从前往后找到第一个目标timestamp <= largestTimeStamp的日志分段( 日志分段的largestTimeStamp是取该日志分段对应的时间戳索引文件的最后一条索引项的时间戳)
(2)在文件中查找:在时间戳索引文件中通过二分查找,找到<= 目标timestamp的 timestamp最大的索引项,
取出目标relativeOffset,然后在偏移量索引文件中通过二分查找,找到<= 目标relativeOffset的 relativeOffset最大的索引项,从索引项的position位置开始往后查找。

日志清理

日志删除

1、基于时间的删除
查询日志分段对应时间戳索引文件中最后一条索引项, 若最后一条索引项的时间戳小于保留时间(now - retentionMs)则删除该日志分段
2、基于日志大小的删除
3、基于日志起始偏移量的删除

日志压缩(Log Compaction)

Log Compaction对于有相同key的不同value值, 只保留最后 一 个版本。如果应用只关心key对应的最新value值,则可以开启Kafka的日志清理功能, Kafka会定期将相同key的消息进行合并, 只保留最新的value值。

Kafka中的Log Compaction 可以类比于Redis中的RDB的待久化模式。 试想一下,如果 一 个系统使用 Kafka来保存状态, 那么每次有状态变更都会将其写入Kafka 。 在某 一 时刻 此系统 异常崩溃, 进而在恢复时通过读取Kafka中的消息来恢复其应有的状态, 那么我们关心的是此系统每条数据的最新状态而不是历史时刻中的每 一 个状态。

Kafka中用于保存消费者消费位移的主题 __consumer_offsets使用的就是Log Compaction策略。用于保留最新的offset。

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

Kafka使用SkimpyOffsetMap(类似hashtable,采用线性探测法处理冲突)来存储每个key最大的offset。日志压缩需要遍历两次日志文件, 第 一 次遍历把每个 key和最后出现的offset都保存在SkimpyOffsetMap中 ,第 二次遍历如果发现消息的offset小于SkimpyOffsetMap中的offset,则标记删除该消息。需要注意由于历史日志文件可能会很多,不可能把全部日志文件中的数据都加载到SkimpyOffsetMap(内存),因此可能出现具有相同的key的多条消息分布在不同日志分段文件中。

参数 min.compaction.lag.ms 用来控制 消息从产生到被压缩之前必须经过的最短时间。log.cleaner.delete.retention.ms: How long are delete records retained? 。
一条消息在被真正删除前会先打上delete标记(软删),过段时间后才是硬删。delete.retention.ms用于控制消息在软删状态持续的时间。

墓碑消息是key不为null, 但value为null的消息。墓碑消息可以删除SkimpyOffsetMap清理范围内的指定key的消息,且墓碑消息最终也会被删除。

Log Compaction执行过后的日志分段的大小会比原先的日志分段的要小, 为了防止出现太多的小文件 , Kafka 在实际清理过程 中并不对单个的日志分段进行单独清理,而是按照日志分段的顺序对所有日志分段进行分组, 每组中日志分段的大小之和不超过 log.segment.bytes (默认1GB),每个日志分段只属于一个组。同 一 个组的多个日志分段会映射到同一个SkimpyOffsetMap清理, 清理过后每个组只会生成一个新的日志分段 。 如果这个新生成的日志分段的体积小于log.segment.bytes ,则它在下次清理时还会和其他经过清理的日志分段组成新的组再次被清理,直到这个组生成的新日志分段的体积大于log.segment.bytes,因此一个日志分段会被清理多次以尽量去除重复的key。

常见错误2:__consumer_offsets占用太多的磁盘。
如果发现这个主题消耗了过多的磁盘空间,一定要显式地用jstack命令查看一下kafka-log-cleaner-thread前缀的线程状态。通常情况下,这都是因为该线程挂掉了,无法及时清理此内部主题。倘若真是这个原因导致的,那我们就只能重启相应的Broker了。另外,请你注意保留出错日志,因为这通常都是Bug导致的,最好提交到社区看一下。

posted @ 2022-12-27 21:29  zoo-keeper  阅读(293)  评论(0)    收藏  举报