Kafka 架构

本页目录

Kafka 将事件保存为可回放的分区日志。理解它可以从两个独立问题开始:⁠记录保存在哪里,读者读到了哪里⁠。前者由 broker 的日志与复制机制负责,后者由消费者的位置与消费组负责。

本文以 Kafka 4.1 文档中的常规 topic、复制日志和 consumer group 为基准。具体客户端默认值与集群功能应按部署版本确认;share group 不属于本文的分区独占消费模型。

日志为什么适合多个独立读者

设订单服务依次产生三条事件:创建、付款、发货。库存服务关注库存,统计服务关注成交额,审计服务要保留完整历史。三者速度不同,也可能分别重启。

如果“某个读者读过”就删除事件,其他读者便无法独立推进。日志模型把保留策略和消费进度分开:事件继续留在日志中,每个读者维护自己的位置。消费者恢复时移动游标,而不必要求生产者重发整段历史。

同一份分区日志,多个独立消费位置 100 创建 101 付款 102 发货 103 末尾位置 统计组 库存组 审计组 提交进度不会删除记录;日志是否保留由清理策略决定。

offset 是分区内的位置标识,不是全局消息 ID,也不等于“剩余消息数”。日志压缩和事务控制记录等因素会让可见业务记录的 offset 不连续。

Topic、Partition、Replica 的分工

概念职责不应混淆的边界
Topic给事件流命名并配置保留等策略一个 topic 可以有多个独立有序分区
Partition追加与排序的基本单位分区之间没有天然全序
Replica保存某分区的副本增副本主要提高冗余,不等于增加写入分片
Broker托管多个分区副本并服务请求一台 broker 通常同时承担不同分区的 leader/follower
Controller管理集群元数据与领导权变化控制面的元数据共识不等于每条业务记录都写进控制器日志

分区数量决定数据可分散到多少个写入与消费单元;副本数量决定同一份数据被保存多少份。两者都会消耗资源,但解决的问题不同。KRaft 控制面负责元数据管理,数据分区仍有自己的复制路径。参见 Kafka KRaft。

一条记录的写入路径

记录写入:分区、组批、复制与确认 序列化并选择分区 key 的编码和分区策略决定映射 1 按分区组批 批次缓冲摊薄网络与压缩成本 2 发送到分区 leader leader 追加记录,followers 拉取复制 3 返回写入结果 确认条件由 acks 与副本状态共同决定 4 应用收到成功的含义,必须结合复制与持久性边界解释。

客户端批处理能摊薄网络与协议开销,但等待组批也会增加延迟。压缩通常作用于批次;更多分区可能把流量切成更小的批次。因此“分区越多越快”不成立,还要考虑热点、批次大小、文件句柄、复制连接和恢复成本。

对需要同一业务实体有序处理的流,常把 order_id 用作 key。保证依赖分区器、key 序列化和分区数量保持一致。扩分区改变映射后,同 key 的新旧记录可能落在不同分区,迁移时需要单独考虑顺序。

无 key 的记录如何分配由客户端实现决定,不能统一写成逐条 round-robin;Java 客户端的批处理与分区策略应查 生产者配置。同 key 也只确定排序所在的分区:多个独立生产者之间的业务先后关系需要应用自己建立。

复制、ISR 与确认条件

Leader 接受写入,follower 持续拉取。ISR 是被认为跟得上的副本集合,包含 leader;副本是否留在集合中与其复制进展及相关配置有关。

配置生产者何时收到成功需要理解的代价
acks=0不等待 broker 确认发送成功不证明 broker 已接收
acks=1leader 接受写入后leader 故障时尚未复制的数据可能丢失
acks=all满足当前 ISR 的复制确认要求确认等待与副本可用性耦合

min.insync.replicas 与 acks=all 配合,为可接受写入设置最小 ISR 条件。它不是“配置为 2 就只等任意两个副本”。例如复制因子为 3、最小 ISR 为 2:ISR 剩一个副本时,系统宁可拒绝这类写入,也不把单副本成功伪装成足够冗余的成功。详细语义见 Kafka Replication。

