kafka:生产并持久化消息

106 阅读24分钟

本章通常使用官方的 Kafka Java 库,或者当生产者不运行在 JVM 中时,使用基于 C 库 librdkafka(github.com/confluentin…)的客户端库。

小提示(TIP):我们通常不建议使用其他库,因为尽管它们有时更易用,但通常缺少许多功能和优化。

深入讨论 Kafka 开发的各个方面可以写成一本书;由于本书不是开发手册,这里我们只简要介绍最重要的细节。为便于说明,代码示例大多使用 Python。在 Java 和其他编程语言中,与 Kafka 通信的基本概念非常相似。

8.1 生产者

8.1.1 发送消息

使用 confluent-kafka Python 库,可以用如下代码将消息发送到 Kafka:

from confluent_kafka import Producer
producer = Producer({
    'bootstrap.servers': 'localhost:9092',    #1
    'acks': -1,     #1
    'enable.idempotence': True,     #1
    'partitioner': 'murmur2_random',     #2
})

def delivery_report(err, msg):    #3
    if err is not None:    #4
        print(f"Delivery failed for record {msg.key()}: {err}")
        return
    print(f"Record {msg.key()} successfully produced to {msg.topic()}"
        f" [{msg.partition()}] at offset {msg.offset()}")

producer.produce(
    "products.prices.changelog",    #5
    key="cola",
    value="2",
    on_delivery=delivery_report)    #6
#1 推荐的可靠生产者配置
#2 确保该生产者与 Java 客户端兼容(hash 函数一致)。
#3 定义回调函数
#4 错误处理
#5 要写入的 topic
#6 使用回调函数

在能与 Kafka 通信之前,我们需要创建一个生产者(Producer)。传入 bootstrap.servers 是最重要的参数。如果同时使用 Java 库与基于 librdkafka 的客户端库,应手动将 librdkafka 的分区哈希函数设置为 murmur2_random,以与 Java 客户端生成的分区一致,因为 librdkafka 默认使用不同的哈希算法。最坏情况下,不同的分区器会导致相同 key 的消息被分配到不同分区,从而无法再保证消息序列性(顺序性)。

初始化生产者后,就可以发送消息了。在 Python 中我们使用 produce() 方法;在 Java 中通常先手动创建要发送的消息对象,然后调用 send() 方法。

调用 produce() 时,至少要给出要写入的 topic 和 value(值)。可选地可以传入回调函数(如上例的 delivery_report),当消息收到 ACK(确认)后该回调会被调用。消息由 value(值)和可选的 key(键)组成。

在调用 produce() 到回调被调用之间,会发生很多事情,但幸运的是 Kafka 客户端库为我们处理了大部分细节。生产者工作流的高级概览见图 8.1。

image.png

8.1.2 消息的生产过程

我们前面已经多次提到,Kafka 不会解析消息内容,它只能处理字节数组(byte arrays)。这意味着生产者负责对数据进行序列化(serialization)。在我们的 Python 示例中,这个“魔法”对我们不可见,因为 Python 库期望输入是字符串或字节数组(string/bytes)。字符串会由字符串序列化器转换为字节数组。

大多数 Kafka 客户端库默认支持若干常用的序列化器,例如 JSON、Protocol Buffers(Protobuf,developers.google.com/protocol-bu…)或 Apache Avro(avro.apache.org/)。如果使用其它数据格式,也可以相对容易地实现自定义的序列化器和反序列化器(deserializers)。第 13 章会更详细地讨论这些格式及其在模式(schema)管理中的作用。

当 key 和 value 都变成字节数组后,分区器(partitioner)决定把消息写入哪个分区。当然我们也可以直接在 produce() 方法里指定一个固定分区,但除非有充分理由,否则应依赖客户端提供的分区策略。我们之前多次讲到:分区器通常依据 key 的哈希值决定目标分区;如果没有 key,则采用 round-robin(轮询)方式分配。

对于每个要写入的 topic 的每个分区,客户端有一个缓冲区,分区器将消息写入该缓冲区。默认情况下,生产者的总缓冲区大小为 32 MiB(由 buffer.memory 配置控制)。从该缓冲区中会形成多个批次(batch),每个批次的最大大小由 batch.size 决定(默认 16 KiB)。默认情况下,生产者不会等待批次被填满才发送;Kafka 会尽快把应该发送的消息发给 broker。当消息产生速度超过逐条发送的能力时,批处理(batching)就会派上用场以提高吞吐量。

