Kafka的可靠性

84 阅读12分钟

本章内容包括

  • Kafka 的确认(ACK)设置
  • Kafka 中的数据可用性与容错性
  • Kafka 的投递保证(delivery guarantees)
  • Kafka 的事务能力
  • Kafka 中的主从(Leader–Follower)原则

现在我们知道了 Kafka 中的主题和消息的样子,以及 Kafka 如何与“日志”相关联。接下来的各节里,我们将探讨 Kafka 运行的一些关键方面。在下一章更仔细地研究如何影响 Kafka 集群性能之前,本章将检视可以调整的参数以提升可靠性。

谈到可靠性,我们大致可以把它分为三类。第一类是数据持久性(data durability) ,也就是 Kafka 如何确保数据被正确地、持久地保存。第二类是可用性(availability) ,首先指消费者在可能的情况下能否随时访问我们写入的数据,其次指生产者能否随时写入数据。通过复制(replication),Kafka 可以相对良好地实现数据持久性与可用性。

第三类则是一致性(consistency) ,这在 Kafka 的可靠性概念中比较复杂且更有挑战性。一致性本身有多种表现形式,我们将在本章后面详细讨论;总体目标是确保集群中的数据按期望被正确持久化。在像 Kafka 这样的分布式系统中实现一致性并不容易。为确保发送的消息确实到达,Kafka 使用确认(ACKs)、幂等写入(idempotent writes)和事务(transactions),允许生产者以原子方式发送消息并在多次写入中确保数据完整性。

在深入讨论 ACK、事务与复制机制之前,先简要看看在数据持久性、可用性与一致性方面可能出现的问题。

假设我们的 Kafka 集群由三台 broker 组成(如前面示例)。只要一切正常,数据持久性、可用性和一致性都不会有问题。但如果其中一台 broker 突然故障,会发生什么?我们的主题仍然可读吗?这对数据一致性意味着什么?我们还能继续生产并写入数据吗?如果那台 broker 突然重新上线或又有另一台 broker 故障,会怎样?数据的持久存储还能得到保证吗?我们还能继续读取数据,还是会产生一致性问题?由此可见,有时我们必须在这些属性之间设定优先级:对我们来说哪个更重要——在出现严重故障时尽可能保持可读可写,还是确保数据绝对正确?这些问题将在接下来的各节中逐一解答。

5.1 确认(Acknowledgments)

ACK(确认)被许多网络协议使用,例如 TCP 或 IEEE 802.11(Wi-Fi),以确认数据已成功传输。然而,下层通信层的机制并不总是足以保证应用层数据传输的成功。例如,当链路极度拥塞甚至中断时,即使是 TCP 也无法保证成功传输;此时 TCP 在多次尝试失败后会中止传输。即便传输成功,也无法保证上层应用已正确处理。因此,许多应用会使用自己的 ACK 机制,Kafka 也不例外。

在 Kafka 中,ACK 由生产者使用。消费者使用不同的方法来保证数据已被成功读取,我们将在后续章节详细讨论。Kafka 中的 ACK 不仅用于确认 broker 已经从生产者接收到了数据,还作为复制策略的一部分发挥作用。ACK 的配置完全由生产者控制,与主题的配置独立。但主题配置与 ACK 的组合会对可靠性与性能产生重要影响。

5.1.1 Kafka 中的 ACK 策略

在深入讨论 ACK 在 Kafka 中的后果之前,先看看我们可以如何配置 ACK 以及有哪些选项。ACK 在生产者端直接控制,通常有三种选项(见图 5.1)。下面按对整体系统可靠性影响由大到小的顺序来讨论它们。

image.png

我们的 kafka-console-producer.sh 可通过 --producer-property 参数来传递生产者配置项,例如设置 ACK 策略:

$ kafka-console-producer.sh \
    --topic products.prices.changelog \
    --bootstrap-server localhost:9092 \
    --producer-property acks=all

第一种情况我们将 ACK 策略设为 all。这表示:在成功接收消息后,分区的 broker(即 leader)在把消息成功分发到所有 in-sync replicas(ISR,同步副本) 之前,不会向生产者返回 ACK。副本被认为是“同步”的条件是它们在过去 30 秒内已拉取到当前数据。

注意:设置 acks=-1 等价于 acks=all

另一种可能是设置 acks=1

$ kafka-console-producer.sh \
    --topic products.prices.changelog \
    --bootstrap-server localhost:9092 \
    --producer-property acks=1

这个设置可以很好地类比 TCP:只要 leader 收到消息,就向生产者发送 ACK。相比 acks=all,生产者能更快收到 ACK,从而稍微降低延迟,性能有所提升。但如果 leader 在发送 ACK 后立刻故障,而又没来得及把消息分发给 follower,就必须预期会发生数据丢失。此外,如果没有其它保护措施,消息可能会被写入两次(稍后章节会讨论如何避免重复写入)。最后一种变体是 acks=0

