《Java 消息中间件·可靠消息与消费治理》14.2 故障注入:消费端崩溃

kill -9 一个处理到一半的消费者,实测重复了 61 条:崩溃时处理到 offset 460,最后提交的是 400,重启从 400 继续。再用 SIGSTOP 冻结进程做对照,发现 10 秒后组里变成「no active members」,恢复后从 offset 0 重读。两次实验证明 session.timeout.ms 保护的是「进程卡住」,不是「进程死掉」。

崩溃时到底丢了什么、重复了什么

消费端崩溃是消息系统里最日常的故障,但它的后果经常被说错。常见的两种说法都不准确:

  • 「消费者挂了,消息就丢了」——如果位移还没提交,消息不会丢,会重投。
  • 「重启之后会重复」——重复多少、从哪开始重复,取决于崩溃那一刻的提交状态,不是一个固定现象。

这一节用一次真实的 kill -9 把这两个问题变成具体数字,再用一个对照实验说明 session.timeout.ms 到底在保护什么。

先说结论:崩溃本身不丢消息,只产生重复;重复的条数等于「已处理但未提交」的条数。

实验设置

单节点 KRaft,1 个分区,先灌 1000 条消息。消费者用 kafka-clients 4.3.0,关键参数:

p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
p.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "10");
p.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "10000");

处理逻辑刻意做得可观测:每处理一条打印 P <partition> <offset>,每处理满 100 条调用一次 commitSync 并打印 C <partition> <offset+1>,每条之间睡 50 毫秒。

for (ConsumerRecord<String, String> r : records) {
    System.out.println("P " + r.partition() + " " + r.offset());
    System.out.flush();
    sleep(processMillis);
    sinceCommit++;
    if (sinceCommit >= commitEvery) {
        consumer.commitSync(Collections.singletonMap(tp, new OffsetAndMetadata(r.offset() + 1)));
        System.out.println("C " + r.partition() + " " + (r.offset() + 1));
        sinceCommit = 0;
    }
}

然后等日志里第 450 条左右出现时,从外部 pkill -9。

kill -9:61 条重复的来源

第一轮结束后的三行原始输出:

=== run1 tail ===
P 0 455
P 0 456
P 0 457
P 0 458
P 0 459
P 0 460

崩溃那一刻,消费者已经处理到 offset 460。再看位移状态:

GROUP           TOPIC           PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
rel14-crash-g   rel14.crash     0          400             1000            600

CURRENT-OFFSET=400,说明最后一次成功提交停在 400(提交的值是 401,表示下一条要读的位置是 401;CLI 显示的 400 是「已提交到的位置」这种口径的换算结果,具体换算在 5.1 节讨论过)。

重启消费者之后的头几行:

=== run2 head ===
P 0 400
P 0 401
P 0 402
P 0 403
P 0 404

它从 400 开始,而不是从 461。

于是重复范围是 offset 400 到 460,共 61 条。这 61 条在崩溃前已经处理过一遍,重启后又被处理了一遍。

数字对得上:

已处理到的位置 460 − 最后提交的位置 400 + 1 = 61

这个式子说明了重复的唯一来源:处理进度领先于提交进度的那一段。kill -9 没有制造额外的重复,它只是把这段本来就存在的「未提交窗口」暴露了出来。

为什么 2 秒就恢复了

实验里还有一个细节值得停下看。时间线:

[14:14:20] processed 459, sending kill -9
[14:14:22] run1 killed
[14:14:24] run2 start
P 0 400

从杀掉到新进程开始消费,只用了约 4 秒,其中还包括 JVM 启动和组加入的时间。而 session.timeout.ms 是 10 秒。

如果恢复真的要等满 session 超时,这里应该看到 10 秒以上的空白。没有。原因是 kill -9 让操作系统关闭了进程持有的 TCP 连接,协调器检测到连接断开,立即把这个成员移除,不等心跳超时。

这是一个容易误解的地方:session.timeout.ms 的语义是「最后一次心跳之后多久没声音就判定死亡」,它的前提是连接还在、只是没消息。连接已经断开的场景走的是另一条更快的路径。

那么 session.timeout.ms 到底在什么情况下起作用?下一节用对照实验回答。

