DD 数据开发学习站DATA ENGINEERING
模块 05 · 实时计算

Kafka 原理与实战

topic/partition/offset、消费组、副本 ISR、可靠性与顺序性。

难度 · 进阶 约 60 分钟 必修 练习 7 项 计划内
建议先学:CDC 与增量同步
本页目录 · 31 节

本章目标:能讲清 Kafka 的定位、分区与消费组模型,能本地起一个单机 Kafka,跑通生产与消费,并回答“为什么快、怎么不丢、怎么不乱序”三个高频问题。 前置知识:会使用命令行,了解基本的程序运行方式;本章是“概念必会 + 本地模拟”,不要求生产集群经验。

1. 为什么需要它

1.1 一个没有 Kafka 的埋点系统

假设 App 每产生一个行为就调用一次后端接口,后端直接把事件写进业务库。会出现三个问题:

  1. 耦合:埋点字段一改,所有下游系统都要跟着改。
  2. 扛不住峰值:晚上八点流量是平时的十倍,数据库连接被打满。
  3. 下游故障会拖垮上游:推荐服务挂了,埋点接口跟着超时。 Kafka 把“写”和“读”拆开:上游只负责把事件写进 Kafka,下游按自己的节奏读。它相当于数据链路里的缓冲带和高速公路。

1.2 它解决的四类问题

作用 具体表现 例子
解耦 生产者不知道消费者是谁 埋点系统只管发事件,实时大屏和数据仓库各自订阅
削峰 峰值先写入,下游按能力消费 大促一分钟 100 万条,Flink 每秒处理 20 万条
异步 上游不用等下游处理完 下单成功后发一条事件,积分、推荐、风控各自处理
重放 消息保留一段时间,可重新消费 新上线一个指标,从三天前的数据开始算

1.3 它在数据链路里的位置

业务库 MySQL ──CDC──┐
App 埋点 ────日志采集──┤
                      ↓
                 Kafka(缓冲 + 分发)
                      ↓
        ┌─────────────┼─────────────┐
        ↓             ↓             ↓
    Flink 实时计算  离线落湖/仓   其他订阅方
        ↓
   Doris / ClickHouse / 大屏

记住一句话:Kafka 不负责计算,也不负责长期存储,它负责可靠地传和缓冲。


2. 核心概念

2.1 broker、topic、partition、offset

  • broker:一个 Kafka 服务进程。三台机器各起一个 broker,就是一个三节点集群。
  • topic:消息的逻辑分类,例如 order_events、review_events。相当于一个文件夹名。
  • partition:topic 的物理分片。一个 topic 可以拆成多个 partition,分布在不同 broker 上。
  • offset:消息在某个 partition 内的递增编号,从 0 开始。跨 partition 的 offset 没有可比性。
topic: order_events
├── partition 0: [0][1][2][3][4] ...
├── partition 1: [0][1][2] ...
└── partition 2: [0][1][2][3] ...

partition 是最重要的概念,因为:

  1. 它决定并行度:一个 partition 同一时刻只能被同一个消费者组里的一个消费者消费。
  2. 它决定顺序范围:Kafka 只保证单 partition 内有序。
  3. 它是副本的基本单位:每个 partition 有 1 个 leader 和若干 follower。

面试高频:topic 是逻辑概念,partition 是物理分片;offset 只在 partition 内有意义。

2.2 replica、leader、follower、ISR

每个 partition 可以配置多个副本(replication factor)。例如副本数为 3:

partition 0
  leader   (broker 1)  ← 生产者和消费者都找它
  follower (broker 2)  ← 持续从 leader 拉取
  follower (broker 3)
  • leader:处理这个 partition 的读写请求。
  • follower:同步 leader 的数据,leader 挂了可以顶上。
  • ISR(In-Sync Replicas):当前与 leader 保持同步的副本集合,包含 leader 自己。落后太多的副本会被踢出 ISR。

acks=all 的含义不是“等所有副本”,而是“等当前 ISR 里的副本都确认”。所以还要配合 min.insync.replicas:

replication.factor = 3
min.insync.replicas = 2
acks = all

含义是:至少 2 个副本在 ISR 里,写入才算成功。坏掉一台机器仍然可写;坏掉两台就拒绝写入,避免把数据只写到一个随时会丢的副本上。

还有一个参数 unclean.leader.election.enable,默认 false。如果打开,允许非 ISR 副本当 leader,可能丢数据,一般不要为了可用性打开它。

2.3 controller:谁来管集群

controller 是集群里的一个特殊 broker,负责:

  • 监听 broker 上下线;
  • 为 partition 选举新的 leader;
  • 把元数据变更同步给其他 broker。