$ kafka-console-producer.sh \
    --topic products.prices.changelog \
    --bootstrap-server localhost:9092 \
    --producer-property acks=0

此时 broker 不会向生产者返回任何 ACK。这种配置可与 UDP(无连接、不可靠传输)类比:生产者发送数据,但如果数据没有到达,生产者也不关心。因此生产者只会发送一次消息,Kafka 不保证消息会被成功持久化。这并不表示消息一定不会到达,而且在传输层 Kafka 仍然使用 TCP。但显然这种 ACK 策略提供了最低的可靠性,仅当可以容忍数据丢失时才应使用。

作为回报,acks=0 在吞吐量/性能上通常优于其它两种策略。比如某个温度传感器每毫秒采集一次温度并发送到 Kafka 集群,除非是针对药品等极其敏感场景,否则丢失某些数值可能并不重要——我们更关心当前值,而且重传失败的历史值会不必要增加网络与 broker 负担。在可容忍数据丢失的情况下,acks=0 在 ACK 策略中性能最好;而 acks=all 在数据一致性上最强。

注意:从 Kafka 3.0 起,Kafka 的默认 ACK 设置为 acks=all。在 Kafka 3.0 之前的默认值是 acks=1

5.1.2 ACK 与 ISR

主题配置属性 min.insync.replicas(最小同步副本数)会在与生产者配置 acks=all 一起使用时生效。如上所述,acks=all 时,leader 在所有 ISR 成功持久化数据之前不会向生产者发送 ACK。min.insync.replicas 用来指定在 acks=all 情况下必须至少有多少个副本处于同步状态才能向生产者返回 ACK。如果可达或同步的副本数量不足,生产者会收到相应的错误并尝试重发消息。

举例:我们创建一个新的测试主题 products.prices.changelog.min-isr-2,设置 replication-factor=3partitions=3,并指定 min.insync.replicas=2

$ kafka-topics.sh \
    --create \
    --topic products.prices.changelog.min-isr-2 \
    --replication-factor 3 \
    --partitions 3 \
    --config min.insync.replicas=2 \
    --bootstrap-server localhost:9092

使用 --config 参数可以更精细地配置主题,这里我们把最小同步副本数设为 2。查看新主题:

$ kafka-topics.sh \
    --describe \
    --topic products.prices.changelog.min-isr-2 \
    --bootstrap-server localhost:9092
Topic: products.prices.changelog.min-isr-2 PartitionCount: 3
    ReplicationFactor: 3 Configs: min.insync.replicas=2
  Topic: products.prices.changelog.min-isr-2 Partition: 0
    Leader: 1 Replicas: 1,2,3 Isr: 1,2,3
  Topic: products.prices.changelog.min-isr-2 Partition: 1
     Leader: 2 Replicas: 2,3,1 Isr: 2,1,3
  Topic: products.prices.changelog.min-isr-2 Partition: 2
    Leader: 3 Replicas: 3,1,2 Isr: 1,2,3

如预期,我们有 3 个分区、每分区 3 个副本且均为同步状态(ISR)。在所有三个 broker 都可达的情况下,ACK 策略与之前无异。下面我们故意关闭两个 broker 来观察行为差异。

注意:如果 ID 为 2 的 broker 恰好是当前活跃的 controller,那么关闭它会导致集群无法响应,因为剩下的节点无法选举出新的 controller。这个问题可以通过先启动一个离线 broker,再停止同一个 broker 的办法临时解决(示例环境中附录 A 提供 kafka-broker-stop.sh 脚本用于停 broker)。

示例中我们用脚本停掉 broker 3 和 broker 2,然后再次查看主题:

$ kafka-broker-stop.sh 3
$ kafka-broker-stop.sh 2
$ kafka-topics.sh \
    --describe \
    --topic products.prices.changelog.min-isr-2 \
    --bootstrap-server localhost:9092
Topic: products.prices.changelog.min-isr-2 PartitionCount: 3
    ReplicationFactor: 3 Configs: min.insync.replicas=2
  Topic: products.prices.changelog.min-isr-2 Partition: 0
    Leader: 1 Replicas: 1,2,3 Isr: 1
  Topic: products.prices.changelog.min-isr-2 Partition: 1
    Leader: 1 Replicas: 2,3,1 Isr: 1
  Topic: products.prices.changelog.min-isr-2 Partition: 2
    Leader: 1 Replicas: 3,1,2 Isr: 1

可以看到:Broker 1 接管了 Partition 2(原 leader 为 Broker 3)和 Partition 1(原 leader 为 Broker 2);同时 Broker 2 和 Broker 3 不再出现在 ISR 列表中(它们不同步或不可达)。现在我们尝试用不同的 ACK 策略发送消息。先在另一个终端启动消费者读取消息:

