一、先搞清楚消息是怎么一步步走进死信队列的

平时咱们用 RocketMQ 发消息,就像给同事发了一条微信。正常情况下,消费端拿到消息,处理完业务,回一个 ACK,这条消息就算圆满结束了。但如果消费端处理过程中出了岔子,比如数据库连不上、接口超时、代码抛出异常,RocketMQ 就会认为这条消息没被成功消费。这时候它不会立刻扔掉消息,而是会按照你设置的重试策略,把消息重新投递过来,让你再试试。

这个“再试试”的过程,就是消息重试。RocketMQ 默认允许消费端重试 16 次,重试间隔会越来越长,从 1 秒、2 秒、4 秒……一直翻倍到 2 小时。你可以把它理解成追债的过程:第一次打电话没人接,过一会儿再打,再没人接就等更久再打,直到彻底放弃。

那什么是死信队列呢?当一条消息重试次数达到了上限,还是没被成功消费,RocketMQ 就会把这条消息“扫地出门”,扔到一个特殊的主题里,这个主题就叫死信队列。死信队列里的消息,一般不会自动再投递给消费者了,需要人工介入去查看、修复,然后决定是重新投递还是丢掉。

很多开发者在线上遇到消息反复失败时,第一反应是“为什么会这样?”,然后一顿乱查。其实只要理解了这条链路,你就知道,真正要做的只有两件事:第一,去死信队列里把原始消息捞出来看;第二,翻出消费端的异常堆栈,把失败的真实原因找出来。下面咱们一步步来。

二、拿到死信日志先别慌,按这个顺序看

2.1 死信日志里有什么关键信息

当你发现死信队列里的消息越来越多,第一步不是改代码,而是先去看死信日志。RocketMQ 的死信日志通常记录了这条消息的原始信息,比如:

  • 消息 ID:每条消息唯一的身份证,可以用来追踪这条消息从生产到消费的完整轨迹。
  • 重试次数:到底重试了多少次才进了死信队列,能帮你判断是不是重试配置不合理。
  • 原始主题和消息体:消息具体内容是什么,是 JSON 还是字符串。
  • 投递时间:最后一次投递的时间点,可以帮你和当时的系统状态做对照。

举个例子,你在控制台看到一条死信消息,消息体是这样的:

{
  "orderId": "20250214001",
  "userId": 88888,
  "amount": 199.99,
  "promotionId": "spring-sale"
}

这看起来是一条订单消息,那问题很可能就出在处理这个订单的业务逻辑上。如果消息体本身是乱码或者缺字段,那又得去查生产端是不是序列化出了问题。

2.2 从死信消息体找出线索

死信消息体是你排查的第一手证据。你最好把消息体和线上的业务表数据对照一下。比如消息里的 userId 在用户表里不存在,那大概率是生产端传了个无效用户 ID,消费端查用户信息时查不到,直接抛了空指针异常。

还有一种情况,消息体里有个 promotionId,但促销活动已经下线了,消费端去校验这个 ID 时发现状态不对,然后抛了一个业务异常。这种属于典型的“数据状态和预期不符”导致的重试失败。

三、消费异常堆栈才是真正的破案现场

死信日志告诉你“谁死了”,异常堆栈告诉你“是怎么死的”。所以,一定要把消费端的应用日志打开,搜索对应的消息 ID 或者订单号,找到那一条失败日志。

3.1 常见异常类型分类

我根据线上经验,把导致消息反复失败的异常分成了三类:

  • 临时性异常:比如数据库连接超时、网络抖动、服务暂时不可用。这类异常重试几次大概率就能成功,但如果你配置的重试次数太多,或者重试间隔太短,反而会拖垮下游。
  • 数据性异常:比如消息体里缺字段、字段格式不对、关联数据不存在。这类异常属于“永远不可能成功”的,重试多少次都白搭,因为问题不在消费端,而在消息本身。
  • 逻辑性异常:比如代码里有个 bug,处理某个特定场景时会抛异常。这类异常也是重试无效的,必须修复代码。

3.2 从堆栈定位代码位置

假设你在应用日志里看到了这样的堆栈:

java.lang.NullPointerException: null
    at com.example.order.service.OrderService.processOrder(OrderService.java:108)
    at com.example.order.consumer.OrderMessageListener.onMessage(OrderMessageListener.java:56)
    ...

这里清楚地写着 OrderService 的第 108 行出现了空指针。你打开代码一看,发现是这样一段逻辑:

