本章内容包括
- 操作 Kafka 主题
- 主题如何构建 Kafka 中的数据流结构
- 消息 ------ Kafka 中的基本数据单元
在本章中,我们将深入探讨 Apache Kafka 的基础要素:主题以及消息的细节。Kafka 主题是组织与分发数据的通道,而消息则是在这些通道中流动的单条数据。我们会研究不同类型的消息、它们的结构与数据格式,并理解这些元素如何促进高效的数据流与处理。到本章末,你将全面掌握如何有效管理 Kafka 主题以及 Kafka 消息的详细组成。
3.1 主题(Topics)
Kafka 主题对于组织数据流至关重要,类似于数据库中的表。主题帮助有序地管理不同类型和不同规模的信息。复杂的数据可能分散在多个主题中,因此高效管理主题对于平滑的数据流与处理非常重要。
3.1.1 查看主题
创建某个 Kafka 主题的人通常并非该主题的唯一使用者。在多个用户会共同使用同一个主题的场景下,其他人了解该主题的总体配置就显得很有价值。理解这些配置有助于协作,并确保所有用户对影响主题行为的重要参数达成一致。举例来说,我们来看一下上一章中提到的主题 products.prices.changelog
:
sql
$ kafka-topics.sh \
--describe \
--topic products.prices.changelog \
--bootstrap-server localhost:9092
Topic: products.prices.changelog TopicId: GGYA9u_aRPSd0JRaGn2eBA
PartitionCount: 1 ReplicationFactor: 1 Configs:
Topic: products.prices.changelog Partition: 0 Leader: 3 Replicas: 3
Isr: 3 Elr: LastKnownElr:
我们用 kafka-topics.sh
命令来管理 Kafka 中的主题。通过 --describe
参数可以更详细地查看主题信息。--topic
和 --bootstrap-server
参数与上一章中相同,用来选择本地 Kafka 集群(--bootstrap-server localhost:9092
)上的 products.prices.changelog
主题(--topic products.prices.changelog
)。
现在来看输出内容。第一行包含关于 products.prices.changelog
主题的一般信息。字段 TopicId
包含一个唯一标识符,Kafka 会为每个主题自动生成该 ID。PartitionCount
表示主题的分区数量,对应于创建主题时设置的值(例如 --partitions 1
)。Kafka 中的分区用于并行化数据处理,通过允许多个生产者并发写入不同分区以及多个消费者并发读取分区,从而提升系统的可扩展性与吞吐量。ReplicationFactor
定义消息被冗余存储的次数。复制因子为 1 意味着消息不被复制,因此没有故障转移(failover)。分区与复制的细节将在后续章节中详述。
主题的更多配置可以在 Configs
下找到。在我们的示例中,该字段为空,因为我们没有修改默认配置。Configs
的内容也会在本书后续章节中更详细地讨论。最后一行包含关于该单个分区及其副本(此例中只有一个)的信息。如果我们有多个分区,输出会按分区分别列出多行。
要理解其它那些数字的含义,我们需要绕到 Kafka 的架构部分做个短暂停留。到目前为止,我们一直在谈论 Kafka 集群。那我们究竟指的是什么?Kafka 由若干组件构成,见图 3.1。

