# 写在前面：你会学到什么

如果你刚听说 Kafka，却不知道“消息队列”“分区”“消费者”是什么意思，这本书就是为你准备的。你只需要会打开终端、复制命令，不需要先学分布式系统，也不必先写 Java。

读完并完成练习，你应该能够：说清 Kafka 解决什么问题；在自己的电脑上启动一个练习环境；发送和读取订单事件；观察同一份数据被不同业务独立读取；解释消息为什么可能重复、为什么重启后没有重新读取；知道生产环境还缺哪些条件。

本书将 Kafka 作为“可保留、可被多个业务独立读取的分区日志”来学习。若你已经读过 RocketMQ，这里的重点增量是日志位置与消费进度彼此独立：通知组可以暂停，统计组继续读取；通知组恢复或回放时，仍使用自己的书签。两种系统都支持消息与消费协作，选型应结合具体能力，不能仅凭“队列”和“日志”两个称呼下结论。

全书围绕三个问题推进：记录还在不在，由保留与压缩策略决定；某个业务读到哪里，由组偏移量决定；当前谁负责哪个分区，由组分配与再均衡决定。把这三条时间线分开，才能解释回放、扩缩容与故障重启。示例只观察真实 Kafka 单节点和命令行消费者，不冒充带数据库、短信或支付的完整应用。

本书是一条循序渐进的路线，不是参数百科。建议每天学习一到两章。第一次阅读先完成第 1 至 8 章，再学可靠性和项目。遇到名词可以翻到词汇表。

本书依据 **Kafka 4.1 系列官方文档**，示例固定到官方快速入门当前列出的 **4.1.2**，使用 **KRaft**，不需要 ZooKeeper。这是为了让命令有明确版本，不代表断言它是所有时间点的最新版本。官方网页会更新，资料访问日期为 2026 年 10 月 20 日。附录给出可直接打开的来源。

为了照顾初学者，正文把“消息”“事件”“记录”作为接近的说法。严格地说，事件强调“发生过的事实”，记录是 Kafka 实际保存的数据单位。代码块前面的解释会说明在哪里执行；复制命令时，不需要额外输入 `$` 或 `>` 提示符。


# 第 1 章 一笔订单为什么需要 Kafka

## 先从一个熟悉的场景开始

你在购物网站按下“下单”。订单服务把订单写进数据库，随后还需要通知库存、发送短信、更新销售统计。最直接的做法是订单服务挨个调用这些系统。

这会产生两个问题。第一，慢系统拖累下单：短信服务卡了五秒，用户也可能等待五秒。第二，业务彼此绑得很紧：每增加一个新系统，订单服务都需要增加调用逻辑。

我们可以改成：订单服务记录“订单已经创建”这个事实，把它交给 Kafka；短信服务、统计服务分别从 Kafka 读取它。订单服务不用知道所有读者是谁。

```text
订单服务 ──发送──> Kafka 的 orders 主题
                       ├──> 通知服务
                       ├──> 销售统计
                       └──> 风控分析
```

这叫作异步解耦。“异步”表示后续工作不必全部在同一次请求中结束；“解耦”表示系统减少对彼此实现细节和可用性的依赖。

## 再理解削峰和回放

假设平时每秒 100 个订单，促销时变成每秒 5,000 个。下游每秒只能处理 1,000 个，Kafka 可以暂时保存来不及处理的记录，让下游稍后继续。这叫削峰填谷。它不会凭空增加处理能力：流量长期高于消费能力时，积压仍然持续增长，最终会遇到存储或保留时间边界。

如果后来新增一个“订单地区分析”系统，它可以读取仍被保留的历史记录。这叫回放。已经被保留策略删除的数据不能靠修改消费进度找回来。

## Kafka 到底是什么

Kafka 是分布式事件流平台。现在先把它想象成一套“可多人订阅、按序追加、保存一段时间的日志”。日志在这里是一串数据记录，不只是程序报错文件。

Kafka 适合持续产生的大量事件、多个独立读者、需要历史回放的业务。它不自动替你完成库存事务、短信重试和订单查询。只需要少量后台任务时，更简单的任务队列也可能足够；按订单号查当前状态，通常仍然使用业务数据库。

**本章自测**：Kafka 能让慢消费者永远不扩容吗？不能。它能缓冲压力，不能消除长期的处理能力缺口。


# 第 2 章 用核心概念搭好地图

## 生产者、主题和消费者

生产者（Producer）负责写入，例如订单服务。主题（Topic）是一类记录的逻辑集合，例如 `orders`。消费者（Consumer）负责读取，例如通知服务。Kafka 服务进程称为 Broker，一个集群通常有多个 Broker。

生产者把记录交给主题，而不是直接指定“发给某个消费者”。消费者可以暂时离线，之后再读取仍然保留的数据。

## 分区和偏移量

主题可以拆成多个分区（Partition）。每个分区是一条独立的追加日志。记录在某个分区里的位置叫偏移量（Offset）。

```text
orders
  分区 0：offset 0 → offset 1 → offset 2
  分区 1：offset 0 → offset 1
  分区 2：offset 0 → offset 1 → offset 2 → offset 3
```

“分区 0 的 offset 2”和“分区 2 的 offset 2”是不同记录。偏移量只在同一分区内有意义，也不应被当作精确的业务消息计数：压缩、事务和日志管理都可能让可见记录的位置有空洞。