// OrderService.java 第105-112行
public boolean processOrder(OrderMessage msg) {
    UserInfo user = userMapper.selectById(msg.getUserId()); // 查询用户
    String userName = user.getUserName(); // 第108行:如果user为null,这里就抛空指针
    ...
}

问题一下就明白了:消息里的 userId 在用户表里根本不存在,查询结果 user 是 null,然后直接调用了 user.getUserName(),必空指针。而且这种异常每次重试都会发生,因为用户数据不会自动长出来。

3.3 结合日志上下文确认根因

光看堆栈还不够,你还需要把异常发生前后的业务日志也翻出来。比如同样是空指针,一种是查询用户表返回 null,另一种是远程调用返回 null,修复的方式完全不一样。你可以通过打印的关键参数,确认到底是谁返回了 null。

四、一个完整的排查示例(Java技术栈)

下面我用一个完整的 Java 示例,带你走一遍从死信日志到定位根因的过程。假设我们使用的是 RocketMQ 的 Spring Boot 集成。

4.1 消费端代码(有bug版本)

先看看这个有问题的消费者代码:

// OrderConsumer.java
package com.example.order.consumer;

import com.example.order.service.OrderService;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RocketMQMessageListener(
        topic = "order-topic",           // 订阅的主题
        consumerGroup = "order-consumer", // 消费组
        maxReconsumeTimes = 5             // 设置最大重试次数为5次,方便演示
)
public class OrderConsumer implements RocketMQListener<OrderMessage> {

    @Autowired
    private OrderService orderService;

    /**
     * 消费消息的入口方法
     * @param msg 消息体,由 JSON 自动反序列化成 OrderMessage 对象
     */
    @Override
    public void onMessage(OrderMessage msg) {
        log.info("收到订单消息: {}", msg);
        // 调用业务方法处理订单,如果抛异常,RocketMQ 会自动触发重试
        orderService.processOrder(msg);
    }
}

假设对应的 OrderMessage 类是这样的:

// OrderMessage.java
package com.example.order.consumer;

import lombok.Data;

@Data
public class OrderMessage {
    private String orderId;   // 订单号
    private Long userId;      // 用户ID
    private BigDecimal amount; // 订单金额
}

4.2 业务处理逻辑(带着bug)

// OrderService.java
package com.example.order.service;

import com.example.order.consumer.OrderMessage;
import com.example.order.entity.UserInfo;
import com.example.order.mapper.UserMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class OrderService {

    @Autowired
    private UserMapper userMapper;

    /**
     * 处理订单核心逻辑
     * @param msg 订单消息
     */
    public boolean processOrder(OrderMessage msg) {
        // 1. 根据消息里的userId查询用户信息
        UserInfo user = userMapper.selectById(msg.getUserId());
        log.info("查询到用户信息: {}", user);

        // 2. 给用户增加积分
        // 注意:这里如果user为null,调用getUserId()就会空指针
        Integer points = calculatePoints(user.getUserId(), msg.getAmount());
        log.info("本次订单可获得积分: {}", points);

        // 3. 更新订单状态
        updateOrderStatus(msg.getOrderId());
        return true;
    }

    private Integer calculatePoints(Long userId, BigDecimal amount) {
        // 简化:每消费1元给1积分
        return amount.intValue();
    }

    private void updateOrderStatus(String orderId) {
        // 简化:更新数据库订单状态
        log.info("订单{}已处理完成", orderId);
    }
}

这段代码如果遇到 user 为 null,在第 36 行 user.getUserId() 处就会抛出空指针异常。因为 userId 在数据库里没有对应记录。这条消息重试 5 次后,就会被投递到死信队列。

4.3 查看死信队列消息

在 RocketMQ 控制台,进入死信队列,你可以看到类似这样的消息记录:

Topic: %DLQ%order-consumer
消息ID: C0A8000100002A2B0000000000000001
重试次数: 5
原始Topic: order-topic
消息体: {"orderId":"20250214001","userId":99999,"amount":299.9}

看到 %DLQ%order-consumer,说明这个死信队列归属于 order-consumer 消费组。消息体里的 userId=99999 就是嫌疑最大的点。

4.4 在应用日志里找异常堆栈

去消费者应用的日志文件里,搜索消息 ID 或者 orderId,你会看到这样的记录:

2025-02-14 10:23:45.678 ERROR [MessageThread_1] c.e.o.c.OrderConsumer - consume failed
java.lang.NullPointerException: null
    at com.example.order.service.OrderService.processOrder(OrderService.java:36)
    at com.example.order.consumer.OrderConsumer.onMessage(OrderConsumer.java:31)
    ...