$ kafka-console-consumer.sh \
    --topic products.prices.changelog.min-isr-2 \
    --from-beginning \
    --bootstrap-server localhost:9092

然后启动生产者并用 acks=0 发送一条消息:

$ kafka-console-producer.sh \
    --topic products.prices.changelog.min-isr-2 \
    --bootstrap-server localhost:9092 \
    --producer-property acks=0
>cola 0

尽管没有出现明显的错误(broker 不可达或发送失败),该消息仍然不会在消费端出现。由于我们使用的是 acks=0,无法确定消息是否已被持久化——而在当前状态下,消费消息需要满足 min.insync.replicas 的要求。同理,用 acks=1 发送时的表现如下:

$ kafka-console-producer.sh \
    --topic products.prices.changelog.min-isr-2 \
    --bootstrap-server localhost:9092 \
    --producer-property acks=1
>cola 1

尽管消费者窗口看不到该消息,但因为使用了 acks=1,我们可以确信该消息已被分区的 leader 成功持久化。

现在尝试用 acks=all 发送:

$ kafka-console-producer.sh \
    --topic products.prices.changelog.min-isr-2 \
    --bootstrap-server localhost:9092 \
    --producer-property acks=all
>cola all

你会看到类似这样的警告与错误(节选):

[…] WARN [Producer clientId=console-producer] Got error produce response
with correlation id 10 on topic-partition
products.prices.changelog.min-isr-2-2, retrying (2 attempts left).
Error: NOT_ENOUGH_REPLICAS
...
[…] ERROR Error when sending message to topic
products.prices.changelog.min-isr-2 ... 
org.apache.kafka.common.errors.NotEnoughReplicasException: Messages are
rejected since there are fewer in-sync replicas than required.

首先出现 NOT_ENOUGH_REPLICAS 的警告,生产者会尝试重试;重试若多次失败,最终会报错并拒绝写入,错误表明可用的 ISR 数量少于 min.insync.replicas 的要求(本例要求至少 2 个 ISR,但当前只有 1 个 ISR),因此消息被拒绝。

如果我们只停掉了一个 broker,那么在 acks=all 的情况下通常仍可以正常生产和消费。接着把 broker 重新启动:

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

重启后的 broker 会跟上 leader 的进度,成为同步副本(ISR),此时我们之前用 acks=0acks=1 发送的 cola 0cola 1 应该就能被消费到。

注意:在先前的 Kafka 版本中,消费一条消息并不要求 min.insync.replicas 必须都处于同步状态;只要剩余的 ISR 中包含该消息即可。

这个例子清楚地演示了 ACK 策略与主题属性 min.insync.replicas 结合使用时,会对 Kafka 集群产生多么大的影响。稍后章节我们会更详细地说明生产者发送数据的具体流程以及在类似场景下的行为。如果不设置 min.insync.replicas 或将其设为 1,而当前 ISR 也仅为 1,那么尽管使用了 acks=all,仍可能发生数据丢失——因为在这种情况下 acks=all 等价于 acks=1。因此为 min.insync.replicas 选择一个合理值非常重要。现在我们可以把 broker 都重启回来。

提示:通常建议在 replication.factor=3 的情况下将 min.insync.replicas 设为 2。但需要根据你的故障场景来仔细权衡。如果需要更高的抗故障性(例如希望能在丢失两台 broker 的情况下仍能继续运行),可以把 replication.factor 设为 4,同时把 min.insync.replicas 设为 2。

5.1.3 Kafka 中的消息投递保证

我们已经讨论过:当使用 acks=0 时,无法保证消息一定会到达 broker。但至少可以确定不会产生重复消息。因此,acks=0 提供的是最多一次(at-most-once) 的投递保证,如图 5.2 所示。

image.png

通常我们希望配置更可靠一些,因此会设置 acks=all。不过还有一个问题我们尚未考虑:如果消息已经被成功持久化,但 ACK 没有到达生产者,会发生什么?在这种情况下,生产者会重发该消息,因为它必须假定消息未能成功持久化(见图 5.3)。结果就是我们可能在集群中不知不觉地将同一条消息存了两次。把这个情况套到温度传感器的例子上可能问题不大,但如果消息是一次银行交易,则可能导致金额被重复记入或记出——显然这不是期望的行为。

image.png

此时我们先来看分布式系统里的消息投递保证会很有帮助:最多一次(at most once)至少一次(at least once)恰好一次(exactly once)

最多一次保证消息最多被投递一次,因此不会产生重复,但也不能保证消息一定会到达(这就是 acks=0 的情形)。这种保证适用于像连续大批量上报的传感器读数这类场景:我们希望不出现重复读数,丢失个别读数并不重要。