我们在上一章中已经了解了生产者和消费者。Broker 负责在 Kafka 集群中处理与存储消息。协调集群用于管理这些 broker,但我们暂且不讨论它。
输出最后一行中的其它数字(Topic: products.prices.changelog Partition: 0 Leader: 3 Replicas: 3 Isr: 3, Elr:, LastKnownElr:
)分别对应我们三个 broker 的某个 ID。Replicas
表示该分区在哪些 broker 上有副本。在本例中,它只存在于 ID 为 3 的 broker 上。缩写 ISR
表示 in-sync replicas(同步副本),标明哪些 broker 对该分区是最新的。字段 Leader
表示哪个 broker 对该分区负有主读写责任。因为我们之前使用的 ReplicationFactor
为 1,分区并未被复制,所以相关条目也只包含我们三个 broker 中的一个 ID。创建新主题时,Kafka 会尽量将分区与副本均匀分布到所有 broker 上,以保证负载均衡。由于目前我们只创建了一个主题,所以选中的 broker 是任意的------在本例中被选中了 ID 为 3 的 broker。
ELR
表示 eligible leader replicas(候选 leader 副本),包含那些同时在 ISR 中且在当前 leader 故障时有资格成为该分区 leader 的 broker 列表。LastKnownElr
包含此前曾发生不干净(unclean)关闭的、曾为候选 leader 的副本列表。我们将在第 5 章和第 8 章中更详细地解释 ISR 与 ELR 的概念。
注意:如果后面讲解不需要,我们会略去 --describe
输出中的某些字段以简化示例。
到目前为止,我们还忽略了一个重要问题:如何知道 Kafka 集群中存在哪些主题?在本示例里回答相对简单,因为我们仅创建了 products.prices.changelog
一个主题。但在实际生产环境中,Kafka 集群可能包含由不同人员创建的许多主题。要追踪这些主题,我们可以使用 kafka-topics.sh
命令列出集群中的所有主题:
css
$ kafka-topics.sh \
--list \
--bootstrap-server localhost:9092
__consumer_offsets
products.prices.changelog
这里我们用 --list
参数并再次指定一个 Kafka broker(--bootstrap-server localhost:9092
)。实际上哪个 broker 管理该主题并不重要,所以我们也可以连接到其它两个 broker(分别为 --bootstrap-server localhost:9093
或 --bootstrap-server localhost:9094
)。结果中 __consumer_offsets
这一项很突出:这是 Kafka 自动创建的主题(从名称前面的双下划线可见)。该主题用于存储每个消费者的当前读取位置。我们可以把 offset 当作消费者的书签。事实上,我们在第一个示例中就已经使用了 offset,示例如下:
sql
# Window Consumer 2
$ kafka-console-consumer.sh \
--topic products.prices.changelog \
--from-beginning \
--bootstrap-server localhost:9092
coffee pads 10
coffee pads 11
coffee pads 12
coffee pads 10
默认情况下,kafka-console-consumer.sh
会从主题的末尾开始读取,也就是只读取新消息。若使用 --from-beginning
标志,则会从最早的 offset 开始读取,从而也会读出所有已生成的消息。
警告:理论上可以手动创建以双下划线开头的主题名,但我们强烈不建议这样做。Kafka 将以双下划线开头的主题名保留给内部主题和特殊用途。若你为自己的主题采用这种命名,可能会与 Kafka 的内部主题冲突,导致将来兼容性问题以及管理混乱。
3.1.2 创建、定制与删除主题
既然我们已经学会如何查看主题及其属性,下面再看如何创建与定制主题。首先,我们用 --delete
参数删除 products.prices.changelog
主题,清空环境:
css
$ kafka-topics.sh \
--delete \
--topic products.prices.changelog \
--bootstrap-server localhost:9092
如果现在再列出 Kafka 集群中的主题,会发现 products.prices.changelog
已不再存在:
css
$ kafka-topics.sh \
--list \
--bootstrap-server localhost:9092
__consumer_offsets
注意:被删除主题在 __consumer_offsets
中对应的 offsets 已在删除后被清理。
在集群恢复为空(仅剩 __consumer_offsets
)后,我们可以重新创建 products.prices.changelog
主题:
css
$ kafka-topics.sh \
--create \
--topic products.prices.changelog \
--replication-factor 2 \
--partitions 2 \
--bootstrap-server localhost:9092
Created topic procuts.prices.changelog.
我们再次使用熟悉的 kafka-topics.sh --create
命令。这里关注点在于 ReplicationFactor
(复制因子)和分区数(partitions)。与入门示例中使用的 1 相比,我们在此将两者设为 2:这既提高了可靠性(通过复制),也提高了并行处理能力(更多分区)。关于可靠性与性能的详细机制将在后文展开讨论。现在,用 --describe
查看新创建的主题:
yaml
$ kafka-topics.sh \
--describe \
--topic products.prices.changelog \
--bootstrap-server localhost:9092
Topic: products.prices.changelog TopicId: 2VvA24HxRwqhF-znSY1tAQ
PartitionCount: 2 ReplicationFactor: 2 Configs:
Topic: products.prices.changelog Partition: 0 Leader: 3
Replicas: 3,2 Isr: 3,2
Topic: products.prices.changelog Partition: 1 Leader: 1
Replicas: 1,3 Isr: 1,3
不出所料,我们现在看到 PartitionCount
与 ReplicationFactor
都为 2,并生成了一个新的 TopicId
。更有意思的是下面两行:每行代表一个分区,分区编号从 0 开始。分区 0 的副本位于 broker ID 为 3 和 2 的机器上(Replicas: 3,2
),其中 broker 3 被选为该分区的 leader(Leader: 3
),因此对分区 0 负主责。分区 1 位于 broker 3 和 broker 1(Replicas: 1,3
)上,此时 broker 1 为 leader(Leader: 1
),如图 3.2 所示。

