Kafka 的基本存储单元是分区。分区无法在多个 broker 间进行再细分,也无法在同一个broker 的多个磁盘上进行再细分。
所以,分区的大小受到单个挂载点可用空间的限制(一个挂载点由单个磁盘或多个磁盘组成,如果配置了 JBOD就是单个磁盘,如果配置了RAID,就是多个磁盘。)。
在配置 Kafka 的时候,管理员指定了一个用于存储分区的目录清单——也就是 log.dirs 参数的值(不要把它与存放错误日志的目录混淆了,日志目录是配置在 log4j.properties 文件里的)。
该参数一般会包含每个挂载点的目录。接下来我们会介绍 Kafka 是如何使用这些目录来存储数据的。
首先,要知道数据是如何被分配到集群的 broker 上以及 broker 的目录里的。
然后,还要知道** broker 是如何管理这些文件的,特别是如何进行数据保留的**。
随后,会深入探讨文件和索引格式。
最后,会讨论日志压缩及其工作原理。日志压缩是 Kafka 的一个高级特性,因为有了这个特性,Kafka 可以用来长时间地保存数据。
从2018年底开始,Apache Kafka社区开始合作进行一个重大的项目,为Kafka增加分层存储功能。该项目的工作正在进行中,并计划在3.0版本中进行。
The motivation is fairly straightforward: Kafka is currently used to store large amounts of data, either due to high throughput or long retention periods.
动机相当简单:由于Kakfa具有要高吞吐量以及数据保留期长的特点,Kafka 目前一般用于存储大量的数据,而这引入了以下问题:
在分层存储方法中,Kafka 集群配置了两层存储:本地和远程。
本地层与当前的 Kafka 存储层相同——它使用 Kafka broker 上的本地磁盘来存储日志段。新的远程层使用专门的存储系统,如HDFS或S3,来存储已完成的日志段。
Kafka 用户可以选择为每一层设置单独的存储保留策略。由于本地存储通常比远程层昂贵得多,因此本地层的保留期通常只有几个小时甚至更短,而远程层的保留期可能更长——几天甚至几个月
本地存储的延迟明显低于远程存储的延迟。这很有利,因为对延迟敏感的应用程序执行尾部读取,并从本地层提供服务,所以他们受益于现有的Kafka机制,有效地使用页面缓存来提供数据。从故障中恢复的回写,和其他应用程序需要比本地层中的数据更早的数据,则从远程层中提供。
分层存储中使用的双层架构能够扩展Kafka集群的存储能力,而不受限于集群中内存大小和CPU性能。这使得 Kafka 能成为一个长期的存储解决方案。这也减少了本地存储在 Kafka 代理上的数据量,因此减少了在恢复和重新平衡期间需要复制的数据量
远程层中可用的日志段是从远程层提供的服务,不需要在Broker上崩溃恢复或延迟恢复。由于并非所有数据都存储在代理上,因此增加保留期不再需要扩展 Kafka 集群存储和添加新节点。
同时,整体的数据保留时间仍然可以更长,不再需要单独的数据管道将数据从Kafka复制到外部存储(目前许多场景就是这样做的)。
在创建Topic时,Kafka首先会决定如何在 broker 间分配分区。假设你有 6个broker,打算创建一个包含10个分区的主题,并且复制系数为 3。那么 Kafka 就会有30 个分区副本,它们可以被分配给6个 broker。在进行分区分配时,我们要达到如下的目标。
为了实现这个目标,我们先随机选择一个 broker(假设是 4),然后使用轮询的方式给每个 broker 分配分区来确定首领分区的位置。
于是,首领分区 0 会在 broker 4 上,首领分区1 会在 broker 5 上,首领分区 2 会在 broker 0 上(只有 6 个 broker),并以此类推。然后,我们从分区首领开始,依次分配跟随者副本。如果分区 0 的首领在 broker 4 上,那么它的 第一个跟随者副本会在 broker 5 上,第二个跟随者副本会在 broker 0 上。分区 1 的首领在 broker 5 上,那么它的第一个跟随者副本在 broker 0 上,第二个跟随者副本在 broker 1 上。
如果配置了机架信息,那么就不是按照数字顺序来选择 broker 了,而是按照交替机架的方式 来选择 broker。
为分区和副本选好合适的 broker 之后,接下来要决定这些分区应该使用哪个目录。
我们单独为每个分区分配目录,规则很简单:计算每个目录里的分区数量,新的分区总是被添加到数量最小的那个目录里。也就是说,如果添加了一个新磁盘,所有新的分区都会被创建到这个磁盘上。因为在完成分配工作之前,新磁盘的分区数量总是最少的。
要注意,在为 broker 分配分区时并没有考虑可用空间和工作负载问题,但在将分区分配到磁盘上时会考虑分区数量,不过不考虑分区大小。
也就是说, 如果有些 broker 的磁盘空间比其他 broker 要大(有可能是因为集群同时使 用了旧服务器和新服务器),有些分区异常大,或者同一个 broker 上有大小不同的磁盘,那么在分配分区时要格外小心。在后面会讨论 Kafka 管理员该如何解决这种 broker 负载不均衡的问题。
保留数据是 Kafka 的一个基本特性,Kafka 不会一直保留数据,也不会等到所有消费者都 读取了消息之后才删除消息。相反,Kafka 管理员为每个主题配置了数据保留期限,规定数据被删除之前可以保留多长时间,或者清理数据之前可以保留的数据量大小。
因为在一个大文件里查找和删除消息是很费时的,也很容易出错,所以我们把分区分成若干个片段。
默认情况下,每个片段包含 1GB 或一周的数据,以较小的那个为准。
在 broker 往分区写入数据时,如果达到片段上限,就关闭当前文件,并打开一个新文件。
当前正在写入数据的片段叫作活跃片段。活动片段永远不会被删除,所以如果你要保留数据 1 天,但片段里包含了 5 天的数据,那么这些数据会被保留 5 天,因为在片段被关闭之前这些数据无法被删除。
如果你要保留数据一周,而且每天使用一个新片段,那么你就会看到,每天在使用一个新片段的同时会删除一个最老的片段——所以大部分时间该分区会有7 个片段存在。
需要注意的是:broker会为分区里的每个片段打开一个文件句柄,哪怕片段是不活跃 的。这样会导致打开过多的文件句柄,所以操作系统必须根据实际情况做一些调优。
我们把 Kafka 的消息和偏移量保存在文件里。
保存在磁盘上的数据格式与从生产者发送过来或者发送给消费者的消息格式是一样的。
因为使用了相同的消息格式进行磁盘存储和网络传输,Kafka 可以使用零复制技术给消费者发送消息,同时避免了对生产者已经压缩过的消息进行解压和再压缩。
除了键、值和偏移量外,消息里还包含了消息大小、校验和、消息格式版本号、压缩算法 (Snappy、GZip 或 LZ4)和时间戳(在 0.10.0 版本里引入的)。
时间戳可以是生产者发送消息的时间,也可以是消息到达 broker 的时间,这个是可配置的。
如果生产者发送的是压缩过的消息,那么同一个批次的消息会被压缩在一起,被当作“包 装消息”进行发送(如下图所示)。于是,broker 就会收到一个这样的消息,然后再把它发送给消费者。
消费者在解压这个消息之后,会看到整个批次的消息,它们都有自己的时间戳和偏移量。
也就是说,如果在生产者端使用了压缩功能(极力推荐),那么发送的批次越大,就意味着在网络传输和磁盘存储方面会获得越好的压缩性能。
同时意味着如果修改了消费者使用 的消息格式(例如,在消息里增加了时间戳),那么网络传输和磁盘存储的格式也要随之修改,而且 broker 要知道如何处理包含了两种消息格式的文件。
Kafka 附带了一个叫 DumpLogSegment 的工具,可以用它查看片段的内容。它可以显示每个消息的偏移量、校验和、魔术数字节、消息大小和压缩算法。运行该工具的方法如下:
bin/kafka-run-class.sh kafka.tools.DumpLogSegments
如果使用了 **–deep-iteration **参数,可以显示被压缩到包装消息里的消息。
消费者可以从 Kafka 的任意可用偏移量位置开始读取消息。
假设消费者要读取从偏移量 100 开始的 1MB 消息,那么 broker 必须立即定位到偏移量 100(可能是在分区的任意一个片段里),然后开始从这个位置读取消息。
为了帮助 broker 更快地定位到指定的偏移量,Kafka 为每个分区维护了一个索引。索引把偏移量映射到片段文件和偏移量在文件里的位置。
索引也被分成片段,所以在删除消息时,也可以删除相应的索引。
Kafka 不维护索引的校验和。如果索引出现损坏,Kafka 会通过重新读取消息并录制偏移量和位置来重新生成索引。
如果有必要,管理员可以删除索引,这样做是绝对安全的,Kafka会自动重新生成这些索引。
一般情况下,Kafka 会根据设置的时间保留数据,把超过时效的旧数据删除掉。
不过,试想一下这样的场景,如果你使用 Kafka 保存客户的收货地址,那么保存客户的最新地址比保存客户上周甚至去年的地址要有意义得多,这样你就不用担心会用错旧地址,而且短时间内客户也不会修改新地址。
另外一个场景,一个应用程序使用 Kafka 保存它的状态,每次状态发生变化,它就把状态写入 Kafka。在应用程序从崩溃中恢复时,它从 Kafka 读取消息来恢复最近的状态。
在这种情况下,应用程序只关心它在崩溃前的那个(最新)状态,而不关心运行过程中的那些状态。
Kafka 通过改变主题的保留策略来满足这些使用场景。早于保留时间的旧事件会被删除, 为每个键保留最新的值,从而达到清理的效果。
很显然,只有当应用程序生成的事件里包含了键值对时,为这些主题设置 compact 策略才有意义。
如果主题包含 null 键,清理就会失败。
每个日志片段可以分为以下两个部分。
干净的部分
这些消息之前被清理过,每个键只有一个对应的值,这个值是上一次清理时保留下来的。
污浊的部分
这些消息是在上一次清理之后写入的。 两个部分的日志片段示意如下图所示:
注;这里的清理不是指删除,而是只保留key对应的最新的值
如果在 Kafka 启动时启用了清理功能(通过配置 **log.cleaner.enabled **参数),每个 broker 会启动一个清理管理器线程和多个清理线程,它们负责执行清理任务。这些线程会选择污浊率(污浊消息占分区总大小的比例)较高的分区进行清理。
为了清理分区,清理线程会读取分区的污浊部分,并在内存里创建一个 map。map 里的每个元素包含了消息键的散列值和消息(最新)的偏移量,键的散列值是 16B,加上偏移量总共是 24B。如果要清理一个 1GB 的日志片段,并假设每个消息大小为 1KB,那么这个片段就包 含一百万个消息,而我们只需要用 24MB 的 map 就可以清理这个片段。(如果有重复的键, 可以重用散列项,从而使用更少的内存。)这是非常高效的!
管理员在配置 Kafka 时可以对 map 使用的内存大小进行配置。每个线程都有自己的 map, 而这个参数指的是所有线程可使用的内存总大小。如果你为 map 分配了 1GB 内存,并使用了 5 个清理线程,那么每个线程可以使用 200MB 内存来创建自己的 map。
Kafka 并不要求分区的整个污浊部分来适应这个 map 的大小,但要求至少有一个完整的片段必须符合。 如果不符合,那么 Kafka 就会报错,管理员要么分配更多的内存,要么减少清理线程数量。如果只有少部分片段可以完全符合,Kafka 将从最早的片段开始清理,等待下一次清理剩余的部分。
清理线程在创建好偏移量 map 后,开始从干净的片段处读取消息,从(干净片段)最早的消息开始,把它们的内容与 map 里的内容进行比对。它会检查消息的键是否存在于 map 中,如果不存在, 那么说明消息的值是最新的,就把消息复制到替换片段上。如果键已存在,消息会被忽略, (由于map中存的是新的key与最近index,所以)因为在分区的后部已经有一个具有相同键的消息存在。
在复制完所有的消息之后,我们就将替换片段与原始片段进行交换,然后开始清理下一个片段。完成整个清理过程之后,每个键对应一个不同的消息——这些消息的值都是最新的。清理前后的分区片段如下图所示:
如果只为每个键保留最近的一个消息,那么当需要删除某个特定键所对应的所有消息时, 我们该怎么办?
这种情况是有可能发生的,比如一个用户不再使用我们的服务,那么完全可以把与这个用户相关的所有信息从系统中删除。
为了彻底把一个键从系统里删除,应用程序必须发送一个包含该键且值为 null 的消息。
清理线程发现该消息时,会先进行常规的清理,只保留值为 null 的消息。
该消息(被称为墓碑消息)会被保留一段时间,时间长短是可配置的。
在这期间,消费者可以看到这个墓碑消息,并且发现它的值已经被删除。
于是,如果消费者往数据库里复制 Kafka 的数据,当它看到这个墓碑消息时,就知道应该要把相关的用户信息从数据库里删除。在这个时间段过后,清理线程会移除这个墓碑消息,这个键也将从 Kafka 分区里消失。
重要的是,要留给消费者足够多的时间,让他看到墓碑消息,因为如果消费者离线几个小时并错过了墓碑消息,就看不到这个键,也就不知道它已经从 Kafka 里删除,从而也就不会去删除数据库 里的相关数据了。
就像 delete 策略不会删除当前活跃的片段一样,compact 策略也不会对当前片段进行清理。只有旧片段里的消息才会被清理。
在 0.10.0 和更早的版本里,Kafka 会在包含脏记录的主题数量达到 50% 时进行清理。
这样做的目的是避免太过频繁的清理(因为清理会影响主题的读写性能),同时也避免存在太多脏记录(因为它们会占用磁盘空间)。浪费 50% 的磁盘空间给主题存放脏记录,然后进行一次清理,这是个合理的折中,管理员也可以对它进行调整。
Kafka计划在未来的版本中加入宽限期,在宽限期内,Kafka保证消息不会被清理。对于想看到主题的每个消息的应用程序来说,它们就有了足够的时间,即使时间有点滞后。
参考了这里