Kafka本身没有内置死信队列 ,死信队列是业务层面的设计方案:消费失败、无法正常处理的消息,转发到专门的死信 Topic,不再重复重试消费,避免阻塞消费。
一、什么时候消息进死信
常见场景:
- 业务异常:消息格式错误、数据非法、缺少字段,无论重试多少次都处理失败(不可恢复错误)
- 重试次数耗尽:重试 N 次后仍然失败,不再继续重试
- 超时、消息内容损坏、反序列化失败
区分:临时故障(数据库短暂不可用)→ 重试;数据本身坏了 → DLQ
二、核心架构(经典链路)
原始Topic → Consumer消费
↓
消费失败
├─可重试异常 → 发送到【重试Topic】,延迟一段时间后重新消费
└─不可重试异常 / 重试次数用完 → 发送到【死信Topic(DLQ)】
- 原始 Topic:正常业务消息
- 重试 Topic:用来做延迟重试(可以多个,按重试轮次拆分)
- DLQ Topic:存放彻底失败消息,不再自动消费,人工排查
Kafka 没有延迟消息,重试一般两种实现:
- 单重试 Topic + 消费时 sleep(简单,不推荐高吞吐)
- 多个重试 topic,不同延时,定时轮询(主流方案)
三、实现思路(Consumer 侧)
- 消费消息,执行业务逻辑
- try-catch 捕获异常
- 如果是可重试异常:记录重试次数,发到重试 Topic
- 如果是不可重试异常,或者重试次数 ≥ 阈值:发送到 DLQ
- 提交 offset(重点! ) ✅ 必须提交 offset,否则这条消息会无限重放,阻塞整个消费分区 很多新手坑:不提交 offset,消息一直卡在原始队列反复抛错
伪代码示意
while (true) {
ConsumerRecords records = consumer.poll();
for (ConsumerRecord record : records) {
try {
process(record); //业务处理
} catch (BusinessException e) {
// 不可恢复异常,转入DLQ
sendToDLQ(record);
} catch (DBTempException e) {
//临时异常,重试
int retryCount = getRetryCount(record);
if(retryCount < 3){
sendToRetryTopic(record, retryCount+1);
}else{
sendToDLQ(record);
}
}
consumer.commitSync(); //提交offset,原始消息不再拉取
}
}
四、DLQ Topic 设计要点
- 分区数:建议和原 topic 分区数保持一致,或者根据死信量单独评估
- 消息保留时间:设置更长保留时长(比如 7 天),方便排查,到期自动清理
- 消息附加元数据(非常重要) 转发到 DLQ 时,不要只转发原始消息体 ,额外带上 header:
- 原 topic、分区、offset
- 失败时间、异常堆栈
- 重试次数
- 消息产生时间 方便定位是哪条消息、什么时候失败
- 消费隔离:死信 Topic 单独消费,不能和业务 consumer 共用 死信一般人工消费、导出、审计、修复后重放,不自动回写业务。
五、常见坑
- ❌ 忘记提交 offset → 消息无限重试,消费卡死
- ❌ 死信不带元数据 → 出问题找不到原始上下文
- ❌ 所有异常一律丢 DLQ:临时故障也直接丢,消息丢失
- ❌ DLQ 不做监控:死信堆积没人发现,故障延迟很久才感知
建议监控:DLQ 消息数量,一旦新增消息触发告警
六、框架内置支持
很多框架封装好了 DLQ,不用自己写转发逻辑:
- Spring Kafka:
DeadLetterPublishingRecoverer,配置重试、死信 - Flink Kafka Consumer、Spark Streaming 也提供 DLQ 配置
- 原生 Kafka 客户端:需要自己编码实现转发
七、死信消息后续怎么处理
- 监控告警:DLQ 有消息触发告警
- 排查:读取死信消息 + 异常信息定位 bug
- 修复数据 / 代码后:
- 方案 1:写工具消费 DLQ,修复后重新投递到原始 topic
- 方案 2:人工补发