Kafka 保证的是分区内的日志顺序，不是整个多分区主题的全局顺序。即使使用同一分区，消费者把任务交给多个线程后，也可能自行打乱完成顺序。

## Key 和消费者组

一条记录通常包含 Key、Value、时间戳，也可以带 Headers。Key 可以是订单号，Value 可以是 JSON。固定分区数量、固定分区策略的前提下，同一个 Key 通常进入同一个分区，因此适合把同一订单的状态变化放在一起。增加分区或改变分区策略后，历史与新记录的映射可能变化。

消费者组（Consumer Group）是一组协作的消费者，使用相同的 `group.id`。同一组里，一个分区在稳定分配时交给一个消费者负责；不同组各自维护进度，都能独立读取同一份记录。

记住一句话：**组内分工，组间独立。** 三个分区、四个组内消费者，通常最多三个消费者同时获得分区；多出来的一个没有分区可读。

## 副本

副本（Replica）是分区数据的复制。普通分区有一个 Leader 负责写入，Follower 从 Leader 复制。Leader 故障后，满足条件的副本可以接替。副本数和分区数不是同一个概念：三个分区、每分区三个副本，总计九份分区副本。

**本章自测**：两个业务都要完整读到订单，该用同一个组吗？不该。使用不同组；同一个组意味着协作分担。


# 第 3 章 准备一个安全的练习环境

## 选择本地脚本路线

本书统一采用官方压缩包与本地脚本，减少“主机地址”和“容器地址”混用。命令适用于 macOS、Linux，Windows 建议在 WSL2 的 Linux 终端运行。WSL2 里的 Java 和文件目录也应在对应 Linux 环境内准备。

你需要 Java 17 或更高版本、约 2 GB 可用内存和至少数 GB 的空闲磁盘。建议使用 Java 21。先执行：

```bash
java -version
```

预期输出含 `17`、`21` 或更新的受支持 Java 版本。如果系统提示找不到运行时，先安装 JDK 并确认 `JAVA_HOME`、`PATH` 指向它。看到一个 `java` 命令路径不代表真的装好了运行时。