老版本 Kafka 依赖 ZooKeeper 存元数据。新版本使用 KRaft 模式,controller 由一组 broker 内部选举产生,不再需要 ZooKeeper。KRaft 在 2.8 是预览,3.3 起达到生产可用,4.0 起只支持 KRaft。本章 Docker 示例用 Kafka 3.7 的 KRaft 单机模式。

2.4 生产者:消息怎么落到 partition

生产者发送消息时可以带一个 key。默认分区策略是:

  • 有 key:对 key 做哈希,落到固定 partition;
  • 无 key: newer 版本使用粘性分区,一批消息先集中写一个 partition,攒够批次再换,提升吞吐。

关键参数:

参数 作用 常见设置
acks 需要多少副本确认 all 最安全
retries 发送失败重试次数 大于 0,并配合幂等
enable.idempotence 生产者幂等,避免重试造成重复 true
linger.ms 为了攒批最多等多久 5–20 ms
compression.type 压缩算法 lz4、zstd
max.in.flight.requests.per.connection 单连接未确认请求数 开幂等时可到 5

幂等生产者的原理:broker 给每个生产者分配 PID,生产者给每个 partition 的消息带递增序列号。broker 发现重复序列号就丢弃,从而避免“生产者重试导致重复”。它只保证单个生产者会话、单个 partition 内不重复,不能解决下游重复消费。

事务用于“读 Kafka → 处理 → 写 Kafka”这种跨 partition 的原子写。配置 transactional.id,通过 initTransactions、beginTransaction、commitTransaction 控制。消费者设置 isolation.level=read_committed,只读已提交事务的消息。

2.5 消费者与消费者组

消费者从 partition 拉取消息,并提交 offset 表示“我处理到哪里了”。

消费者组(consumer group) 的规则:

  • 同一个组内,一个 partition 只能分给一个消费者;
  • 不同组之间互不影响,各自有各自的 offset;
  • 消费者数超过 partition 数时,多出来的消费者空闲。
topic 有 3 个 partition,组 g1 有 2 个消费者:
  C1 -> partition 0, 1
  C2 -> partition 2

如果组 g1 再加一个 C3:
  C1 -> partition 0
  C2 -> partition 1
  C3 -> partition 2
再加 C4:C4 空闲

消费位置存在 Kafka 的内部 topic __consumer_offsets 里,不是存在消费者本地。

auto.offset.reset 只在“这个组没有已提交 offset”时生效:

  • earliest:从最早的消息开始;
  • latest:只消费新消息;
  • none:找不到 offset 就报错。

2.6 rebalance:分区重新分配

当以下情况发生时,消费组会触发 rebalance:

  • 消费者加入或离开(包括心跳超时、进程被杀);
  • 订阅的 topic 变化;
  • partition 数量增加。

rebalance 期间,分区会先全部收回再重新分配(eager 模式),消费会短暂停止。老版本是“全场暂停”,新版本支持 cooperative-sticky,尽量只迁移需要迁移的分区。

常见调优方向:

  • 合理设置 session.timeout.ms 和 heartbeat.interval.ms;
  • 保证单条消息处理时间小于 max.poll.interval.ms,否则消费者会被认为死了;
  • 用 group.instance.id 配置静态成员,滚动重启时减少无谓 rebalance;
  • 消费者数量不要远大于 partition 数。

注意:rebalance 不是“故障”,它是消费组维持负载均衡的机制;但频繁 rebalance 会让消费延迟飙升,要排查。

2.7 顺序性、acks、幂等与事务的关系

顺序性:

  • Kafka 只保证单 partition 内按写入顺序消费;
  • 想保证同一订单的事件有序,就用 order_id 作为 key,让同一订单进同一个 partition;
  • 不同订单之间没有全局顺序,也不需要。

不丢、不重、有序的常用组合:

目标 生产者/ broker 配置 消费者配置
不丢 acks=all、retries>0、enable.idempotence=true、副本数 3、min.insync.replicas=2、unclean.leader.election.enable=false 关闭自动提交,处理完再手动提交
不重 幂等生产者解决“生产端重试重复”;跨系统重复要靠事务或下游幂等 下游按业务主键去重,或事务写
有序 相同 key 进同一 partition;开幂等后 max.in.flight<=5,不开则设为 1 单 partition 内单线程处理

要诚实理解边界:幂等生产者不等于端到端 exactly-once;消费者“处理成功但提交 offset 前挂了”,重启后会重复消费,这是至少一次语义;真正的端到端精确一次需要“可重放的数据源 + 状态/offset 一起做 checkpoint + 事务或幂等写出”,这部分在 Flink 进阶 展开。