至少一次保证可以通过把 min.insync.replicas 设为合理值(例如 2)并将 acks 设为 all 来实现。在这种配置下,消息在任何情况下都会被保证到达,但可能会出现重复消息(即前面描述的问题)。在 Kafka 引入恰好一次语义(exactly-once semantics)之前,at-least-once 是大多数应用的首选保证。

接下来是恰好一次,它保证消息恰好被持久化一次,且顺序正确,从而实现数学意义上的幂等性(idempotence) 。幂等性的含义是:用相同输入多次调用某个函数或操作,总是得到相同结果。Kafka 通过让生产者为消息分配**序列 ID(sequence ID)**来实现幂等性。默认情况下,在 Kafka 3.3 之前,生产者并未启用该功能,消息发送时没有序列 ID。示例命令(启用幂等性)如下:

$ kafka-console-producer.sh \
    --topic products.prices.changelog.min-isr-2 \
    --bootstrap-server localhost:9092 \
    --producer-property acks=all \
    --producer-property enable.idempotence=true

与 ACK 类似,我们可以通过 --producer-property 参数启用幂等性(enable.idempotence=true)。

注意:启用幂等性需要满足以下条件:acks=allmax.in.flight.requests.per.connection 不大于 5(默认值为 5),以及 retries 大于 0。默认的重试次数是 2,147,483,647MAX_LONG),但不用担心,存在交付超时(delivery timeout)来防止消息被无限次重发。配置 max.in.flight.requests.per.connection 用于控制每个连接上可以发送但尚未收到 ACK 的未确认请求数。

当启用幂等性后,分区的 leader 在接收消息时会检查消息是否按正确顺序到达。如果该消息已经被持久化,则只会重发 ACK,而不会重复持久化该消息(如图 5.4 所示)。

image.png

如果消息以错误的顺序到达,leader 会向生产者发送一个否定确认(NACK),生产者随后会尝试重新发送该消息,如图 5.5 所示。

image.png

启用幂等性后,我们就可以确保消息在一个分区内按正确顺序写入并且恰好写入一次。幂等性在 Kafka 3.0 之前默认未启用,是为了给社区留出时间来更新各自的客户端。启用幂等性会带来极小的性能开销。

提示:在此我们强烈建议保持幂等性开启。如果遇到性能问题,通常不是由幂等性引起的,应该先排查其它原因。

5.2 事务

本节我们将深入探讨 Kafka 中事务管理的核心原理。Kafka 的事务对于保证数据一致性与可靠性至关重要,它允许跨多个分区进行原子写入并确保消息得到正确处理。我们将介绍初始化、执行和维护事务的关键步骤,说明 Kafka 在复杂运行场景下如何保障事务完整性。

消息在分区中被正确存储固然重要,但通常我们还希望对这些数据进行后续处理。生产者端的幂等性对消费者或第三方应用并无直接影响;在消费链路中,如果不加小心,仍然可能出现消息重复或丢失的情况。仔细看 Kafka 的消费过程会发现,每次读操作同时又伴随一次写操作——因为我们需要提交(commit)offset。Kafka 为我们提供了将 offset 存储在 Kafka 中的选项,但这并非强制要求。

5.2.1 数据库中的事务

如果想把 Kafka 的数据“恰好一次”地写入数据库,有两种常见做法可以实现恰好一次语义。一种做法是照常使用 Kafka 消费者并将 offset 提交到 Kafka,但在提交 offset 之前先把数据写入数据库。前提是必须保证对数据库的写入是幂等的,也就是说在每次写入之前检查数据是否已存在,仅在不存在时写入。最容易的实现方式是使用 UPSERT 语句。在 PostgreSQL 中可以类似这样写:

INSERT INTO products_price_changelog (id, timestamp, name, price)
    VALUES (…)
    ON CONFLICT DO NOTHING;

第二种做法是将 offset 存储在数据库中,而不是存回 Kafka。如果我们把数据和 offset 一起在一个数据库事务中写入,就可以保证每条来自 Kafka 的消息在数据库中恰好出现一次,因为数据与 offset 是原子性地一起提交的。例如在 PostgreSQL 中可以这样实现:

BEGIN TRANSACTION;
    INSERT INTO products_price_changelog (id, timestamp, name, price)
        VALUES (…);
    INSERT INTO offsets (topic, partition, offset)
        VALUES ('products_price_changelog', 0, 123);
COMMIT;

但如果我们想从一个 Kafka 主题读取数据、处理后再写入到另一个 Kafka 主题,该如何保证恰好一次?在这种情况下,我们需要一种方法来对多个分区进行原子写入,即既要写入目标主题的分区,也要写入 __consumer_offsets 主题的分区,保证要么全部写入成功,要么全部失败(如图 5.6 所示)。读取、处理并将结果写回另一个主题是个非常常见的场景,Kafka Streams 因此提供了相应的库来尽量简化该流程。

