一条消息的三种「以为」
用 Kafka 的人大多写过这样的代码:
producer.send(new ProducerRecord<>("library.loan.created", bookId, payload));
这行代码返回一个 Future,大多数人从不看它。背后的假设是:发送成功就意味着消息已经到了。
也有人这样推理:同一个 topic 里消息是有序的,所以我按顺序发,消费者就按顺序收。背后的假设是:topic 有序等于全局有序。
还有人这样配置:acks=all、retries=Integer.MAX_VALUE、enable.idempotence=true,然后认为丢消息这件事已经解决了。背后的假设是:一组正确的配置等于一条正确的链路。
这三个假设都是错的,而且错得很具体——不是「理论上有边界」那种错,是「你把这三段代码跑一遍就能看见」那种错。这本书的全部工作,就是把这些「以为」换成「我见过」。
这本书不打算写成一册配置速查,也不打算把官方文档翻译一遍。它想做的只有一件事:把每一个看起来理所当然的结论,拆成可以被命令验证的步骤。所以你在每一节里看到的,会是命令、输出、判读,而不是「众所周知」。
误判一:发送成功不等于消息可用
send() 返回的 Future 完成,只说明这条记录被放进了客户端的发送缓冲区,并且 broker 对它返回了一个响应。响应里只有分区号和位移,没有「这条消息现在能被消费者读到」这个信息。
中间隔着的量叫高水位(high watermark,HW)。leader 收到消息、写进自己的日志,这只是第一步;它还要等 ISR 里的副本都追上这个位置,HW 才会推进。消费者只能读到 HW 之前的消息。所以存在一个窗口:生产者已经拿到成功响应,消费者还看不到这条消息。
这个窗口在任何一份「Kafka 保证」的宣传材料里都不会写,但它天天在发生。本书第 4.1 节会把这个窗口的两端都测出来:一端是 send().get() 返回的 offset,另一端是消费者能读到的位置。
更极端的情况是响应本身就不代表持久化。acks=1 时 leader 写完就响应,此时 leader 所在的机器掉电,消息就没了,而生产者那边显示成功。第 3.2 节会用三节点集群把这件事做出来:同一个发送循环,acks=1 和 acks=all 在杀掉 leader 之后的表现完全不同。
还有一层更隐蔽:构造生产者本身就可能阻塞。很多代码在应用启动时 new KafkaProducer<>(props),以为这是个纯内存操作。实测下来,max.block.ms 设为 1500 毫秒、broker 不可达时,构造阶段就会抛异常:
producer failed (expected, no broker): TimeoutException
因为构造过程要拉取集群元数据。这个细节在第 7.1 节会展开,但它已经足以说明一件事:Kafka 客户端里没有「纯内存、不会失败」的 API。
误判二:分区有序不等于 topic 有序
Kafka 只保证单个分区内的消息顺序。一个 topic 有多个分区,跨分区没有任何顺序承诺。
这不是理论。本书在准备阶段跑的第一个实验就是证据。用 3 个分区、5 条消息、key=book-0 到 key=book-4,发送顺序是 0、1、2、3、4,返回的落点是:
sent p=1 off=0
sent p=2 off=0
sent p=2 off=1
sent p=0 off=0
sent p=1 off=1
按分区聚集后,消费者看到的全局顺序是 book-3, book-0, book-4, book-1, book-2。如果业务依赖「先创建借阅、再归还」这样的先后关系,而这两条消息用了不同的 key,它们就会落到不同分区,顺序随时可能颠倒。
这里有个容易忽略的推论:默认分区器在 key 为 null 时会粘性分批。也就是说,即使你不指定 key,消息也不是严格轮询到每个分区的,而是一批一批地走。很多「为什么我的分区负载不均」的问题,根源都在这里。第 3.3 节会把默认分区器的行为、自定义分区器的写法、以及热点 key 造成的倾斜一起做完。
想清楚这件事,需要先想清楚另一个问题:分区的数量到底在换什么。第 1.2 节会把这笔账算清楚——分区数买来的是并行度,付出的是顺序范围收窄、内存占用上升、故障恢复变慢。这三个代价都是可测量的,不是玄学。
误判三:正确的配置不等于正确的链路
acks=all 加上 min.insync.replicas=2,确实是 Kafka 给出的最强写入保证。但它有一个前提:ISR 里至少还有 2 个副本。
本机三节点集群上做的实验是这样的:topic 是 3 副本,min.insync.replicas=2,partition 0 的初始 leader 是 broker 2。docker stop k2 之后,leader 切到 broker 3,ISR 变成 {3,1},仍然满足 2 个副本,写入继续成功。再 docker stop k3,ISR 只剩 {1},此时再发消息:
Error
NOT_ENOUGH_REPLICAS
生产者被明确拒绝。这个行为是正确的——宁可拒绝,也不接受一条无法保证持久化的消息。但如果应用层把这个异常吞掉、或者无限重试直到超时,故障就从「Kafka 拒绝了写入」变成了「业务静默丢了一整批借阅记录」。
所以可靠性从来不是一组配置,而是配置 + 异常处理 + 监控 + 演练四件事的组合。卷二整卷都在处理这个组合。
为什么以 Kafka 为主线
市面上可选的 Java 消息中间件不少,本书为什么只精讲 Kafka?
| 维度 | Kafka 4.3.0 | 其它常见选择 |
|---|---|---|
| 协议与实现 | 服务端 Scala/Java,客户端纯 Java,源码可读 | RocketMQ 全 Java,但生态与文档多偏国内社区 |
| 部署形态 | KRaft 单进程,容器化简单,无外部依赖 | 部分产品仍需独立协调组件 |
| 本书能做的实验 | 单节点、三节点、故障转移、事务、流处理,全部本机可跑 | 取决于镜像与资源,可控性参差 |
| 概念通用性 | 分区/副本/位移/消费组这套词汇被广泛借用 | 换了产品这些词仍然成立 |
更实际的原因是:Kafka 的机制足够「不神奇」。它没有隐藏的数据库,没有魔法缓存,主要靠顺序写、页缓存、副本复制和一套客户端状态机。这让「读源码能解释现象」这件事成为可能。而本书的写作原则正是:凡是能读到源码的地方,就不靠类比解释。
卷二和卷三会提到其它 MQ 的设计取舍,但只做概念对照和配置差异表,不混写客户端 API。这样做是为了让读者在自己的技术栈上迁移时,知道哪些是 Kafka 特有的、哪些是消息系统共有的。
这本书怎么处理「我听说」
技术书里最危险的一类句子是「众所周知,Kafka 的零拷贝让它很快」。它听起来对,但你没法验证,也没法判断它对你的场景成不成立。
本书的做法是给每个结论配三样东西:
- 一条能跑的命令——读者可以原样复制到自己机器上;
- 一段原始输出——不是「大概是这个意思」的转述,是终端里复制出来的文本;
- 一个适用边界——什么条件下这个结论成立,什么条件下不成立。
举个具体的例子。第 10.1 节讲零拷贝,不会只画一张 sendfile 的图,而是会说明:Kafka 的快,主要来自顺序追加写和页缓存命中,sendfile 省掉的是用户态与内核态之间的拷贝次数;如果你的消费端在做反序列化和业务处理,这一层的收益会被后面的处理开销稀释掉。慢的是你的处理器,不是 Kafka 的发送路径。
同样,第 12 章讲压测时会明确写:本机 load average 常年在十几到二十几之间,所有耗时数字都是「单次运行、随调度波动、不作基准」。能给出的是相对关系(批次 16 KB 比 1 KB 快多少倍),不是绝对吞吐。把一台被别的任务压着的笔记本上的数字当成容量规划依据,比不做实验更危险。
这条原则还有一个反面:跑不出来的实验就承认跑不出来。比如「多机架跨 AZ 的副本流量」,本机只有一台机器,没有真实机架,正文会写「未在本机执行」,并给出命令与预期形态,而不是编一组看起来合理的数字。读者信任这本书的前提,是它不骗人。
三卷各回答什么问题
这套书分三卷,每卷有自己的问题域,不互相重复。
| 卷 | 书名 | 回答的问题 | 不该在别处找的答案 |
|---|---|---|---|
| 卷一 | Kafka 内核与客户端 | 一条消息从 send() 到落盘,中间发生了哪些事? | 分区、副本、KRaft、位移、客户端源码、存储与性能 |
| 卷二 | 可靠消息与消费治理 | 怎么让这条链路不丢、不重、可恢复? | 幂等、事务、积压、死信、outbox、Saga、演练 |
| 卷三 | 事件驱动架构 | 什么时候该用事件,而不是用消息替代 RPC? | 事件建模、CQRS、流处理、契约演进、迁移与治理 |
卷一不写业务。你不会在这里看到「用户下单后发一条消息扣库存」这类例子,因为那属于可靠性设计。本卷的贯穿项目 libraryd-mq 只做一件事:把图书馆的借阅动作变成可观察的消息,然后用它验证机制。
卷二不重讲机制。需要分区与副本的细节时,它会写「见卷一第 4 章」,然后直接进入「所以幂等键该怎么选」。
卷三不写配置。它讨论的是「这个业务该切成几个事件」「读模型怎么重建」,配置细节交给前两卷。
这样分的原因很实际:三卷合起来 150 篇,如果每卷都把基础讲一遍,读者会读到三遍 acks 的含义,而真正难的部分——比如再平衡期间到底会重复多少条——反而没篇幅写透。
贯穿项目 libraryd-mq
全书所有例子都落在同一个项目上。它不是玩具,也不是完整业务系统,而是一组可运行的实验夹具:图书馆的借阅、归还、预约、逾期四个动作,每个动作都对应一条消息。
libraryd-mq/
mq-core/ 事件 record、主题常量、序列化器、ID 生成
mq-lab/ 生产者、消费者、事务、流处理、故障注入
mq-ops/ compose 文件、运维脚本、采集与验收报告
事件定义用 record,时间统一 Instant,金额统一用 long 表示分。六个主题的名字从第一页到最后一页不变:
| 主题 | 语义 | key |
|---|---|---|
library.loan.created | 借阅创建 | bookId |
library.loan.returned | 归还 | bookId |
library.loan.overdue | 逾期 | loanId |
library.hold.placed | 预约登记 | bookId |
library.hold.fulfilled | 预约履约 | bookId |
library.inventory.adjusted | 库存调整 | bookId |
实现类也按固定顺序演进,这样跨章节引用时读者不用重新理解上下文:
NaiveProducer → IdempotentProducer → TransactionalProducer
AtMostOnceConsumer → AtLeastOnceConsumer → IdempotentConsumer
选这个域的原因是它有天然的顺序要求(先借后还)、天然的分区键(bookId)、天然的重复场景(同一次归还被重试),以及一个不涉及金钱的失败代价——写错不会让人亏钱,但错误依然清晰可见。
为什么不写 Spring Kafka
本书有一个刻意的选择:不使用 Spring Kafka,也不使用任何框架封装。
理由不是框架不好。Spring Kafka 在真实项目里能省下大量样板代码,它的 @KafkaListener、MessageListenerContainer、事务管理器都是成熟设计。但本书要讲的是「消息是怎么流动的」,而框架恰好把流动过程藏起来了。一个 @KafkaListener 方法背后有容器、有轮询线程、有提交策略、有错误处理器,这些在框架里都是配置项,在本书里必须是可见的代码。
所以你会看到这样的写法:
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
consumer.subscribe(List.of("library.loan.created"));
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
for (ConsumerRecord<String, String> r : records) {
handle(r);
}
consumer.commitSync();
}
}
这段代码很笨,但它把「拉取、处理、提交」三个动作摆在同一屏里。读者能清楚地看到:如果 handle 抛异常,commitSync 就不会执行,位移不会推进,下次重启会重新消费这一批。这正是卷二要讨论的重复来源之一。
等你理解了这些,再去用 Spring Kafka,你会知道它的每个配置在替你做什么。这比反过来要容易得多。
术语约定
同一件事在不同团队里有不同叫法,本书统一如下,第一次出现时会括注英文:
| 中文 | 英文 / 代码 | 不写作 |
|---|---|---|
| 分区 | partition | 分片(分片专指 sharding) |
| 副本 | replica | 备份 |
| 同步副本集合 | ISR | ISR 集合(可直接写 ISR) |
| 位移 | offset | 偏移量(首次可括注) |
| 消费组 | consumer group | 消费群 |
| 再平衡 | rebalance | 重平衡(首次可括注) |
| 提交 | commit | 确认(ack 才叫确认) |
| 积压 | lag | 堆积 |
| 至少一次 | at-least-once | 最少一次 |
| 恰好一次 | exactly-once | 精确一次(首次可括注) |
有两个词需要特别小心。消费组的英文是 consumer group,但 Kafka 4.x 里还有一个更细的概念叫 group protocol,指消费组内部用哪套协调协议(classic 或 KIP-848),这两者不是一回事。位移和消费位置也不完全等同:位移是提交到 __consumer_offsets 的记录,消费位置是消费者内存里当前读到哪,两者在自动提交开启时会短暂不一致。
本卷的阅读路线
按照你要解决的问题,有三条路:
从零建立机制理解:按 1.1 到 15.3 顺序读。第 1–3 章建立直觉和实验环境,第 4–6 章进入服务端机制,第 7–9 章读客户端源码,第 10–12 章讲存储与性能,第 13–15 章是运维与故障。大约 30 小时。
已经在用 Kafka,想补原理:直接读第 1、4、6、7、8 章。这五章覆盖了「为什么我的消费者会重复」「为什么 rebalance 会停顿」「为什么分区数不能乱加」这三个最常被问到的问题。
正在处理集群问题:翻附录 A 的命令速查,配第 14 章的故障图谱,再看 15.1 的指标阈值。这条路径不追求理解,只追求定位。
本卷会实跑的实验清单
为了让读者知道哪些结论是硬证据、哪些是推理,先把本卷的核心实验列在这里:
| 实验 | 所在节 | 已验证 |
|---|---|---|
| 单节点 KRaft 启动,0 ERROR | 2.1 | 是 |
| 三节点集群,RF=3,leader 分散 | 2.1 / 4.1 | 是 |
| 杀 leader 触发选举,ISR 从 3 收缩到 2 | 4.2 | 是 |
再杀一个 broker,写入报 NOT_ENOUGH_REPLICAS | 4.2 | 是 |
事务提交与 abort,read_committed 的可见性 | 引用卷二 3.1 | 是 |
| 分区落点与跨分区乱序 | 1.2 | 是 |
| 日志段与稀疏索引的物理文件 | 2.2 | 是 |
| 自定义分区器造成热点 | 3.3 | 是 |
metrics() 的实际条目数 | 9.3 | 是(77 项) |
| 官方压测脚本的相对吞吐 | 12.1 | 是(相对值) |
| 跨机架流量 | 13.1 | 否,本机无机架 |
最后一行是刻意留的。一本书如果宣称所有实验都做过,反而不可信。
实验环境的一句话交代
全书实验在这样一台机器上完成:
| 项目 | 值 |
|---|---|
| 机器 | Apple M1 Pro,10 核,32 GB 内存 |
| JDK | Temurin 25.0.5+8 |
| 容器 | colima + Docker 29.5.2(Ubuntu 24.04.4 / cgroup v2 / 4 vCPU / 7.7 GiB) |
| Kafka | apache/kafka:4.3.0,KRaft 模式,无 ZooKeeper |
| 客户端 | kafka-clients 4.3.0 |
| 编排 | 独立的 docker-compose 5.5.0(本机没有 docker compose 插件) |
需要说明的是,colima 虚拟机只有 4 个 vCPU 和 7.7 GiB 内存,三节点集群每个 broker 限 512 MB 堆。这让本书可以做机制验证(leader 会不会切、ISR 会不会收缩、事务会不会 abort),但不能做性能结论(吞吐能到多少、延迟有多低)。这两件事的区别,第 12 章会用一整章来讲。
一个提醒
读这本书时,最有价值的行为不是记住结论,是在自己的集群上重跑一遍。
本书的每个实验都给了完整的命令和配置。如果你手上的 Kafka 版本不同、分区数不同、磁盘不同,跑出来的数字一定和书里不一样。那个「不一样」本身就是信息——它会告诉你哪些结论是机制的必然,哪些只是这台机器的巧合。
第 1.1 节从一个更基本的问题开始:消息系统到底有几种形态,Kafka 为什么选了最难解释的那一种。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。