《Java 消息中间件·事件驱动架构》前言:实验环境、Kafka Streams 与案例口径

把这一卷的实验底座写清楚:机器与工具链、Kafka Streams 的最小可运行验证、三个必须自己补的依赖,以及 libraryd-mq 的工程结构与架构图的画法约定。最后说明本卷的验证规矩——什么样的内容算实测,什么情况下必须标注未在本机执行。

为什么先把环境与口径写清楚

这一卷和卷二有一个区别:卷二的结论大多可以复现,卷三的结论大多需要判断。

「停一台 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-streams4.3.02.2 MB拓扑、状态存储、窗口
kafka-clients4.3.09.7 MB生产者/消费者、序列化
rocksdbjni10.1.369.4 MB持久化状态存储的原生实现
jackson-databind2.21.21.6 MBJSON 序列化
jackson-core2.21.2582 KBdatabind 的底层
jackson-annotations2.2180 KBdatabind 的注解
slf4j-api1.7.3640 KB日志门面
slf4j-simple1.7.3615 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 的结果对不对。

这个差异带来两个实际操作上的注意点:

  1. 时序类数字在卷三更少,但不是没有。 投影延迟、状态恢复时间、窗口触发时间都是时序量,同样要标「单次运行,不作基准」。
  2. 结果类输出必须可复现。 投影表的行数、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。
  • 架构图统一用文本围栏的上下文图、事件流图、时序图,符号固定。
  • 本卷沿用前两卷的验证规矩:实测、推断、未执行三档,每节至少一种可验证物。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收