5.2.2 Kafka 中的事务

要在 Kafka 中可靠地实现事务,需要两个要素。首先,需要一种向单个分区可靠地生产消息的方法——我们通过幂等生产者来实现这一点。其次,需要一种能够同时原子性地写入多个分区的方法,也就是说事务内的所有消息要么全部写入成功,要么全部不写入。

image.png

我们可以像在数据库中那样在代码中使用事务。首先通知生产者我们要使用事务。然后,开启事务、写入消息并(可选地)提交 offset。最后,提交事务本身以成功完成它。只有在提交事务之后,消费者才可以看到并处理这些已写入的消息。如果我们中止(abort)事务,不会从分区中删除已写入的消息,而是将它们标记为应被消费者忽略。

一个经典的事务示例是用户之间的转账(比如 Bob 向 Alice 转账)。为此,我们需要将 Bob 的账户余额减少相应金额,同时将 Alice 的账户余额增加相同金额。两个操作应当具有原子性:要么转账成功且两者余额都被调整,要么出现故障两者都不变。在 Kafka 中,我们可以用例如下面的 Python 代码来实现:

from confluent_kafka import Producer    #1

producer = Producer({
    'bootstrap.servers': 'localhost:9092',    #2
    'acks': 'all',     #2
    'enable.idempotence': True,      #2

    'partitioner': 'murmur2_random',    #3

    'transactional.id': 'transaction-1',    #4
})

producer.init_transactions()    #5
producer.begin_transaction()    #6

producer.produce("customer.balance",
    key="bob", value="-10")    #7
producer.produce("customer.balance",
    key="alice", value="+10")    #7

producer.commit_transaction()    #8
#1 Imports the confluent_kafka Python library
#2 Our recommended settings for reliable producers
#3 Makes the producer compatible with Java producers
#4 ID of the transactional producer
#5 Initializes the transactional producer
#6 Begins an actual transaction
#7 Produces as usual
#8 Commits the transaction

我们导入所用的 Kafka 库(此处使用 Confluent 官方支持的 Python 客户端 confluent-kafka)。该库基于 librdkafka,这是我们偏好的 C 语言 Kafka 客户端。首先,用熟悉的 bootstrap.serversmurmur2_random 分区器初始化生产者,以保证与 Java 客户端兼容。除此之外,还必须为该生产者指定一个唯一的 transactional.id。必须确保在任何时候不会有两个生产者使用相同的 transactional.id,否则会相互干扰。Kafka 使用这个 ID 来识别“僵尸”生产者(如果出现同一 transactional.id 的新生产者,旧的会被标记为僵尸)。在使用事务之前,必须先用 init_transactions() 初始化生产者;然后调用 begin_transaction() 开始事务,使用 produce() 写消息,最后用 commit_transaction() 成功结束事务。若要中止事务,调用 abort_transaction() 即可。

如果我们要编写一个既包含消费者又包含生产者、并希望保证消息“恰好一次”处理的应用,情况会更复杂。Kafka 只有生产者才能参与事务。因此,如果在应用的消费者部分直接提交 offset,该提交不会包含在事务内,可能会导致消息丢失或重复。

为了解决这个问题,需要禁用消费者的自动提交 offset:将消费者配置 enable.auto.commit 设为 False。这样消费者在处理消息时就不会自动提交 offset。相反,在消费消息并处理后,应用应调用生产者的 send_offsets_to_transaction() 函数,将 offset 作为事务的一部分提交到生产者中。这样 offset 的提交会与消息的生产一起以原子方式发生,要么同时成功,要么同时回滚。通过这种方式,我们确保消息与其 offset 在生产者端都在同一个事务内被处理,实现恰好一次处理语义。

5.2.3 事务与消费者

关键的一点是:必须将消费者的 isolation.level 设置为 read_committed,否则消费者会读取到未提交的消息,从而使事务机制失去意义。

提示:我们建议将所有消费者的 isolation.level 都设为 read_committed,即便当前暂时不打算使用事务。将来若改变方案,这样设置会让你省去后来去逐一更新所有消费者的麻烦。

为什么必须设置隔离级别?这些事务是如何工作的?Kafka 没有重新发明轮子,它使用的是两阶段提交(Two-Phase Commit)协议的变体来实现对多个分区的原子写入。可以把这个过程类比为预订旅行:我们想同时预订酒店和机票,但该过程有时会出错。我们不希望出现只有酒店预订成功而机票失败的情形。为此,先向酒店预定一个可取消的房间(并告知在一定时间内会确认),然后再尝试订机票;若机票订失败,则取消酒店预订;若成功,则确认酒店。这就是两阶段提交思想的简化类比。