一旦消息进入缓冲区,生产者就可以把它们发送到 broker。记住:消息只会被发送到相应分区的 leader broker。通常我们拥有的分区数会多于 broker 数量。生产者会将属于同一 broker 的各分区的消息分别聚合为更大的、基于分区的批次,然后一起发送给该 broker,从而减少网络请求并提高效率。

8.1.3 生产者与 ACK

接下来发生的事情取决于 ACK(确认)设置。如果生产者将 acks=0,生产者发送消息后就不关心后续结果。消息通常会到达,但无法保证一定到达。对于重要数据,我们通常设置 acks=all,这样不仅保证消息到达 leader(acks=1 情况),还保证消息已成功复制到所有 in-sync replica(ISR)。关于 ACK 的细节在第 5 章已有深入讨论。

当我们让生产者等待 ACK 时,生产者会等待 ACK,直到该请求超时为止。如果在超时时间内没有收到 ACK,或收到了 broker 返回的错误,生产者会认为投递不成功,然后尝试重新发送该消息(见图 8.2)。

image.png

我们来仔细看看这些超时(timeout)。produce()(或 send())方法负责把消息写入缓冲区。如果缓冲区已满,生产者线程会等待可用空间。它在放弃等待之前会等待的时长由 max.block.ms 控制,默认是 60 秒。也就是说,如果缓冲区已满,生产者线程会阻塞并不断尝试发送消息,直到缓冲区有空间或达到 60 秒超时为止。

另外,Kafka 会在发送前对消息进行批处理(batching),批处理机制会最多等待 linger.ms(默认 0 ms)以收集更多消息然后再发送该批次。一旦批次准备好就会发送到 broker,生产者随后等待响应。响应超时由 request.timeout.ms 控制(默认 30 秒)。如果在该时间内没有收到响应或发生错误,生产者会在等待 retry.backoff.ms(默认 100 ms)后重试发送操作。

如果生产者在 delivery.timeout.ms(默认 2 分钟)允许的时间内仍然无法成功发送这些消息,它将放弃并抛出异常,应用程序必须对该异常进行适当处理。

