《Java 消息中间件·可靠消息与消费治理》6.1 死信队列的设计

死信队列不是失败消息的垃圾桶,而是一份可回放的失败证据。本篇给出进入死信的判据、必须随消息搬运的元信息清单,并在 Kafka 4.3.0 上跑通完整链路:九条消息中一条重试耗尽进入 DLT,贴出死信头原文,再把修复后的消息回灌原主题,最后给出可验收的判据与反模式清单。

死信队列解决的是「处理不了,又不能装作没看见」

消费者处理一条消息失败,只有三种结局:重试、跳过、或者把它挪走。原地重试会占住分区;直接跳过会丢掉证据,而且没人知道丢过;死信队列(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-topiclibrary.loan.created不知道该回灌到哪
kafka_dlt-original-partition1无法定位、无法按 key 复核
kafka_dlt-original-offset1无法和原日志逐条对账
kafka_dlt-original-timestamp1793251870728无法判断消息新旧,TTL 与回灌顺序失去依据
kafka_dlt-original-timestamp-typeCreateTime时间戳含义不明(创建时间还是写入时间)
kafka_dlt-exception-fqcnjava.lang.IllegalArgumentException无法按异常类型聚类
kafka_dlt-exception-messageinventory service rejected payload: member M-004 not found只能靠人翻日志
x-retry-count3分不清「一次就废」和「重试耗尽」
x-first-failure-at1793251880126无法计算「从首次失败到进 DLQ」的延迟
x-failed-bylibraryd-inventory-consumer多消费者订阅同一主题时无法归因

kafka_dlt- 是社区常见的死信头前缀,选它不是标准强制,而是让工具和人都能一眼认出。前缀之外至少要补齐三样:重试次数、首次失败时间、失败者身份。只存异常字符串是不够的,异常消息会被改写、会被本地化,异常类名才适合做聚合统计。

有两个容易忽略的点:

  1. 时间戳要存原始消息的,不要存进 DLQ 的时间。 前者用于判断业务时效,后者用于清理,两个都要有。本篇只存了前者,进 DLQ 的时间用消息自身的日志追加时间表达。
  2. 原消息的 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,就必须显式复制;否则死信到了人工手里,已经断了和上游请求的关联。

死信的消费:别用主业务组

死信主题要单独一个消费组,理由有三条:

  1. 隔离故障。 主业务组消费 DLT 时如果再次抛异常,主链路的重平衡会被牵连。
  2. 位移语义不同。 主链路是「处理完提交」,DLT 通常是「人工看过、修复完才提交」,一条消息可能在 DLT 里停留几天。
  3. 权限不同。 能读死信的人通常比能改主链路的人少。

读 DLT 的工具应当只做展示与导出,不做自动重试。自动重试 DLT 是危险的:一条毒消息会在 DLT 和重试主题之间来回弹,制造写放大,还会让「死信」这个名字失去意义。

保留期也要单独定。主主题按业务时效保留 7 天,DLT 往往要按「人工处理窗口」保留 30 天以上;用同一套清理策略,会出现「事故还在排查,证据已经被删」。

DLT 的主题参数也不需要和主主题一致:

参数建议理由
partitions1–3,按人工处理并行度死信量通常远小于主链路,分区过多只会增加归档成本
retention.ms不小于补偿窗口,常见 30 天证据要在人处理完之前都在
cleanup.policydelete死信没有「按 key 压缩」的语义,保留最新一条会丢历史
compression.type跟随集群默认死信量小,压缩收益有限,不要为此单独调
max.message.bytes不小于主主题否则原样搬运会因超限写不进去

最后一项是实际会踩的坑:如果主主题允许 2 MB 消息,DLT 用了默认 1 MB,那么最大那条消息恰好写不进死信,消费者会卡在「主主题处理失败、死信也写不进去」的两难里。要么两个主题的上限一致,要么在写 DLT 前先截断并附带原文的存储指针。

回灌:先修数据,再谈重发

回灌(redrive)不是把 DLT 的消息原样扔回主主题。原样回灌等于把同一个失败再演一遍,还会因为重试次数重置而无限循环。正确顺序是:

  1. 读一条死信,确认 original-topic 与 original-offset;
  2. 人工或脚本修复 value(补字段、改类型、纠正业务键),不改 key;
  3. 回灌前做幂等检查:这条消息对应的业务操作是否已经部分生效;
  4. 发送到原主题,带上 x-redrive=1、修复批次号、操作人;
  5. 等发送确认后,提交 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 重试主题与退避

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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