Kafka Streams:在日志之上构建流处理
前十篇覆盖了 Kafka 作为消息基础设施的核心机制。这一篇开始看 Kafka 的生态扩展——第一个是内置的流处理库 Kafka Streams。 流处理容易被误解成"又一套需要独立部署的集群"。Kafka Streams 的核心定位不是集群,而是一个嵌入应用 JVM 的 Java 库。它把 Kafka topic 当作输入和输出,在应用进程内完成流式计算。 本文只抓一个问题:Kafka Streams 怎样在追加日志之上构建出有状态的流处理。 架构定位:库,不是集群 12345678910111213141516┌─────────────────────────────────────────┐│ Application JVM ││ ┌───────────────────────────────────┐ ││ │ Kafka Streams Library │ ││ │ ┌─────────┐ ┌───────────────┐ │ ││ │ │ Topol...
日志压缩:把 topic 当 KV 表用
前面的文章中,日志的保留策略一直是基于时间或大小的删除。这一篇介绍另一种保留策略:日志压缩。 日志压缩容易被理解成"压缩存储空间"。更准确的说法是:日志压缩是一种按 key 去重的保留策略,对每个 key 只保留最新的 value,把一个 append-only 的日志变成一个可以反映最新状态的 KV 快照。 本文只抓一个问题:日志压缩的语义、执行机制和适用场景。 两种保留策略 Kafka 的日志保留由 topic 级别的 cleanup.policy 参数控制,有三种取值: 123cleanup.policy=delete 基于时间或大小删除整个 segmentcleanup.policy=compact 按 key 去重,保留每个 key 的最新 valuecleanup.policy=compact,delete 先压缩去重,再按时间/大小删除 delete 策略是前面几篇文章一直在讨论的默认行为:segment 文件超过 retention.ms 或 retention.bytes 后被整体删除,不关心消息的 key 是什么。 comp...
Exactly-Once 与事务:跨 partition 的原子写入
前面在第 04 篇解决了单 partition 内的消息去重。这一篇解决更难的问题:一批消息写到多个 partition,要么全部可见,要么全部不可见。 幂等 Producer 的序列号去重是 per-PID、per-partition 的。它不解决两个场景:一是 Producer 重启后 PID 改变导致无法关联之前的写入;二是一个业务操作涉及多个 partition 的写入,部分成功部分失败时没有回滚机制。Kafka 的事务机制(Transactional API)正是为了解决这两个问题而设计的。 本文只抓一个问题:Kafka 事务是怎么通过 transactional.id、Transaction Coordinator 和两阶段提交协议实现跨 partition 原子写入的。 为什么幂等不够 用一个具体场景说明。一个 stream processing 应用从 input topic 消费消息,处理后写入 output topic,同时提交 consumer offset。这三个动作涉及不同的 partition: 12input-topic (partition-0) ...
Controller 与 KRaft:从 ZooKeeper 到内置共识
上一篇看到了副本机制的细节。这一篇解决元数据层的核心问题:谁来决定哪个 replica 是 leader,以及这个决定怎么达成共识。 这个问题容易和副本选举混淆。副本选举本身只是一个结果——“partition-0 的 leader 从 broker-1 变成 broker-2”。真正的问题是:谁有权做出这个决定,这个决定如何传播到集群中的每个 broker,以及当做出决定的那个节点自己挂了会发生什么。 本文只抓一个问题:Kafka 的元数据管理从外部依赖(ZooKeeper)演化到内置共识(KRaft)的完整路径。 ZooKeeper 模式下的 Controller 在 ZooKeeper 模式下,Kafka 集群中有且仅有一个 broker 担任 controller 角色。Controller 是通过 ZooKeeper 的临时节点(ephemeral node)竞选产生的: 123456789101112 ZooKeeper Ensemble ┌───────────────────┐ │ /co...
副本与 ISR:高可用的代价和折中
前六篇走完了生产和消费两端。这一篇进入 Kafka 的高可用核心:副本机制。 副本机制容易被简化成"多写几份"。更准确的说法是:Kafka 在每个 partition 级别维护一组副本,用 ISR(In-Sync Replicas)动态集合代替固定多数派投票,在可用性和持久性之间做出了一系列显式的折中。 本文只抓一个问题:一条消息从被 leader 接收到被所有 ISR 副本确认,中间经历了哪些步骤,以及这些步骤中的每一个参数如何影响数据安全。 副本模型 Kafka 的副本模型是 per-partition 的 leader-follower 结构: 12345678910111213141516171819202122Producer │ ▼┌──────────────┐│ Partition-0 ││ Leader (B-0) │ ◄── 所有读写都经过 leader│ [0][1][2][3] │└──────┬───────┘ │ FetchRequest (replicaId=1) ▼┌─────────...
Offset 管理:提交、重置与消费语义
上一篇解决了 partition 怎么分给 consumer。这一篇解决分到之后的核心问题:consumer 读到哪里了,怎么记住。 这个"记住"机制看起来简单——不过是记录一个数字(offset)。但"什么时候记"和"怎么记"的选择,直接决定了消息会不会丢、会不会重复处理。auto-commit 的默认行为经常被误解为"自动保证 exactly-once",实际上它给出的是 at-least-once 甚至更弱的语义。 本文只抓一个问题:offset 的提交策略如何影响消费语义。 三个 offset 理解 offset 管理需要区分三个位置: 123456789Partition Log:┌────┬────┬────┬────┬────┬────┬────┬────┬─────┐│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ ... │└────┴────┴────┴────┴────┴────┴────┴────┴─────┘ ...
Consumer Group 协议:分配、重平衡与静态成员
前面四篇走完了写入端。这一篇转向读取端的核心问题:一组 consumer 怎么瓜分一个 topic 的所有 partition。 这个问题经常被简化成"负载均衡",但 consumer group 协议解决的不是负载均衡——它解决的是 partition 所有权的分配与变更。分配发生在 group 成员变化时,而每次变更的代价远比想象中大。 本文只抓一个问题:consumer group 的分配协议是怎么运作的,重平衡为什么贵,怎么降低代价。 consumer group 的基本契约 Kafka consumer group 有一条硬约束:同一个 group 内,一个 partition 在任意时刻只能被一个 consumer 消费。 这条约束的直接后果: group 内 consumer 数量超过 partition 数量时,多出来的 consumer 空转,分不到任何 partition。 partition 的消费进度由唯一的 consumer 负责推进,不存在两个 consumer 同时消费同一 partition 的情况。 consumer 数量变...
幂等 Producer 与序列号:消息不重不丢的第一层
上一篇走完了 Producer 的发送管线。这一篇解决一个写入端的经典问题:网络超时后重试,消息会不会写两遍。 重试导致重复是分布式系统的常见症状。Producer 发出一个请求,broker 写入成功但响应在网络中丢失,Producer 不知道成功了于是重发——同一条消息在日志中出现了两次。Kafka 从 0.11 版本开始,在 broker 端引入了基于序列号的去重机制,称为 idempotent producer。 本文只抓一个问题:幂等 Producer 是怎么用 PID 和序列号在 broker 端做去重的,它的边界在哪里。 重复问题的根源 先看一个没有幂等保护时的场景: 1234567891011Producer Broker (Leader) | | |--- ProduceRequest(msg-A) ------->| | |--- 写入 partition log...
Producer 内部机制:攒批、分区与 acks
上一篇拆解了 partition 内部的存储结构。这一篇转向写入端——Producer 把一条消息发出去,到底经过了哪些步骤。 很多使用者把 producer.send() 当成一次网络调用。实际情况是,send() 只是把消息丢进了一个内存缓冲区,真正的网络 I/O 发生在另一个线程里。Producer 内部是一条两阶段管线:主线程负责序列化和分区选择,后台 Sender 线程负责攒批和网络传输。 本文只抓一个问题:一条消息从 send() 调用到 broker 确认,经过了哪些对象、哪些线程、哪些等待。 发送管线总览 Producer 内部的数据流可以压成下面的路径: 123456789101112131415161718192021222324252627282930Main Thread Sender Thread | | v | Interceptors ...
日志存储:Segment、Index 与零拷贝
上一篇看到了 Kafka 集群的全景:broker 存储日志,controller 管理元数据,topic 通过 partition 映射到磁盘目录。这一篇进入单个 partition 内部,看日志文件到底怎么组织。 一个 partition 不是一个巨大的文件。更准确的说法是:一个 partition 是一组按 offset 范围切分的 segment 文件,每个 segment 由数据文件、offset 索引文件和时间索引文件组成。 本文只抓一个问题:一条 produce 请求写入的字节如何变成磁盘上的 segment 文件,以及 fetch 请求如何通过零拷贝把这些字节送到网络上。 Segment 文件:日志的物理切片 一个 partition 的日志目录中,segment 文件按 base offset 命名。Base offset 是该 segment 中第一条记录的 offset,用 20 位数字左补零表示。 12345678910partition 目录: orders-0/├── 00000000000000000000.log ← segme...