还要区分“复制到多个节点”和“每条消息已在每块盘上执行 fsync”。确认策略不能脱离日志刷盘、故障域、供电与恢复策略单独解释为绝对持久。允许落后副本成为 leader 的恢复策略也可能以丢失已确认记录换取可用性。

三种位置:末尾、复制提交与事务可见

Kafka 的读写位置不是同一个数字。以下用“下一条的位置”为边界,并作概念性简化:

三个日志边界:LEO、HW 与 LSO 10 11 12 13 14 15 LSO = 12 事务稳定边界 HW = 14 复制提交边界 LEO = 16 下一追加位置 稳定记录 有未完成事务 复制尾部 图为简化示例:read_committed 还会跳过已中止事务的记录。
  • LEO(Log End Offset):某副本日志末尾的下一位置。各副本可能不同。
  • HW(High Watermark):复制提交边界,普通读取不能越过它读取未提交的复制尾部。
  • LSO(Last Stable Offset):事务稳定边界,read_committed 读取还要受它限制,并跳过中止事务。

这个区分能解释一种现象:磁盘日志已经增长,消费者却暂时看不到后面的记录。原因可能是复制进度,也可能是前面的未完成事务。只看“文件里有没有这条消息”无法判断它是否应对消费者可见。

同一个 HW,为什么两种隔离级别读到不同记录

沿用图中的位置:10、11 是普通记录,事务 T 从 12 开始,13 又是一条普通记录。HW=14 表示这些位置都已进入复制提交范围,但 T 仍未完成。read_uncommitted 能返回 10、11、12、13;read_committed 只能返回 10、11。13 虽然不属于 T,也不能越过前面的事务稳定边界。

下面将边界作为输入,模拟可见性过滤。事务状态 committed 或 aborted 表示完成标记也已达到稳定条件,不是生产者刚发出了提交请求。模型省略 broker 复制和事务协调过程。

records = [(10, None), (11, None), (12, "T"), (13, None)]

def visible(records, hw, transactions, committed_only):
    # transactions: 事务 ID -> (首条 offset, 稳定状态)
    first_open = [first for first, status in transactions.values()
                  if status == "open"]
    lso = min([hw] + first_open)
    boundary = lso if committed_only else hw
    return [offset for offset, tx in records
            if offset < boundary
            and (not committed_only or tx is None
                 or transactions[tx][1] == "committed")]

assert visible(records, 14, {"T": (12, "open")}, False) == [10, 11, 12, 13]
assert visible(records, 14, {"T": (12, "open")}, True) == [10, 11]
assert visible(records, 15, {"T": (12, "committed")}, True) == [10, 11, 12, 13]
assert visible(records, 15, {"T": (12, "aborted")}, True) == [10, 11, 13]
assert visible(records, 11, {"T": (12, "open")}, True) == [10]
assert visible([], 0, {}, True) == []

后两个完成场景假设 offset 14 存放事务完成标记、HW 已到 15;标记不作为业务记录放入模型。T 中止时,12 被过滤,13 恢复可见,因而读取结果出现空洞。这种空洞不表示消费者丢了一条需要处理的记录。

这里的 LSO 统一采用排他上界⁠:读取 offset 小于 LSO 的记录;未完成事务从 12 开始,边界就是 12。endOffsets() 在 read_committed 下返回 min(HW, 最早未完成事务起点),可查 KafkaConsumer 的事务读取与末尾位置定义。它也解释了为什么只看相对于 LSO 的 lag 为零,不能证明复制日志末尾没有被事务阻挡的积压。

记录已经复制,为什么仍读不到?

“请求提交事务”与“提交标记已经稳定”也要分开。下面比较三种独立历史;完成历史在 offset 14 有标记,但只有 HW 到达 15,事务才被视为稳定结束。所有可见性边界均为排他上界;中止后的空洞来自过滤,不应让消费者去等待一个永远不会交付的 offset 12。

正在呈现知识画面
记录已经复制,为什么仍读不到?

read_committed 受排他的 LSO 限制。未完成事务会挡住后面的普通记录;中止后,事务记录被过滤,后续普通记录可恢复可见。

消费位置与已提交位置