这就把问题定位到了 OrderService 的第 36 行。你打开代码,发现第 36 行正是 user.getUserId()。结合前面的日志,发现 user 为 null。

4.5 修复方式

既然根因是消息里的 userId 在用户表中不存在,那就要针对这种情况做容错处理。比如校验用户是否存在,存在才继续算积分,不存在就打日志记录,然后返回成功,避免无限重试。

// OrderService.java 修复后的关键代码
public boolean processOrder(OrderMessage msg) {
    // 1. 查询用户信息
    UserInfo user = userMapper.selectById(msg.getUserId());
    if (user == null) {
        // 用户可能已被删除,这条消息本身无法正常处理,记录日志后按成功处理
        log.warn("用户不存在,userId={},订单{}无法处理", msg.getUserId(), msg.getOrderId());
        return true;
    }

    // 2. 增加积分
    Integer points = calculatePoints(user.getUserId(), msg.getAmount());
    log.info("本次订单可获得积分: {}", points);

    // 3. 更新订单状态
    updateOrderStatus(msg.getOrderId());
    return true;
}

这样改完之后,即使遇到脏数据,也不会再反复重试了。当然,如果你希望保留这些坏消息做后续分析,也可以主动把消息发到一个自定义的异常主题,而不是让它进死信队列。

五、常见根因和对应的解决办法

5.1 数据格式不对

比如生产端发送的消息体是 JSON,但消费端用了一个字段类型不匹配的类去接收。Java 反序列化时会报错,重试也会一直报错。

解决办法:字段先用 Object 接收,再手动转换;或者在消费者入口加一层 try-catch,对反序列化异常做特殊处理。

5.2 外部依赖出问题

消费端需要调用第三方接口,比如查余额、发短信。如果第三方接口一直返回超时,那消息就会一直重试。这种问题属于下游故障,需要熔断降级。

解决办法:在调用外部依赖时设置超时时间,如果超时了不要抛异常,而是记录状态,把消息发到专门的重试主题,等待外部恢复后再处理。

5.3 业务逻辑bug

就像上面的空指针例子,代码对某个边界情况没有处理,导致异常。这种问题只能靠审查堆栈、修复代码来解决。

5.4 环境变化

比如线上数据库的某个字段被删了,或者某个配置项被改了,导致消费端 SQL 执行失败。这类问题可能不是单条消息的问题,而是批量失败。这时候要看整个消费者是否在同一时间段大量报错,然后检查最近的配置变更。

六、怎么避免这类问题再次发生

6.1 消费端保护措施

一是做好参数校验,消息进来先检查必需的字段,缺失就快速失败,别走到后面业务逻辑才报错。二是对所有外部调用设置合理的超时时间,并且捕获超时异常,不要直接抛出。三是给消费逻辑加上 try-catch,对于确定的、无法重试成功的异常,直接记录并返回成功,避免死循环式重试。

6.2 监控告警

死信队列里的消息数量应该被重点监控。一旦发现死信堆积,立刻报警,这样你就能在用户投诉之前发现问题。同时,消费端的异常日志也要做关键字告警,比如出现 NullPointerException、TimeoutException 时就触发告警。

6.3 重试参数调优

RocketMQ 默认最大重试 16 次,但很多时候不需要那么多。你可以根据自己的业务特性设置合理的重试次数和间隔。比如读操作重试 3 次就够了,写操作可以多一点。另外,RocketMQ 还支持通过 messageDelayLevel 自定义重试间隔,不要全用默认值。

七、总结

线上消息反复失败进入死信队列,这件事本身不可怕,可怕的是没有思路乱查一气。你只要记住一个核心思路:死信日志告诉你是哪条消息出了问题,异常堆栈告诉你为什么出了问题,两者一结合,根因无处可逃。

在实际操作中,你的排查路径应该像这样:先看死信队列里的原始消息体,再到消费者日志里搜对应的异常堆栈,沿着堆栈找到具体代码行,再结合业务数据判断是临时性异常、数据性异常还是逻辑性异常。最后根据根因修代码或调整数据,并且补上监控告警,避免下次再踩同一个坑。

记住,重试机制是 RocketMQ 给你的一次“自我急救”机会,但遇上治不好的病,就得靠死信队列这个“太平间”来给你留线索了。希望这篇文章能让你下次面对死信消息时,不再手足无措。