本质上,Kafka 的事务流程类似:首先把需要写入的分区向事务协调者(transaction coordinator)注册;然后对这些分区正常写入,但将写入的消息标记为“暂定”(tentative);当事务端完成后,通知事务协调者,协调者会在这些分区上写入提交标记(commit markers)以确认事务;若事务中止,协调者则写入中止标记(abort markers)。

注意:事务协调者保证事务可靠工作,即使 Kafka brokers 或生产者崩溃,也不会违反事务保证。

Broker 会确保只有已提交的消息可被设置为 read_committed 的消费者读取。图 5.7 展示了三种简单情况。首先,若存在已开始但尚未提交或中止的事务消息,则 broker 会阻止这些消息被发送给消费者,消费者看起来像是没有收到新消息。即便是不属于任何事务的消息,broker 在这种状态下也可能暂时不发送,因为 broker 仍必须保证消息的有序性。

image.png

注意:我们的消费者并不关心任何事务的存在——无论是进行中的、已提交的还是已中止的事务——因为这些由 broker 全权负责处理。

在第二种情况中,如果事务被提交,那么从事务的第一条消息到提交标记之间的所有消息都会被释放并可被消费。最后在第三种情况中,如果事务被中止,所有属于该已中止事务的消息都会被跳过。不属于任何事务的消息当然会按正确顺序转发给我们的消费者并照常处理。

注意:如果在提交标记之前还有另一个正在进行的事务,那么只能消费到第一个事务的前一条消息为止。我们必须等待那个事务也被提交或中止后,才能继续。

这会带来一定的性能影响。因为事务协调者必须为每个分区写入额外的消息,开销取决于事务中所涉及的分区数量,但不取决于写入消息的数量。这意味着对于持续时间在毫秒级的非常短的事务,开销显得很明显,随着事务变长开销会降低。例如在事务刚被引入 Kafka 时,开发者测量到:当事务持续 100 ms 且生产者以最大速率发送时,开销约为 3%;但对于仅持续 10 ms 的短事务,额外开销约为 3 ms,相当于 30% 的开销。

5.3 复制与主从(leader–follower)原则

在前面的章节中,我们已经学到不少关于 Kafka 的分区与复制。我们知道 Kafka 使用主从(leader–follower)原则来管理副本。本节将更深入地探讨 Kafka 中主从机制的内部工作,看看 broker 是如何成为某个分区的 leader 以及随之而来的职责;还会讨论 follower 与 leader 之间如何交互,以及当 leader 故障时会发生什么。

现在用示例来说明。首先我们创建主题 products.prices.changelog.replication-3

$ kafka-topics.sh --create \
    --topic products.prices.changelog.replication-3 \
    --replication-factor 3 \
    --partitions 3 \
    --config min.insync.replicas=2 \
    --bootstrap-server localhost:9092
Created topic products.prices.changelog.replication-3.

接着查看这些分区是如何分配到各 broker 的:

$ kafka-topics.sh --describe \
    --topic products.prices.changelog.replication-3 \
    --bootstrap-server localhost:9092
Topic: products.prices.changelog.replication-3 PartitionCount: 3
    ReplicationFactor: 3 Configs: min.insync.replicas=2
  Topic: products.prices.changelog.replication-3 Partition: 0
    Leader: 2 Replicas: 2,3,1 Isr: 2,3,1 Elr:
  Topic: products.prices.changelog.replication-3 Partition: 1
    Leader: 3 Replicas: 3,1,2 Isr: 3,1,2 Elr:
  Topic: products.prices.changelog.replication-3 Partition: 2
    Leader: 1 Replicas: 1,2,3 Isr: 1,2,3 Elr:

由于我们设置了复制因子为 3,且总共只有三台 broker,因此每个分区都被复制到了三台 broker 上。分区的 leader 在各 broker 之间尽量均匀分布以平衡负载:Broker 1 是分区 2 的 leader,Broker 2 是分区 0 的 leader,Broker 3 是分区 1 的 leader(如图 5.8 所示)。

图 5.8:我们主题的分区分布在三台 broker 之间。Broker 2 是分区 0 的 leader,Broker 3 是分区 1 的 leader,Broker 1 是分区 2 的 leader。因为复制因子为 3 且 broker 数为 3,每个分区都存在于每台 broker 上。

image.png

在第 5.1 节关于 ACK 的内容中,我们已经了解到生产者总是将消息发送到分区的 leader,而且 leader 会根据 ACK 策略在 follower 也收到消息之后才向生产者确认接收。但消息如何从 leader 传到 follower 呢?Kafka 尽可能把工作外包给客户端。从 Kafka(或具体的分区 leader)的视角来看,follower 就像消费者一样,使用专门的 fetch 请求来拉取消息,如图 5.9 所示。