一次 poll() 返回若干记录,客户端的读取位置随之推进,但业务处理未必完成。已提交 offset 是故障恢复时的进度承诺,两者应分开。

拉取位置与处理完成位置不同 100 已完成 101 已完成 102 未完成 103 未完成 可安全提交的下一位置:102 只有前面的记录全部完成,连续提交水位才能向右推进。

常规 consumer group 中一个分区在同一时刻分配给一个成员,不同 group 可以独立读取同一分区。客户端按 offset 读取也不自动保证异步工作线程按同样顺序完成。接管过程与并发提交规则见 消费组与协作。

Retention 与 Log Compaction

两种清理方式对应两种数据用途:

方式关注什么适合表达什么
按时间或大小删除旧日志段历史保存多久有期限的事件回放
按 key 压缩旧版本每个 key 的最新状态从变更日志重建状态
按 key 压缩:保留最新状态,offset 不重编号 offset 0 A=1 offset 1 B=7 offset 2 A=2 后台清理旧版本 旧值可被移除 offset 1:B=7 offset 2:A=2 清理是异步过程,读者仍可能读到尚未清理的多个版本。

压缩在后台进行,不保证读取时每个 key 只出现一次,也不重排或重编号剩余记录。删除状态通常用 tombstone 表达,过了相应保留期后连 tombstone 也可能被清理;落后太久的读者可能无法靠剩余日志恢复出正确删除历史。Log Compaction 详细说明了这些保证。

因此,是否能回放取决于保留边界,是否能重建状态取决于压缩键与删除语义。事件审计与最新状态同步不能仅靠“都存在 Kafka 里”就视为相同需求。

删除标记消失后,空库重建与旧快照续接并不等价

设日志依次为 0:A=1、1:B=7、2:A=2、3:B=null。最后一条删除 B。下面模拟一次“所有旧版本都已清理”的结果,故意不模拟实际 cleaner 的异步调度:

log = [(0, "A", 1), (1, "B", 7), (2, "A", 2), (3, "B", None)]

def compact_snapshot(records, keep_tombstones):
    latest = {}
    for record in records:
        latest[record[1]] = record
    return sorted((record for record in latest.values()
                   if keep_tombstones or record[2] is not None),
                  key=lambda record: record[0])

def replay(initial, records):
    state = dict(initial)
    for _, key, value in records:
        if value is None:
            state.pop(key, None)
        else:
            state[key] = value
    return state

with_delete = compact_snapshot(log, True)
without_delete = compact_snapshot(log, False)
assert with_delete == [(2, "A", 2), (3, "B", None)]
assert without_delete == [(2, "A", 2)]  # offset 保持为 2
assert replay({}, log) == replay({}, without_delete) == {"A": 2}
stale_snapshot = {"A": 1, "B": 7}
assert replay(stale_snapshot, with_delete) == {"A": 2}
assert replay(stale_snapshot, without_delete) == {"A": 2, "B": 7}

旧快照里的 B 不会因为日志里“没有 B”就自动删除。消费者无法区分“B 从来没有变更”与“B 被删除,但删除证据已过期”。所以恢复协议必须把快照对应的 offset、增量日志可用范围与删除保留范围一起约定;不能只检查还能连接 broker。

这里空库重建能得到正确结果,是因为输入是固定、完整的压缩后集合。真实重建期间 cleaner 仍在运行:若读者先读到 B 的旧值,却没来得及读到随后被清理的 tombstone,同样可能保留错误状态。因此从空库开始还需要满足读取进度与删除保留条件,不能把模型等式当成无限时长恢复的保证。相关条件见 Log Compaction 的保证。

架构推演:订单流如何分工

一个合理的拆分是用订单 ID 分区,让同一订单的事件具有分区内顺序;库存组与统计组各自提交进度;审计需求决定历史保留期;复制与确认策略决定故障时的写入承诺。

若某个大客户产生一半流量,其 key 可能形成热点。增加消费者无法把一个独占分区自动拆开。改用更细的 key 能提高并行度,但必须重新定义需要保持顺序的业务范围。性能取舍因此来自业务关系,而不只是调大配置。

参考

关键词:Kafka、partition、replica、ISR、acks、offset、HW、LSO、retention、log compaction