bigywater.tech
Kafka 内部原理 · 33

Poison Message:一条坏消息如何卡死整个 Partition

Kafka 的一个分区是严格按 offset 顺序消费的,消费者没有"先跳过这条"的默认行为。一条无法被反序列化或无法被处理的消息,会让排在它后面的所有消息都拿不到。这次分别验证两种"毒药":反序列化失败(发生在 poll() 内部)和处理逻辑失败(发生在业务代码里),并对比几种处理方式的结果。

验证:反序列化失败时,poll() 每次都在同一个 offset 抛异常,后面的消息永远拿不到
"naive"的写法是捕获异常后继续下一轮 poll():offset 0 到 29 正常处理,之后每一次 poll()
验证:处理逻辑失败并且无限重试时,消费者卡在坏消息上,积压持续增长
消费者在 offset 30 处不断重试,已处理的条数始终停在 30,重试次数从 1 涨到 53,而积压(日志末尾 of

验证:反序列化失败时,poll() 每次都在同一个 offset 抛异常,后面的消息永远拿不到

命令 · java // topic poison-deser: 1 分区;用 ByteArraySerializer 写入 100 条:offset 0~99, // 其中 offset 30 只有 3 个字节("abc"),其余是合法的 4 字节整数 // 消费者 value.deserializer = IntegerDeserializer,15 秒内反复 poll
真实实测 · 本机执行输出
第一次异常: RecordDeserializationException at poison-deser-0 offset=30 :
  Error deserializing VALUE for partition poison-deser-0 at offset 30.
  If needed, please seek past the record to continue consumption.

naive: 正常处理 30 条,最后一条成功的 offset=29,poll 抛异常 546821 次,异常 offset=[30]
skip : 正常处理 99 条,最后一条成功的 offset=99,poll 抛异常 1 次,异常 offset=[30]

"naive"的写法是捕获异常后继续下一轮 poll():offset 0 到 29 正常处理,之后每一次 poll() 都在 offset 30 抛出同一个异常,15 秒里一共抛了 546821 次(几乎是空转的忙循环),offset 31 到 99 这 69 条一条也没有拿到。异常信息里已经给出了处理办法:"seek past the record"。"skip"的写法是在捕获异常后调用 seek(partition, e.offset() + 1) 跳过坏消息,结果只抛了 1 次异常,其余 99 条全部处理完。两种写法唯一的区别就是这一行 seek。

验证:处理逻辑失败并且无限重试时,消费者卡在坏消息上,积压持续增长

命令 · java // topic: 先写 60 条(offset 30 的内容是 "POISON",处理函数遇到它会抛异常),之后 Producer 每 50ms 写入 1 条 // 消费者:每条处理失败就 sleep 200ms 后重试,一直重试;手动按 offset+1 提交;运行 16 秒
真实实测 · 本机执行输出
  [ 3435ms] retry 卡在 offset 30,已重试  1 次,已处理 30 条,积压  85
  [ 6085ms] retry 卡在 offset 30,已重试 14 次,已处理 30 条,积压 134
  [ 8731ms] retry 卡在 offset 30,已重试 27 次,已处理 30 条,积压 184
  [11376ms] retry 卡在 offset 30,已重试 40 次,已处理 30 条,积压 233
  [14015ms] retry 卡在 offset 30,已重试 53 次,已处理 30 条,积压 283
最终[retry]:16 秒内成功处理 30 条,进入死信 0 条,积压 320 条

消费者在 offset 30 处不断重试,已处理的条数始终停在 30,重试次数从 1 涨到 53,而积压(日志末尾 offset 减去消费者当前位置)从 85 涨到 320,涨幅大约等于 Producer 的写入速度(每秒约 20 条)。整个过程里这个分区是"健康"的:Broker 没有报错,消费组也在线,只是有一条消息卡住了流水线。要从监控上发现这种问题,只能看积压是否在持续增长而已处理数不变。

验证:限制重试次数并把坏消息转入死信 topic,流水线不再被卡住

命令 · java // 同样的数据和 Producer;处理失败重试 3 次后,把这条消息写入死信 topic(poison-deadletter3),继续处理下一条
真实实测 · 本机执行输出
  [ 3199ms] dlq 已处理  10 条,进入死信 0 条,积压 110
  [ 5741ms] dlq 已处理 167 条,进入死信 1 条,积压   1
  [10770ms] dlq 已处理 261 条,进入死信 1 条,积压   1
  [15827ms] dlq 已处理 355 条,进入死信 1 条,积压   1
最终[dlq]:16 秒内成功处理 359 条,进入死信 1 条,积压 1 条

死信 topic 的内容:POISON        (log end offset = 1)

同样的场景,限制重试 3 次并转入死信后:16 秒内处理了 359 条,积压始终只有 1 条左右;被转走的坏消息完整保存在死信 topic 里,内容就是 "POISON",可以事后排查或修复后重新投递。对照上一组:无限重试处理 30 条、积压 320 条,死信方案处理 359 条、积压 1 条。这两组数据来自同一份逻辑,只是处理失败后是"重试到成功为止"还是"重试 3 次后转走"。

结论

坏消息卡住分区是可复现的:反序列化失败会让 poll() 在同一个 offset 上空转抛异常(这次 15 秒 54 万多次),处理失败加无限重试会让积压线性增长(约等于生产速率)。解决办法的核心是"跳过或转走":反序列化异常里 seek(e.offset() + 1),处理异常时限制次数并写入死信 topic。死信 topic 只保证坏消息没有丢,之后怎么处理仍要人工或另外的流程。

分步演示:一条毒药消息,是怎么卡死流水线又被解决的

每一步都是本文上面几节运行Java Consumer得到的原始统计结果。

一条坏消息,能把整条流水线卡到积压涨到320条
这一步新验证的方案

    参考与说明

    • 数据来自本机 3 节点 Kafka 4.3.1 集群,每个 topic 1 分区 3 副本;积压是用 endOffsets - position 在消费者内部算出来的,每次运行时间约 16 秒,每种方案只跑了一次。
    • 死信实验第一次跑时,我把死信 topic 和被消费的 topic 用了同一个名字,消息写回被消费的 topic 又被消费,程序永远不会结束,已经杀掉重来;第二次因为在消费者刚启动、还没分到分区时就读取位置而抛异常,修正后才得到上面的结果。这两次的输出都没有采用。
    • 反序列化那组用的是 Java 客户端的 IntegerDeserializer,坏消息是 3 字节的 "abc";其他语言的客户端遇到坏消息的表现没有验证。
    • 没有验证:ErrorHandlingDeserializer 这类包装反序列化器的效果;多分区下一个分区被卡住时其他分区的消费;Kafka Streams、Kafka Connect 自带的错误处理和死信配置;死信 topic 里的消息重新投递的做法。
    京ICP备2026067502号-1京公网安备11010502063487号