像普通消费者一样,follower 也会向 leader 通知它们当前的 offset(即在日志中的当前读取位置)。这样,leader 就知道某个 follower 何时已经“消费”到某条消息,然后可以相应地向生产者发送确认。

当我们观察 broker 故障且无法访问时,情况就变得有意思了。为此,我们先用 kafka-broker-stop.sh 脚本停止 Broker 3,然后再描述(describe)该主题:

$ kafka-broker-stop.sh 3
$ kafka-topics.sh --describe \
    --topic products.prices.changelog.replication-3 \
    --bootstrap-server localhost:9092
Topic: products.prices.changelog.replication-3 PartitionCount: 3
    ReplicationFactor: 3 Configs: min.insync.replicas=2
  Topic: products.prices.changelog.replication-3 Partition: 0
    Leader: 2 Replicas: 2,3,1 Isr: 2,1 Elr:
  Topic: products.prices.changelog.replication-3 Partition: 1
    Leader: 1 Replicas: 3,1,2 Isr: 1,2 Elr:
  Topic: products.prices.changelog.replication-3 Partition: 2
    Leader: 1 Replicas: 1,2,3 Isr: 1,2 Elr:

注意:取决于我们在创建 products.prices.changelog.replication-3 主题之前集群中分区的分布,可能并不是每台 broker 都是某个分区的 leader。如果出现这种情况,为了能跟着本例演示,我们需要确保所停止的 broker 在该主题上确实被分配为某个分区的 leader。

可以看到 Broker 3 仍在 replicas 列表中,但不再出现在 ISRs 列表里。如果某个作为 leader 的 broker 发生故障,Kafka(或 controller)必须为相应分区指定新的 leader,否则该主题将无法访问。因此 Broker 1 接管了 Partition 1 的 leader 角色(如图 5.10 所示)。此外,并非每个副本都具备成为新 leader 的资格;只有处于 ISR(in-sync replicas,同步副本)或 ELR(eligible leader replica,具备成为 leader 资格的副本)中的副本才可被选为 leader。为什么会这样以及如何判断某个副本是否处于同步或具备 leader 资格将在第 8 章详细讨论。

在 Kafka 中,我们可以用 kafka-topics.sh 显示当前副本数量不足的所有主题或分区。为此传入 --describe--under-replicated-partitions 参数:

$ kafka-topics.sh --describe \
    --bootstrap-server localhost:9092 \
    --under-replicated-partitions
  Topic: products.prices.changelog.replication-3 Partition: 0
    Leader: 2 Replicas: 2,3,1 Isr: 2,1
  Topic: products.prices.changelog.replication-3 Partition: 1
    Leader: 1 Replicas: 3,1,2 Isr: 1,2
  Topic: products.prices.changelog.replication-3 Partition: 2
    Leader: 1 Replicas: 1,2,3 Isr: 1,2

image.png

不足为奇,我们看到主题 products.prices.changelog.replication-3 的所有分区都处于副本不足(under-replicated) 状态。等价地,我们也可以使用 --under-min-isr-partitions 来显示当前 ISR 数量低于 min.insync.replicas 要求的分区。但在本例中不会得到任何结果(即不会显示任何分区),因为我们仍然有两个 ISR,正好满足 min.insync.replicas 的要求。 这两个命令在调试 Kafka 集群时都非常有用——可以快速判断跨主题是否存在问题。

现在我们已经看到 broker 宕机时的情况,接着看看该 broker 恢复可用时会发生什么。要重启 broker,只需再次运行 kafka-server-start.sh。然后再查看主题的当前状态:

$ kafka-server-start.sh ~/kafka/config/kafka3.properties
$ kafka-topics.sh --describe \
    --topic products.prices.changelog.replication-3 \
    --bootstrap-server localhost:9092
Topic: products.prices.changelog.replication-3 PartitionCount: 3
    ReplicationFactor: 3 Configs: min.insync.replicas=2
  Topic: products.prices.changelog.replication-3 Partition: 0
    Leader: 2 Replicas: 2,3,1 Isr: 2,1,3
  Topic: products.prices.changelog.replication-3 Partition: 1
    Leader: 1 Replicas: 3,1,2 Isr: 1,2,3
  Topic: products.prices.changelog.replication-3 Partition: 2
    Leader: 1 Replicas: 1,2,3 Isr: 1,2,3

Broker 3 重新出现在 ISR 列表中,但 Broker 1 依然是 Partition 1 的 leader,Broker 3 恢复后只能作为 follower(从副本)继续工作,如图 5.11 所示。

image.png

Broker 3 必须先完成自我同步;然后,默认情况下在几分钟后它会重新成为其作为首选 leader 的分区的 leader。我们通过 replicas 列表顶部的条目来识别首选 leader(首位即为首选)。根据集群的配置,leader 会在一段时间后自动进行重新均衡,或者需要我们手动重新均衡。我们可以使用脚本 kafka-leader-election.sh 手动触发 leader 的重新均衡:

