在微服务架构中,消息队列是解耦、削峰、异步的核心组件。但现实很骨感——消费者可能因网络抖动、数据库超时、业务异常等原因消费失败。如果处理不当,轻则数据丢失,重则系统雪崩。
Apache RocketMQ 提供了完善的失败重试 + 死信队列(DLQ)机制,帮助我们优雅应对消费异常。本文将从原理到实战,手把手教你:
- 消费失败后如何自动重试?
- 什么条件下消息会进入死信队列?
- 如何编写代码消费死信消息并人工干预?
一、消费失败 ≠ 消息丢失:RocketMQ 的重试机制
当消费者处理消息抛出异常或返回 ConsumeConcurrentlyStatus.RECONSUME_LATER 时,RocketMQ 不会丢弃消息,而是将其放入重试队列(Retry Queue) ,延迟后重新投递。
✅ 重试规则(集群模式下)
-
默认最多重试 16 次
-
采用指数退避策略,延迟时间依次为:
1s → 5s → 10s → 30s → 1m → 2m → ... → 2h -
重试消息存储在特殊 Topic:
%RETRY%<消费者组名>
📌 广播模式(BROADCASTING)不支持重试!
🔧 自定义最大重试次数
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("order-service-group");
consumer.setMaxReconsumeTimes(3); // 超过3次失败就进死信队列
二、死信队列(DLQ):最后的安全网
当消息重试次数达到上限仍未成功,RocketMQ 会将其投递到死信队列(Dead Letter Queue) ,避免无限循环重试。
📥 进入 DLQ 的条件
- 消费失败 + 重试次数 ≥
maxReconsumeTimes - 消息未过期(默认保留 3 天)
🏷️ DLQ 命名规则
%DLQ%<消费者组名>
// 例如:%DLQ%order-service-group
⚠️ 无需手动创建!第一条消息进入时自动创建。
三、实战:模拟消费失败 → 进入 DLQ → 人工处理
步骤 1:启动一个“故意失败”的消费者
// OrderConsumer.java
public class OrderConsumer {
public static void main(String[] args) throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("order-service-group");
consumer.setNamesrvAddr("localhost:9876");
consumer.subscribe("ORDER_TOPIC", "*");
consumer.setMaxReconsumeTimes(2); // 仅重试2次
consumer.registerMessageListener((List<MessageExt> msgs, ConsumeConcurrentlyContext context) -> {
for (MessageExt msg : msgs) {
System.out.println("收到消息: " + new String(msg.getBody()));
// 模拟业务异常
throw new RuntimeException("数据库连接失败!");
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});
consumer.start();
System.out.println("订单消费者启动,故意失败...");
}
}
运行后,你会看到日志:
收到消息: {"orderId": "1001"}
收到消息: {"orderId": "1001"} // 5秒后
收到消息: {"orderId": "1001"} // 10秒后
// 第3次失败 → 消息进入 %DLQ%order-service-group
步骤 2:编写死信消费者,人工处理
// DeadLetterConsumer.java
public class DeadLetterConsumer {
public static void main(String[] args) throws MQClientException {
// ⚠️ 必须使用相同的消费者组!
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("order-service-group");
consumer.setNamesrvAddr("localhost:9876");
// 订阅死信队列
String dlqTopic = "%DLQ%order-service-group";
consumer.subscribe(dlqTopic, "*");
consumer.registerMessageListener((List<MessageExt> msgs, ConsumeConcurrentlyContext context) -> {
for (MessageExt msg : msgs) {
try {
System.out.println("【死信消息】MsgId: " + msg.getMsgId());
System.out.println("原始 Topic: " + msg.getProperty(MessageConst.PROPERTY_REAL_TOPIC));
System.out.println("消息内容: " + new String(msg.getBody()));
// TODO: 人工处理逻辑
// 1. 记录到数据库(含错误原因)
// 2. 发送企业微信/钉钉告警
// 3. 修复后重新发送到原 Topic(谨慎操作!)
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
} catch (Exception e) {
// DLQ 本身不再重试!务必返回 SUCCESS 避免堆积
log.error("处理死信失败,MsgId={}", msg.getMsgId(), e);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});
consumer.start();
System.out.println("死信消费者启动成功");
}
}
💡 关键点:
- Consumer Group 必须与原消费者一致
- 永远不要在 DLQ 消费者中返回
RECONSUME_LATER(否则会无限堆积)
四、生产环境最佳实践
✅ 1. 监控 + 告警
- 使用
mqadmin或 RocketMQ Dashboard 监控 DLQ 消息量 - 设置告警:
DLQ 消息数 > 0
✅ 2. 提供人工干预入口
开发一个管理后台,支持:
- 查看死信消息详情
- 一键重发(发送到原 Topic)
- 标记为“已忽略”
✅ 3. 日志记录完整上下文
log.warn("消息进入死信队列 | MsgId={} | OriginalTopic={} | Body={}",
msg.getMsgId(),
msg.getProperty(MessageConst.PROPERTY_REAL_TOPIC),
new String(msg.getBody())
);
✅ 4. 避免“重试地狱”
对于确定性业务错误(如 JSON 格式错误),应直接记录日志并返回 CONSUME_SUCCESS,不要触发重试。
五、总结
| 场景 | RocketMQ 行为 |
|---|---|
| 消费失败 | 进入重试队列 %RETRY%group,延迟重试 |
| 重试超限 | 进入死信队列 %DLQ%group |
| DLQ 消费 | 需手动编写消费者,无自动重试 |
| 消息保留 | 默认 3 天(可配置) |
死信队列不是垃圾桶,而是“待救援区” 。
合理利用它,才能真正做到 消息不丢、故障可溯、系统健壮。