千亿消息“过眼云烟”?Kafka 把硬盘当内存用的性能魔法,全靠这一手!
Kafka 消息队列
Apache Kafka 是一个开源的分布式消息队列,由 LinkedIn 公司开发并于 2011 年贡献给 Apache 软件基金会。Kafka 被设计用来处理千亿量级的实时数据,被广泛应用于互联网大规模数据处理平台中。


Kafka 强大的数据吞吐量,其中最重要部分在于它的消息日志格式的设计,包括几个特别重要的概念,主题(topic)、分区(partition)、分段(segment)、偏移量(offset)。1)主题:Kafka 按主题对消息进行分类。 主题是逻辑上的概念。2)分区:硬盘实际根据分区存储日志。一个主题下通常有多个分区,分区分布在不同的 broker 上,这使 Kafka 提供给了并行的消息处理和横向扩容能力。并且分区通常会分组,每组有一个主分区、多个副本分区,并分布在不同的 broker 上,从而起到容灾的作用。3)分段:宏观上看,一个分区对应一个日志(Log)。由于生产者生产的消息会不断追加到 log 文件末尾,为了防止 log 日志过大,Kafka 又引入了日志分段(LogSegment)的概念,将 log 切分为多个 LogSegement,相当于一个巨型文件被平均分配为相对较小的文件,这样也便于消息的维护和清理。Log 和 LogSegement 也不是纯粹物理意义上的概念,Log 在物理上只是以文件夹的形式存储,而每个 LogSegement 对应于磁盘上的一个日志文件(.log)和两个索引文件(.index、timeindex),以及可能的其他文件(比如以.txindex 为后缀的事务索引文件)。4)偏移量:消息在日志中的位置,消息在被追加到分区日志文件的时候都会分配一个特定的偏移量。偏移量是消息在分区中的唯一标识,是一个单调递增且不变的值。Kafka 通过它来保证消息在分区内的顺序性,不过偏移量并不跨越分区,也就是说,Kafka 保证的是分区有序而不是主题有序。
日志格式

Kafka 日志是以批为单位进行日志存储的,所谓的批指的是 Kafka 会将多条日志压缩到同一个批次(Batch)中,然后以 Batch 为单位进行后续的诸如索引的创建和消息的查询等工作。对于每个批次而言,其默认大小为 4KB,并且保存了整个批次的起始偏移量和时间戳等元数据信息,而对于每条消息而言,其偏移量和时间戳等元数据存储的则是相对于整个批次的元数据的增量,通过这种方式,Kafka 能够减少每条消息中数据占用的磁盘空间。

Kafka 将日志消息只写入 Page Cache,而 Page Cache 中的数据通过 Linux 的 flusher 程序进行异步刷盘,将数据顺序追加写到硬盘日志文件中。由于 Page Cache 是在内存中进行缓存,因此读写速度非常快。顺序追加写充分利用顺序 I/O 写操作,可有效提升 Kafka 的吞吐量。Kafka 甚至鼓励使用足够的内存让活跃数据集能完全驻留在 Page Cache 中。

具体到日志文件中的每个 LogSegement 都有一个基准偏移量 (baseOffset),用来标识当前 LogSegement 中第一条消息的 offset。偏移量是一个 8 字节的长整形。日志文件和两个索引文件都是根据基准偏移量命名的,名称固定为 20 位数字,没有达到的位数则用 0 填充。比如第一个 LogSegment 的基准偏移量为 0,对应的日志文件为 00000000000000000000.log。

示例中第 2 个 LogSegment 对应的基准偏移量是 256,也说明了该 LogSegment 中的第一条消息的偏移量为 256,同时可以反映出第一个 LogSegment 中共有 256 条消息(偏移量从 0 至 255 的消息)。
日志索引 Kafka 主要有两种类型的索引文件:偏移量索引文件(.indx)和时间戳索引文件(.timeindex)。偏移量索引文件中存储的是消息的偏移量与该偏移量所对应的消息的物理地址;时间戳索引文件中则存储的是消息的时间戳与该消息的偏移量值。Kafka 中的索引文件以稀疏索引(Sparse index)的方式构造消息的索引,它并不保证每个消息在索引文件中都有对应的索引项。如上日志格式中所述,Kafka 至少写入 4KB 消息数据之后,才会在索引文件中增加一个索引项。
偏移量索引偏移量索引分为 2 个部分,总共占 8 个字节。1)relativeOffset(4B):消息的相对偏移量,即 offset - baseOffset,其中 baseOffset 为整个 segmentLogFile 的起始消息的 offset。2)position(4B):物理地址,也就是日志在分段日志文件中的实际位置。