到 [Apache 官方下载页](https://kafka.apache.org/downloads/) 或 [4.1.2 归档目录](https://archive.apache.org/dist/kafka/4.1.2/) 获取 `kafka_2.13-4.1.2.tgz`。选择二进制包，不是 source 源码包。下载目录提供签名和 SHA-512 文件，可按官方说明校验。

在下载文件所在目录执行：

```bash
tar -xzf kafka_2.13-4.1.2.tgz
cd kafka_2.13-4.1.2
```

文件名里的 `2.13` 是构建使用的 Scala 版本标记，不要求你先学 Scala。

## 隔离本书的数据

Kafka 的日志数据和程序日志不是一回事。我们把数据放在解压目录下，避免误碰其他练习实例。以下操作只用于新解压的练习目录，**不要拿去格式化已有集群**。

```bash
mkdir -p book-data
cp config/server.properties config/book.properties
python3 - <<'PY'
from pathlib import Path
p = Path('config/book.properties')
s = p.read_text()
data = str(Path('book-data').resolve())
lines = [line for line in s.splitlines()
         if not line.strip().startswith(('log.dirs=', 'metadata.log.dir='))]
lines.append('log.dirs=' + data)
p.write_text('\n'.join(lines) + '\n')
print('练习数据目录：' + data)
PY
```

这一步额外需要 Python 3。若没有 Python，也可以用文本编辑器把 `config/book.properties` 中的 `log.dirs` 改成练习目录的绝对路径，并确保没有另一条 `metadata.log.dir` 指向旧目录。练习路径尽量不含空格。

## 初始化并启动

只在第一次初始化时执行这两条：

```bash
KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"
bin/kafka-storage.sh format --standalone \
  -t "$KAFKA_CLUSTER_ID" -c config/book.properties
```

随后启动：

```bash
bin/kafka-server-start.sh config/book.properties
```

这个终端会持续显示日志。保持它开启。另开终端时，都先 `cd` 到同一解压目录，再执行后文命令。`localhost:9092` 是本机 Broker 的访问地址。

重启时只运行启动命令，**不要重新生成集群 ID 或再次格式化**。停止时在服务终端按 `Ctrl+C`，等待进程退出。本地单节点同时承担 Broker 与 KRaft Controller 角色，用于学习；生产集群需要专门规划角色、仲裁和故障域。

如果你已经熟悉 Docker，官方还有 `apache/kafka:4.1.2` 镜像。本文后续命令使用解压目录中的 `bin/`，不要直接把容器内路径与本地路径混用。


# 第 4 章 发出和读到第一条消息

## 创建单分区主题

在新的终端，进入 Kafka 解压目录，执行：

```bash
bin/kafka-topics.sh --bootstrap-server localhost:9092 \
  --create --topic hello-kafka \
  --partitions 1 --replication-factor 1
```

预期看到 `Created topic hello-kafka.`。若提示已经存在，可以继续使用；本章的消息数预期以第一次创建的空主题为准。

查看它：

```bash
bin/kafka-topics.sh --bootstrap-server localhost:9092 \
  --describe --topic hello-kafka
```

重点找 `PartitionCount: 1` 和 `ReplicationFactor: 1`。`Leader`、`Replicas`、`Isr` 后面的数字是节点编号，不是消息数量。

## 写入三行

```bash
bin/kafka-console-producer.sh --bootstrap-server localhost:9092 \
  --topic hello-kafka
```

进入交互界面后，输入下面三行，每行后回车：

```text
你好，Kafka
这是第二条消息
订单 A1001 已创建
```

每一行是一条独立记录。命令不会自动把多行 JSON 合成一条消息。按 `Ctrl+C` 退出生产者，Broker 仍然保持运行。

## 从头读取

```bash
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 \
  --topic hello-kafka --from-beginning \
  --group hello-readers
```

第一次使用这个组，应读到刚才三行，然后持续等待新消息。等待不是卡死。打开另一个生产者再输入一行，你会看到消费者很快显示它。

消费不会把记录从主题里删掉。停止消费者，再换一个从未使用过的组，比如 `hello-readers-2`，仍可以从头读取保留着的消息。

## 为什么重启后没有从头显示

`--from-beginning` 并不是“强行覆盖已有消费进度”。在没有有效已提交进度时，它让消费者从最早的可用位置开始。已有组通常从保存的进度继续。需要从头演示时，最简单的方法是换一个新组；正式重放需要有计划地重置进度。

本章检查点：你应当能看到消息；能解释主题不会因消费而清空；能区分“新组从头读”和“旧组继续读”。


# 第 5 章 用订单号理解 Key 和分区

## 建立三个分区的主题

```bash
bin/kafka-topics.sh --bootstrap-server localhost:9092 \
  --create --topic orders \
  --partitions 3 --replication-factor 1
```

接着启动带 Key 的生产者：

```bash
bin/kafka-console-producer.sh --bootstrap-server localhost:9092 \
  --topic orders \
  --property parse.key=true \
  --property key.separator='|'
```

输入以下三行，注意每行中间的 `|`：

```text
A1001|{"event_id":"e-001","order_id":"A1001","type":"OrderCreated","amount_fen":9900,"schema_version":1}
A1002|{"event_id":"e-002","order_id":"A1002","type":"OrderCreated","amount_fen":2000,"schema_version":1}
A1001|{"event_id":"e-003","order_id":"A1001","type":"OrderPaid","amount_fen":9900,"schema_version":1}
```

左边是 Key，右边是 Value。金额用“分”的整数表示，避免用二进制浮点数直接表示钱。`event_id` 标识一次业务事件，`order_id` 标识一笔订单，两者用途不同。

## 显示记录位置

```bash
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 \
  --topic orders --from-beginning --group orders-inspect-v1 \
  --property print.key=true \
  --property print.partition=true \
  --property print.offset=true
```

你应看到分区、偏移量、Key 和 JSON。观察 `A1001` 的两个事件：在本书默认分区策略和未改分区数时，它们进入同一分区，偏移量按追加顺序增大。不同订单也可能落入同一分区，Key 与分区不是一对一关系。

不能依赖屏幕显示的三行恰好是发送时的全局顺序，因为消费者从多个分区取数据。也不要背下“A1001 一定进分区几”，分区映射不是业务规则。

## 业务顺序还有额外条件

把同一订单作为 Key 只是基础。若两个生产者同时写同一订单，“谁先发生”的业务顺序仍需要由上游协调，或者在事件里带业务版本号。消费者若并行处理同一分区，也要保证同一订单的处理顺序。

新增分区可以增加并行度，但可能改变 Key 映射。需要严格保持历史与后续顺序时，应先设计迁移和切换方案，而不是在高峰时随手扩分区。

**练习**：把 `A1001` 改成固定 Key `all-orders` 会怎样？所有这些消息会集中到同一个分区，失去按订单分摊流量的优势。


# 第 6 章 消费者组：独立进度、协作与再均衡

## 先做“组间独立”实验

开两个消费者终端，使用不同组：

```bash
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 \
  --topic orders --from-beginning --group notification-demo
```

```bash
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 \
  --topic orders --from-beginning --group analytics-demo
```

首次运行且历史数据仍保留时，两边都能读到全部三条记录。再发送一条新订单，两边都会看到。这分别对应通知服务与统计服务。

## 再做“组内分工”实验

开两个终端，执行同一条命令：

```bash
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 \
  --topic orders --from-beginning --group workers-demo \
  --property print.partition=true --property print.key=true
```

这两个消费者共同分担三个分区。历史记录可能在第二个进程启动前已经被第一个读完，因此不要只看启动时的输出。等组稳定后，再通过上一章的生产者发送多个不同 Key 的新事件。

查看分配和进度：

```bash
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --describe --group workers-demo
```

同一个分区会对应一个当前负责它的消费者。同一个消费者可以负责多个分区。不同 Key 可能哈希到同一个分区，所以少量事件不保证两个终端都立刻有输出。

## 什么是再均衡

组里有人加入、退出，或者订阅主题的分区发生变化时，分区分配需要调整，这叫 Rebalance，中文常译“再均衡”。退出一个消费者后，剩余成员会接管它的分区。

再均衡可能造成短暂等待，也可能让尚未提交进度的记录被再次处理。业务应用需要正确处理分区撤销、任务结束和提交进度。Kafka 4.x 有新的消费者组协议能力，但具体启用方式受客户端和 Broker 配置影响；本书不依赖把旧客户端参数原样迁移到新协议。

## 用分配表验证接管

判断再均衡是否完成，不能只看“第二个进程启动成功”。先确认三个分区被一个成员覆盖，加入第二个后确认仍恰好覆盖这三个分区且由两个成员分担，再停止第二个，等待第一个重新负责全部分区。观察期间可能出现临时无分配或过渡状态，应等待稳定后再比较。

随书 `scripts/run_lab.py` 按这三个阶段读取组描述并检查成员与分区覆盖。它证明受控启动、加入与退出后的分配行为，不证明真实业务的在途任务已经正确交接；异步业务仍要自行处理撤销、提交与幂等。

**本章自测**：把消费者数量从 3 加到 30，就能让三个分区快十倍吗？不能。先看分区并行度，再看真正的瓶颈。


# 第 7 章 消费进度：书签记录在哪里

## 读到了与处理完了是两件事

消费者有“当前位置”，表示接下来取哪里；还有“已提交偏移量”，表示组恢复时从哪里继续。通常提交的是**下一条要处理的记录位置**。如果分区 0 的 offset 0、1、2 已经成功完成，应提交 3。

```text
已完成：0  1  2 | 下一条：3  4  5
提交进度：3
```

消费者组的进度由 Kafka 管理，通常保存在内部主题 `__consumer_offsets`。它不是刻在每条业务记录上的“已读”标记，所以多个组能拥有不同书签。

## 为什么会重复

考虑以下时间线：消费者读到 offset 2；短信已成功发送；进程突然退出；offset 3 还没提交。重启后，它可能再次读到 offset 2，再发送一次短信。

如果反过来，先提交 offset 3，再发短信，进程在两步之间退出，短信就可能永远漏发。

这不是调一个超时时间就能彻底解决的问题，而是外部业务结果与 Kafka 进度之间缺少一个共同的原子提交边界。后文会讲幂等和事务。

## Lag 是什么

上一章的组描述通常包含 `CURRENT-OFFSET`、`LOG-END-OFFSET` 和 `LAG`。可以先近似理解为“已经提交的位置”“日志末端位置”和“两者差距”。

Lag 反映偏移量距离，不等于精确的待办订单数量，也不等于等待秒数。事务、压缩和记录可见性会影响解释。更有用的是同时观察 Lag 趋势、事件时间到处理完成的延迟、处理吞吐和错误率。

## 练习：有意识地回放

先停止 `hello-readers` 组的所有消费者，再预览：

```bash
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --group hello-readers --topic hello-kafka \
  --reset-offsets --to-earliest --dry-run
```

确认只涉及本书的练习组和主题后执行：

```bash
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --group hello-readers --topic hello-kafka \
  --reset-offsets --to-earliest --execute
```

重启该组消费者，就会从最早保留位置继续。若对真实扣款业务回放，可能重新触发副作用，因此生产回放必须明确目标范围并先确保幂等。

重置成功只说明书签被修改，还应重新启动同一个组，实际核对三条中文记录确实再次读到；同时比较另一业务组的已提交位置，确认没有被一起重置。完整回放方案需要记录目标主题和分区、起止位置、操作者、业务幂等依据以及停止条件。需要独立重算又不想修改原组时，可以用新的组读取保留日志，但新增读者仍会消耗集群资源。


# 第 8 章 数据保存多久：消费和删除彼此独立

## 保留策略

Kafka 通常按时间或大小保留日志，清理由后台按日志段推进。`retention.ms` 表示时间策略，`retention.bytes` 是每个分区的大小策略；设置了两者时，任一条件都可能推动旧日志段清理。

例如，为 orders 设置七天时间保留：

```bash
bin/kafka-configs.sh --bootstrap-server localhost:9092 \
  --entity-type topics --entity-name orders --alter \
  --add-config retention.ms=604800000
```

这里 `604800000` 毫秒等于七天。它不是保证每条记录精确存在七天的计时器。段滚动、检查周期、时间戳等都会影响实际清理时点。消费者落后太久时，想读的位置可能已被删除。

## 日志压缩

`cleanup.policy=compact` 用于按 Key 保留最新状态的场景，例如用户资料变更流。压缩在后台运行，不会立即变成“每个 Key 恰好一条”。删除语义通常用相同 Key、Value 为 null 的墓碑记录表达；墓碑也有自己的保留和清理条件。

订单创建、支付、退款是一串有价值的历史事实，随意只保留订单最后一次事件，可能破坏审计和重算。需要完整过程时，先考虑按时间保留事件流；需要状态视图时，再设计压缩主题。

## 配置压缩不等于清理已经发生

可以为状态视图单独建立一个练习主题，不要直接改变保存完整订单历史的 `orders`：

```bash
bin/kafka-topics.sh --bootstrap-server localhost:9092 \
  --create --topic order-state --partitions 1 --replication-factor 1 \
  --config cleanup.policy=compact
bin/kafka-configs.sh --bootstrap-server localhost:9092 \
  --entity-type topics --entity-name order-state --describe
```

读回 `cleanup.policy=compact` 证明配置已生效，不证明后台清理器已经去掉某条旧值。本轮实验仅核对保留和压缩配置，未通过加速段滚动观察物理删除或墓碑清理。压缩可能保留同一 Key 的多个版本一段时间，offset 也不会被重新连续编号；消费者必须能容忍位置空洞。对重要业务，应另行验证清理延迟、恢复窗口与状态重建。

## 粗算磁盘

假设平均每秒 1,000 条，每条序列化后约 1 KB，保存七天：原始写入约 1 MB/秒，一天约 86.4 GB，七天约 604.8 GB；三个副本约 1.81 TB。这里用十进制单位估算。

真实容量还要考虑压缩率、Key 与协议开销、索引、峰值、故障副本恢复和磁盘余量。别把这道估算题当成采购结论。监控磁盘增长速度，比只看配置更可靠。

**本章自测**：某组把所有消息读完，会释放主题磁盘吗？不会。删除主要由保留、压缩或显式管理操作决定。


# 第 9 章 可靠性：一条消息究竟算不算送达

## 先给成功一个边界

生产者返回成功、Broker 保存记录、所有副本完成复制、消费者读到、业务数据库更新成功，是不同阶段。讨论“不丢”或“不重复”时，必须说明在哪个阶段、容忍哪些故障。

生产者发出请求但没有收到回应，也不等于 Broker 没收到：请求可能成功了，只是响应在网络里丢了。这也是重试可能制造重复的原因。

## acks、副本和 ISR

`acks=0` 不等待服务端确认，吞吐需求不能让它自动变可靠。`acks=1` 等待 Leader 确认，但此时尚未复制的数据可能在 Leader 故障时丢失。`acks=all` 等待当前同步副本集合 ISR 的确认。

ISR 是与 Leader 保持足够同步的副本集合。`min.insync.replicas` 决定允许接受这类可靠写入的最低 ISR 数量。常见设计起点是副本数 3、`min.insync.replicas=2`、生产者 `acks=all`：当 ISR 小于 2 时拒绝写入，以牺牲部分可用性保护可靠性。这仍要求正确的故障域、存储、选主等条件。

本书单节点只有一个副本，不能靠把 `acks` 调成 `all` 得到三台机器的保护。也不能在只有一个副本的主题上设 `min.insync.replicas=2` 后期待照常写入。

## 生产者幂等

Kafka 4.1 Java 生产者在没有冲突配置时默认启用幂等。显式 `enable.idempotence=true` 时，相关条件包括 `acks=all`、`retries>0`、`max.in.flight.requests.per.connection<=5`。不要照搬“必须把 in-flight 设成 1”的旧教程。

生产者幂等主要避免协议重试造成的日志重复。它不识别业务上两次分别调用 `send` 的同一订单事件，也不自动防止消费者重复发短信。

建议应用显式检查发送完成结果，对异步发送处理回调或 Future；只把记录放进客户端缓冲区就立刻退出程序，不能证明记录已成功送达。

## 三种处理语义

| 语义 | 典型失败结果 | 适用思考 |
|---|---|---|
| 至多一次 | 可能漏处理，通常不重试 | 能接受丢失的场景才考虑 |
| 至少一次 | 尽力避免漏处理，但可能重复 | 配合业务幂等很常见 |
| 恰好一次 | 在明确事务边界内只产生一次有效结果 | 必须讲清系统和故障假设 |

“恰好一次”不是万能开关。Kafka 读入、处理、写回 Kafka 的链路可以把输出与消费进度放进同一个 Kafka 事务；下游需要适当使用 `read_committed`。Kafka Streams 也提供相应的处理保证。普通数据库更新或外部支付接口不自动包含在这个事务里。

**本章自测**：设置生产者幂等后，短信还能重复吗？能。短信副作用与消费进度之间仍需业务设计。


# 第 10 章 把重复变成可承受：幂等与事务发件箱

本章提供数据库幂等与 Outbox 的设计推导，没有附带数据库、CDC 转发器或真实支付调用。第 11 章的 CLI 项目验证日志和消费进度，不是本章事务设计的端到端实现。Kafka 事务的原理说明也不代表本轮做过跨系统事务实验。

## 用事件 ID 识别同一次事件

幂等的意思是：同一个业务操作执行多次，最终有效结果与执行一次相同。例如对 `event_id=e-001` 的积分增加只能生效一次。

常见做法是在数据库中建立处理记录表，以 `(consumer_name, event_id)` 为唯一键。消费者在同一个数据库事务中插入去重记录并更新业务表。唯一键冲突说明这一业务消费者已处理过该事件，可以跳过业务更新。

```text
读取事件
  ↓
开始数据库事务
  ├─ 插入处理凭证（唯一约束）
  └─ 执行业务更新
提交数据库事务
  ↓
提交 Kafka 消费进度
```

若数据库事务成功、Kafka 进度未提交，重启后会再次读到；数据库唯一约束让第二次更新失效。若业务事务失败，不能提交进度假装成功。

## 去重表不是随便查一下

“先查没有，再插入”会有并发竞争，必须由数据库唯一约束和事务保证正确性。去重记录保留期还要覆盖可能重放的历史，否则删除去重凭证后，老事件可能再次生效。

外部发短信或支付不能和普通本地数据库事务自然原子化。优先使用对方提供的幂等请求号，或把待执行任务写入本地发件箱，再由独立执行器按幂等规则调用并核对结果。

## 数据库写成功，消息没发出去怎么办

订单服务面临另一个问题：先写订单库，再发 Kafka，两步之间可能宕机；先发 Kafka，再写库，也可能产生没有真实订单的事件。

事务发件箱（Transactional Outbox）的思路是，在同一个数据库事务里写订单表与 outbox 表。后台转发器或 CDC 工具再把 outbox 的事件送进 Kafka。转发也可能重复，因此消费者仍应幂等。

这提供的是可恢复的协调方式，需要监控未转发事件、重试、顺序和清理，不等于“装一个插件就自动解决所有一致性问题”。

## 处理失败与坏消息

短暂网络失败可以有限重试并退避；字段格式错误反复重试通常不会自己变好。达到边界后，可以送入死信主题，记录原主题、分区、偏移量、事件 ID、错误原因和失败时间。

死信需要告警、责任人、修复和重放流程。若把同一订单的失败事件移走后继续处理后续事件，业务顺序可能改变。把它作为明确的业务决策，而不是默认“跳过就好”。


# 第 11 章 一份可以交付的小项目

## 项目目标与事件契约

现在你要完成一个“订单事件观察站”：订单生产者写入 `orders`；通知组和统计组独立读取；能观察分区和组进度；可以演示停止、恢复和回放。它不需要真的发送短信或扣款，终端输出代表下游收到事件。

事件的约定叫契约。项目采用以下字段：

| 字段 | 含义 | 示例 |
|---|---|---|
| event_id | 一次事件的唯一标识 | e-101 |
| order_id | 业务订单号，同时作为 Key | A2001 |
| type | 已发生的事实类型 | OrderCreated |
| amount_fen | 金额，整数分 | 5800 |
| schema_version | 数据格式版本 | 1 |

真实项目还应加入明确时区的事件发生时间、追踪标识等。不要随意把身份证号、银行卡号或密钥放进事件；历史回放会放大敏感数据传播范围。

## 运行项目

保持第 3 章 Broker 运行，并已按第 5 章创建 `orders`。启动两个新组：

```bash
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 \
  --topic orders --from-beginning --group project-notify \
  --property print.key=true --property print.partition=true
```

```bash
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 \
  --topic orders --from-beginning --group project-stats \
  --property print.key=true --property print.partition=true
```

生产者命令与第 5 章相同，依次输入：

```text
A2001|{"event_id":"e-101","order_id":"A2001","type":"OrderCreated","amount_fen":5800,"schema_version":1}
A2002|{"event_id":"e-102","order_id":"A2002","type":"OrderCreated","amount_fen":3900,"schema_version":1}
A2001|{"event_id":"e-103","order_id":"A2001","type":"OrderPaid","amount_fen":5800,"schema_version":1}
```

两个终端都应该包含这三个事件 ID。同一订单的两个事件应落入同一分区。消费者还可能显示前几章的历史数据，这是预期现象，按事件 ID 判断本次结果。

## 做三次故障练习

第一，暂停通知消费者，统计消费者保持运行，再发送一条新订单。统计仍能读到；通知组恢复后，应能读取暂停期间仍然保留的事件。由于停机前的提交时机不同，恢复时也可能包含重复。

第二，为通知组启动第二个实例，使用 `kafka-consumer-groups.sh --describe` 观察分区分配。停止其中一个后，再观察接管。

第三，停止两个通知实例，按第 7 章的方法对 `project-notify` 做回放。统计组不需要随之回放，因为组进度彼此独立。

## 项目验收表

- 能在主题描述中找到三个分区。
- 两个不同组均能看到 `e-101`、`e-102`、`e-103`。
- 同一个订单的创建与支付事件有相同分区编号。
- 停止一个业务组不阻止另一个业务组继续读取。
- 能解释重放为什么可能重复触发副作用。

这些命令验证了消息流动与组行为，没有实现真正的短信幂等和订单数据库事务。进阶时可选择一种熟悉的语言，把第 10 章的数据库去重流程做成小程序，再设计“业务成功后立即杀进程”的故障测试。


# 第 12 章 生产者与消费者怎样写得稳

## 生产者的五个关注点

第一，序列化：Kafka 保存字节，字符串和 JSON 需要编码；双方要约定字符集和字段格式。第二，Key 选择：它决定分区关联和热点风险。第三，可靠性：明确确认方式、幂等、超时与错误处理。第四，吞吐：批量与压缩能提高效率，但要评估延迟和 CPU。第五，关闭：正常退出时等待已发送任务完成，并处理失败。

`batch.size` 控制批次大小上限，`linger.ms` 影响等待聚合的时间，`compression.type` 选择压缩方式。它们需要用真实消息大小和流量验证，不是值越大越好。Kafka 4.0 起 Java 生产者的默认 `linger.ms` 调整为 5 毫秒，旧文章里“默认是 0”的结论不能直接套用到本书版本。

## 消费者的五个关注点

第一，配置正确的 `group.id`。第二，明确起点，`auto.offset.reset=earliest` 仅在没有有效进度时生效，不能覆盖已有书签。第三，决定提交时机。第四，控制批次处理时间，避免长时间不调用 `poll` 而触发组管理问题。第五，处理退出、再均衡和重试。

手动提交不是“关掉自动提交就万事大吉”。如果先把一批记录扔给线程池，马上提交整批进度，任务还没完成就宕机，仍会漏掉业务处理。

## 伪代码：至少一次处理

下面只是单线程、同步处理的思路，不是可以直接运行的语言代码：

```text
关闭自动提交
循环：
    records = poll()
    按分区顺序处理本批记录
    每条记录：
        通过数据库事务与事件 ID 做幂等业务处理
        如果失败：进入既定重试策略，不跨过未完成记录提交
    本批各分区全部成功后：
        提交各分区“最后成功记录 offset + 1”
```

若批次中途失败，已成功但未提交的记录会重来，需要幂等。更细粒度提交可以降低重复范围，但会增加复杂度和开销。先把正确性做对，再优化。

## 数据格式演进

第一版有 `order_id`，第二版新增可选字段 `coupon_id`，通常比直接删除或改名旧字段容易兼容。消费者应明确必需字段、默认值、未知字段处理和错误策略。金额不能今天用元、明天无提示改成分。

Avro、Protobuf、JSON Schema 和 Schema Registry 是常见进阶工具；Schema Registry 并不是 Kafka Broker 自动附带的功能，兼容策略也需要团队约定。


# 第 13 章 运维入门：先看什么，再改什么

## 四类基本信号

服务可用性：Broker、Controller、客户端是否健康，是否有频繁断连。数据安全：副本是否同步、是否存在不可用分区、低于最小同步副本的情况。资源：磁盘剩余与增长、网络、CPU、内存和 GC。业务进度：Lag、端到端延迟、错误率和死信数量。

“CPU 不高”并不表示系统没有瓶颈。一个热点分区、外部数据库慢查询或磁盘写入限制，都可能拖慢处理。

## Lag 一直上涨的排查顺序

先确认输入速率是否增加，再看消费速率是否下降；观察是否只有某个分区落后；检查错误重试、下游数据库、第三方 API 和再均衡；最后才判断是否需要增加消费者、分区或 Broker。

如果只有某个大客户占了绝大部分流量，且 Key 是客户号，新增消费者也可能无法分担那个热点分区。修改 Key 又可能影响顺序，所以需要在顺序要求与负载分摊之间设计。

## 常见故障对照

| 现象 | 优先检查 | 可以采取的下一步 |
|---|---|---|
| 连接 localhost:9092 失败 | Broker 是否启动、端口、终端环境 | 看 Broker 日志，确认地址是否同一台机器 |
| bootstrap 能连，后续连接失败 | advertised.listeners 是否可被客户端访问 | 修正通告地址，特别注意容器与远程访问 |
| 提示 topic 已存在 | 是否重复执行创建 | 先 describe，确认后继续 |
| 消费没有输出 | 主题名、组进度、是否有新数据 | 换全新练习组从头读取 |
| 持续重复处理 | 提交是否成功、业务是否异常 | 检查处理与提交顺序、幂等凭证 |
| NOT_ENOUGH_REPLICAS | ISR 与 min.insync.replicas | 修复副本健康，别先降低可靠性目标 |
| 磁盘快速增长 | 流量、保留、失衡与副本恢复 | 测算增长，规划容量和策略 |

## 生产环境边界

本书的 `PLAINTEXT` 本机单节点没有加密认证，也没有机器故障容错。生产部署需要按业务要求规划多节点与故障域、KRaft 仲裁、安全访问、容量、监控告警、升级和恢复演练。

TLS 保护传输，SASL 或双向 TLS 等机制提供身份验证，ACL 限制谁能读写哪些资源。只开放端口并不等于权限管理。安全配置应以对应版本官方文档为准，凭证不要写进书籍示例或提交到代码仓库。


# 第 14 章 Kafka Connect、Streams 与下一步

## Connect：搬运数据

Kafka Connect 是 Kafka 生态中的数据集成框架。Source Connector 把外部系统的数据送进 Kafka，Sink Connector 把 Kafka 数据送到外部系统。例如数据库变更进入主题，主题数据写入分析仓库。

框架与连接器不是同一件事。不同连接器的安装、许可证、容错和投递语义不同。CDC 表示捕获数据变化，常用于数据库变更流；它仍要处理快照、表结构变化、权限和运维。

## Streams：处理事件

Kafka Streams 是用于构建流处理应用的 Java/Scala 客户端库，不是必须额外搭建的独立 Kafka Broker。它可以做筛选、聚合、窗口计算和关联，应用本身仍需要部署、监控和扩容。

举例：统计每五分钟的订单金额，需要明确事件时间还是处理时间、窗口边界、迟到事件和重复事件如何处理。“五分钟统计”并不只是对数组求和那么简单。

## 四周学习安排

第一周完成第 1 至 5 章，每个概念配一个自己的例子。第二周完成组、进度和保留练习，记录重启和回放的结果。第三周精读可靠性与幂等，画出“业务成功、进度未提交”的故障时间线。第四周完成项目，选择熟悉的语言实现一个带数据库去重的消费者。

之后再研究多节点 KRaft、客户端编程、Connect、Streams、安全和容量。不要同时追逐所有参数；用一个真实问题推动下一次学习。


# 附录 A 常用命令速查

以下命令均在 Kafka 解压目录执行。请先启动本书 Broker。

查看主题列表：

```bash
bin/kafka-topics.sh --bootstrap-server localhost:9092 --list
```

查看订单主题：

```bash
bin/kafka-topics.sh --bootstrap-server localhost:9092 \
  --describe --topic orders
```

查看消费者组列表和进度：

```bash
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --list
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --describe --group project-notify
```

查看主题级覆盖配置：

```bash
bin/kafka-configs.sh --bootstrap-server localhost:9092 \
  --entity-type topics --entity-name orders --describe
```

结束练习时，先在消费者和生产者终端按 `Ctrl+C`，再停止 Broker。需要再次学习时直接启动 `config/book.properties`，原数据和组进度仍可继续使用。

如果完全不再需要环境，确认所有进程已停，再通过文件管理器删除专门解压的练习目录。本文不提供自动递归清理命令，以免初学者在错误目录执行。


# 附录 B 词汇表

| 英文 | 中文 | 先记住这个意思 |
|---|---|---|
| Event / Record | 事件 / 记录 | 一条被写入的数据 |
| Producer | 生产者 | 把记录写进 Kafka 的应用 |
| Consumer | 消费者 | 从 Kafka 读取记录的应用 |
| Topic | 主题 | 一类记录的逻辑集合 |
| Partition | 分区 | 主题中的一条有序日志 |
| Offset | 偏移量 | 记录在分区里的位置 |
| Broker | 服务节点 | 运行 Kafka 数据服务的进程 |
| Consumer Group | 消费者组 | 协作读取并维护组进度的成员集合 |
| Replica | 副本 | 分区的一份数据复制 |
| Leader / Follower | 主副本 / 跟随副本 | 接受写入的副本及复制它的副本 |
| ISR | 同步副本集合 | 与 Leader 保持足够同步的副本 |
| Rebalance | 再均衡 | 调整组内的分区归属 |
| Lag | 消费落后量 | 消费进度与日志末端的距离指标 |
| Retention | 保留策略 | 决定旧数据何时被清理 |
| Compaction | 日志压缩清理 | 按 Key 在后台清理旧值 |
| Idempotence | 幂等 | 重复执行不重复产生业务效果 |
| KRaft | Kafka 元数据共识机制 | 通过 Controller 仲裁管理集群元数据 |
| Outbox | 事务发件箱 | 与业务事务一起写入的待发事件表 |


# 附录 C 综合练习与参考答案

**1. 四个分区，同一组有六个消费者，会有多少消费者同时负责分区？** 最多四个，具体取决于订阅和分配；其余成员可以空闲。

**2. 同一主题有三个业务组，是否要把主题复制三份？** 不需要。各组读取同一份保留数据，维护各自进度；读取压力仍需要容量规划。

**3. 已处理 offset 9，应该提交多少？** 若此前该分区的记录均已成功处理，通常提交 10，表示下一条的位置。

**4. 重启旧组时加 `--from-beginning`，为什么没从头读取？** 组有有效已提交进度，该参数不会覆盖它。可用新练习组验证历史，或停止原组后有计划地重置。

**5. acks=all 是否意味着至少有三个副本？** 不是。它等待当前 ISR，副本数量由主题等配置决定，还需要 min.insync.replicas 等配合。

**6. 两条同一 Key 的消息是否一定按业务发生时间处理？** 不能无条件保证。还要考虑上游并发、分区映射变化、消费者并发以及业务版本。

**7. 业务数据库更新成功，提交进度前宕机，怎么处理？** 允许再次读到，通过数据库事务中的去重凭证避免重复更新，再提交进度。

**8. 保留七天，消费者停了十天，还能从原位置读完吗？** 不能保证，旧数据可能已经清理。需要定义恢复点、备份或重新构建方案。

**9. Kafka 事务能自动保证支付接口只扣一次吗？** 不能。支付系统需要幂等键、结果查询或相应的业务协调机制。

**10. Lag 增长就一定应该增加分区吗？** 不一定。先排查输入增长、下游变慢、热点 Key、异常重试和再均衡，扩分区还会影响映射与顺序。


# 附录 D 资料来源与继续阅读

本书根据下列官方资料整理、解释并原创编写，不是官方文档逐字翻译。首次访问日期：2026-10-20，发布前于 2026-10-21 复核上述九个官方来源。版本路径固定在 4.1，页面内部可能更新补丁版本。阅读时请留意版本切换器，不要把不同大版本的默认值混在一起。

1. [Apache Kafka 4.1 Quick Start](https://kafka.apache.org/41/getting-started/quickstart/)：压缩包、Java 要求、KRaft 初始化、启动及命令行消息练习。对应第 3 至 4 章。
2. [Introduction](https://kafka.apache.org/41/getting-started/introduction/)：事件、主题、分区、生产者与消费者。对应第 1 至 2 章。
3. [Design](https://kafka.apache.org/41/design/design/)：日志、复制、投递语义、幂等、事务与消费位置。对应第 5、7、9 至 10 章。
4. [Producer Configs](https://kafka.apache.org/41/configuration/producer-configs/)：acks、enable.idempotence、in-flight、批量和 linger.ms。对应第 9、12 章。
5. [Consumer Configs](https://kafka.apache.org/41/configuration/consumer-configs/)：group.id、auto.offset.reset、自动提交、poll 与组协议相关配置。对应第 6、7、12 章。
6. [Basic Kafka Operations](https://kafka.apache.org/41/operations/basic-kafka-operations/)：主题、配置、分区与管理操作。对应第 6 至 8、13 章和命令速查。
7. [Security Overview](https://kafka.apache.org/41/security/security-overview/)：传输加密、认证与授权入口。对应第 13 章。
8. [Kafka Streams](https://kafka.apache.org/41/streams/)：流处理库及后续教程入口。对应第 9、14 章。
9. [Kafka Connect Configs](https://kafka.apache.org/41/configuration/kafka-connect-configs/)：Connect Worker 与连接器相关配置入口。对应第 14 章。

如果你只想再读一页官方材料，先读 Quick Start；如果想弄明白为什么会重复，读 Design 中的消息投递语义部分；如果要调参数，直接查对应版本的配置页，并通过自己的压测验证效果。