SIGSTOP 对照:session.timeout.ms 真正保护什么

把 kill -9 换成 SIGSTOP:进程被冻结,CPU 不再执行它的任何指令,但 TCP 连接仍然存在,内核没有发出任何关闭信号。对 broker 来说,这个消费者「还连着,只是不说话」。

实验用另一个组、另一批 300 条消息,处理间隔 100 毫秒,每 100 条提交一次。处理到 41 条时冻结。冻结后 6 秒查组状态:

[14:15:41] group state at +6s after freeze (session.timeout=10s not yet elapsed)
GROUP           CONSUMER-ID                                    #PARTITIONS
rel14-pause-g   consumer-rel14-pause-g-1-d0d0d38e-...           1

成员还在,手里还有 1 个分区。再等 8 秒(累计 14 秒):

[14:15:50] group state at +14s after freeze (session.timeout elapsed)
Consumer group 'rel14-pause-g' has no active members.

no active members。组空。

恢复执行(SIGCONT)之后:

[14:15:55] SIGCONT
[14:16:03] consumer log tail after resume
P 0 39
P 0 40
P 0 41
P 0 42

它从 offset 0 附近重新开始读——因为这个消费者一次都没提交过(提交点是每 100 条,冻在 41 时还没到),committed 位置还是最初的值。所以已处理的 42 条全部重来。

两次实验放在一起,结论就清楚了:

故障方式连接状态组里何时移除成员重复范围
kill -9断开(FIN/RST)立即,几秒内已处理未提交的 61 条
SIGSTOP保持(无信号)约 10 秒后,等于 session.timeout.ms全部已处理的 42 条

session.timeout.ms 保护的是「进程还活着但卡住了」——GC 长停顿、线程死锁、死循环、被 SIGSTOP。 对「进程死了」这种情况,连接断开本身就是最快的信号,超时配置不参与。

这解释了一个常见的困惑:有人把 session.timeout.ms 调到 60 秒,以为能加快故障恢复,结果没有任何变化,因为他们的故障场景是进程崩溃,走的是连接断开那条路。反过来,如果故障是「消费线程卡在某次外部调用上」,调小 session.timeout.ms 才会真的让组更快地重新分配分区——但也要同时接受网络抖动导致的误判。

提交频率决定重复上限

两次实验的重复条数(61 和 42)看起来是随机的,其实都由同一个公式决定:

最坏重复条数 = 提交间隔(条数) − 1

第一轮每 100 条提交一次,崩溃时恰好落在第 61 条未提交的位置。如果崩在第 99 条未提交的位置,重复就是 99 条。第二轮一次都没提交,所以全部重复。

这个公式给出了一个直接的调参依据:你能容忍多少重复,就按多少设定提交间隔。 但这里有取舍:

  • 提交越频繁,最坏重复越少,但 commitSync 是同步 RPC,每次都增加延迟。
  • 提交越稀疏,吞吐越高,但崩溃后重放的量越大。

两者之间没有免费的选择。实际的折中方式通常是:接受较大范围的重复,用幂等消费把它消化掉,而不是把提交间隔压到很小。第 2 章讲过这个分工——传输层的重复不可避免,业务层的幂等才是终局方案。

把恢复时间线拆开看

上面那个「4 秒」是脚本打印的时间戳差,不能直接当成 RTO。脚本里在杀进程和启动新进程之间有一个 sleep 2,所以真实的恢复过程比 4 秒短。把这段拆开,每一段分别是什么:

阶段发生了什么这段能不能缩短
发出信号到进程消失kill -9 不可捕获,内核直接回收不能,这段几乎是瞬时的
连接关闭到组内移除broker 收到连接断开,立刻移除成员并触发重平衡不能,这是最快的路径
新进程启动JVM 启动、加载类、构造 KafkaConsumer能,预热或常驻进程可省掉
加入组JoinGroup 往返,等协调器分配分区视组大小与协议而定
首次 poll 返回拉第一批数据能,用 fetch.min.bytes=1 减少等待

真正能优化的只有第三和第五段。第二段已经是最优——这正是上一节 kill -9 比 SIGSTOP 恢复快的原因。