分区与副本在各个 broker 之间的分布并非完全随机,因为 Kafka 会尝试将负载均匀分布到所有 broker。无论在我们这个小示例中多少次删除并重建主题,同一 broker 不会同时成为两个分区的 leader。不过,哪个 broker 负责哪个分区是可能变化的。副本在 broker 之间的分配也是类似的:在三个 broker 的集群中,四个副本不会只分布在两个 broker 上,但副本在各 broker 之间的具体分布可能会改变。
但是,如果我们事后发现需要调整分区数或复制因子(例如因为性能不够或可靠性不足),该怎么办?每次删除并重建主题不仅不切实际,有时也不可行,因为会丢失所有数据。幸运的是,Kafka 对此也提供了解决方案。首先,我们将分区数增加到 3:
css
$ kafka-topics.sh \
--alter \
--topic products.prices.changelog \
--partitions 3 \
--bootstrap-server localhost:9092
要定制主题,我们使用 kafka-topics.sh
命令并加上 --alter
参数。我们选择 products.prices.changelog
主题(--topic products.prices.changelog
),并把分区数设置为 3(--partitions 3
)。运行命令后,可以进一步查看变更情况:
yaml
$ kafka-topics.sh \
--describe \
--topic products.prices.changelog \
--bootstrap-server localhost:9092
Topic: products.prices.changelog PartitionCount: 3 ReplicationFactor: 2
Configs:
Topic: products.prices.changelog Partition: 0 Leader: 3
Replicas: 3,2 Isr: 3,2
Topic: products.prices.changelog Partition: 1 Leader: 1
Replicas: 1,3 Isr: 1,3
Topic: products.prices.changelog Partition: 2 Leader: 2
Replicas: 2,3 Isr: 2,3
可以看到 PartitionCount
已被增加到 3。输出末尾新增了一行,显示新创建的分区 2 的信息。--alter
不会改变已存在分区的 leader 与副本选择。在本例中,Broker 2 被选为新分区的 leader,而 Broker 3 被选为其副本,如图 3.3 所示。

