为什么先把环境与口径写清楚
这一卷和卷二有一个区别:卷二的结论大多可以复现,卷三的结论大多需要判断。
「停一台 broker 后 ISR 几秒收缩」是一个可以复现的数字;「什么场景该用事件」是一个需要判断的结论。判断类结论的可信度,取决于两件事:它建立在什么事实上,以及它的边界在哪里。
所以这一节先把底座交代清楚:跑在什么机器上、用什么版本、哪些依赖必须自己补、示例工程长什么样、架构图怎么画。读者照着搭一遍,后面每一章的输出都应该能对上。
机器与工具链
实验全部在一台开发机上完成,宿主是 macOS,容器跑在 colima 里。
$ colima status
colima is running
$ docker version --format '{{.Server.Version}}'
29.5.2
$ docker info --format '{{.NCPU}} vCPU / {{.MemTotal}} bytes'
4 vCPU / 8307167232 bytes
8307167232 字节约等于 7.7 GiB。这个数字比标称的 8 GiB 小,写容量估算时要按实际值算,不要按购买规格算。
宿主机 JDK:
$ ~/.jdks/jdk-25.0.5+8/Contents/Home/bin/java -version
openjdk version "25.0.5" 2026-04-21
OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8)
OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (build 25.0.5+8, mixed mode, shared)
容器里的运行环境不一样,这一点在第 7–9 章尤其要注意:
$ docker run --rm apache/kafka:4.3.0 java -version
openjdk version "21.0.11" 2026-04-21
OpenJDK Runtime Environment (build 21.0.11+10)
OpenJDK 64-Bit Server VM (build 21.0.11+10, mixed mode)
宿主机编出的 class 文件是 69.0(JDK 25),容器里的 JRE 21 最高只能读 65.0。把编译产物拷进容器执行时,会看到:
java.lang.UnsupportedClassVersionError: TopologyProbe has been compiled by a more
recent version of the Java Runtime (class file version 69.0), this version of the
Java Runtime only recognizes class file versions up to 65.0
解决办法是编译时显式指定目标版本:
javac --release 21 -cp "$STREAMS_JAR:$CLIENTS_JAR" -d out src/TopologyProbe.java
这条在卷二第 14.2 节已经踩过一次,卷三只要有「宿主机编译、容器内执行」的环节,都要记得加 --release 21。
Kafka Streams 的最小验证
在信任任何 Streams 章节之前,先确认三件事:依赖能加载、拓扑能构建、描述能打印。不需要 broker。
import org.apache.kafka.streams.StreamsBuilder;
import org.apache.kafka.streams.Topology;
import org.apache.kafka.streams.kstream.KStream;
public class TopologyProbe {
public static void main(String[] args) {
StreamsBuilder builder = new StreamsBuilder();
KStream<String, String> loans = builder.stream("libraryd.loan.events");
loans.filter((k, v) -> v != null && v.contains("OVERDUE"))
.to("libraryd.overdue.events");
Topology topology = builder.build();
System.out.println(topology.describe());
}
}
编译运行(classpath 的组成在下一节解释):
L=~/.cache/mq-arch/libs
javac --release 21 -cp "$L/kafka-streams-4.3.0.jar:$L/kafka-clients-4.3.0.jar" \
-d out src/TopologyProbe.java
java -cp "$L/*:$HOME/.cache/mq-arch/out" TopologyProbe
本机实测输出:
Topologies:
Sub-topology: 0
Source: KSTREAM-SOURCE-0000000000 (topics: [libraryd.loan.events])
--> KSTREAM-FILTER-0000000001
Processor: KSTREAM-FILTER-0000000001 (stores: [])
--> KSTREAM-SINK-0000000002
<-- KSTREAM-SOURCE-0000000000
Sink: KSTREAM-SINK-0000000002 (topic: libraryd.overdue.events)
<-- KSTREAM-FILTER-0000000001
三行结构对应三个节点:源、处理器、汇。编号 0000000000 是拓扑构建时分配的节点 ID,从零开始递增。
这张输出有两个用途。第一,它证明依赖链是完整的——少一个包,Topology.describe() 会在类加载或方法调用时直接抛异常。第二,它是理解 Streams 的最短路径:拓扑不是代码结构,是一张处理器图。你在代码里写的 filter(...).to(...) 会被展开成三个节点和两条边,运行时按边传递记录。
第 7 章会把这张图展开讲,包括什么时候会分裂出第二个 sub-topology。
版本核实:
$ java -cp "$L/*:out" VersionProbe
kafka-clients: 4.3.0
streams default commit interval ms: 30000
第二行是 StreamsConfig.COMMIT_INTERVAL_MS_CONFIG 的默认值,第 8 章讲聚合时会用到这个数。
三个必须自己补的依赖
kafka-streams 的 POM 声明了四个 compile/runtime 依赖,但 Maven 之外的构建方式(手工 classpath、Gradle 之外的脚本)需要自己下载。这里有三个坑。
坑一:kafka-clients 不含 slf4j
很多资料说「kafka-clients 内置了 slf4j-api」,本机核实不是这样:
$ unzip -l kafka-clients-4.3.0.jar | grep -c 'org/slf4j'
0
零个条目。不加 slf4j-api,编译时能过(你只用到 Kafka 自己的类),运行时一旦触发日志就会抛 NoClassDefFoundError: org/slf4j/LoggerFactory。加 slf4j-api 之后如果没有任何绑定实现,会打印:
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
不是错误,但你会失去全部日志,排查问题时很难受。本卷统一用 slf4j-simple:1.7.36,它小、无依赖、输出到 stderr。
坑二:Streams 的有状态处理需要 rocksdb 原生库
kafka-streams 的 POM 里 rocksdbjni 是 compile 依赖,版本 10.1.3,jar 有 69.4 MB——因为里面打包了多个平台的原生库:
librocksdbjni-linux64.so
librocksdbjni-linux-aarch64.so
librocksdbjni-osx-arm64.jnilib
librocksdbjni-osx-x86_64.jnilib
Apple Silicon 上的 colima 是 aarch64,对应 linux-aarch64.so;宿主 macOS 直接跑对应 osx-arm64.jnilib。两边都有,这是好消息:同一份 jar 可以在宿主机调试、在容器里运行。
没有这个包,有状态拓扑会在启动时失败,错误信息通常指向 RocksDB 类的初始化。如果确实不需要持久化状态,也可以用内存存储(第 7.2 节会对比两种存储的取舍),但依赖仍然要在 classpath 上,因为类加载是静态的。
坑三:JSON 序列化的三个 jackson 包
jackson-databind 依赖 jackson-core 和 jackson-annotations,三个包都要在 classpath 上。只加 databind 会在运行时抛 NoClassDefFoundError: com/fasterxml/jackson/core/JsonFactory。
完整的依赖表:
| 包 | 版本 | 大小 | 作用 |
|---|---|---|---|
kafka-streams | 4.3.0 | 2.2 MB | 拓扑、状态存储、窗口 |
kafka-clients | 4.3.0 | 9.7 MB | 生产者/消费者、序列化 |
rocksdbjni | 10.1.3 | 69.4 MB | 持久化状态存储的原生实现 |
jackson-databind | 2.21.2 | 1.6 MB | JSON 序列化 |
jackson-core | 2.21.2 | 582 KB | databind 的底层 |
jackson-annotations | 2.21 | 80 KB | databind 的注解 |
slf4j-api | 1.7.36 | 40 KB | 日志门面 |
slf4j-simple | 1.7.36 | 15 KB | 日志实现 |
体积的大头是 rocksdb。如果只做无状态拓扑,理论上可以裁掉它,但本卷的实验统一保留完整依赖,避免「本地能跑、换台机器不行」。
libraryd-mq 的工程结构
全书共用一个示例工程 libraryd-mq。它不是完整可部署的系统,而是每个章节可以直接摘出来跑的最小模块集合。
libraryd-mq/
├── domain/ 聚合与事件的 record 定义
│ ├── Loan.java
│ ├── Book.java
│ └── events/ LoanCreated / BookReturned / OverdueDetected ...
├── producer/ 出库发布器与 outbox 轮询
├── consumers/ 投影、通知、对账三类消费者
├── streams/ Streams 拓扑与状态存储
└── ops/ 建主题脚本、演练脚本、核对清单
四类模块的职责:
| 模块 | 职责 | 对应章节 |
|---|---|---|
domain | 聚合与事件定义,不含中间件代码 | 第 2、5 章 |
producer | 事件怎么产生、怎么保证发出去 | 第 4、6 章 |
consumers | 读模型、通知、对账 | 第 3、13 章 |
streams | 流处理拓扑 | 第 7–9 章 |
ops | 主题与演练 | 第 10–12、15 章 |
domain 刻意不依赖 Kafka。事件是业务概念,序列化是基础设施的事——这条边界如果破了,后面讲契约演进时就没法把「业务变更」和「格式变更」分开。
本卷出现的所有代码片段都来自这个结构,片段里省略的 import 与样板代码在正文中会说明。不是每段代码都能单独编译——有些是片段,正文会标注;能完整运行的会在对应章节给出完整文件和运行输出。
架构图的画法约定
卷三会出现大量图。为了不产生歧义,统一用三种图,符号固定:
上下文图:一个系统与外部的关系。方框是系统边界,箭头是数据流向,箭头上的文字是「什么事件」。
[借阅服务] --LoanCreated--> [事件总线] --LoanCreated--> [库存服务]
事件流图:一条事件从产生到消费经过哪些主题与消费者。实线箭头是事件流,虚线箭头是查询或调用。
[出库] ==LoanCreated==> (libraryd.loan.events)
|
+==> [库存投影]
+==> [通知服务]
+==> [对账任务]
时序图:跨服务的一次交互,按时间展开。竖线是参与者,横向箭头是消息。
用户 -> 借阅服务: 借书请求
借阅服务 -> DB: 写借阅记录 + outbox
借阅服务 --> 用户: 已受理
outbox -> Kafka: LoanCreated
Kafka -> 库存服务: LoanCreated
三种图都用 text 围栏,不用图片。原因是文本图可以 diff、可以复制、可以在终端里读——这比好看重要。
本卷的验证规矩
延续前两卷的标准,这一卷同样区分三种内容:
实测。 有命令、有输出、可以在同版本环境复现。标注格式固定:
本机实测(Temurin 25.0.5+8 / kafka-streams 4.3.0 / 2026-10-29):……
推断。 基于实测结果或源码的推理,没有直接观测。写法是「据此推断」「预期是」,不写成结论。
未执行。 本机做不出来的实验,写明缺什么条件、命令是什么、预期输出长什么样。绝不编造数字。
这一卷的架构类章节较多,所以还多一条规矩:每节至少要有一种可验证物——可运行的代码、可重放的拓扑、可对照的表、可执行的清单。如果某节确实只有观点,开头必须写「本节是决策框架,不含实验」,并且篇幅受限。
原因很简单:架构讨论最容易变成形容词的堆砌。一张对照表至少可以被质疑和修改,一句「事件驱动能提升系统弹性」不能。
与卷二实验环境的差异
卷二的实验集中在「故障与恢复」,大量使用 docker stop / kill -9 / SIGSTOP,关注的是时间点。
卷三的实验集中在「结构与数据」,使用单节点或三节点集群,关注的是输出内容:投影表长什么样、拓扑怎么分裂、join 的结果对不对。
这个差异带来两个实际操作上的注意点:
- 时序类数字在卷三更少,但不是没有。 投影延迟、状态恢复时间、窗口触发时间都是时序量,同样要标「单次运行,不作基准」。
- 结果类输出必须可复现。 投影表的行数、join 的匹配条数、拓扑的节点结构,这些是确定性的,必须精确对上。写「大约」说明实验没做好。
小结
- 实验环境:colima 4 vCPU / 7.7 GiB,Docker 29.5.2,宿主机 Temurin 25.0.5+8,容器内 Temurin 21.0.11。
- 跨环境执行必须
javac --release 21,否则UnsupportedClassVersionError。 kafka-clients:4.3.0不含 slf4j,必须自己加slf4j-api与一个绑定。kafka-streams:4.3.0依赖rocksdbjni:10.1.3(69.4 MB,含 linux/macOS 多平台原生库)。- 拓扑探测的输出证明依赖完整,也是理解 Streams 的入口:代码写的是链式调用,运行时是三节点两边的处理器图。
libraryd-mq分domain/producer/consumers/streams/ops五块,domain不依赖 Kafka。- 架构图统一用文本围栏的上下文图、事件流图、时序图,符号固定。
- 本卷沿用前两卷的验证规矩:实测、推断、未执行三档,每节至少一种可验证物。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。