2. 消息系统基础
要理解 Kafka 与 Flink 的 Exactly-Once 链路,必须先回到 Kafka 自身的存储与消费模型。Kafka 并不是一个简单的“消息队列”,而是一个基于分区日志的分布式提交系统。它把消息持久化到磁盘,用 Offset 标记位置,用 Consumer Group 协调并行消费。只有把这几个概念的关系理清,后面讨论 Checkpoint、两阶段提交和幂等写入时,才不会把“消息不丢”和“状态一致”混为一谈。
核心原理:Topic、Partition 与 Offset 的三层结构
Kafka 中消息按 Topic 逻辑分类,例如订单事件写入 order-events,支付回调写入 payment-callback。Topic 只是逻辑名称,真正承载存储和并行的是 Partition。每个 Partition 是一个追加写、不可变、有序的日志文件,消息写入后获得一个单调递增的编号,这就是 Offset。
这里有一个关键结论:Kafka 只保证单 Partition 内有序,不保证 Topic 全局有序。如果业务要求同一订单的事件严格有序,就必须让同一订单 ID 路由到同一 Partition,通常通过 Key 的哈希实现。Offset 是消费者位点,不是消息 ID;它只在 Partition 内有意义,跨 Partition 比较 Offset 没有业务含义。
方法流程:Producer 写入与 Consumer 消费的完整链路
Producer 发送消息时,会经过序列化、分区器、累加器、Sender 线程几个阶段。分区器决定消息进入哪个 Partition:指定 Partition 则直接使用;指定 Key 则按 Key 哈希;两者都没有则轮询或粘性分区。消息在累加器中按 Partition 批量聚合,达到 batch.size 或 linger.ms 后由 Sender 线程发送。
Properties props = new Properties();
props.put("bootstrap.servers", "kafka1:9092,kafka2:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("acks", "all"); // 等待 ISR 全部确认
props.put("enable.idempotence", "true"); // 幂等生产者,防重试重复
props.put("max.in.flight.requests.per.connection", "5");
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.send(new ProducerRecord<>("order-events", orderId, payload));
Consumer 侧则通过 poll() 拉取消息,返回一批记录,并自动或手动提交 Offset。Offset 提交有两种语义:自动提交可能在消息处理完成前提交,导致消息丢失;手动提交可以在处理完成后提交,实现至少一次。若要接近精确一次,还需要配合幂等处理或事务。
案例场景:订单事件流的并行消费
假设订单系统每秒产生 3 万条事件,Topic order-events 设置 6 个 Partition。一个 Consumer Group 内启动 3 个消费者实例,Kafka 会通过 Rebalance 将 6 个 Partition 平均分配,每个消费者负责 2 个 Partition,实现 3 倍并行。若消费者增加到 6 个,则每个消费者负责 1 个 Partition;若增加到 8 个,多出的 2 个消费者会处于空闲状态,因为分区数决定了组内有效并行的上限。
当某个消费者宕机,Coordinator 触发 Rebalance,把它负责的 Partition 转交给其他存活消费者。此时 Offset 提交策略直接决定故障后的行为:如果 Offset 已提交但消息未处理完,重启后会从提交位点继续,造成丢失;如果处理完但未提交,重启后会重复消费。这正是 Flink 需要 Checkpoint 对齐 Offset 的原因。
| 概念 | 作用 | 关键参数/命令 | 常见误区 |
|---|---|---|---|
| Topic | 消息逻辑分类 | num.partitions |
认为 Topic 全局有序 |
| Partition | 物理日志与并行单元 | replication.factor |
分区越多越好,忽略元数据开销 |
| Offset | 分区内消费位点 | auto.offset.reset |
把 Offset 当消息唯一 ID |
| Producer | 写入与分区路由 | acks、enable.idempotence |
acks=0 还能保证不丢 |
| Consumer Group | 并行消费与容错 | group.id、max.poll.interval.ms |
消费者数超过分区数仍能提升并行 |
误区与复盘:三个最容易踩的坑
- 误区一:把“消息不丢”等同于“Exactly-Once”。Kafka 的 acks=all 只保证写入不丢,消费端仍可能重复处理。Exactly-Once 是端到端语义,需要生产、消费、状态和下游写入共同保证。
- 误区二:忽略 Rebalance 的代价。消费者频繁上下线会导致分区反复迁移,消费暂停。应合理设置
session.timeout.ms与max.poll.interval.ms,避免处理过慢被踢出组。 - 误区三:Offset 提交时机随意。先提交后处理会丢消息,先处理后提交会重复。Flink 通过 Checkpoint 把 Offset 与算子状态一起快照,才让“位点”和“状态”保持一致。
consumer group 的 Lag,再看分区分配是否倾斜,最后确认 Offset 提交策略与处理逻辑是否匹配。Lag 持续增长通常不是 Kafka 本身慢,而是消费逻辑阻塞或并行度不足。掌握 Topic、Partition、Offset、Producer、Consumer 与 Consumer Group 的职责边界后,就能理解 Kafka 为什么能同时做到高吞吐、可扩展和可重放。下一层要解决的问题是:当消费位点和计算状态必须一起成功或一起失败时,Kafka 与 Flink 如何通过 Checkpoint 和事务机制把“至少一次”推进到“精确一次”。