因为我们集群里已经存在另一个主题 __consumer_offsets
(该主题包含 50 个分区),所以有一个 broker 被分配为 17 个分区的 leader,而另外两个 broker 各自被分配为 16 个分区的 leader。基于已有的分布,products.prices.changelog
主题的前两个分区就被分配给了那些 leader 分区较少的 broker。不过,哪台 broker 成为第 3 个分区的 leader 并不是固定的。如果我们将分区数增加到 5(再增加 3 个分区),每台 broker 都会额外被分配一个 leader 分区,从而得到更均衡的分布。
警告:在 Kafka 中无法减少分区数量,因为那会导致数据丢失。数据不能在分区间移动这一点是 Kafka 保证分区内消息顺序(message ordering)这一基本原则的关键,我们将在第 6 章中对此进一步探讨。
如果我们想修改复制因子(replication factor),必须在 kafka-topics.sh
命令中使用 --replica-assignment
参数,或使用 kafka-reassign-partitions.sh
命令。此外要记住,复制因子永远不能大于 broker 的数目。
注意:如果你只是想为单个主题重新分配分区,
kafka-topics.sh
有时可以满足需求。但对于更复杂的重新分配,尤其是同时处理多个主题时,建议使用kafka-reassign-partitions.sh
工具。该工具更灵活,支持更复杂的重分配操作,甚至可以自动为多个主题同时重新分配副本。
3.2 消息(Messages)
在上一节我们介绍了主题。下一个问题是:我们通常往这些主题里生产(write)什么?在第 1 章,我们向 Kafka 发送并接收了字符串。Kafka 对数据类型是无偏好的(agnostic):它在内部仅以字节数组(byte arrays)来处理数据。字节数组就是一连串字节,字节是计算机中存储数据的基本单位。由于 Kafka 以字节数组为工作单元,它能处理各种格式与结构的消息,因此非常灵活,能应对多样化的数据类型。
需要记住的一点是:Kafka 被优化用于处理大量小消息。消息的默认最大大小为 1 MB。虽然可以调整这个限制,但通常不建议这样做,因为更大的消息会严重影响性能与资源使用。那么"大量小消息"具体意味着什么?极端案例是:像 LinkedIn 这样的公司在 2019 年用 Kafka 每天处理了约 7 万亿(trillion,不是 billion,即十万亿)条消息。当然,LinkedIn 当时并不是只有一个 Kafka 集群,而是大约 100 个 Kafka 集群。但即便是规模较小的部署,也能非常快地处理大量消息。我们只需确保消息大小低于 1 MB 限制即可。例如,使用 Kafka 传输大文件并不合理,这类用例应该选用其他解决方案。
3.2.1 消息类型
我们通常往 Kafka 写入什么数据?这高度依赖于用例,但从经验来看,常见的四类消息是:状态(states) 、差分(deltas) 、事件(events)和命令(commands) 。在实际系统中,通常会混合使用这些消息类型。例如,一个 ERP 系统可能发布一条事件,表示某个商品因促销而修改了价格。另一个监控促销的服务随后可能发送一条命令给通知服务,要求其将促销信息通知客户。该通知服务是独立运行的,其他服务也可以复用它来发送通知。下面分别简要描述这些类型。
状态(STATES)
消息描述一个主题或对象的当前状态,包含对象的完整信息。背后的提问是我们关心对象现在或历史上是什么样子。如果我们只关心对象的最新状态,可以结合 key 使用日志压缩(log compaction)来减少所需存储,这一点将在第 10 章讨论。举例,我们希望在 products.changelog
主题中同时存储商品的当前库存和价格:
bash
# 每行一条消息
{"id": 123, "name": "coffee pad", "price": "10", "stock": 101010}
{"id": 234, "name": "cola", "price": "2", "stock": 52}
{"id": 345, "name": "energy drink", "price": "3", "stock": 42}
差分(DELTAS)
很多情况下,每次小变动不必重写整个状态;这时只发送状态变更(delta)即可。在库存管理系统的场景下,我们可以使用 products.stocks.changes
主题来收集库存变更,这些变更可能由客户购买触发,也可能由补货触发:
bash
# 每行一条消息
{"id": 123, "stock": 1000}
{"id": 234, "price": 2}
{"id": 345, "stock": 60, "price": 3}
{"id": 234, "name": "cola zero"}
通过避免频繁重复未变的字段,我们可以节省大量数据量。此外,使用差分更容易发现自上次消息以来到底发生了哪些变化。
事件(EVENTS)
事件在消息中添加上下文,描述发生了什么。例如,我们可以描述收到了一次补货,或者顾客完成了一笔购买。日志(logs)是一类特殊的事件,Kafka 广泛用于收集日志,提供了可靠且可扩展的方案来聚合与处理来自不同来源的日志数据。Kafka 本身在很多方面都类似于日志------这一主题将在下一章中详述。
通常我们会把状态和差分作为原始数据(raw data),再基于这些原始数据在另一个主题中产生事件(events)。回顾我们最初的 Kafka 示例,可以把 products.prices.changelog
视为一个事件主题,我们也可以往里写入如下消息:
bash
# 每行一条消息
{"id": 123, "event": "vat_adjustment", "payload": {"price": 2.19}}
{"id": 234, "event": "data_correction", "payload": {"name": "Coke Zero"}}
{"id": 345, "event": "storehouse_delivery", "payload": {"stock": 100}}
{"id": 234, "event": "promotion_start", "payload": {"price": 1.99}}
从这些消息中我们可以识别出"刚刚发生了什么?",这正是事件要回答的问题。
注意:Kafka 记录(records)总是包含时间戳(timestamp)。
命令(COMMANDS)
有时我们需要指示另一个系统执行某个动作。与描述已发生事项的事件不同,命令是要求接收方在未来执行某个动作。例如,一条消息可能会指示通知服务向客户发送促销通知:
bash
# 每行一条消息
{"command": "notify_customer", "customer_id": 123456, "message": "The promotion for Coke Zero started. It's now 1.99"}
{"command": "notify_customer", "customer_id": 123457, "message": "The promotion for Coke Zero started. It's now 1.99"}
{"command": "notify_customer", "customer_id": 123458, "message": "The promotion for Coke Zero started. It's now 1.99"}
命令会带来更强的耦合性,因为我们依赖另一个系统去执行这些命令。与事件不同,发送者在发送事件时通常并不关心谁是接收者或接收者之后会做什么;而命令则要求接收方响应或执行特定动作。
3.2.2 消息格式
既然我们对要在 Kafka 中存储的消息类型有了概念,就该考虑使用什么数据格式了。Kafka 只把数据当作字节数组来处理,并不会解析我们发送的内容------这意味着它没有内建的数据处理函数。正是这个设计决定,成就了 Kafka 的高性能。
在 Kafka 生态中,最流行的数据格式之一是 JSON ,其优点在于人和机器都容易读取。但与 XML 类似,文本格式会使数据量显著膨胀,即使使用消息压缩也难以完全缓解。如果需要尽量减小数据体积,可以采用二进制格式,例如 Google 的 Protocol Buffers(Protobuf;developers.google.com/protocol-bu...)或在 Kafka 社区广泛使用的 Apache Avro(avro.apache.org/)。不过我们的经验是,几乎每个主题在其生命周期中至少会被人类查看一次,二进制格式在这方面会稍显不便。
比起钻研具体格式的细枝末节,更重要的是统一选定一种一致的数据格式。不要为每个主题用不同的格式------随着集群中主题数量的增加并且运行时间越长,这个决策的重要性越明显。在这种情境下,为数据格式编写文档至关重要。对于二进制数据格式,必须有schema(模式) 来解释数据。实际上 schema 就像蓝图或规则集,定义了数据的结构与格式。schema 不仅适用于二进制格式,也适用于 JSON(可用 JSON Schema:json-schema.org/)和 XML(可用 XSD 或 DTD)。真正的力量在于使用 schema,保证主题间的一致性并便于按约定解析数据。schema 管理及具体的 schema 会在本书后文详细讨论。
注意: 你可能留意到前面 delta 示例中消息格式看起来有所不同,这并不冲突------schema 可以包含可选字段,具体是否携带这些字段取决于用例。
3.2.3 消息结构
仔细观察 Kafka 中的消息会发现,消息并非单一实体。在 Kafka 中,一条消息通常包含以下组件(见图 3.4):可选的 key、value、自定义可选 header、以及时间戳 。每个字段在 Kafka 的数据交换与处理流程中都有其独特作用。如果你不在 kafka-console-producer.sh
中显式指定 key,则生产的消息默认没有 key。
警告 :把 headers 看作类似于 HTTP header------它们用于携带技术性元数据,例如 tracing ID。不要在 headers 中放置业务数据;而且完全不使用 headers 也是可以的。