2.8 为什么快

  1. 顺序写磁盘:消息只追加到日志末尾,不做随机写。机械盘顺序写也能到很高吞吐。
  2. 页缓存(page cache):Kafka 写入时先写操作系统页缓存,由操作系统批量刷盘,不自己维护大对象缓存。
  3. 零拷贝:消费时通过 sendfile 把页缓存直接送到网卡,减少用户态与内核态之间的数据复制。
  4. 批处理 + 压缩:生产者攒批,broker 按批存储,网络和磁盘都更省。
  5. 分区并行:多个 partition 分布到多台 broker,读写并行扩展。

面试高频:不要只背“零拷贝”。完整的答案是“顺序写 + 页缓存 + 零拷贝 + 批量压缩 + 分区并行”。

提示:Kafka 只负责传输和缓冲,不擅长复杂 SQL 聚合。计算交给 Flink,查询服务交给 Doris / ClickHouse,T+1 校准交给离线数仓。


3. 动手示例

3.1 Docker 起一个单机 Kafka

先确认 Docker Desktop 已经启动。使用 KRaft 单机模式,不需要 ZooKeeper:

docker run -d --name kafka -p 9092:9092 apache/kafka:3.7.0
docker logs -f kafka

看到 broker 启动完成的日志后,按 Ctrl+C 退出日志查看。再确认容器在运行:

docker ps --filter name=kafka

注意:如果 9092 端口被占用,把 -p 9092:9092 换成 -p 19092:9092,后面所有客户端地址都要相应改成 localhost:19092。如果镜像拉取失败,先检查网络或镜像源;两小时内解决不了就只保留概念,不要卡在环境上。

3.2 常用命令

创建 3 个分区、1 个副本的 topic,并查看详情:

docker exec -it kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic order_events --partitions 3 --replication-factor 1
docker exec -it kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic order_events

用控制台生产消息,key 和 value 用冒号分隔:

docker exec -it kafka /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic order_events --property parse.key=true --property key.separator=:

在提示符后依次输入:

O1001:{"order_id":"O1001","event_type":"pay","amount":2999}
O1002:{"order_id":"O1002","event_type":"pay","amount":59}
O1001:{"order_id":"O1001","event_type":"ship","amount":2999}

另开一个终端消费,并打印分区、offset、key:

docker exec -it kafka /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic order_events --from-beginning --property print.partition=true --property print.offset=true --property print.key=true

你会看到两条 O1001 落在同一个 partition,且 offset 递增;O1002 可能落在另一个 partition。查看消费组积压:

docker exec -it kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group g1 --describe

重置消费位置到最早(先停掉该组消费者):

docker exec -it kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group g1 --topic order_events --reset-offsets --to-earliest --execute

3.3 Python 生产与消费 demo

安装客户端后,写生产者脚本 realtime_demo/kafka_producer.py:

pip install kafka-python
import json
import time

from kafka import KafkaProducer

producer = KafkaProducer(
    bootstrap_servers="localhost:9092",
    acks="all",
    retries=5,
    linger_ms=10,
    key_serializer=lambda k: k.encode("utf-8"),
    value_serializer=lambda v: json.dumps(v, ensure_ascii=False).encode("utf-8"),
)

for i in range(1000):
    order_id = f"O{100000 + i}"
    event = {
        "order_id": order_id,
        "event_type": "pay",
        "category": ["手机", "图书", "家电"][i % 3],
        "amount": round(50 + (i % 20) * 10.5, 2),
        "ts": int(time.time() * 1000),
    }
    producer.send("order_events", key=order_id, value=event)

producer.flush()
producer.close()
print("sent 1000 events")

消费者脚本 realtime_demo/kafka_consumer.py,手动提交 offset:

import json

from kafka import KafkaConsumer

consumer = KafkaConsumer(
    "order_events",
    bootstrap_servers="localhost:9092",
    group_id="g1",
    auto_offset_reset="earliest",
    enable_auto_commit=False,
    key_deserializer=lambda b: b.decode("utf-8"),
    value_deserializer=lambda b: json.loads(b.decode("utf-8")),
)

for msg in consumer:
    print(msg.partition, msg.offset, msg.key, msg.value)
    consumer.commit()

先运行 python realtime_demo/kafka_producer.py,再运行消费者脚本,按 Ctrl+C 停止。观察同一个 key 的消息是否始终在同一个 partition。再连续发送 10 条 O1001 和 10 条 O2001,验证每组 key 只出现在一个 partition 内。