这对 RTO 的启示是:消费端的恢复时间主要由「进程启动 + 加入组」构成,而不是由故障检测构成。 如果业务要求秒级恢复,先看进程启动有多慢,再看组有多大。一个 200 个消费者的组做一次 rebalance,光是分配就要好几秒,这跟故障检测毫无关系。

14.3 节会把这几段放进一次完整演练里计时。

多分区下的重复:一个算例

上面的实验是单分区,重复就是一条直线上的差值。真实主题通常有多个分区,而每个分区的提交进度是独立的,总重复量要分别算再相加。

假设一个 3 分区的主题,消费者每 100 条提交一次,崩溃瞬间三个分区的状态是:

分区已处理到的 offset最后提交的 offset未提交条数
p046040061
p132030021
p2700600100(刚提交完第 600 条)
合计——182

重启后,三个分区各自从自己的提交位置继续:p0 从 400、p1 从 300、p2 从 600。总重复 182 条。

注意 p2 这一行:它已经处理到 700,但最后提交是 600,未提交 100 条——恰好等于提交间隔。这说明「最坏重复 = 提交间隔 − 1」是单分区口径,多分区时每个分区都能同时接近这个上限,总量是分区的累加。

由此得到两个实用结论:

  1. 分区数会放大重复总量。 12 个分区各自最多重复 99 条,最坏情况接近 1200 条,不是 99 条。
  2. 去重键必须能跨分区工作。 如果去重表按分区建,每个分区各自去重,总量还是 1200;只有全局唯一的键(eventId)才能把重复压到真正的业务重复。

重新平衡期间的提交会失败

还有一个细节会影响崩溃恢复的观测:被踢出组之后,那个进程如果还活着,它的下一次提交会失败。

这不是崩溃场景,而是「被踢出」场景——比如处理时间超过 max.poll.interval.ms,或者像上面的 SIGSTOP 实验那样心跳中断。此时 commitSync 会抛 CommitFailedException:

org.apache.kafka.clients.consumer.CommitFailedException:
  Offset commit cannot be completed since the consumer is not part of an active group

重启之后的第一批 poll 会触发重新加入组,日志里能看到新的 CONSUMER-ID:

[group] rel14-pause-g   consumer-rel14-pause-g-1-5c844004-e09d-44ff-8a6f-1251dab19360 ...

对比冻结前那个 d0d0d38e,ID 变了。这不是 bug,是新会话的正常标识。也正因为 ID 变了,生产端幂等的序列号窗口(PID 变化)无法帮这里去重——生产端幂等和消费端重复是两回事,12.2 节讲异步提交时也提到过这个边界。

这个实验没有覆盖什么

三条限制要写清楚,避免把结论用过头:

  1. 单节点、单分区。 多分区时各分区的提交相互独立,重复范围要按分区分别算,总重复量是各分区之和。
  2. 没有测「消费者卡住但心跳线程还活着」。 4.x 的心跳在后台线程,某些卡顿(比如只有处理线程卡住)不会影响心跳,session.timeout.ms 不会触发,只有 max.poll.interval.ms 能发现。这是另一种故障形态。
  3. 重复条数是确定的,但重放顺序依赖分区内的位移顺序。 单分区时简单,多分区时「重放」不是一个全局有序的概念。

第 14.3 节会把这两次故障放回一次完整的演练时间线里,计算 RTO 与 RPO。本篇只负责把「重复多少」这件事说准。

小结

  • 消费端崩溃不丢消息,只产生重复;重复条数 = 已处理未提交的条数。
  • 实测:处理到 460、提交到 400、重启从 400 继续,重复 61 条。
  • kill -9 走连接断开路径,几秒内就能重新分配分区,与 session.timeout.ms 无关。
  • SIGSTOP 走心跳超时路径,10 秒后组变成 no active members,这 10 秒才是 session.timeout.ms 真正管的事。
  • 最坏重复条数 = 提交间隔 − 1;想减少重复,要么提高提交频率,要么用业务幂等消化。

阅读导航:上一节 14.1 故障注入:broker 宕机 | 下一节 14.3 恢复演练与 RTO/RPO

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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