一条消息的三种「以为」
可靠性讨论最常见的开场是一张配置清单:acks=all、关掉自动提交、打开幂等、加事务。照做之后,大多数人会觉得问题解决了。
但卷一已经跑出过几个反例:
- 用
docker pause冻结一个 follower,生产者返回了 offset 5–9,消费者一条都读不到。同一秒内 leader 磁盘上有 19 条,对消费者可见的只有 16 条。 - 幂等生产者开着,重复的消息照样进了下游表,因为重复来自消费端的位移回退,跟生产端的序列号没有关系。
- 事务
commitTransaction()返回成功,业务库里却查不到这笔操作,因为业务库不在这条事务的范围内。
这三个反例都不是配置写错了,而是配置只能覆盖它所在的那一段。
可靠性是一条链:生产者 → broker → 消费者 → 下游存储。四段各有各的失败模式,任何一段漏了,整条链就失信。配置是每段的局部手段,把四段的配置拼起来,不等于端到端的保证。
这一卷要做的事,就是把「消息不丢」这句口号拆开,逐段给出判据、代码和演练记录。
下面三个小节讲三种最常见的「以为」。它们都不是错的,而是只对了一半——错的那一半,恰好是事故所在。
误判一:acks=all 就等于不丢
acks=all 的字面含义是「所有 ISR 都写入之后才返回」。这句话成立需要三个前提,缺一个都不成立。
前提一:ISR 里确实有足够的副本。
第 4.1 节演示过 ISR 收缩的三条路径:follower 落后超过 replica.lag.time.max.ms、follower 请求超时、新副本加入还在追赶。任何一条发生,ISR 都会变小。ISR 变成 1,3 时 acks=all 仍然返回成功,但它此时只等两个副本,而不是三个。
前提二:min.insync.replicas 配置得当。
acks=all 与 min.insync.replicas 是一对。前者说「等所有 ISR」,后者说「ISR 少到这个数就拒写」。只配前者不配后者,等于允许「只有一个副本时也算成功」。第 4.2 节的值是 2:ISR 掉到 1 时写入立刻被拒。
前提三:客户端真的处理了异常。
第 13.2 节实测:min.insync.replicas=2 时停掉第二台 broker,生产者在 26 毫秒内收到 NotEnoughReplicasException。这个异常是可重试的,默认 retries=2147483647、delivery.timeout.ms=120000。也就是说,只要 ISR 在 2 分钟内恢复,业务代码根本看不到异常,只能看到一次延迟尖峰(那一次卡了 2.6 秒)。
如果 ISR 没能在 2 分钟内恢复,异常会从回调里冒出来。此时业务代码如果只写 e.printStackTrace(),这条消息就丢了——不是因为 Kafka 丢了它,而是因为没人重发。
还有一层 acks=all 覆盖不到的:写入成功不等于业务成功。
生产者的 send() 返回、回调收到 RecordMetadata,只说明这条消息被写进了 Kafka 的日志。它不说明消费者读过、不说明下游写成功、更不说明这笔业务被处理了。
所以 acks=all 是必要条件:没有它一定不可靠;有了它,可靠性才刚刚开始。
误判二:手动提交就等于不重
关掉自动提交、改成处理完再 commitSync(),这是「至少一次」的标准写法。它确实解决了自动提交最大的问题——自动提交按时间间隔提交,可能在消息还没处理完时就把位移推过了。
但它解决不了重复。至少有三种情况会产生重复:
情况一:处理完、提交前进程崩溃。
这是最典型的一种。消息处理完,结果落盘了,commitSync() 还没来得及发出去,进程被杀。重启后从上次提交的位移继续,这条消息会被重新投递。业务侧的处理逻辑如果写成了「插入」,就会撞唯一键;如果写成了「更新计数 +1」,就会多算。
情况二:再平衡把分区拿走了。
第 6.2 节的再平衡时间线里,JoinGroup 到 SyncGroup 之间有一段时间分区不属于任何人。如果消费者在这个窗口里还在处理上一条消息,处理完去提交时已经不再拥有这个分区,提交会失败;新接手的消费者从上一次提交的位移开始,重复不可避免。
情况三:提交本身失败但被忽略。
异步提交(commitAsync())不会阻塞,回调里如果只打印日志、不做补偿,失败就静默了。下一次提交可能成功,于是位移跳跃,中间那一段被「跳过」——这不是重复,是丢。
三种情况指向同一个结论:手动提交把语义从「至多一次」变成了「至少一次」,重复是它的固有属性,不是 bug。
真正决定「不重复」的,不是提交方式,而是消费逻辑本身能不能承受重复投递。这就是第 2 章要解决的问题。
误判三:事务就等于业务恰好一次
Kafka 事务能做到的事情,边界非常清晰:在一个 Kafka 事务里写入多个分区的消息,对 read_committed 的消费者要么全部可见,要么全部不可见。
第 3.1 节会实测这条边界:同一事务里发 3 条,commit 后 read_committed 消费者看到 3 条;abort 后看到 0 条;而同期的 read_uncommitted 消费者能看到被标记为 abort 的 3 条。这就是「隔离级别」在 Kafka 里的具体样子。
它做不到的事情同样清晰:
边界一:跨系统的副作用不在事务里。
最常见的模式是「消费一条消息 → 写数据库 → 发下一条消息」。Kafka 事务可以把「提交消费位移」和「发出下一条消息」放进同一个事务,但它管不到中间那次数据库写入。数据库写成功、事务 abort,数据就多了一份;数据库写失败、事务 commit,消息就少了一条。
边界二:事务会超时、会被 fencing。
transaction.timeout.ms 到期,broker 会主动 abort 这个事务。两个使用同一 transactional.id 的生产者实例,后初始化的一方会把前一方的 epoch 提高,导致前者的写入被 ProducerFencedException 拒绝。这些都是正确行为,但它们意味着事务不是「开了一定成功」,而是「开了一定要在超时前结束」。
边界三:业务幂等仍然必需。
即使事务完美工作,消费者仍然可能因为位移回退而重复处理。事务保证的是「Kafka 里的写入原子性」,不是「业务逻辑只执行一次」。
所以事务是一个强工具,但它解决的是「Kafka 内部的一致性」,而不是「端到端的恰好一次」。把这两件事混为一谈,是第 2.3 节要专门拆开的误解。
为什么必须按段拆开算
把四段列成一张表,问题就清楚了。
| 段 | 它负责什么 | 它的失败模式 | 它管不到什么 |
|---|---|---|---|
| 生产者 | 把消息交给 broker,并在失败时重发 | 重试导致重复;超时后放弃;序列化异常 | 消息是否被消费 |
| broker | 持久化、复制、按位移提供读取 | 副本不足时拒写;磁盘满;分区不可用 | 消费进度 |
| 消费者 | 拉取、处理、提交位移 | 处理失败;提交失败;再平衡踢出 | 下游是否写成功 |
| 下游存储 | 承接处理结果 | 写入失败;部分成功;唯一键冲突 | 消息是否被重复投递 |
端到端的可靠性不是四段保证的并集,而是交集:最弱的那一段决定整条链的上限。
这也解释了为什么「可靠性等级」总是和成本挂钩。把生产者从 acks=1 提到 acks=all,付出的是一次额外的网络往返;把副本数从 2 提到 3,付出的是 50% 的存储和更高的跨机架流量;把消费端从「至多一次」改成「至少一次 + 幂等」,付出的是去重表的容量和一次额外的查询。
第 15.2 节会把这三档成本算成具体的数字。在那之前,先要知道每一档买到了什么。
四个词:丢、重、乱、慢
可靠性讨论经常把这四件事混在一起说,但它们的原因和处理手段完全不同。本卷统一用这四个字,各自只指一件事。
丢(loss):消息写进去之后不可再读,或者根本没写进去。典型原因是副本不足时被拒、保留期到期被删、生产者吞了异常、消费者提交越过了未处理的消息。判断丢要看两个位置:日志里还有没有这条消息,以及下游有没有对应的事实。
重(duplicate):同一条业务事实被处理了多次。注意是「业务事实」而不是「消息」——一条消息被投递两次但幂等处理,不算重;两条不同的消息携带同一个业务事实,也算重。重的原因是重试、再平衡、位移回退、人工重放,全部属于「至少一次」的固有代价。
乱(out-of-order):同一个业务键的消息,处理顺序与产生顺序不一致。Kafka 只保证分区内有序,所以乱序的第一来源是分区数大于 1;第二来源是 max.in.flight 大于 1 且重试;第三来源是消费端多线程处理。乱不一定是问题,取决于业务是否依赖顺序。
慢(lag):消息可读但还没被处理,在累积。慢本身不是错误,它是容量与消费能力之间的差额。只有当积压增长速度超过消费能力、或者积压导致保留期内的数据被删时,慢才变成丢。
这四件事的排查路径是分开的:丢要看副本与提交,重要看幂等键,乱要看分区与并发,慢要看消费速率。把它们统称为「消息出问题了」,只会让排查停在表面。
读这一卷需要什么
需要:能读懂 Java 代码(用到 lambda、CompletableFuture、try-with-resources);能在命令行跑 docker 与 java;知道消息队列的基本概念(生产者、消费者、分区、位移)。
不需要:读完卷一。卷一解释的是「为什么这样实现」,本卷解释的是「怎么用它保证不丢不重」。需要机制细节时会给出卷一的章节号,但不读完也能跟上。
不需要:Spring、Kafka Streams、任何第三方框架。本卷所有代码只用 kafka-clients 与 JDK 标准库。这不是排斥框架,而是因为框架会把「谁在什么时候提交位移」藏起来,而这一卷恰恰要看清这件事。
建议:手边有一个能起容器的环境。本卷一半以上的结论来自「停掉一个东西,看会发生什么」,只读不跑会失去大部分价值。
读完本卷你应该能回答的十个问题
这十个问题分别对应十五个章节,可以用来检验有没有读懂。
acks=all加上min.insync.replicas=2,能覆盖哪些失败,覆盖不了哪些?- 手动提交之后为什么仍然会重复?重复的范围怎么估计?
- Kafka 事务的保证到哪里为止?跨数据库的那一步谁来保证?
- 去重表的 TTL 应该按什么定?定短了会怎样?
- outbox 为什么比「先写库再发消息」可靠?它的代价是什么?
- 积压到什么程度应该扩容,什么程度应该跳过,判据是什么?
- 死信队列里应该放哪些元信息,才能让人工处理不需要翻日志?
- 一条毒消息为什么能阻塞整个分区?隔离它的正确姿势是什么?
- 重分区之后,同一个 key 还会落到原来的分区吗?
- RTO 和 RPO 分别怎么测,测出来的数字怎么写进演练记录?
如果读完只能答出一半,说明有些章节读得太快。
这本书怎么处理「我听说」
卷一的规矩在这一卷继续执行,而且更严格,因为可靠性的说法最容易被传成口号。
每条「本机实测」背后都有命令和原始输出。 如果某个实验在本机做不出来(例如需要五个节点才能触发的场景),正文会写「未在本机执行」,并给出命令形态与预期结果,不编数字。
不写「某公司线上事故」。 没有出处的案例无法验证,也无法对应到具体配置。取而代之的是可以复现的失败:kill 一个进程、停一个 broker、把消息放大到超过上限。
区分「机制」与「工程流程」。 例如 fencing 的机制可以在源码和日志里确认,但「两个实例同时持有同一个 transactional.id 时谁先被踢」这类时序,需要特定的启动顺序才能复现。前者给实测,后者给条件。
三卷各回答什么问题
| 卷 | 回答的问题 | 典型内容 |
|---|---|---|
| 一《Kafka 内核与客户端》 | 消息系统怎么工作 | 日志与段、副本与 ISR、KRaft、生产者/消费者源码、存储与网络 |
| 二《可靠消息与消费治理》(本卷) | 怎么做到不丢、不重、可恢复、可治理 | 责任边界、幂等、事务、DLQ、outbox、Saga、积压、演练 |
| 三《事件驱动架构》 | 用消息怎么设计系统 | 事件建模、CQRS、流处理、拓扑演进、组织与治理 |
分界线很清楚:凡是问「Kafka 内部发生了什么」,在卷一;凡是问「这条链路会不会丢、会不会重、出事怎么救」,在本卷;凡是问「系统应该长成什么样」,在卷三。
本卷会反复引用卷一的实验结论(尤其是第 4、6、13、14 章),但不会重复讲机制。
本卷不写什么
三卷本最容易出问题的地方是重复。为了把边界钉住,这里明确列出本卷不写的内容。
不写机制。 分区、副本、ISR、KRaft、日志段、零拷贝、生产者与消费者的类结构,都在卷一。本卷提到它们时只给结论和章节号,例如「HW 追上之后才对消费者可见,见卷一 4.1」,不重新推导一遍。
不写架构。 CQRS、事件驱动拓扑、Kafka Streams、流表连接,都留给卷三。本卷出现的「事件溯源入门」只是一个最小实现,用来对比 outbox,不展开成架构范式。
不写框架。 不引入 Spring Kafka、不引入任何 ORM 或 RPC 框架。原因在前面说过:框架把提交时机和重试策略藏在配置里,而这一卷要看的正是这些细节。全部示例只用 kafka-clients 与 JDK 标准库,读者可以照着抄进任何项目。
不写「理论上」。 凡是没有在命令行里跑过的结论,都会明确标注为方案或推论。第 7.2 节的 Debezium 只做架构对照,第 13.3 节的 MirrorMaker2 只给配置骨架,两者都会写清楚「未在本机执行」。
贯穿项目 libraryd-mq 的可靠性需求
三卷共用同一个虚构项目:一个图书借阅系统 libraryd-mq。它足够小,能在一台机器上跑完;又足够真实,包含了消息系统最典型的几类需求。
本卷关心的业务事件有四类:
| 事件 | 触发 | 可靠性要求 |
|---|---|---|
BookBorrowed | 用户借书成功 | 不能丢。丢一条,账上就少一本书 |
BookReturned | 用户还书 | 不能丢,不能重。重复归还会导致库存虚增 |
ReservationCreated | 预约登记 | 不能丢,可以短时延迟 |
InventoryAdjusted | 库存调整 | 不能重。重复扣减是直接的资损 |
「不能丢」与「不能重」是两种不同的成本。前者靠副本、重试、确认;后者靠幂等、去重、状态机。本卷的每一章,最终都会落回这四个事件中的某一个。
本卷会实跑的实验清单
下面这些实验会在对应章节里给出完整命令与原始输出。列在这里,是为了让你知道这一卷的证据密度。
| 章节 | 实验 | 观察什么 |
|---|---|---|
| 1.2 | 杀 leader 下 acks=1 与 acks=all 对照 | 丢了多少条 |
| 1.3 | 自动提交与手动提交对照 | 崩溃后重复或丢失的范围 |
| 2.1 | 构造四类重复 | 每类的日志特征 |
| 2.2 | 去重表 + 状态机 | 重复投递下的幂等性 |
| 3.1 | 事务 commit / abort | read_committed 与 read_uncommitted 的差异 |
| 3.2 | 事务超时 | abort 后的数据形态 |
| 4.2 | 四种 assignor | 多分区少消费者下的分配结果 |
| 4.3 | 优雅停机 | 再平衡日志与提交时序 |
| 5.1 | 三种 lag 口径 | 同一条消息在三个视角下的数值 |
| 6.1 | 死信链路 | 什么进了 DLQ、元信息是否完整 |
| 6.3 | 毒消息 | 一条消息阻塞整个分区 |
| 7.1 | outbox | 崩溃恢复后不丢不重 |
| 9.1 | Saga 补偿 | 失败步骤的回滚范围 |
| 10.1 | max.in.flight 与顺序 | 不同设置下的乱序数 |
| 11.1 | 消息放大 | 超过 max.request.size 的表现 |
| 12.2 | 异步提交 | 位移丢失的复现 |
| 14.2 | kill -9 消费者 | 重复范围与 session.timeout.ms 的关系 |
有些实验需要停节点、杀进程,不适合在共享环境里跑。所有涉及破坏性操作的实验,命令都会标出「在测试集群执行」。
版本与代码约定
本卷所有示例基于 Kafka 4.3.0 与 kafka-clients 4.3.0,宿主机 JDK 25,容器内是 Temurin 21。四个版本相关的约定先写在这里,正文不再重复:
kafka-clients不自带 slf4j-api,需要显式加slf4j-api与slf4j-simple,否则启动时只会看到一行「no SLF4J providers were found」,之后所有日志静默。- CLI 参数有变化:
--producer-props已废弃,用--command-property;--messages已废弃,用--num-records。老教程里的写法在 4.x 上会直接报错。 JmxTool的包名是org.apache.kafka.tools,不再是kafka.tools,而且不在默认 classpath 里,要显式写-cp "/opt/kafka/libs/*"。- 代码片段省略了
import与部分异常包装,重点是调用顺序与配置组合。完整可运行的版本集中在附录 B 的模板里。 - 消费组默认用经典协议:
group.protocol=consumer(KIP-848)在 4.3.0 上可用,但在 3.9.0 broker 上会抛UnsupportedVersionException。本卷的再平衡示例走经典协议,KIP-848 只在第 4.2 节做对照。 - 所有耗时与吞吐数字都标注「单次运行,不作基准」。本机是 4 vCPU 的虚拟机,数字的量级有意义,绝对值没有。
一个提醒
这一卷的很多结论是「否定式」的:acks=all 不够、手动提交不够、事务不够。读起来可能让人泄气。
但换个角度看,这些「不够」恰好是可以设计的地方。知道 acks=all 只覆盖写入,就会去设计消费端的确认;知道手动提交必然重复,就会去设计幂等键;知道事务管不到下游,就会去设计 outbox。
可靠性的工程实践,本质上就是把这些「不够」一条条补上,并且知道每补一条要付多少钱。
下一节先把实验环境和故障注入的口径交代清楚,然后从第 1 章开始,逐段拆这条链。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。