Kafka使用的是Logging(日志文件)这种很原始的方式来存储消息

对于存储设计有一些知识点: Append Only、Linear Scans、磁盘顺序写、页缓存、零拷贝、稀疏索引、二分查找等等。

Append Only Data Structures 的一些存储系统比如HBase, Cassandra, RocksDB


文章目录

1.Kafka存储难度

Kafka 通过简化消息模型,将自己退化成了一个海量消息的存储系统。

Kafka高性能设计之存储设计_数据
Kafka的存储选型:

1.功能需求: 存储的是什么数据, 量级多少, 需要存多久, CRUD 的场景
2.非功能需求: 性能和稳定性的要求是什么, 是否考虑扩展性

  1. 存的数据主要是消息流:消息可以是最简单的文本字符串,也可以是自定义的复杂格式。
    但是对于 Broker 来说,它只需处理好消息的投递即可,无需关注消息内容本身。
  2. 数据量级非常大:因为 Kafka 作为 Linkedin 的孵化项目诞生,用作实时日志流处理(运营活动中的埋点、运维监控指标等),按 Linkedin 当初的业务规模来看,每天要处理的消息量预计在千亿级规模。
  3. CRUD 场景足够简单:因为消息队列最核心的功能就是数据管道,它仅提供转储能力,因此 CRUD 操作确实很简单。
  • 消息等同于通知事件,都是追加写入的,根本无需考虑 update。
  • 对于 Consumer 端来说,Broker 提供按 offset(消费位移)或者 timestamp(时间戳)查询消息的能力就行。
  • 长时间未消费的消息(比如 7 天前的),Broker 做好定期删除即可。
  1. 性能要求:之前的文章交代过,Linkedin 最初尝试过用 ActiveMQ 来解决数据传输问题,但是性能无法满足要求,然后才决定自研 Kafka。ActiveMQ 的单机吞吐量大约是万级 TPS,Kafka 显然要比 ActiveMQ 的性能高一个量级才行。
  2. 稳定性要求:消息的持久化(确保机器重启后历史数据不丢失)、单台 Broker 宕机后如何快速故障转移继续对外提供服务,这两个能力也是 Kafka 必须要考虑的。
  3. 扩展性要求:Kafka 面对的是海量数据的存储问题,必然要考虑存储的扩展性。

1、功能性需求:其实足够简单,追加写、无需update、能根据消费位移和时间戳查询消息、能定期删除过期的消息。

2、非功能性需求:是难点所在,因为 Kafka 本身就是一个高并发系统,必然会遇到典型的高性能、高可用和高扩展这三方面的挑战。

2.Kafka 的存储选型分析

为什么 Kafka 最终会选用 logging(日志文件)来存储消息呢?而不是用我们最常见的关系型数据库或者 key-value 数据库呢?

2.1 存储领域的基础知识

1、内存的存取速度快,但是容量小、价格昂贵,不适用于要长期保存的数据。

2、磁盘的存取速度相对较慢,但是廉价、而且可以持久化存储。

3、一次磁盘 IO 的耗时主要取决于:​​寻道时间和盘片旋转时间​​​,提高磁盘 IO 性能最有效的方法就是:​​减少随机 IO,增加顺序 IO​​。

4、磁盘的 IO 速度其实不一定比内存慢,取决于我们如何使用它。

磁盘顺序写入速度可以达到几百兆/s,而随机写入速度只有几百KB/s,相差上千倍。此外,磁盘顺序 IO 访问甚至可以超过内存随机 IO 的性能。

Kafka高性能设计之存储设计_kafka_02
数据存储领域的两个极端方向:

  1. 加快读:通过索引( B+ 树、二份查找树等方式),提高查询速度,但是写入数据时要维护索引,因此会降低写入效率。
  2. 加快写:纯日志型,数据以 append 追加的方式顺序写入,不加索引,使得写入速度非常高(理论上可接近磁盘的写入速度),但是缺乏索引支持,因此查询性能低。

基于这两个极端泛化出三种底层数据结构:

  1. 哈希索引:通过哈希函数将 key 映射成数据的存储地址,适用于等值查询等简单场景,对于比较查询、范围查询等复杂场景无能为力。
  2. B/B+ Tree 索引:最常见的索引类型,重点考虑的是读性能,它是很多传统关系型数据库,比如 MySQL、Oracle 的底层结构。
  3. LSM Tree 索引:数据以 Append 方式追加写入日志文件,优化了写但是又没显著降低读性能,众多 NoSQL 存储系统比如 BigTable,HBase,Cassandra,RocksDB 的底层结构。

2.2 Kafka 的存储选型考虑

Kafka 所处业务场景的特点:

  • 写入操作:并发非常高,百万级 TPS,但都是顺序写入,无需考虑更新
  • 查询操作:需求简单,能按照 offset 或者 timestamp 查询消息即可

如果单纯满足 Kafka 百万级 TPS 的写入操作需求,采用 Append 追加写日志文件的方式显然是最理想的,前面讲过磁盘顺序写的性能完全是可以满足要求的。

剩下的就是如何解决高效查询的问题。如果采用 B Tree 类的索引结构来实现,每次数据写入时都需要维护索引(属于随机 IO 操作),而且还会引来“页分裂”等比较耗时的操作。而这些代价对于仅需要实现简单查询要求的 Kafka 来说,显得非常重。所以,B Tree 类的索引并不适用于 Kafka。

相反,哈希索引看起来却非常合适。为了加快读操作,如果只需要在内存中维护一个「从 offset 到日志文件偏移量」的映射关系即可,每次根据 offset 查找消息时,从哈希表中得到偏移量,再去读文件即可。(根据 timestamp 查消息也可以采用同样的思路)
但是哈希索引常驻内存,显然没法处理数据量很大的情况,Kafka 每秒可能会有高达几百万的消息写入,一定会将内存撑爆。

可我们发现消息的 offset 完全可以设计成有序的(实际上是一个单调递增 long 类型的字段),这样消息在日志文件中本身就是有序存放的了,我们便没必要为每个消息建 hash 索引了,完全可以将消息划分成若干个 block,只索引每个 block 第一条消息的 offset 即可,先根据大小关系找到 block,然后在 block 中顺序搜索,这便是 Kafka “稀疏索引” 的来源。

Kafka高性能设计之存储设计_分布式_03
Append 追加写日志 + 稀疏的哈希索引,形成了 Kafka 最终的存储方案。

3.Kafka 的存储设计

Kafka的存储结构:

Kafka高性能设计之存储设计_分布式_04
Topic -> 分区Partition -> Segment分段 -> 索引文件.index -> 日志文件.log -> 消息信息Message

Kafka 是一个「分区 + 分段 + 索引」的三层结构:

  1. 每个 Topic 被分成多个 Partition,Partition 从物理上可以理解成一个文件夹。
    Partition 主要是为了解决 Kafka 存储上的水平扩展问题,如果一个 Topic 的所有消息都只存在一个 Broker,这个 Broker 必然会成为瓶颈。因此,将 Topic 内的数据分成多个 Partition,然后分布到整个集群是很自然的设计方式。
  2. 每个 Partition 又被分成了多个 Segment,Segment 从物理上可以理解成一个「数据文件 + 索引文件」,这两者是一一对应的。
    如果不引入 Segment,一个 Partition 只对应一个文件,那这个文件会一直增大,势必造成单个 Partition 文件过大,查找和维护不方便。
    在做历史消息删除时,必然需要将文件前面的内容删除,不符合 Kafka 顺序写的思路。而在引入 Segment 后,则只需将旧的 Segment 文件删除即可,保证了每个 Segment 的顺序写。