假设 Kafka 需要找出偏移量为 23 的消息:1)首先通过二分法找到不大于 23 的最大偏移量索引【22,656】;2)然后从 position(656)开始顺序查找偏移量为 23 的消息。

时间戳索引时间戳索引分为 2 部分,公占 12 个字节。1)timestamp:当前日志分段文件中建立索引的消息的时间戳。2)relativeoffset:时间戳对应消息的相对偏移量。

假设 Kafka 需要找出偏移量为 28 的消息:1)在时间戳索引文件中找到不大于该时间戳的最大时间戳对应的最大索引项【1526384718283,28】;2)在偏移量索引文件中检索不超过对应 relativeoffset(28)的最大偏移量索引的项【26,838】;3)按照偏移量索引的检索方式找到对应的具体消息。

日志清理 Kafka 提供了两种日志清理策略。1)日志删除(Log Deletion):按照一定的保留策略来直接删除不符合条件的日志分段。2)日志合并(Log Compaction):针对每个消息的 key 进行整合,对于有相同 key 的的不同 value 值,只保留最后一个版本。
日志删除 Kafka 的默认清理策略是日志删除,主要有三种方式。1)基于日志大小:检查当前日志的大小 size 是否超过设定的阈值 retentionSize 的差值,来寻找可删除的日志分段的文件集合 deletableSegments。

2)基于时间:检查当前日志文件中是否有保留时间超过设定的阈值 retentionMs,来寻找可删除的的日志分段文件集合 deletableSegments。

3)基于日志起始偏移量:检查某日志分段的下一个日志分段的 baseOffset 是否小于等于 logStartOffset,来寻找可删除的的日志分段文件集合 deletableSegments。

日志合并对于特定主题(通常用于存储状态变更或配置信息),可以开启日志合并。它确保对于每个消息 Key,分区中至少保留其最新 Value 的记录。旧版本的值会被清理。压缩过程在后台进行,扫描日志段,对具有相同 Key 的消息,只保留最新偏移量(即最新版本)的那条。已标记为删除(Value 为 null)的 Key,若其所有版本都被压缩,则该 Key 最终会被移除。这类似于 LSM 树的合并思想,适用于那些只需要关心每个 Key 最新状态的场景。

总结: 殊途同归,拥抱顺序 I/O 的制胜法宝在现代高性能存储系统的设计中,一个关键出发点在于将存储介质的物理特性(如速度、成本、寿命和访问模式)与实际业务需求(如读写比例、数据量和延迟要求)完美结合,并通过合适的数据结构和 I/O 策略实现最优匹配。以 MySQL 的 InnoDB 引擎为例,为应对关系型数据库中复杂的范围查询和排序需求,InnoDB 采用了 B+树索引。这种结构具备高扇出、多层级的特点,能够将逻辑查找高效映射到磁盘页访问。同时,B+树的叶子节点之间通过链表连接,配合顺序预读和操作系统的 Page Cache 机制,使得在读密集型或混合负载场景下具备良好性能。RocksDB 则采用了不同的路径。它基于 LSM 树的设计理念,先将写入数据暂存于内存(MemTable),再批量、有序地写入磁盘(SSTable 文件)。后台的合并机制异步合并文件、清理冗余,从而保持读写平衡。这种将随机写转化为顺序写的方式,特别适合 SSD 介质,能大幅提升写入吞吐量。在 Kafka 中,核心思路则是将消息写入分区日志,以追加的方式顺序写入磁盘。每个分区天然支持并行扩展,Kafka 利用了机械硬盘的顺序写优势,同时在 SSD 上也有良好的性能表现。加上操作系统 Page Cache 的高效缓存机制,它在实际场景中甚至可以提供接近内存级别的读写速度。这三种系统虽然应用场景不同,底层结构各异,但有一个共识:顺序 I/O 是提升存储系统性能的关键路径。它们都充分利用了 Page Cache 和底层硬件的特点,体现了技术设计中对硬件现实的尊重与对系统性能的极致追求。
很高兴与你相遇!
如果你喜欢本文内容,记得关注哦!!!
版权声明: 本文为 InfoQ 作者【poemyang】的原创文章。
原文链接:【http://xie.infoq.cn/article/0ab067d2607a6cba17a9396cb】。文章转载请联系作者。
评论