《Java 消息中间件·可靠消息与消费治理》序:可靠性是设计出来的,不是配置出来的

为什么 acks=all、手动提交、事务这三张配置牌各自只对了一半:把端到端可靠性拆成生产、broker、消费、下游四段,逐段说明每段能保证什么、在哪里漏、要付什么代价,以及本卷用哪些可复现的实验和判据把每一段钉死,最后给出三卷的分工与贯穿项目 libraryd-mq 的可靠性需求。

一条消息的三种「以为」

可靠性讨论最常见的开场是一张配置清单: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 标准库。这不是排斥框架,而是因为框架会把「谁在什么时候提交位移」藏起来,而这一卷恰恰要看清这件事。

建议:手边有一个能起容器的环境。本卷一半以上的结论来自「停掉一个东西,看会发生什么」,只读不跑会失去大部分价值。

读完本卷你应该能回答的十个问题

这十个问题分别对应十五个章节,可以用来检验有没有读懂。

  1. acks=all 加上 min.insync.replicas=2,能覆盖哪些失败,覆盖不了哪些?
  2. 手动提交之后为什么仍然会重复?重复的范围怎么估计?
  3. Kafka 事务的保证到哪里为止?跨数据库的那一步谁来保证?
  4. 去重表的 TTL 应该按什么定?定短了会怎样?
  5. outbox 为什么比「先写库再发消息」可靠?它的代价是什么?
  6. 积压到什么程度应该扩容,什么程度应该跳过,判据是什么?
  7. 死信队列里应该放哪些元信息,才能让人工处理不需要翻日志?
  8. 一条毒消息为什么能阻塞整个分区?隔离它的正确姿势是什么?
  9. 重分区之后,同一个 key 还会落到原来的分区吗?
  10. 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 / abortread_committed 与 read_uncommitted 的差异
3.2事务超时abort 后的数据形态
4.2四种 assignor多分区少消费者下的分配结果
4.3优雅停机再平衡日志与提交时序
5.1三种 lag 口径同一条消息在三个视角下的数值
6.1死信链路什么进了 DLQ、元信息是否完整
6.3毒消息一条消息阻塞整个分区
7.1outbox崩溃恢复后不丢不重
9.1Saga 补偿失败步骤的回滚范围
10.1max.in.flight 与顺序不同设置下的乱序数
11.1消息放大超过 max.request.size 的表现
12.2异步提交位移丢失的复现
14.2kill -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 章开始,逐段拆这条链。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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