提示:partition 数一旦增加,key 到 partition 的映射会变化,历史 key 可能落到新 partition。所以“增加分区”会破坏跨时间的同 key 顺序,生产上要提前规划。


4. 常见坑

  1. 以为 Kafka 保证全局有序。它只保证单 partition 有序。需要业务顺序就设计 key,不要依赖单分区。
  2. 消费者数超过 partition 数。多出来的消费者不会提升吞吐,只会空闲。
  3. 开了自动提交就以为不会丢。自动提交可能“消息还没处理完,offset 已经提交”,进程崩溃后这条消息就丢了。关键链路手动提交。
  4. 只开 acks=all,不配 min.insync.replicas。副本都掉线时仍可能写成功但不可靠;三个副本至少两个同步。
  5. 用 order_id 做 key 后,发现某个订单的数据倾斜。大促时超级大客户可能产生海量事件,需要单独拆分或加随机后缀再在计算层合并。
  6. 频繁重试但有非幂等副作用。生产者重试可能重复写;下游写库要按主键 upsert 或用事务。
  7. 把 Kafka 当永久存储。它有保留时间,默认 7 天左右;需要长期保存要落湖或落仓。
  8. Docker 单机只设了 1 个副本,却照抄生产参数。本地 demo 的 replication-factor=1 不具备高可用,只是方便练习。

5. 练习

练习 1:分区数与消费者数量

一个 topic 有 3 个 partition,消费者组 g1 先启动 2 个消费者,再启动第 3 个、第 4 个。分别说明分区怎么分配、第 4 个消费者的状态。

参考答案

2 个消费者时,通常一个分到 2 个 partition,另一个分到 1 个;第 3 个消费者加入后触发 rebalance,最终每人 1 个 partition;第 4 个消费者没有可分配的分区,处于空闲状态,不会提升消费能力。

结论:同一消费组的并行度上限等于 partition 数。要提升消费能力,先增加 partition,再增加消费者。

练习 2:怎么保证同一订单的事件有序

订单事件有 pay、ship、complete 三种,要求同一订单的事件按发生顺序被处理。给出 key 设计和至少两个注意事项。

参考答案

用 order_id 作为消息 key,相同 key 经过哈希后进入同一个 partition,Kafka 保证单 partition 内有序。注意四点:不要改成随机 key;增加 partition 前要规划,因为映射会变化;生产者开启幂等并把 max.in.flight.requests.per.connection 控制在 5 以内,不开幂等则设为 1,避免重试乱序;消费端对同一 partition 串行处理。

练习 3:设计“不丢”的配置

三台 broker,要求任意一台宕机时不丢已确认的消息,且仍能写入。写出副本、ISR 和生产者配置,并解释坏两台时为什么应该拒绝写入。

参考答案
replication.factor = 3
min.insync.replicas = 2
acks = all
retries > 0
enable.idempotence = true
unclean.leader.election.enable = false

坏一台时还剩 2 个同步副本,满足 min.insync.replicas=2,可以继续写。坏两台时只剩 1 个副本,ISR 不足,broker 拒绝写入(NOT_ENOUGH_REPLICAS)。这是用“暂时不可写”换“不丢数据”,比让一个落后副本当 leader 更安全。消费者侧还要处理完再提交 offset,否则“Kafka 没丢,消费端自己丢了”。

练习 4:为什么开了幂等生产者还会重复

生产者已经配置 enable.idempotence=true,为什么下游数据库里仍可能出现重复订单?给出两种解决方案。

参考答案

幂等生产者只解决“生产者重试导致的 broker 端重复”。它不能解决:消费者处理成功、提交 offset 前崩溃,重启后从旧 offset 重新消费;下游写入超时,但数据库其实已经写入成功,应用层再次重试。

方案:下游按业务主键做 upsert 或唯一索引;用事务把“业务写入 + offset 提交”放在一起,或使用 Flink 的 checkpoint + 两阶段提交;建立去重表,用 event_id 唯一键记录已处理事件。

练习 5:消费积压排查

监控显示某个消费者组的 lag 持续上涨,写出你的排查顺序。

参考答案
  1. 看 lag 是全局上涨还是个别 partition 上涨。个别上涨优先怀疑 key 倾斜。
  2. 看消费者是否频繁 rebalance,检查日志里的 JoinGroup、SyncGroup 和超时。
  3. 看单条消息处理耗时,是否超过 max.poll.interval.ms;看下游数据库或外部接口是否变慢,线程是否卡在 I/O。
  4. 看消费者数量和 partition 数是否匹配,是否有消费者空闲。
  5. 看生产端流量是否突增,确认是“生产变快”还是“消费变慢”。

