Kafka 基础:Topic、Partition 和 Offset
1. 概述
Kafka 是一个分布式消息代理系统,用于在多个服务之间传输高吞吐量的数据。在编写第一个 producer/consumer 之前,你需要掌握三个核心概念:topic、partition、offset。本文解释了每个概念,并附带具体的 Spring Boot 示例。读完本文后,你将理解 Kafka 中数据的组织和读取方式,避免初次使用时的常见错误。
2. 什么是 Topic
Topic 类似于包含消息的“表”或“通道”。Producer 将消息发送到 topic,consumer 从 topic 读取消息。一个 topic 可以同时有多个 producer 和多个 consumer。
例如,在电商系统中,你可能有以下 topic:
orders.created
orders.cancelled
payments.processed
每个 topic 包含与特定类型事件相关的消息。Kafka 不强制规定消息的 schema——你可以自由选择格式(JSON、Avro、Protobuf……)。
通过 CLI 创建 topic 来更好地理解:
kafka-topics.sh --create \
--topic orders.created \
--bootstrap-server localhost:9092 \
--partitions 3 \
--replication-factor 1
这条命令创建了一个 topic orders.created,包含 3 个 partition。这正是接下来要讨论的概念。
3. Partition——并行化的单位
3.1. 为什么需要 Partition
一个 topic 被分成多个 partition。每个 partition 是一个独立的日志文件,消息被追加到末尾,永远不会被修改或删除。Partition 让 Kafka 能够:
- 将数据分散到多个 broker。
- 允许多个 consumer 并行读取(每个 consumer 读取一个 partition)。
Topic: orders.created (3 partitions)
Partition 0: [msg0][msg1][msg4][msg7]...
Partition 1: [msg2][msg5][msg8]...
Partition 2: [msg3][msg6][msg9]...
3.2. Key 决定选择哪个 Partition
发送消息时,Kafka 使用 key 来计算 partition(通过哈希函数),确保相同 key 的消息总是进入同一个 partition——从而保持相关消息的顺序。
// 假设按 orderId 作为 key 发送事件
ProducerRecord<String, String> record =
new ProducerRecord<>("orders.created", "order-123", payloadJson);
kafkaTemplate.send(record);
这行代码创建了一个 ProducerRecord,key 为 "order-123"。这个订单的所有事件都会进入同一个 partition,因此 consumer 可以按发送顺序读取它们。
注意: 如果不提供 key(key =
null),Kafka 会以轮询方式分发消息(或从 Kafka 2.4+ 开始使用 sticky partitioner),不保证消息之间的顺序。
3.3. 顺序只在单个 Partition 内得到保证
这是最容易引起混淆的地方:Kafka 仅在单个 partition 内保证顺序,不保证整个 topic 的顺序。如果你需要为一组消息保证绝对顺序(例如某个 orderId 的所有事件),应该为这些消息使用相同的 key。
4. Offset——Partition 中的读取位置
partition 中的每条消息都有一个递增的序号,称为 offset,从 0 开始。
Partition 0:
offset: 0 1 2 3 4
message: m0 m1 m2 m3 m4
Offset 是 partition 范围内的唯一编号,而非整个 topic。Consumer 使用 offset 来知道“已经读到了哪里”。
4.1. Consumer Group 和 Offset Commit
Kafka 通过 consumer group 跟踪已读的 offset。每个 consumer group 对每个 partition 都有独立的 offset,保存在内部 topic __consumer_offsets 中。
# application.properties
spring.kafka.consumer.group-id=order-service
spring.kafka.consumer.auto-offset-reset=earliest
spring.kafka.consumer.enable-auto-commit=false
group-id:标识 consumer group。多个具有相同 group-id 的实例将共享该 topic 的 partition。auto-offset-reset=earliest:如果还没有保存 offset(新的 consumer group),则从 topic 的开头开始读取。enable-auto-commit=false:禁用自动提交,以便手动控制何时提交 offset(避免在中途处理错误时丢失消息)。
4.2. 同一 Group 中的多个 Consumer
Kafka 按照以下原则将 partition 分配给同一 group 中的 consumer:在任何时刻,一个 partition 只能被该 group 中的一个 consumer 读取。
Topic orders.created 有 3 个 partition
Consumer group "order-service" 有 2 个实例:
Consumer A -> partition 0, partition 1
Consumer B -> partition 2
如果你为一个有 3 个 partition 的 topic 运行 4 个 consumer,第 4 个 consumer 将 空闲,不会获得任何 partition。因此有用的 consumer 数量的上限 = partition 数量。
5. 在 Spring Boot 中设置 Kafka
5.1. 依赖
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
5.2. Producer 发送消息
@Service
public class OrderEventProducer {
private final KafkaTemplate<String, String> kafkaTemplate;
public OrderEventProducer(KafkaTemplate<String, String> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publishOrderCreated(String orderId, String payloadJson) {
kafkaTemplate.send("orders.created", orderId, payloadJson);
}
}
kafkaTemplate.send(topic, key, value) 将消息发送到 topic orders.created,使用 orderId 作为 key。Spring Boot 根据 spring.kafka.bootstrap-servers 配置自动创建 KafkaTemplate bean。
5.3. Consumer 读取消息和手动提交 Offset
@Component
public class OrderEventConsumer {
@KafkaListener(topics = "orders.created", groupId = "order-service")
public void onOrderCreated(
ConsumerRecord<String, String> record,
Acknowledgment ack) {
System.out.printf("partition=%d offset=%d key=%s value=%s%n",
record.partition(), record.offset(), record.key(), record.value());
// 在这里处理业务逻辑
ack.acknowledge(); // 在处理成功后提交 offset
}
}
record.partition()和record.offset()告诉你这条消息在哪个 partition,位置是多少。ack.acknowledge()仅在处理完成后提交 offset——如果服务在中途崩溃,消息将在下次重启时被重新读取(这是因为enable-auto-commit=false结合AckMode.MANUAL)。
需要添加以下配置以使 Acknowledgment 工作:
spring.kafka.listener.ack-mode=manual
试运行时的输出(发送 3 条带不同 key 的消息):
partition=1 offset=0 key=order-123 value={"status":"CREATED"}
partition=0 offset=0 key=order-456 value={"status":"CREATED"}
partition=2 offset=0 key=order-789 value={"status":"CREATED"}
每个 key 由于哈希值不同而进入不同的 partition——这与第 3.2 节的理论相符。
6. 使用 JUnit 5 进行测试
使用 spring-kafka-test 在单元测试中运行嵌入式 Kafka,不需要真实的 broker。
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@EmbeddedKafka(partitions = 1, topics = "orders.created")
class OrderEventProducerTest {
@Autowired
private OrderEventProducer producer;
@Autowired
private EmbeddedKafkaBroker embeddedKafka;
@Test
void shouldPublishMessageWithGivenKey() throws Exception {
Consumer<String, String> consumer = createTestConsumer();
embeddedKafka.consumeFromAnEmbeddedTopic(consumer, "orders.created");
producer.publishOrderCreated("order-123", "{\"status\":\"CREATED\"}");
ConsumerRecord<String, String> record =
KafkaTestUtils.getSingleRecord(consumer, "orders.created");
assertThat(record.key()).isEqualTo("order-123");
assertThat(record.value()).contains("CREATED");
assertThat(record.offset()).isEqualTo(0L);
}
private Consumer<String, String> createTestConsumer() {
Map<String, Object> props = KafkaTestUtils.consumerProps(
"test-group", "true", embeddedKafka);
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
DefaultKafkaConsumerFactory<String, String> factory =
new DefaultKafkaConsumerFactory<>(props);
return factory.createConsumer();
}
}
@EmbeddedKafka 启动一个仅用于测试的内存中 Kafka broker。KafkaTestUtils.getSingleRecord 从 topic 获取第一条消息,我们断言 key、value 和 offset 符合预期——offset 为 0,因为这是唯一 partition 的第一条消息。
7. 常见错误
- 混淆 partition 数和 consumer 数:创建过多的 consumer 会导致某些 consumer 无所事事。Partition 数决定了最大的并行程度。
- 在需要保持顺序时不设置 key:如果你需要同一个实体(订单、用户等)的事件按正确的顺序处理,必须使用 key,不能为
null。 - 在已投入生产后增加 partition:增加已有数据的 topic 的 partition 数量会改变 key 的哈希方式 → 相同 key 的消息可能会进入不同的 partition,破坏了旧数据的顺序保证。应该从一开始就确定合理的 partition 数量。
- 默认的自动提交(
enable-auto-commit=true):offset 按固定周期(默认 5 秒)提交,与是否处理完消息无关——当 consumer 在处理中途崩溃时容易丢失消息。
注意: 如果你的应用不需要严格的顺序且吞吐量不是问题,你可以跳过 key/partition 的优化——Kafka 使用默认配置仍然可以正常工作。在没有实际需求时不要过早优化。
8. 结论
Topic 是按主题存放消息的地方,partition 是用于并行化和分散的较小单元,offset 是每个 partition 中的读取位置。深入理解这三个概念可以帮助你正确设计:选择合理的 key 来保持顺序、选择与 consumer 数量相适应的 partition 数、控制 offset 提交以避免在出错时丢失数据。这是在深入研究更高级主题(如 rebalancing、exactly-once 语义或 Kafka Streams)之前必须掌握的基础。