$ kafka-leader-election.sh \
    --election-type=preferred \
    --all-topic-partitions \
    --bootstrap-server localhost:9092
Successfully completed preferred leader election for partitions
products.prices.changelog.replication-3-1

命令输出(products.prices.changelog.replication-3-1)显示了执行首选 leader 选举的主题和分区。每个分区号都与其对应的主题以连字符连接。

我们必须对 election-type 参数格外小心。这里可选 preferreduncleanpreferred 表示尝试把分区的原始(也就是首选的)leader 恢复为 leader。只有在极端紧急情况下才应执行 unclean leader 选举。那么什么情形算作“极端紧急”呢?

如前所述,当一个分区的 leader 发生故障时,Kafka 会从 ISR 列表中选出新的 leader。但如果没有其他 ISR 可用,会发生什么?在这种情况下,Kafka 默认无法选出新的 leader——这意味着既不能向该分区写入新消息,也不能从该分区消费消息。

进行 unclean leader election 时,甚至那些不同步的副本也可以被指定为新的 leader。由于这些副本很可能并不包含所有消息,因此会有数据丢失的风险,所以这种方法被称为“不干净(unclean)”。我们也可以在 broker 配置中启用或禁用不干净 leader 选举(unclean.leader.election.enable),但强烈不建议开启。此外,kafka-leader-election.sh 命令既可以仅对特定主题生效(--topic products.prices.changelog.replication-3),也可以作用于所有主题(--all-topic-partitions)。尤其在执行不干净 leader 选举时,务必只针对特定主题执行!

警告:仅在极端紧急情况下才应执行不干净的 leader 选举。即便如此,也应仅对特定主题进行操作。

最后再看一次主题状态:

$ kafka-topics.sh \
    --describe \
    --topic products.prices.changelog.replication-3 \
    --bootstrap-server localhost:9092
Topic: products.prices.changelog.replication-3 PartitionCount: 3
    ReplicationFactor: 3 Configs: min.insync.replicas=2
  Topic: products.prices.changelog.replication-3 Partition: 0
    Leader: 2 Replicas: 2,3,1 Isr: 2,1,3
  Topic: products.prices.changelog.replication-3 Partition: 1
    Leader: 3 Replicas: 3,1,2 Isr: 1,2,3
  Topic: products.prices.changelog.replication-3 Partition: 2
    Leader: 1 Replicas: 1,2,3 Isr: 1,2,3

如我们所见,Broker 3 又重新成为了 Partition 1 的 leader,而 Broker 1 被降为普通的 follower(参见图 5.12)。

image.png

总结

  • ACK(确认)是 Kafka 可靠性的基石。

  • ACK 是可选的,并且可以为每个生产者单独配置。

  • 生产者可以选择不要求 ACK(acks=0)以换取速度,但这会牺牲可靠性。

  • 生产者可以选择在 leader 接收到消息后返回 ACK(acks=1),或在消息被复制到所有同步副本后才返回 ACK(acks=all);后者现在是默认且最可靠的选择。

  • Kafka 提供三种消息投递保证:最多一次(at-most-once)、至少一次(at-least-once)和恰好一次(exactly-once)。

    • 最多一次:通过 acks=0 实现;
    • 至少一次:通过 acks=all 并配合足够的 min.insync.replicas 实现;
    • 恰好一次:需要启用幂等性(idempotence)并设置 acks=all
  • Kafka 中的事务允许跨多个分区进行原子写入,保证事务内的所有消息要么全部写入要么全部不写入,从而维护数据一致性。

  • Kafka 使用幂等生产者来实现可靠的消息写入,并采用两阶段提交协议的变体来管理事务的提交标记,从而确保消息被恰好一次地处理。

  • 消费者必须将 isolation.level 设置为 read_committed,以避免读取到未提交的消息,确保只处理完全提交的事务以维护数据完整性。

  • Kafka 的事务协调者(transaction coordinator)保证事务的可靠性,即便在 broker 或生产者崩溃的情况下也能维持语义,但这会带来一定的性能开销。

  • 每个分区都有一个 broker 担任 leader,负责处理所有请求;followers 通过向 leader 拉取(fetch)数据来复制分区数据。

  • 如果分区的 leader 不可用,则某个同步副本(ISR)会接管成为新的 leader。

  • 当原先的 leader 恢复并与集群同步后,它可以重新成为 leader(即恢复为“首选 leader”)。

  • 在严重故障情况下,Kafka 可以执行“不干净” leader 选举(unclean leader election),允许非 ISR 成为 leader,但这可能导致数据丢失。

  • Kafka 的主从(leader–follower)原则在 broker 故障时确保高可用性和容错性。