短期扩容消费者或增加 partition;长期优化处理逻辑、批量写、异步 I/O。回答时不要只说“加机器”,要说明先定位再扩容。

练习 6:命令行操作与 offset

用命令行完成:创建 6 分区 topic order_events_demo;发送 3 条带 key 的消息;用组 g_demo 消费;查看该组 offset;把 offset 重置到最早。

参考答案
docker exec -it kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic order_events_demo --partitions 6 --replication-factor 1
docker exec -it kafka /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic order_events_demo --property parse.key=true --property key.separator=:
# 依次输入 u1:{"event_type":"login"}、u2:{"event_type":"view"}、u1:{"event_type":"cart"}
docker exec -it kafka /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic order_events_demo --group g_demo --from-beginning
docker exec -it kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group g_demo --describe
docker exec -it kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group g_demo --topic order_events_demo --reset-offsets --to-earliest --execute

重置 offset 前必须先停止该组的消费者,否则会报“组内有活跃消费者”。


6. 面试延伸

面试题:Kafka 为什么快?

是什么:Kafka 是把消息追加到分区日志的分布式发布订阅系统,写入和读取都以顺序 I/O 为主。

解决什么问题:在高吞吐场景下,随机写磁盘、频繁数据复制和逐条网络传输会成为瓶颈。Kafka 用顺序写、页缓存、零拷贝、批量压缩和分区并行,把单机吞吐做高。

我的实操边界:我在本地 Docker 单机跑过生产消费,观察过同一 key 落在同一 partition;了解零拷贝是 sendfile 减少用户态复制,但没有在生产集群做过磁盘、网络和 JVM 参数调优。

面试题:如何保证消息不丢?

是什么:不丢要覆盖生产、存储、消费三段,任何一段断开都可能丢。

解决什么问题:生产端网络超时重试可能丢或重;broker 副本不足可能丢;消费端提前提交 offset 会丢。

我的实操边界:配置层面我掌握 acks=all、副本 3、min.insync.replicas=2、关闭自动提交、处理完再 commit;也理解幂等生产者只解决生产端重试重复。真实集群的磁盘故障演练我没有做过。

答法分三段:生产端用 acks=all、重试和幂等生产者;存储端副本数至少 3、min.insync.replicas=2、不允许非 ISR 副本选主;消费端手动提交、处理幂等、失败可重放。

面试题:rebalance 是什么,怎么减少影响?

是什么:消费组内 partition 所有权重新分配的过程。

解决什么问题:消费者增减或 topic 变化时,需要重新均衡负载,保证一个 partition 只被组内一个消费者处理。

我的实操边界:我理解触发条件和 eager/cooperative 两种模式,本地用命令行观察过消费者加入后分区变化;没有处理过生产环境大规模 rebalance 风暴。

减少影响的办法:合理设置心跳和 max.poll.interval.ms、控制单批处理时间、使用静态成员、消费者数量与 partition 匹配、避免频繁上下线。

面试题:怎么保证同一订单的消息有序?

是什么:Kafka 的顺序保证范围是单 partition。

解决什么问题:订单状态机要求 pay → ship → complete 按顺序处理,跨 partition 无法保证。

我的实操边界:我用 order_id 做 key 验证过同 key 同分区,了解幂等生产者与 max.in.flight 的关系;没有在生产环境处理过扩容导致 key 映射变化的迁移。

答案是:order_id 做 key,保证同订单进同一 partition;生产者开启幂等并控制未确认请求数;消费者对同一 partition 串行处理。如果业务要求全局顺序,代价是只能单 partition,吞吐会受限,通常不应该这样设计。


7. 本章验收

  • 能画出 broker、topic、partition、offset、leader/follower 的关系图,并解释 offset 为什么只在 partition 内有意义。
  • 能说出消费者组的分配规则,并能计算 3 个 partition、4 个消费者时的空闲消费者数量。
  • 能写出一组“不丢”的配置,并解释 acks=all 与 min.insync.replicas 的关系。
  • 能解释为什么 order_id 做 key 可以保证同一订单有序,以及扩容 partition 的风险。
  • 能在本地启动单机 Kafka,完成创建 topic、生产、消费、查看消费组、重置 offset。
  • 能用自己的话回答“Kafka 为什么快”,至少说出顺序写、页缓存、零拷贝、批处理中的三个。
  • 能说清幂等生产者与端到端 exactly-once 的区别。

下一章:Flink 基础。再往后:实时数仓 会把 Kafka、Flink 和 OLAP 串成一条链路。

本章任务0 / 7
本页目录