死信队列解决的是「处理不了,又不能装作没看见」
消费者处理一条消息失败,只有三种结局:重试、跳过、或者把它挪走。原地重试会占住分区;直接跳过会丢掉证据,而且没人知道丢过;死信队列(Dead Letter Queue,下称 DLQ)是第三种:把消息连同「它为什么失败」一起搬到另一个主题,然后提交原位移,让主链路继续。
三个词要先分清:
| 名称 | 位置 | 谁写入 | 语义 |
|---|---|---|---|
| 重试主题 | xxx.retry.N | 消费者 | 还可能成功,等待退避后再试 |
| 死信主题 | xxx.DLT | 消费者 | 重试耗尽,自动处理失败 |
| 隔离主题 | xxx.quarantine | 人工或工具 | 疑似毒消息,等人判断 |
DLQ 的边界是「自动流程的终点」,不是「问题已经解决」。消息进了 DLQ,只是从阻塞主链路变成了待办事项;如果没有 owner、没有告警、没有回灌工具,它比直接丢弃更糟,因为它制造了「我们已经处理了」的错觉。
什么进死信:先分类,再决定重试几次
把「失败」当成一个整体,是 DLQ 设计最常见的错误。至少分成四类,处理方式完全不同:
| 失败类型 | 典型异常 | 可重试 | 正确去向 |
|---|---|---|---|
| 瞬时故障 | 下游超时、连接被拒、限流 | 是 | 重试主题,带退避 |
| 确定性业务拒绝 | 会员不存在、状态机不允许、金额越界 | 否 | DLQ 或业务补偿队列 |
| 数据损坏 | JSON 解析失败、schema 不匹配、编码错误 | 否 | 隔离主题 + 告警 |
| 代码缺陷 | 空指针、数组越界、类型转换失败 | 有限次 | DLQ + 告警,修完回灌 |
判据是「同样的输入再跑一次,结果会不会不同」。网络抖动会不同,所以值得重试;memberId 在库里查不到,跑一百次也查不到,重试只是把失败推迟。把不可重试异常也塞进三次重试,除了浪费 3 倍时间,还会让 DLQ 的「重试次数」字段失去意义。
本篇的实验为了演示完整链路,对失败消息做了 3 次原地重试再进 DLT;生产代码应当先按异常类型分流,只有瞬时故障才走重试主题(6.2 节展开)。
死信必须携带的元信息
一条死信消息的 value 通常原样保留,真正决定它「能不能被处理」的是 headers。下面这组字段是本篇实现采用的清单:
| header | 示例 | 没有它会怎样 |
|---|---|---|
kafka_dlt-original-topic | library.loan.created | 不知道该回灌到哪 |
kafka_dlt-original-partition | 1 | 无法定位、无法按 key 复核 |
kafka_dlt-original-offset | 1 | 无法和原日志逐条对账 |
kafka_dlt-original-timestamp | 1793251870728 | 无法判断消息新旧,TTL 与回灌顺序失去依据 |
kafka_dlt-original-timestamp-type | CreateTime | 时间戳含义不明(创建时间还是写入时间) |
kafka_dlt-exception-fqcn | java.lang.IllegalArgumentException | 无法按异常类型聚类 |
kafka_dlt-exception-message | inventory service rejected payload: member M-004 not found | 只能靠人翻日志 |
x-retry-count | 3 | 分不清「一次就废」和「重试耗尽」 |
x-first-failure-at | 1793251880126 | 无法计算「从首次失败到进 DLQ」的延迟 |
x-failed-by | libraryd-inventory-consumer | 多消费者订阅同一主题时无法归因 |
kafka_dlt- 是社区常见的死信头前缀,选它不是标准强制,而是让工具和人都能一眼认出。前缀之外至少要补齐三样:重试次数、首次失败时间、失败者身份。只存异常字符串是不够的,异常消息会被改写、会被本地化,异常类名才适合做聚合统计。
有两个容易忽略的点:
- 时间戳要存原始消息的,不要存进 DLQ 的时间。 前者用于判断业务时效,后者用于清理,两个都要有。本篇只存了前者,进 DLQ 的时间用消息自身的日志追加时间表达。
- 原消息的 headers 不会被自动继承。 这是实测结论,不是推断:原消息带一个
trace-id,进了 DLT 之后 10 个 header 里没有它。要保留链路信息,必须在写 DLQ 时显式复制。
实验环境与完整链路
本机实测(Temurin 25.0.5+8 / kafka-clients 4.3.0 / apache/kafka:4.3.0 单节点 KRaft / 2026-10-29):主主题 library.loan.created 3 分区,死信主题 library.loan.created.DLT 1 分区,均 RF=1。客户端版本用 AppInfoParser.getVersion() 核对:
kafka-clients 4.3.0
生产 9 条消息,key 为 book-0 到 book-8,其中 book-4 的 value 带 payload=bad-payload,消费端会稳定抛出 IllegalArgumentException:
created library.loan.created partitions=3 / library.loan.created.DLT partitions=1
sent key=book-0 partition=1 offset=0 value=loan-created member=M-000 payload=ok
sent key=book-1 partition=2 offset=0 value=loan-created member=M-001 payload=ok
sent key=book-2 partition=2 offset=1 value=loan-created member=M-002 payload=ok
sent key=book-3 partition=0 offset=0 value=loan-created member=M-003 payload=ok
sent key=book-4 partition=1 offset=1 value=loan-created member=M-004 payload=bad-payload
sent key=book-5 partition=1 offset=2 value=loan-created member=M-005 payload=ok
sent key=book-6 partition=1 offset=3 value=loan-created member=M-006 payload=ok
sent key=book-7 partition=0 offset=1 value=loan-created member=M-007 payload=ok
sent key=book-8 partition=2 offset=2 value=loan-created member=M-008 payload=ok
消费者用 enable.auto.commit=false,max.poll.records=1,每条记录处理成功就 commitSync()。失败记录原地重试 3 次(每次间隔 200ms),仍失败则写入 DLT、先确认 DLT 发送成功再提交原位移。顺序不能反:先提交再发 DLQ,中间崩溃就永久丢失。
核心逻辑:
int attempts = 0;
Instant firstFailure = null;
while (true) {
attempts++;
try {
process(r.value());
consumer.commitSync();
break;
} catch (Exception e) {
if (firstFailure == null) firstFailure = Instant.now();
if (attempts < 3) {
Thread.sleep(200);
continue; // 同一条记录再试
}
ProducerRecord<String, String> dlt = new ProducerRecord<>(DLT, r.key(), r.value());
dlt.headers()
.add(new RecordHeader("kafka_dlt-original-topic",
r.topic().getBytes(StandardCharsets.UTF_8)))
.add(new RecordHeader("kafka_dlt-original-partition",
String.valueOf(r.partition()).getBytes(StandardCharsets.UTF_8)))
.add(new RecordHeader("kafka_dlt-original-offset",
String.valueOf(r.offset()).getBytes(StandardCharsets.UTF_8)))
.add(new RecordHeader("kafka_dlt-exception-fqcn",
e.getClass().getName().getBytes(StandardCharsets.UTF_8)))
.add(new RecordHeader("x-retry-count",
String.valueOf(attempts).getBytes(StandardCharsets.UTF_8)))
.add(new RecordHeader("x-first-failure-at",
String.valueOf(firstFailure.toEpochMilli()).getBytes(StandardCharsets.UTF_8)));
producer.send(dlt).get(); // 等 broker 确认
consumer.commitSync(); // 再提交原位移
break;
}
}
实测结果:一条进死信,八条正常
ok key=book-3 partition=0 offset=0 attempts=1
ok key=book-7 partition=0 offset=1 attempts=1
ok key=book-0 partition=1 offset=0 attempts=1
dlq key=book-4 src=1@1 attempts=3 -> library.loan.created.DLT@0
ok key=book-5 partition=1 offset=2 attempts=1
ok key=book-6 partition=1 offset=3 attempts=1
ok key=book-1 partition=2 offset=0 attempts=1
ok key=book-2 partition=2 offset=1 attempts=1
ok key=book-8 partition=2 offset=2 attempts=1
handled=9/9
消费组最后的位移全部追平,主链路没有被一条坏消息拖住:
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
libraryd-dlq-demo library.loan.created 0 2 2 0
libraryd-dlq-demo library.loan.created 1 4 4 0
libraryd-dlq-demo library.loan.created 2 3 3 0
死信头原文
用独立消费者从 DLT 起点读到末尾,打印每条记录的全部 header:
beginning=0 end=1
DLT offset=0 key=book-4 value=loan-created member=M-004 payload=bad-payload headers=10
header kafka_dlt-original-topic = library.loan.created
header kafka_dlt-original-partition = 1
header kafka_dlt-original-offset = 1
header kafka_dlt-original-timestamp = 1793251870728
header kafka_dlt-original-timestamp-type = CreateTime
header kafka_dlt-exception-fqcn = java.lang.IllegalArgumentException
header kafka_dlt-exception-message = inventory service rejected payload: member M-004 not found
header x-retry-count = 3
header x-first-failure-at = 1793251880126
header x-failed-by = libraryd-inventory-consumer
两个可以直接读出的事实:
original-timestamp=1793251870728对应 2026-10-29 13:31:10.728(+08:00),first-failure-at=1793251880126对应 13:31:20.126。两者差 9.4 秒,包含消费者启动、拉取和重试的时间,不能当作处理耗时。- 原消息的
trace-id不在列表里。 总数 10 个 header 全部是写 DLT 时新加的。如果链路追踪依赖这个 header,就必须显式复制;否则死信到了人工手里,已经断了和上游请求的关联。
死信的消费:别用主业务组
死信主题要单独一个消费组,理由有三条:
- 隔离故障。 主业务组消费 DLT 时如果再次抛异常,主链路的重平衡会被牵连。
- 位移语义不同。 主链路是「处理完提交」,DLT 通常是「人工看过、修复完才提交」,一条消息可能在 DLT 里停留几天。
- 权限不同。 能读死信的人通常比能改主链路的人少。
读 DLT 的工具应当只做展示与导出,不做自动重试。自动重试 DLT 是危险的:一条毒消息会在 DLT 和重试主题之间来回弹,制造写放大,还会让「死信」这个名字失去意义。
保留期也要单独定。主主题按业务时效保留 7 天,DLT 往往要按「人工处理窗口」保留 30 天以上;用同一套清理策略,会出现「事故还在排查,证据已经被删」。
DLT 的主题参数也不需要和主主题一致:
| 参数 | 建议 | 理由 |
|---|---|---|
partitions | 1–3,按人工处理并行度 | 死信量通常远小于主链路,分区过多只会增加归档成本 |
retention.ms | 不小于补偿窗口,常见 30 天 | 证据要在人处理完之前都在 |
cleanup.policy | delete | 死信没有「按 key 压缩」的语义,保留最新一条会丢历史 |
compression.type | 跟随集群默认 | 死信量小,压缩收益有限,不要为此单独调 |
max.message.bytes | 不小于主主题 | 否则原样搬运会因超限写不进去 |
最后一项是实际会踩的坑:如果主主题允许 2 MB 消息,DLT 用了默认 1 MB,那么最大那条消息恰好写不进死信,消费者会卡在「主主题处理失败、死信也写不进去」的两难里。要么两个主题的上限一致,要么在写 DLT 前先截断并附带原文的存储指针。
回灌:先修数据,再谈重发
回灌(redrive)不是把 DLT 的消息原样扔回主主题。原样回灌等于把同一个失败再演一遍,还会因为重试次数重置而无限循环。正确顺序是:
- 读一条死信,确认
original-topic与original-offset; - 人工或脚本修复 value(补字段、改类型、纠正业务键),不改 key;
- 回灌前做幂等检查:这条消息对应的业务操作是否已经部分生效;
- 发送到原主题,带上
x-redrive=1、修复批次号、操作人; - 等发送确认后,提交 DLT 的位移,并在审计表记录「原 offset → 新 offset」。
本机实测的回灌片段:把 bad-payload 改成 ok、把不存在的 M-004 改成 M-999,重发到原主题,落到 partition=1 offset=4:
redrive key=book-4 fixed=loan-created member=M-999 payload=ok -> library.loan.created partition=1 offset=4
redriven=1
ok key=book-4 partition=1 offset=4 attempts=1
handled=1/1
注意回灌后这条消息的 offset 是 4,不是原来的 1。Kafka 的位移不可改写,回灌一定是追加一条新记录。 任何「把消息放回原 offset」的说法都是错的;对账时要按业务键 + 事件 ID 去重,不能按 offset。
回灌的幂等前提
回灌天然是「至少一次」:发送成功、提交 DLT 位移前崩溃,重启后会再发一次。所以下游必须能接住重复。三个前提:
| 前提 | 具体要求 | 不满足的后果 |
|---|---|---|
| 有稳定业务唯一键 | 例如 loanId + eventType,而不是 offset | 无法判断是否已处理 |
| 写入幂等 | 唯一索引、去重表、或状态机条件更新 | 重复扣库存、重复记账 |
| 能处理旧事件 | 回灌的消息可能比同 key 的新消息更晚到 | 状态被旧值覆盖 |
第三点最容易被忽略。毒消息阻塞期间,同 key 的后续消息如果走了别的分区或别的链路,可能已经处理完了;回灌把「旧事件」插到后面,消费者如果直接覆盖状态,就会把新状态改回旧状态。状态机要写成「只允许合法迁移」,而不是「无条件赋值」。
反模式清单
- DLQ 没有 owner。 主题建了,没人订阅,lag 一直涨,等于一个带监控盲区的黑洞。
- DLQ 自己也在失败。 写 DLT 的
send().get()抛异常时,如果代码直接退出,原位移没提交,消息会被重复处理;正确做法是有限次重试写 DLT,仍失败则暂停分区并告警,不能静默丢。 - 把 DLT 当重试队列。 定时把 DLT 全量倒回主主题,毒消息会形成循环,且每次循环都重置重试计数。
- 只存 value。 没有原主题和 offset,回灌只能靠猜。
- DLQ 保留期比排查周期短。 事故复盘时发现证据已被清理。
- 没有回灌审计。 出了问题无法回答「这条消息被谁、什么时候、改成什么放回去的」。
判据:一条死信能不能被处理
给每条死信定一个可验收的标准,比讨论「要不要上 DLQ」有用:
| 判据 | 通过条件 |
|---|---|
| 可定位 | 能从 header 还原原主题、分区、offset、时间戳 |
| 可解释 | 异常类名与消息能支持聚类,不依赖翻应用日志 |
| 可计量 | 能统计进 DLQ 延迟、按异常类型分组的数量 |
| 可回灌 | 有工具、有 dry-run、有修复记录、有回灌审计 |
| 可发现 | DLT 的 lag 与「新增死信速率」都有告警 |
| 可清理 | 保留期与业务补偿窗口匹配,过期有归档 |
两个可观测指标能覆盖大部分问题:死信率(单位时间进 DLT 的条数 ÷ 主主题消费条数)和回灌成功率(成功回灌条数 ÷ 尝试回灌条数)。前者突增通常意味着上游数据或下游依赖变了,后者下降说明修复本身有问题。只盯 DLT 的 lag 不够,因为 lag 在「没人处理」和「处理不过来」两种情况下长得一样。
回到 libraryd
libraryd 的借阅创建链路里,库存服务拒绝请求属于确定性业务失败,不应该重试三次;它应该直接进 library.loan.created.DLT,由库存团队认领。会员不存在则可能是上游数据延迟,值得进重试主题等几分钟。两种失败走两条路,DLQ 里的 x-retry-count 才有区分度。
回灌时按 loanId + eventType 做唯一键,库存扣减用「条件更新 + 影响行数判断」,不要用「先查再改」;这样即使回灌重复,第二次也会因为状态已迁移而被拒绝,而不是重复扣减。
阅读导航:上一节 5.3 消费速率控制与背压 | 下一节 6.2 重试主题与退避
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。