本章内容:
- Kafka 的基于 fetch 的消费模型
- 偏移量管理
- 消费组的作用
- Kafka 如何协调任务分配
- Range Assignor 与 Round Robin Assignor 的影响
- 静态成员与 Cooperative Sticky Assignor
在本章中,我们将深入探讨 Kafka 的消息消费过程。先从 Kafka 的基于 fetch 的消费模型入手,阐明消费者如何与 Kafka 集群交互以检索消息。随后我们会进一步剖析偏移量管理机制,讨论消费者如何指定分区并在 Kafka 中管理其读取进度。
另外,我们会讲解 Kafka 的再均衡(rebalance)协议,说明 Kafka 如何在消费者组成员之间协调分区分配。讨论中还将涉及 Range Assignor 和 Round Robin Assignor 等分配策略,这些策略对于高效的分区分配和工作负载均衡至关重要。
此外,我们还会研究一些高级特性,例如静态成员(static memberships)与 Cooperative Sticky Assignor,它们在优化再均衡行为、确保消费者组顺畅运行方面扮演重要角色。读完本章后,你将对 Kafka 的消息消费工作流以及在消费时如何高效地管理和分配任务有一个全面的理解。
9.1 拉取消息(Fetching messages)
最简单的情况是:我们有一个或多个主题需要消费的消费者。消费者已经成功连接到 Kafka 集群,并且通过对元数据请求的响应,消费者知道哪些 broker 是哪些分区的 leader。
9.1.1 Fetch 请求
要消费消息,消费者会向 leader 发送 fetch 请求,告知它想要消费的分区以及 broker 应从哪个偏移量开始返回消息。我们可以用 kafka-console-consumer.sh 脚本来模拟这一过程,示例如下。
注意:在前一章我们重建了 Kafka 集群,因此首先需要重新创建主题 products.prices.changelog 并写入一些消息。此外,可能需要在命令中调整分区号,因为我们并不知道消息会落在哪个分区。或者,我们可以只创建一个分区的主题以简化演示。
$ kafka-console-consumer.sh \
--topic products.prices.changelog \
--offset 0 \
--partition 0 \
--bootstrap-server localhost:9092
coffee pads 10
coffee pads 11
coffee pads 12
coffee pads 10
为此,我们需要显式指定所需的分区及希望开始接收消息的偏移量。通常我们不会显式指定分区或偏移量,而是依赖 Kafka 客户端库来处理这些细节。我们在第一章已经遇到过 --from-beginning 标志:kafka-console-consumer.sh 默认从分区末尾开始读取,使用 --from-beginning 则从头开始读取。Kafka 客户端库也采用相同的默认行为:消费者启动时默认读取最新消息。若要从分区开头读取,需要把配置项 auto.offset.reset 设为 earliest(默认是 latest)。
9.1.2 从最近的副本拉取(Fetch from the closest replica)
到目前为止,我们一直说消费者从分区的 leader 拉取消息。这个说法并不完全准确:自 Kafka Improvement Proposal 392 (KIP-392)以来,消费者可以从最近的副本(nearest replica)进行消费。
注:Kafka Improvement Proposal(KIP)是 Apache Kafka 社区用来提出和评审新特性或变更的结构化文档,保证社区驱动的有序开发。KIP 经提交、社区讨论与批准流程后才会被合并到 Kafka。
但 Kafka 如何知道哪个副本是“最近”的呢?在 Kafka 中,可以为 broker 指定机架 ID 或位置(broker.rack)。机架通常指物理或逻辑上归为一组的服务器(例如同一数据中心或同一可用区),这使 Kafka 能够按故障域来均匀分布副本以提高容错性。最初,这个配置仅用于让 Kafka 了解 broker 的位置以便进行副本分布。随着 KIP-392,引入了面向客户端的等价配置项 client.rack,允许客户端指定其所在位置,从而选择一个特定的副本来拉取消息,如图 9.1 所示。
9.2 Broker 对消费者 fetch 请求的处理
在详细看看消费者组如何帮助我们持久化偏移量之前,先了解一下 broker 为了给消费者返回满意的响应需要做哪些工作。总体上,broker 处理 fetch 请求的流程与处理 produce 请求非常相似,主要区别在于:broker 不是把数据写到文件系统,而是从文件系统读取数据。broker 同样由网络线程接收请求,并将其放入请求队列进行缓冲。但在此情况下,I/O 线程不是将批次写入文件系统,而是从文件系统读取批次。理想情况下,broker 根本无需访问物理磁盘,因为消息可能仍在页面缓存(page cache)中,可以直接从缓存读取。
在消费消息时,也会使用请求暂缓区(request purgatory),但它等待的不是其他 broker,而是等待额外的消息(当消费者请求时)。这种行为由消费者端配置,因为不同消费者可能对延迟和带宽有不同要求。默认情况下,当至少有一条新消息可用时,broker 会立即向消费者响应,因为 fetch.min.bytes 的默认值是 1。
如果没有新消息,broker 不会无限期等待响应,而是最多等待 fetch.max.wait.ms 指定的时间,默认值为 500 毫秒。如第 6 章所述,这些参数可以优化以提高消费者性能。
当 broker 决定发送响应后,会将响应放入响应队列(response queue),然后由网络线程将响应发送给消费者。整个过程如图 9.2 所示。
9.3 偏移量与消费者
在传统的消息系统中,系统本身会管理哪些消息发送给哪些消费者。然而在 Kafka 中,消费者负责跟踪它从每个分区读取到的消息的位置。这意味着消费者必须记住自己已经读过哪些消息、还没读哪些消息。在 Kafka 中,这通过偏移量(offset)来实现。
当消息被产生时,会被分配一个唯一的偏移量:某个分区中第一条消息的偏移量为 0,第二条为 1,依此类推。Kafka broker 在每条新消息写入时都会递增偏移量。即使某条消息后来被删除(例如由于清理策略),其他消息的偏移量也不会改变。因此,偏移量序列中可能存在“空洞”。这不会影响消息处理,因为 Kafka 会直接发送下一条可用消息及其当前偏移量。
9.3.1 偏移量管理
对于消费者来说,偏移量表示下一条要读取的消息位置。在第 9.1 节我们了解过 auto.offset.reset 设置,它允许配置在消费者不知道某个分区偏移量时应如何处理——例如是从最早的消息开始读取(auto.offset.reset=earliest,即最低偏移量),还是从分区末尾开始(auto.offset.reset=latest,默认值)。
但是,我们的消费者可以把偏移量存在哪里,以便在重启后从上次中断的地方继续读取?有些场景我们不想存储偏移量。例如,如果消费者只是把 Kafka 的数据装入内存缓存(in-memory cache),当消费者崩溃时缓存丢失,需要从头填充,那么我们没必要持久化偏移量。
大多数情况下,我们需要把 Kafka 的数据持久化、处理或转发到别处,这就要求我们存储偏移量。一种方法是在服务本地磁盘上保存偏移量。每次服务重启时读取磁盘上的偏移量并从该位置继续请求消息。这种方法在把 Kafka 数据写入本地数据库或缓存时很有用。
不过,很多场景我们希望服务无状态(stateless),这样就不在服务内保存偏移量。把偏移量存在服务里也会使得水平扩展变得复杂:新实例启动时不知道最后处理的偏移量,难以确保所有消费者从正确位置开始。
另一种选择是将偏移量存储在外部系统。例如,当我们把数据写入数据库时,可以建立一张 offsets 表,并在事务中同时写入数据与偏移量。这样可以给出“恰好一次”(exactly-once)的保证:要么消息(及对应偏移量)写入数据库,要么两者都不写入。许多 Kafka Connect 的连接器就是用这种方式实现 exactly-once 的。
实际上,我们已经有一个存储数据的系统 —— Kafka 自己,而且偏移量本质上也是数据。Kafka 自带了对偏移量管理的支持,即使用名为 __consumer_offsets 的压缩主题(compacted topic)来持久化消费者组的偏移量。这个压缩主题会对旧的偏移量记录进行压缩清理以节省空间。我们将在下一章中学习更多关于压缩(compaction)的内容。
Kafka 本身并不知道哪个偏移量属于哪个消费者,因此需要我们来指定。Kafka 不是为每个消费者分别存储偏移量,而是把偏移量与消费者组(consumer group)关联。这样可以水平扩展消费者:同一消费者组的多个实例可以共同消费同一组分区,并共享偏移量管理。
9.3.2 理解 Kafka 中的偏移量
现在我们创建一个名为 products.prices-offsets 的主题,包含两个分区,命令如下:
$ kafka-topics.sh \
--create \
--topic products.prices-offsets \
--partitions 2 \
--replication-factor 3 \
--bootstrap-server localhost:9092
随后我们向该主题发送一些消息:
$ kafka-console-producer.sh \
--topic products.prices-offsets \
--property parse.key=true \
--property key.separator=: \
--bootstrap-server localhost:9092
> coffee pads:10
> cola:2
> energy drink:4
> coffee pads:11
> coffee pads:12
> energy drink:4
现在启动一个指定 consumer group 的消费者:
$ kafka-console-consumer.sh \
--topic products.prices-offsets \
--bootstrap-server localhost:9092 \
--group products.prices.monitoring \
--from-beginning \
--property print.key=true \
--property key.separator=":"
输出可能为:
energy drink:4
energy drink:3
coffee pads:10
cola:2
coffee pads:11
coffee pads:12
注意我们消费的消息顺序与产生时的顺序不同。这是因为 energy drink 的消息被写入一个分区,而 cola 与 coffee pads 写入另一个分区。在这个简单示例中,消费者很可能先把一个分区的所有消息打印完再打印另一个分区的消息。
如果用 Ctrl-C 中断该命令后再重启,我们不会看到已读过的消息,说明偏移量已被记录。我们可以用 kafka-consumer-groups.sh 工具查看该消费组的偏移信息。示例命令:
$ kafka-consumer-groups.sh \
--group products.prices.monitoring \
--describe \
--bootstrap-server localhost:9092
为便于说明,下表(表 9.1)展示了 kafka-consumer-groups.sh 对消费组 products.prices.monitoring 的典型输出内容(已翻译表头):
表 9.1 描述消费组 products.prices.monitoring
| 组 (Group) | 主题 (Topic) | 分区 (Partition) | 当前偏移 (Current Offset) | 日志结束偏移 (Log End Offset) | 滞后 (Lag) | Consumer ID | Host | Client ID |
|---|---|---|---|---|---|---|---|---|
| products.prices.monitoring | products.prices-offsets | 0 | 2 | 2 | 0 | console-consumer-<ID> | /127.0.0.1 | console-consumer |
| products.prices.monitoring | products.prices-offsets | 1 | 4 | 4 | 0 | console-consumer-<ID> | /127.0.0.1 | console-consumer |
解释要点:
- Partition 0 的 Log End Offset(LEO)为 2,表示下一条写入该分区的消息偏移量为 2。组的当前偏移量(Current Offset)也是 2,说明该组已经读取到分区末尾,滞后(Lag)为 0。
- Partition 1 同理,均已读完,滞后为 0。
- Consumer ID 用于标识消费者组成员,是自动生成的(对于命令行消费者会包含随机 ID);Host 是消费者所在主机的 IP 地址。
client.id可以在消费者应用中配置,用于日志、指标与追踪目的。
现在如果停止消费者、再产生一些新消息,然后重新运行 kafka-consumer-groups.sh,LEO 与滞后会根据新消息变化(见表 9.2)。
表 9.2 在写入更多消息后描述消费组 products.prices.monitoring
| 组 | 主题 | 分区 | 当前偏移 | 日志结束偏移 | 滞后 |
|---|---|---|---|---|---|
| products.prices.monitoring | products.prices-offsets | 0 | 2 | 4 | 2 |
| products.prices.monitoring | products.prices-offsets | 1 | 4 | 7 | 3 |
如果我们重启消费者,它会读取这些新消息,滞后会再次降为 0。即便我们不需要对消费者进行水平扩展,使用消费者组仍然为偏移量存储提供了方便且可靠的方法。
警告(WARNING):组 ID 决定了哪个消费者组负责消费并提交特定的偏移量。如果不小心使用了错误的组 ID(例如拷贝了代码却忘记修改组名),可能会导致一个消费者覆盖另一个消费者的偏移量,进而“窃取”偏移量,使其去消费本该属于别的消费者的消息。在开发或测试中这可能只是烦恼,但在生产环境中可能引发严重后果,导致数据丢失或处理不一致。
9.4 理解与管理 Kafka 消费者组
我们已经多次遇到消费者组。我们使用消费者组来对消费者进行水平扩展,并且在上一节中了解到,消费者组也用于在 Kafka 中方便地管理偏移量。
9.4.1 消费者组管理
要使用消费者组,我们不需要任何额外组件;只需在消费者中设置 group ID。具有相同 group ID 的一个或多个消费者会组成一个消费者组。
在 Kafka 中,我们的目标是允许不同的服务多次读取相同的消息。例如,在本书的引言中,我们创建了主题 products.prices.changelog 并运行了两个服务:一个是销售分析服务,另一个是价格更新服务。我们看到这两个消费者组可以独立地消费同一主题,并且在各自的组内对分区进行分配,如图 9.3 所示。
但是组内的消费者如何相互感知彼此的存在,又如何在它们之间划分分区呢?消费者必须以某种方式协调它们各自的工作。我们在第 4 章对分布式系统的介绍中回忆到,协调是一个非常具有挑战性的问题。不过,我们已经可以利用一个容错的分布式系统——Kafka 的代理(brokers)。与其为每个消费者组运行一个单独的协调集群,不如让 Kafka 的代理来帮助我们协调消费者。
稍后我们会了解到,消费者组并不是唯一以这种方式被 Kafka 支持的组件。同样,Kafka 代理也会协助协调我们的 Kafka Connect 集群和 Kafka Streams 应用程序。
为此,Kafka 提供了一个专门的协议,称为 Kafka 重新平衡协议(Kafka Rebalance Protocol)。该协议能够在组成员之间分配资源(例如:分区、Kafka Streams 任务或 Kafka Connect 任务)。在这里,Kafka 也遵循尽可能将更多工作下放到客户端的理念:Kafka 本身并不关心正在被协调的具体事项。
注意:在撰写本文时,正在推进 KIP-848 中描述的下一代消费者组重新平衡协议的实现。新版本会大幅简化消费者组和 Kafka Streams 的运行。尽管当前版本仍会在某些特定用例中保留,但我们建议在 4.0.0 版本发布后尽快采用下一代重新平衡协议。
那么 Kafka 重新平衡协议是如何工作的呢?首先,在 Kafka 这一侧,需要有一个负责特定组的集中实例。这个 broker 被称为组协调器(group coordinator)。为了更好地分散负载,并不是只有一个 broker 扮演组协调器;相反,Kafka 会尽可能地将组协调器在各个 broker 之间均匀分布。该协议的工作流程如图 9.4 所示。
当消费者想加入一个消费者组时,它们首先向组协调器(group coordinator)发送一个加入请求(1)。组协调器负责管理该组及其成员。加入请求是一个同步点——在所有潜在成员都向协调器汇报之前,协调器不会发送响应。如果组中已有现有成员,协调器会临时将它们移除,以便它们用新的加入请求重新加入。在此过程中,消费者尚未成为组的正式成员,因此不能消费任何消息。
要找到组协调器,消费者首先必须向集群中的任意 broker 发送 FindCoordinator 请求。该 broker 会返回指定消费者组的组协调器的身份(主机与端口)。一旦消费者知道了哪个 broker 是组协调器,就可以直接与该协调器进行组管理相关的通信。这些操作包括 JoinGroup(加入组并触发重新分配)、Heartbeat(心跳,表明消费者仍然存活)和 SyncGroup(在重新分配后接收分区分配结果)。
当组成员都已加入后,第一个加入的成员被指定为组长(group leader)(2)。该组长负责在组内协调工作分配,尤其是决定哪个成员负责哪个分区。组长将这个分区分配方案发送给组协调器(3),协调器随后把这些分配下发给其他组成员(4)。此时,组成员可以开始从自己被分配到的分区继续消费消息。
组形成后,组协调器必须监控成员以确保它们仍然存活。为此,每个消费者会定期发送心跳(5)。如果某个成员在指定时间内(通常为三个心跳间隔)未发送心跳,组协调器就会认为该成员已失效并将其从组中移除。心跳间隔可以通过 heartbeat.interval.ms 调整,而判断消费者失效的超时时间由 session.timeout.ms 控制。不过,对于大多数用例,默认设置通常已足够。
总之,当组协调器收到加入请求时,它不会把组拆散,而是协调新成员的加入并确保为其分配分区。消费者组形成后,第一个加入的消费者成为组长,负责分区分配;组协调器则通过心跳机制管理组成员的存活并在成员发生变更时重新分配分区。
9.4.2 分区到消费者的分配
Kafka 提供了多种将分区分配给消费者的方式。我们可以通过消费者端的配置项 partition.assignment.strategy 来指定分配策略。组长在重平衡(rebalance)期间负责决定分区分配并将结果告知组协调器,其他消费者则按组长的分配结果执行。
警告:同一消费者组内的所有消费者必须使用相同的分区分配策略。如果它们使用了不同的策略,重平衡可能导致分区分配出现不可预测的变化,从而引发数据处理不一致或性能问题。
默认情况下,Kafka 使用 Range Assignor。使用 Range Assignor 时,假定所有消费者都希望消费具有相同分区数的主题。Range Assignor 会保证所有主题的分区 0 都分配给同一个消费者、所有主题的分区 1 都分配给同一个消费者,依此类推。例如,若有两个主题(每个主题各有两个分区)和三个消费者(见图 9.5 的示例),则会把两个主题的分区 0 都分配给消费者 1,把两个主题的分区 1 都分配给消费者 2,而消费者 3 则不会被分配任何分区。
该分配器在我们需要同时处理来自多个主题的数据时非常有用。例如,我们可以将来自 products.prices.changelog 主题的价格数据与来自 products.sales 主题的销售信息合并用于我们的分析服务。在两个主题中我们都使用产品名称作为 key,因此同一产品的消息总是落在相同的分区上,无论是在 products.prices.changelog 还是在 products.sales 中。这意味着如果我们从 products.prices.changelog 的分区 0 读取某个产品的数据,那么在 products.sales 的分区 0 中也能找到该产品的数据。这被称为流连接(stream join),特别是 Kafka Streams 广泛利用了这一点。
注:尽管可以使用 Range Assignor 实现一个朴素的连接(join)版本,但我们强烈建议不要在普通消费者中这样做。如果需要连接多个主题的数据,应使用像 Kafka Streams 这样的流处理库。
通常,当使用简单消费者消费多个主题时,我们可能并不需要在主题间关联数据,甚至可能不使用 key。在这种情况下,我们可以避免 Range Assignor 的限制,而使用 Round Robin Assignor。该分配器会将所有主题的分区在消费者之间均匀分配,如图 9.6 所示。
我们记得在重新平衡(rebalance)期间必须停止消费,并且消费者在重新平衡后可能会被分配到不同的分区。也就是说,消费者需要在重新平衡发生前清理任何内部状态(例如缓存),并提交它们的 offset。举例来说,如果示例图中的 Consumer 1 挂掉,每个消费者可能会被分配到完全不同的分区。我们可以通过使用 Sticky Assignor 来避免这种大幅变更。Sticky Assignor 并不是每次都从头计算分配,而是尽量将变更最小化。其余行为与 Round Robin Assignor 相同。
尽管 Sticky Assignor 相较于 Round Robin 有显著改进(因为它不会试图重新分配所有内容),但它仍然需要在重新平衡时“停止世界”。为了解决这一点,Kafka 3.0 引入了改进的分配器:Cooperative Sticky Assignor。协作式协议允许消费者在某些分区不发生变更时继续消费,而不需要在整个重新平衡期间停止。
我们来看其工作流程:在图 9.7 中,消费组包含两个消费者,共有三个分区。随后有一个新消费者加入。使用 Sticky Assignor 时,所有消费者需要停止消费以完成重新分配;而使用协作协议时,组会继续正常消费。第一次重新平衡时,会“从 Consumer 2 那里拿走”第 3 个分区(此分区的消费暂时停止)。该操作会触发下一次重新平衡,将当前悬空的分区 3 分配给新加入的 Consumer 3。由此减少了因为单个分区需要重新分配而暂停所有分区消费的情况。
提示:在下一代消费者组重新平衡协议(将随 Kafka 4.0.0 发布)可用之前,我们建议在消费者组中使用 Cooperative Sticky Assignor。
因此,Kafka 重新平衡协议(Rebalance Protocol)允许我们在组成员之间分配诸如分区或连接器任务等资源。但由于在协议执行期间需要暂停所有处理,重新平衡的代价非常高。当有新成员想要加入组、成员有意或无意地离开组,或者被消费的主题的分区数量发生变化时,都会触发重新平衡。例如,在一个全自动化环境中,对一个消费组中的三个消费者做滚动重启,会触发六次重新平衡:每次停机时一次、每次重启时一次。
9.4.3 静态成员(Static memberships)
为避免如此频繁的重新平衡,Kafka 在 2.3 版本引入了静态成员的概念。假设我们的基础设施是完全自动化的,如果某个消费者失败,会被立即自动重启——这种情况在云和 Kubernetes 环境中很常见。与其在消费者每次重启时都触发重新平衡,不如将 session.timeout.ms 增大到一个显著更高的值(例如几分钟)。
此外,我们需要为每个消费者提供固定的身份标识。为此要为每个消费者设置唯一的 group.instance.id。关键是要保证消费者在重启后能拿到相同的 ID。可以通过 Kubernetes 的 StatefulSet,或是在云环境中使用不变的主机名来实现这一点。这样配置后,只有在新增消费者、某个消费者数分钟内未上报,或分区数量发生变化时才需要重新平衡。这显著提高了消费者的利用率并避免了数据处理中的大幅中断。
总结
- Kafka 使用拉取(fetch-based)方式让消费者检索消息。
- Kafka broker 承担大部分协调工作,降低了客户端开销。
- Kafka 支持为 broker 配置机架 ID,以提高容错性和负载分布。
- 消费者组用于管理 offset 并在消费者之间分配工作负载。
- Offsets 作为消费者组元数据存储在 Kafka 的内部主题 __consumer_offsets 中。
- Offsets 对消费者跟踪已消费消息至关重要。
- Kafka 重新平衡协议负责在消费者组成员间协调任务分配。
- 重新平衡由组成员变更、主题分区变更或消费者故障等触发。
- Range Assignor 和 Round Robin Assignor 是常用的分区分配策略。
- Range Assignor 保证相同分区号在多个主题上由同一消费者处理(便于流合并)。
- Round Robin Assignor 在不需要跨主题关联时能均匀分配分区。
- 静态成员(static memberships)和 Cooperative Sticky Assignor 能优化重新平衡行为。
- 静态成员通过延长会话超时时间并使用固定的 group.instance.id 来减少重新平衡频率。
- Cooperative Sticky Assignor 通过迭代接近目标分配,从而提高重新平衡效率并减少暂停。