崩溃时到底丢了什么、重复了什么
消费端崩溃是消息系统里最日常的故障,但它的后果经常被说错。常见的两种说法都不准确:
- 「消费者挂了,消息就丢了」——如果位移还没提交,消息不会丢,会重投。
- 「重启之后会重复」——重复多少、从哪开始重复,取决于崩溃那一刻的提交状态,不是一个固定现象。
这一节用一次真实的 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 | 未提交条数 |
|---|---|---|---|
| p0 | 460 | 400 | 61 |
| p1 | 320 | 300 | 21 |
| p2 | 700 | 600 | 100(刚提交完第 600 条) |
| 合计 | — | — | 182 |
重启后,三个分区各自从自己的提交位置继续:p0 从 400、p1 从 300、p2 从 600。总重复 182 条。
注意 p2 这一行:它已经处理到 700,但最后提交是 600,未提交 100 条——恰好等于提交间隔。这说明「最坏重复 = 提交间隔 − 1」是单分区口径,多分区时每个分区都能同时接近这个上限,总量是分区的累加。
由此得到两个实用结论:
- 分区数会放大重复总量。 12 个分区各自最多重复 99 条,最坏情况接近 1200 条,不是 99 条。
- 去重键必须能跨分区工作。 如果去重表按分区建,每个分区各自去重,总量还是 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 节讲异步提交时也提到过这个边界。
这个实验没有覆盖什么
三条限制要写清楚,避免把结论用过头:
- 单节点、单分区。 多分区时各分区的提交相互独立,重复范围要按分区分别算,总重复量是各分区之和。
- 没有测「消费者卡住但心跳线程还活着」。 4.x 的心跳在后台线程,某些卡顿(比如只有处理线程卡住)不会影响心跳,
session.timeout.ms不会触发,只有max.poll.interval.ms能发现。这是另一种故障形态。 - 重复条数是确定的,但重放顺序依赖分区内的位移顺序。 单分区时简单,多分区时「重放」不是一个全局有序的概念。
第 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
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。