在 Kafka 中,键(key) 是与消息关联的可选属性,用于对消息进行分类。键会影响数据如何在分区之间分布。具有相同键的消息通常被认为是属于同一类(稍后会详细说明)。而 值(value) 则是消息的主载荷,承载实际要被消费者处理的信息。主题本身对键是无感知的,也就是说在创建一个可能包含键的主题时,不需要额外指定什么。
键可以是产品名称,例如。首先我们创建 products.prices.changelog.keys
主题:
css
$ kafka-topics.sh \
--create \
--topic products.prices.changelog.keys \
--replication-factor 2 \
--partitions 2 \
--bootstrap-server localhost:9092
Created products.prices.changelog.keys.
现在,启动 kafka-console-producer.sh
,以便我们能生产带键的消息:
ini
# 之前我们已创建了主题 products.prices.changelog.keys
$ kafka-console-producer.sh \
--topic products.prices.changelog.keys \
--property parse.key=true \
--property key.separator=: \
--bootstrap-server localhost:9092
除了常见的主题与 Kafka 集群参数外,我们还传入两个额外参数。通过 --property parse.key=true
我们在 kafka-console-producer.sh
中启用键解析;通过 --property key.separator=:
我们设置冒号(:
)作为键和值之间的分隔符。现在来生产一些消息:
markdown
# Producer 窗口:
> coffee pads:10
> cola:2
> coffee pads:11
> coffee pads:12
如果像之前那样直接运行 kafka-console-consumer.sh
,消费者默认不会显示键,因此只会看到值:
css
$ kafka-console-consumer.sh \
--from-beginning \
--topic products.prices.changelog.keys \
--bootstrap-server localhost:9092
10
2
11
12
Processed a total of 4 messages
我们用 Ctrl-C
取消命令。要查看键,必须显式开启显示键功能,向命令添加 --property print.key=true
:
vbnet
$ kafka-console-consumer.sh \
--from-beginning \
--topic products.prices.changelog.keys \
--property print.key=true \
--property key.separator=":" \
--bootstrap-server localhost:9092
输出变为:
less
coffee pads:10
cola:2
coffee pads:11
coffee pads:12
Processed a total of 4 messages
现在消费者显示了包括键在内的消息。但我们为什么需要键?在键值数据库中这很容易解释:我们总是将数据存储到一个具体的键下,然后可以根据这个键检索或修改数据。在 Kafka 中情况并不那么直观。首先,键是可选的;正如 kafka-console-consumer.sh
的输出所示,拥有相同键的旧消息不会被自动删除。
下一章我们会把 Kafka 看作分布式日志(distributed log),并意识到通常主题由多个分区组成。我们可以使用键来确保消息始终落在同一个分区,因为 Kafka 使用键来确定消息应该写入哪个分区。稍后章节会更深入讨论这一概念。由于 Kafka 仅保证单个分区内 消息的顺序性,如果我们为 products.prices.changelog
主题创建了多个分区,就无法保证消息 coffee pads 10
会在 coffee pads 11
之前被读取。这会导致商店中出现错误的价格,甚至更严重地造成不同系统之间因读取顺序不同而出现不一致。如果我们将 products.prices.changelog.keys
主题设置为多个分区,那么我们可以通过键来保证同一产品(如 coffee pads)的消息始终按正确顺序到达对应分区。但对于不同产品之间的先后顺序,则无法保证,例如可能出现:
makefile
cola:2
coffee pads:10
coffee pads:11
coffee pads:12
在绝大多数情况下,不同键之间的全局顺序并非必须。通常只需保证同一键的消息有序即可。如果不指定键,消息会以轮询(round-robin)的方式分布到不同分区。
注意 :用于处理键的属性是在客户端设置的,而不是在 broker 或主题上设置的。
总结
-
Kafka 通过主题组织数据。
-
主题可以被划分到多个分区以提升性能。
-
主题可以在不同 broker 之间复制以提高可靠性。
-
使用
kafka-topics.sh
脚本可以创建、查看、修改和删除主题。 -
主题的分区数不能减少。
-
分区可以在 broker 之间重新分配以重新分布负载。
-
复杂的分区重新分配可以使用
kafka-reassign-partitions.sh
脚本完成。 -
Kafka 针对大量(甚至万亿级)小消息(每条 ≤ 1 MB)进行了优化。
-
Kafka 消息可分为 states(状态)、deltas(差分)、events(事件)和 commands(命令)。
- States 包含对象的完整信息。
- Deltas 仅包含变更,因而数据量高效,但单条 delta 往往需上下文或完整状态才能有意义。
- Events 为消息增加上下文,描述发生的业务事件。
- Commands 用于指示其他系统执行动作。
-
数据格式与 schema 在 Kafka 中非常重要,以保证一致性。
-
Kafka 中的消息由技术元数据组成,包括时间戳、可选的自定义头(metadata)、可选的键(key)以及作为主要载荷的值(value)。
-
具有相同键的消息会被写入同一分区,因此在单个生产者写入的场景下这些消息的顺序是有保障的。
-
键也可用于日志压缩(log compaction),以清理过时的数据。