那么当我们收到这样的异常时该怎么办?不应盲目地重发这些消息,因为这很可能表明我们的 Kafka 集群处于故障状态。我们应该把重试机制交给 Kafka 客户端库来处理,因为库会处理诸如在启用 enable.idempotence 时保证消息顺序等正确性问题。遇到异常时,可以使用断路器(Circuit Breaker,参见 <www.martinfowler.com/bliki/Circu…

8.2 Broker

上一节我们详细看了 Kafka 生产者库如何发送消息以及如何处理错误。Kafka 将大量工作下放给客户端,从而让 broker 尽可能少做事。当我们生产消息时,leader 必须正确接收这些消息,可能还要检查我们是否有写入该分区的权限,然后尽可能快地把消息写入磁盘。之后,这些消息还需分发给 follower,但幸运的是,followers 自身会负责拉取这些数据。如果我们把生产者配置为以高可靠性投递消息,那么最终 leader 会向生产者返回 ACK。

8.2.1 接收与持久化消息

虽然听起来工作不多,但为尽可能高效且可靠地完成这些任务,Kafka broker 本身仍必须是非常复杂的系统。图 8.3 展示了 broker 内部发生的流程的高层概览。

image.png

首先,网络线程负责接收来自生产者的消息,并在通过授权后将其写入请求队列。如果我们的 I/O 线程没有过载,消息在请求队列中只会停留很短时间,随后由 I/O 线程写入文件系统的正确位置,也就是对应分区当前日志段的末尾。Kafka 并不会保证这些消息已经被成功持久化到磁盘;这是操作系统的任务。日志段(log segment)是 Kafka 为某个分区存储消息的文件。我们将在下一节中详细查看 Kafka 的数据结构。

提示:尽量避免阻塞式同步。相反,应让操作系统在后台独立地将页面缓存(page cache)中的数据写入磁盘。

顺便说一句,断言 “Kafka 完全不关心将消息持久化到硬盘” 并不完全准确。在 Kafka 中,我们确实可以在这方面影响操作系统,有两种配置选项可以控制这一点。首先,可以使用 flush.ms 指定 Kafka 在多少毫秒后触发一次手动 fsync;其次,可以使用 flush.messages 指定在写入多少条消息后触发一次 fsync。默认情况下,这两个设置都被设为可能的最大值(long 类型)。 (所以如果我们的集群运行得足够久,Kafka 确实会触发一次 fsync。例如,就 flush.ms 而言,这将是可怜的 3 亿年!)即便理论上我们可以通过这两个配置选项提高集群的可靠性,但由于会带来显著的性能损失,我们强烈不建议这样做。我们在 Kafka 中通过复制(replication)来实现可靠性。

“不将数据独立提交到磁盘”的性能优化意味着我们必须仔细考虑如何以及在哪里部署 broker。例如,如果我们把某个分区的所有副本都部署在同一台虚拟机宿主机上,而该宿主机随后崩溃,那么我们肯定会丢失数据。因此,应尽可能把 broker 均匀地分布到可用系统上。此外,应使用 Kafka 的机架感知(rack-awareness)特性——我们将在第 9 章对此做更详细的说明。

8.2.2 Broker 与 ACKs

一旦消息被存储到文件系统,broker 需要决定是否向生产者响应以及是否要等待其他 broker 确认已接收消息。这里会用到一个叫做 purgatory 的机制。purgatory 充当一个临时的等待区(暂挂区),broker 在等待例如 follower broker 的确认时会把响应挂在这里。当生产消息时,broker 会等待 follower 的确认。一旦收到所有确认,broker 就把响应写入响应队列,网络线程随后把 ACK 发回给生产者。

8.3 数据与文件结构

在详细了解了消息如何被生产并由 broker 处理之后,本节我们将完全聚焦于 Kafka 的数据与文件结构。我们会仔细查看 Kafka 存储的不同类型数据以及这些数据在 broker 中如何组织。

8.3.1 元数据、检查点与主题

在设置测试环境时,我们为三台 broker 在 ~/kafka/data/kafka<Broker ID> 下各建了一个目录。这正是 broker 存放所有数据的位置。为了本章讲解方便,我们通过删除相应目录并重新创建新的目录来完全重置了 Kafka 集群。先用 ls 看看 Broker ID 为 1 的目录内容:

$ ls -1 ~/kafka/data/kafka1/
__cluster_metadata-0
bootstrap.checkpoint
cleaner-offset-checkpoint
log-start-offset-checkpoint
meta.properties
recovery-point-offset-checkpoint
replication-offset-checkpoint

在我们全新的 Kafka 集群中,总共有 6 个文件和 1 个文件夹。bootstrap.checkpoint 文件以及 __cluster_metadata-0 文件夹(包含 __cluster_metadata 主题第 0 分区的数据)是由 Kafka Raft (KRaft) 使用的;下面的输出中我们将省略这些内容,因为不在此处深入讲解。meta.properties 文件包含了 broker 的 ID、Kafka 集群的 ID、该目录的 ID 以及元数据版本,Kafka 用它来知道如何解析相应文件。其余文件里目前只有两个零。第一行的 0 表示元数据版本,第二行的数字表示随后跟随的实际信息行数。因为我们的集群刚刚建立,还没有任何主题(除了 __cluster_metadata),所以这些值处处为 0。

在深入这些检查点文件里具体存了什么信息之前,我们先创建一个名为 products.prices.changelog.file-structure 的主题。我们采用标准的 replication-factor=3 并把主题分成 3 个分区:

$ kafka-topics.sh \
    --create \
    --topic products.prices.changelog.file-structure \
    --partitions 3 \
    --replication-factor 3 \
    --bootstrap-server localhost:9092
Created topic products.prices.changelog.file-structure.

现在主题创建好了,来看看 replication-offset-checkpoint 文件的内容:

$ cat ~/kafka/data/kafka1/replication-offset-checkpoint
0
3
products.prices.changelog.file-structure 0 0
products.prices.changelog.file-structure 2 0
products.prices.changelog.file-structure 1 0

元数据版本仍是 0(文件第 1 行),但文件现在包含了 3 行信息(第 2 行)。其余每行以我们刚创建的主题名开始。第二列表示分区,第三列包含一个 offset。这里的 offset 表示该分区的日志中,消息已成功复制到 follower 副本的位点(即复制到哪些 offset)。我们将在下一节中更详细地解释这意味着什么。由于我们还没有产生任何消息,因此这些复制位点均为 0。

recovery-offset-checkpoint 文件结构相似,不过那里记录的是消息已被成功持久化(即操作系统已把内存中的数据写入磁盘)的日志偏移量(offset)。

log-start-offset-checkpoint 文件包含每个分区中日志首条消息的 offset;首条消息的 offset 不一定是 0,因为 Kafka 可以对日志进行清理并自动删除旧的不再需要的消息,否则日志会无限增长。

最后一个文件 cleaner-offset-checkpoint 包含了 Kafka 日志清理器(log cleaner)已压缩到的每个分区的 offset。Kafka 如何清理日志及我们可以用的选项将在第 10 章详细讨论。

8.3.2 分区目录

随着我们创建 products.prices.changelog.file-structure 主题,不仅元数据被写入到已有文件,而且在 broker 的数据目录下为每个分区创建了子目录(products.prices.changelog.file-structure-0...-1...-2):

$ ls -1 ~/kafka/data/kafka1/
cleaner-offset-checkpoint
products.prices.changelog.file-structure-0/
products.prices.changelog.file-structure-1/
products.prices.changelog.file-structure-2/
log-start-offset-checkpoint
meta.properties
recovery-point-offset-checkpoint
replication-offset-checkpoint

严格来说,只有分配到当前 broker 的分区才会在该 broker 的数据目录下创建文件。因此因为我们有 replication-factor=3 且集群共有 3 个 broker,在每台 broker 的数据路径下都会为这个主题的 3 个分区创建子目录。现在看一下我们 Partition 0 的目录内文件:

$ ls -1 ~/kafka/data/kafka1/products.prices.changelog.file-structure-0
00000000000000000000.index
00000000000000000000.log
00000000000000000000.timeindex
leader-epoch-checkpoint
partition.metadata

分区目录共有 5 个文件。partition.metadata 包含了元数据版本以及我们主题的 ID(topic_id):

$ cat ~/kafka/data/kafka1/\
products.prices.changelog.file-structure-0/partition.metadata
version: 0
topic_id: q4RDr2lTR8y1Wy6eYUpl6g

leader-epoch-checkpoint 文件的结构与之前的检查点文件类似。它的第一行是元数据版本,第二行是信息行数。该文件内容会根据当前 broker 是否为该分区的 leader 而略有不同。我们先用 kafka-topics.sh --describe 看看哪个 broker 是哪个分区的 leader:

$ kafka-topics.sh --describe \
    --topic products.prices.changelog.file-structure \
    --bootstrap-server localhost:9092
Topic: products.prices.changelog.file-structure
    TopicId: q4RDr2lTR8y1Wy6eYUpl6g PartitionCount: 3
    ReplicationFactor: 3 Configs:
  Topic: products.prices.changelog.file-structure Partition: 0
    Leader: 1 Replicas: 1,3,2 Isr: 1,3,2
  Topic: products.prices.changelog.file-structure Partition: 1
    Leader: 2 Replicas: 2,1,3 Isr: 2,1,3
  Topic: products.prices.changelog.file-structure Partition: 2
    Leader: 3 Replicas: 3,2,1 Isr: 3,2,1

Broker 1 是 Partition 0 的 leader,因此我们查看 Broker 1 上 Partition 0 的 leader-epoch-checkpoint

$ cat ~/kafka/data/kafka1/\
products.prices.changelog.file-structure-0/leader-epoch-checkpoint
0
1
0 0

元数据版本是 0(第 1 行),文件包含 1 行信息(第 2 行)。该行本身是两个零。第一个零表示此前的分区 leader 数(previous leaders);第二个零表示在 leader 选择时的当前日志 offset。leader epoch 会在每次 leader 变更时递增,用来确保不会出现两个 broker 同时声称是同一分区的 leader 这种情况。

下面通过一个例子来说明在什么情况下会发生冲突以及 leader epoch 如何防止它。用附录 A 的 kafka-broker-stop.sh 脚本先停止 ID 为 3 的 broker(它是 Partition 2 的 leader),然后再查看 leader-epoch-checkpoint

$ kafka-broker-stop.sh 3
$ cat ~/kafka/data/kafka1/\
products.prices.changelog.file-structure-0/leader-epoch-checkpoint
0
1
1 0

Kafka 会意识到有 broker 不可访问,并启动一次 leader 选举以决定 Partition 2 的新 leader。分区的 leader epoch 因此加了 1。如果我们使用 kafka-topics.sh --describe 可以看到具体哪台 broker 接管了 Partition 2 的 leader 角色,但对本例并非必需。现在重启 ID 为 3 的 broker:

$ ~/kafka/bin/kafka-server-start.sh -daemon ~/kafka/config/kafka3.properties

此时 leader epoch 的作用体现出来:leader epoch 确保只有当前的 leader 能够分发消息。设想 Broker 3 一直没意识到自己曾短暂不可用,仍然认为自己是 Partition 2 的 leader。当它复制消息时会发送它所知的 leader epoch。如果发送的是过期的 leader epoch,则其他 broker 会忽略这些消息。因为从 Broker 3 的角度看它的 leader epoch 仍是 0,其消息会被拒绝,因为当前实际的 leader epoch 已是 1。

当然,Broker 3 并不会永远被拒绝发送消息。经过短时间后,它会获取到当前的分区 leader 和 leader epoch,Broker 3 之后也会再次成为 Partition 2 的 leader(Kafka 默认每 5 分钟会尝试将首选 leader 恢复为实际 leader,参数为 leader.imbalance.check.interval.seconds)。我们也可以通过运行第 5 章中的 kafka-leader-election.sh 手动加速这一过程:

$ kafka-leader-election.sh \
    --election-type=preferred \
    --all-topic-partitions \
    --bootstrap-server localhost:9092

作为本例的结尾,再来看一下 Broker 3(kafka3)上 Partition 2 的 leader-epoch-checkpoint

$ cat ~/kafka/data/kafka3/\
products.prices.changelog.file-structure-2/leader-epoch-checkpoint
0
1
2 0

Partition 2 的值又递增了,因为 Broker 3 再次接管了 leader 角色。顺便说一下,Partition 1 和 Partition 2 的其他值并未变化,因为还没有发生新的 leader 变更或重新选举。

现在我们先在 products.prices.changelog.file-structure 主题里生产几条消息,然后再查看分区目录中剩下的文件:

$ kafka-console-producer.sh \
    --topic products.prices.changelog.file-structure \
    --bootstrap-server localhost:9092
> cola 2
> coffee pads 8
[…]
> cola 1

但先再快速看看复制偏移量(replication offsets):

$ cat ~/kafka/data/kafka1/replication-offset-checkpoint
0
3
products.prices.changelog.file-structure 0 8
products.prices.changelog.file-structure 2 2
products.prices.changelog.file-structure 1 2

如上所示,消息已经成功复制。消息并没有均匀分布到各分区,因为生产者以批次发送消息,且取决于我们输入消息的速度,多个消息可能落在同一个批次进而落在同一分区,导致负载分布上有些不均衡。

注意:如果我们用了 key(键),消息的分布将是确定性的。若不使用 key,则消息会被轮询或按批次分配,有时可能需要重启生产者才能把消息发送到多个分区。

8.3.3 日志数据与索引

我们现在知道数据已经被复制了,但它们具体存储在哪里、如何存储呢?这就是分区目录中其余文件发挥作用的地方。扩展名为 .log 的文件包含我们的消息。Kafka 提供了 kafka-dump-log.sh 脚本,允许我们更仔细地查看日志。我们只需通过 --files 参数传入要检查的日志段即可。现在来看一下位于 products.prices.changelog.file-structure 主题第 0 分区中的文件 00000000000000000000.log。我们之所以选择这个分区,是因为大多数消息都落在这里:

$ kafka-dump-log.sh \
    --files ~/kafka/data/kafka1/\
products.prices.changelog.file-structure-0/0[…]000.log
Dumping ~/kafka/data/kafka1/
products.prices.changelog.file-structure-0/0[…]000.log
Log starting offset: 0
baseOffset: 0 lastOffset: 1 count: 1 […] position: 0
CreateTime: 1738455138490 size:  68 […] crc: 1092464612
[…]

命令输出展示了我们日志文件的一些元数据。为便于阅读,我们对输出做了简化并只解释当前最重要的部分。可以看到日志段起始的 offset(Log starting offset),然后是每个批次(batch)的概览。我们可以看到该批次第一条消息和最后一条消息的 offset(baseOffsetlastOffset),以及该批次包含消息的总数(count)。还能看到时间戳(CreateTime)和批次的校验和(crc)。size 表示该批次以字节为单位的大小,position 指示该批次在段文件中开始的字节位置。一个批次还包含一些元数据,合计约 60 字节;考虑到 kafka-dump-log.sh 提供的信息量,这并不令人惊讶。在大量小消息的场景下,批次能够显著提升 Kafka 的性能,这是很明显的。

消息载荷本身通常只有几字节(取决于我们的消息内容),因为单个批次里的消息还包含额外的元数据。如果我们在命令中加上 --print-data-log 参数,kafka-dump-log.sh 会显示单条消息的全部信息,包括实际的消息体!这里我们就不贴出该输出了。

扩展名为 .index 的文件用于更快地在日志中查找消息,因为 Kafka 只是把消息或批次顺序地追加到日志中。若没有索引,每次消费者要消费消息时都必须解码整个段并搜索对应的 offset,这效率很低。为此,Kafka 在索引中记录了对应 offset 的字节位置。

注意索引并不是为每一个 offset 都记录条目,而是在自上次索引条目后,日志至少又新增了 4,096 字节(默认值)时才记录一次。这个阈值可以通过主题配置 index.interval.bytes 调整,或者通过 broker 配置 log.index.interval.bytes 更改集群默认值。我们也可以用 kafka-dump-log.sh 显示索引文件的内容。

提示:如果我们减小索引间隔,就可以更接近目标位置进行跳转,但索引会更快增大。默认的 4 KiB 在绝大多数场景下是一个很好的折中选择。

扩展名为 .timeindex 的文件与 .index 类似,但它不是将 offset 映射到字节位置,而是将时间戳映射到 offset。这样我们就可以方便地从某个特定的时间点开始消费消息。一个常见的用例是将消费者偏移重置到当天的起始点,从而重新消费当天的全部消息。

8.3.4 段(Segments)

在本章中我们多次提到段(segment)。我们已经知道消息被写入到主题,而主题又被划分为多个分区;分区进一步被划分为若干段(segment),每个段包含我们前面讲到的日志(.log)和索引文件(.index.timeindex)。整体数据结构如图 8.4 所示。

image.png

将分区再细分的原因在于分区会随着时间自然增长,有时会包含数十 GB 甚至 TB 的数据。单个日志或索引文件很快会变得低效。不过,这个问题与消息的消费关系不大,更多地影响消息在 Kafka 中的清理方式——我们将在第 10 章详细讨论这一点。

此处也有必要仔细看看日志与索引文件的文件名——文件名实际上就是该段(segment)中第一个 offset(起始位移)。那么 Kafka 在什么时候会创建新的段呢?有两个重要参数控制这点:当段超过某一大小(默认 1 GiB)或达到某一年龄(默认 7 天)之一时,Kafka 会创建新段。

这些设置同样可以通过主题配置进行调整。使用 segment.ms 指定创建新段的毫秒数上限;使用 segment.bytes 影响段的最大字节数。也可以在 broker 配置中调整这些默认值(log.segment.byteslog.roll.ms)。

我们可以直接测试:把段的最大年龄降到 60 秒。可以用 kafka-configs.sh 脚本修改 products.prices.changelog.file-structure 主题的配置,例如:

$ kafka-configs.sh \
    --alter \
    --topic products.prices.changelog.file-structure \
    --add-config segment.ms=60000 \
    --bootstrap-server localhost:9092

修改完主题配置后,需要再写入几条消息以触发新段的创建,使用 kafka-console-producer.sh

$ kafka-console-producer.sh \
    --topic products.prices.changelog.file-structure \
    --bootstrap-server localhost:9092
> cola 2
> energy drink 5
[…]
> cola 3

现在 Kafka 应该已经创建了新段。检查分区目录会看到类似文件列表:

$ ls -1 ~/kafka/data/kafka1/products.prices.changelog.file-structure-0
00000000000000000000.index
00000000000000000000.log
00000000000000000000.timeindex
00000000000000000003.index
00000000000000000003.log
00000000000000000003.snapshot
00000000000000000003.timeindex
leader-epoch-checkpoint

如预期,分区中出现了更多的日志与索引文件。此外还多了一个扩展名为 .snapshot 的文件。在本例中,该文件仅记录当前段的起始 offset。除此之外,这个文件用于管理 Kafka 中的幂等(idempotent)生产者;幂等在第 5 章中有详细讨论。

8.3.5 被删除的主题

我们再删除一下该主题(不是为了清理,而是观察 Kafka 如何删除主题):

$ kafka-topics.sh \
    --delete \
    --topic products.prices.changelog.file-structure \
    --bootstrap-server localhost:9092

Kafka 并不会立刻删除文件,而是先将它们标记为删除:

$ ls -1 ~/kafka/data/kafka1/
cleaner-offset-checkpoint
products.prices.changelog.file-structure-0.<UniqueID>-delete/
products.prices.changelog.file-structure-1.<UniqueID>-delete/
products.prices.changelog.file-structure-2.<UniqueID>-delete/
log-start-offset-checkpoint
meta.properties
recovery-point-offset-checkpoint
replication-offset-checkpoint

Kafka 只是把分区目录的名字改为在原名后追加 .<UniqueID>-delete 的形式。该目录在 1 分钟后会被真正删除。在这段时间内,我们理论上还能撤销删除以保留数据。如果需要,可以通过 file.delete.delay.ms(主题级)或 log.segment.delete.delay.ms(broker 全局)来调整这个延迟时间。

8.4 复制(Replication)

本节我们将探讨 Kafka 的复制机制,重点关注 in-sync replicas(ISR)。Kafka 中的复制通过 follower 定期从 leader 拉取数据来实现。复制保证了容错性与可扩展性,因为数据会在多个 broker 上保留冗余副本。我们会讲到 ISR 的概念——它在保障数据一致性与可用性方面扮演关键角色。

另外我们将讨论 High Watermark(高水位)在确定消息提交点(commit point)中的重要性,并分析复制延迟对消费与生产的影响。整体上,这一节帮助理解 Kafka 如何通过复制在分布式环境中维持可靠性与性能。

8.4.1 In-sync replicas

在 Kafka 中,leader 与 follower 之间通过 follower 定期向 leader 发送 fetch 请求来进行数据复制。leader 因此知道每个 follower 在什么时间拉取到了哪些数据。我们已经知道在 acks=all 时,leader 会等到所有 ISR 都复制成功才发送 ACK。但 leader 并不等到所有 follower 都拉到数据(以免受某个慢或不可用 broker 拖累)。为此引入了 ISR 的概念。

如果一个 follower 在过去的 30 秒内从 leader 拉取并获取了最新数据,则该 follower 被视为“in-sync”。这个时间窗口可以通过 broker 配置中的 replica.lag.time.max.ms 调整。leader 始终视自身为 in-sync。默认情况下,replica 每 500 ms 向 leader 发起一次拉取(该间隔可通过 follower broker 的 replica.fetch.wait.max.ms 调整)。

如果某个副本在最大滞后时间内未能至少追到 leader 的 Log End Offset(LEO),leader 会将其从该分区的 ISR 列表中移除。LEO 标记了每个分区及每个副本当前接收到的最后一条消息的位置,也就是指向相应日志的末端。通过这种方式,可以识别出暂时无法跟上复制速度的副本,从而避免某个慢 broker 拖慢整个集群的性能。

如果副本停止响应心跳(heartbeat),也会被立即移出 ISR。心跳是 broker 周期性发送的存活信号,用以确认 broker 正常运行;若心跳超时,broker 会被视为不可用并从 ISR 中剔除。这是 Kafka 容错机制的一部分,确保即使个别 broker 失败,系统仍能继续工作。

8.4.2 High Watermark

那么 leader 如何知道消息已经被 follower 成功复制?LEO 在这里起什么作用?当 partition 的 leader 或 follower 收到新消息时,会相应地更新其 LEO。当 follower 向 leader 发送 fetch 请求时,请求中也包含了该 follower 当前的 LEO(类似于普通消费者向 broker 报告当前偏移)。这会告诉 leader 该 follower 是否已经收到了所有消息,或者哪些消息尚未到达、需要发送给它们。

image.png

此时又出现了另一个偏移量:高水位(High Watermark, HWM)。HWM 表示日志中所有 ISR(in-sync replicas)都已经接收到消息的位置,如图 8.5 所示。换句话说,它是所有 ISR 中提交给 leader 的最小 LEO(Log End Offset)。此外,在 Kafka 中,一条消息仅在所有 ISR 都已收到该消息时才被视为成功提交——也就是说,当 HWM 大于或等于该消息在日志中的位置时,该消息才被视为已提交(这一点与 ACK 策略无关)。当前的 HWM 也会由 leader 在 followers 的 fetch 请求中传播出去。

但 HWM 在 Kafka 中还有另一个用途:消费者只能读取 Kafka 视角下已提交(committed)的消息。日志中能被消费者读取的界限正是由 HWM 精确标记的。由于 Kafka 的复制是完全异步的,刚写入的新消息在被消费前可能需要几秒钟的时间才能被提交,这取决于具体的配置。在正常状态下,与 Kafka 的通信几乎是实时的,我们讨论的延迟通常在毫秒级别。

警告:如果可用的 in-sync replicas 少于 min.insync.replicas,那么 HWM 将不会向前推进,因此对新消息的消费会被阻塞,直到最小数量的副本再次恢复为止。

下面简要看一下消息复制的步骤。当分区的 leader 收到一条新消息时,leader 的 LEO 会首先增加。在 followers 的下一次 fetch 请求中,leader 会发现某些 followers 缺少该消息并将该消息发送给相应的 followers。在随后的一次 fetch 请求中,leader 会判断所有 followers 已经接收到这些消息,因为 followers 的 LEO…

image.png

followers 的 LEO 与 leader 的 LEO 对应,leader 随后将 HWM 增加并在对 followers 的 fetch 响应中把新值传播出去。

现在我们从 follower 的角度来看这个流程,如图 8.6 所示。如果一个 follower 想读取消息,它会带着自己当前在日志中的位置(即它自己的 LEO)向对应分区的 leader 发送 fetch 请求。leader 然后将消息发送给该 follower,并把为该 follower 存储的 Fetch Offset(即该 follower 的最后已提交偏移,Last Committed Offset,缩写 LCO)更新为 follower 在 fetch 请求中发送的偏移值。这时 leader 并不关心这些消息是否会成功到达——如果某个 follower 没有收到消息,它只需再次以相同的偏移发送请求,broker 就会重新发送这些消息。这简化了 broker 的复杂度,因为 broker 在此无需负责错误管理。

8.4.3 复制延迟的影响

最后,我们来看当某个 ISR 滞后(即无法足够快地从 leader 复制消息)时,对数据消费和生产的影响。

只要副本处于同步状态(in sync),它就会影响 HWM,从而决定可以被消费的消息范围。因此,如果某个 ISR 开始落后,所有消费者必然会被放慢,或者无法以近实时读取新产生的消息。经过 30 秒(或我们在配置中设定的最大滞后时间)后,该副本会从 ISR 列表中被移除,HWM 很可能会大幅前移,消费者便能再次赶上进度。除此之外,只要存在分区 leader 且至少有 min.insync.replicas 个副本,消息仍然可以被消费。这也意味着如果当前分区的 leader 挂掉,我们可能会在短时间内无法消费消息(直至新的 leader 当选)。

我们可能会考虑把最大滞后时间降到 2 秒,但这可能引入新的问题。异步复制通常会在 followers 与 leaders 之间产生短暂的小滞后,这会导致副本频繁地从 ISR 列表中被移除又很快被加入回去,从而产生不必要的开销。此外,过快地将副本从 ISR 中移除也会带来跟踪开销,因为 ISR 由 controller 管理,这样做还可能导致 ISR 数量降到 min.insync.replicas 以下。如果 ISR 数量低于配置的最小值,则在使用 acks=all 时无法继续生产新消息。

另外,HWM 将不会前进,所以即便我们不使用 acks=all,消费者也无法消费新消息。尽管出于性能考虑调整最小 ISR 的设置可能看起来有吸引力,但我们强烈建议不要这样做。将最小 ISR 设置为合理的值对保证 Kafka 集群的可靠性至关重要。

Kafka 并不直接负责消息的实际持久化(写入磁盘),而是把这项任务交给操作系统。如果 leader 是唯一的 ISR 且突然失败,就存在被消费的数据尚未真正刷入文件系统的风险。

提示:当复制因子为 3 时,为了提高可靠性,min.insync.replicas 应始终设置为例如 2 这样的合理值。

总结

  • Kafka 中的生产者通常使用官方 Java 库或基于 librdkafka 的库。建议避免使用其他第三方库,因为它们可能缺少功能和优化。
  • 生产者工作流涉及序列化、分区决策和缓冲区管理。
  • 在生产者端处理 ACK(确认)时,需要考虑超时设置和重试机制。
  • Kafka broker 将大量工作下放给客户端,broker 主要负责消息接收和高效持久化。
  • 在接收 produce 请求后,broker 将数据写入操作系统的页面缓存(page cache),并可能在等待 followers 复制后返回 ACK。
  • 网络线程负责接收消息并将其排入请求队列,I/O 线程负责将其写入文件系统。
  • Kafka 依赖操作系统来将数据持久化到磁盘,但可以通过配置影响刷新(fsync)行为。
  • 可以对 broker 组件进行优化和配置以适应特定用例;复杂环境建议寻求专业支持。
  • Kafka 的数据结构(包括元数据、检查点和主题)在 broker 内部组织和管理数据。
  • 主题被分区,分区被划分为段(segments);每个段包含日志和索引文件以提高检索效率。
  • 日志数据与索引优化了在段内的消息存储与检索。
  • 段(segment)由大小或时间触发切分,用于管理分区增长并优化存储效率。
  • 复制通过 followers 定期从 leader 拉取数据来实现,保证多副本的最新性。
  • in-sync replicas(ISR)是指在规定时间内已从 leader 拉取到所有消息的 followers。
  • Log End Offset(LEO)标记了最后接收的消息位置,用于判断副本是否落后。
  • High Watermark(HWM)表示已被所有 ISR 复制并提交的偏移,影响消息的可消费性与可用性。
  • 复制延迟会减慢或阻塞消息的消费与生产;ISR 滞后会影响 HWM 和消费者可读性。
  • 调整如 replica.lag.time.max.msmin.insync.replicasacks=all 等参数会对复制效率与系统性能产生影响,需谨慎配置以取得平衡。