跳到正文

死信队列与消费重试 ​

一句话:消费失败不是直接丢,而是按模式走两条路——并发消费进 %RETRY% 延迟重试,顺序消费在原地重试;都到上限后进 %DLQ%,等人工处理。

适用版本:4.9 / 5.3 / 5.5。重试阶梯与 DLQ 命名一致;顺序消费的默认重试上限在不同客户端版本有差异,需显式设置。

为什么需要 ​

消费失败很常见:下游超时、数据库死锁、消息格式不兼容。如果失败就丢,业务数据直接缺口;如果立刻无限重试,又会把消费者线程和下游打满。

RocketMQ 的兜底链是「延迟重试 + 上限 + 死信」:给 transient 故障留恢复时间,给永久故障设停止条件,最后把无法处理的消息集中到一个可观测、可人工干预的地方。工程上要做的不是消灭失败,而是让失败可见、可控、可重放。

核心机制 ​

并发消费:延迟阶梯重试 ​

并发消费失败时,Consumer 返回 RECONSUME_LATER,客户端把消息发回 Broker 的重试 Topic %RETRY%{consumerGroup},原 Queue 的位点继续前进,不阻塞后续消息。

重试消息带延迟级别,第 N 次重试对应 delayLevel = N + 2(第一次是 level 3)。

第几次延迟级别间隔
1310s
2430s
351m
462m
573m
684m
795m
8106m
9117m
10128m
11139m
121410m
131520m
141630m
15171h
16182h

默认最多 16 次,累计等待约 4 小时 46 分(10s + 30s + 1m + … + 2h = 17140s),不是「16 次 × 固定间隔」。第 16 次仍失败,消息进入死信 Topic %DLQ%{consumerGroup}。

maxReconsumeTimes 可以在 Consumer 上调整;把它设小能让消息更快进 DLQ,适合演示或明确不可重试的业务。

顺序消费:原地重试,阻塞队列 ​

顺序消费不能把失败消息发到重试 Topic,否则会打乱顺序。它的做法是:

  1. 消费失败后不提交位点,锁住当前 Queue。
  2. 等待 suspendCurrentQueueTimeMillis(默认 1000ms)后重投给本地消费者。
  3. 重试次数达到 maxReconsumeTimes 后,调用 sendMessageBack 把消息转入 DLQ,再放行后续消息。

因此顺序消费的失败代价是整条 Queue 停住。另外,顺序消费的 maxReconsumeTimes 默认值在部分版本表现为不限制(Integer.MAX_VALUE),不显式设置可能导致一条坏消息永久卡住队列。生产上必须设置一个合理上限,并监控 DLQ。

DLQ 的几个事实 ​

  • 命名:%DLQ% + ConsumerGroup,例如 %DLQ%order-consumer;不需要手工创建。
  • 权限:DLQ 是系统 Topic,普通 Consumer 不会自动订阅它;要用控制台或管理工具查看、重发。
  • 保留:与普通消息相同,受 fileReservedTime 控制,默认 72 小时(3 天),到期随过期文件清理删除。
  • 进入 DLQ 后不会自动重试,也不会自动恢复;它是一个「待处理队列」。

重投 DLQ 消息的正确方式 ​

mqadmin resetOffsetByTime 只重置原 Topic 上某个消费组的位点,它不会把 DLQ 里的消息搬回原 Topic,也不会让消费组去消费 DLQ。用它「重投死信」是错的。

可行做法:

  1. 控制台重发:在 RocketMQ Console 的死信页面选中消息,修改后重新发送到原 Topic(或目标 Topic)。这是最常用、最可控的方式。
  2. 写一个 DLQ 消费者:订阅 %DLQ%{group},把消息体读出后重新构造并发送到原 Topic,成功后提交 DLQ 位点。适合批量处理。
  3. 确认丢弃:业务确认消息已过期或无价值后,停止处理并让它在 72 小时后随文件清理消失;不要假装它被「修复」了。

重发时要保留原消息的 key、业务唯一 ID,消费端靠幂等去重。

实战场景 ​

一、死信只保留 72 小时,修复时已经没有消息 ​

现象:账务消费组连续两周有零星死信,没人处理。第三周修复了根因,准备重放 %DLQ%account-consumer 里的消息时发现全部为空——Broker 的 fileReservedTime=72 把过期文件连同死信一起清掉了。缺失的 2400 余条消息只能从上游业务流水重新构造,人工耗时两天(经验值,需校准)。

排查:DLQ 是普通消息存储,同样受保留时间控制。

定位层取证据判定
DLQ 保留时长%DLQ%<group> 消息的存储时间 + fileReservedTime=72默认 3 天,到期随过期文件删除
是否有人在消费DLQ 是否有活跃消费者(普通 Consumer 不会自动订阅)无消费者即“只进不看”
告警是否存在%DLQ% 新增计数是否接了告警未接即静默积累后消失
数量趋势控制台死信条数按天曲线长期非零说明根因未修
上游能否重建业务流水是否有足够信息重构消息决定事故的实际代价

死信是“待处理队列”,不是“备份队列”:进入 DLQ 后不会自动重试,也不会自动恢复。

对症做法:

java
// 1. 给每个生产消费组建一个死信导出消费者,落到长期存储
consumer.subscribe("%DLQ%account-consumer", "*");
consumer.registerMessageListener((MessageListenerConcurrently) (msgs, ctx) -> {
    for (MessageExt m : msgs) {
        quarantineRepo.save(m.getTags(), m.getKeys(), m.getBody(),
                m.getUserProperty("RETRY_TIMES"), m.getMsgId());   // 转存到 DB/对象存储
        metrics.dlqArchived(m.getTopic());
    }
    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});
text
2. 告警按“新增即告警”,而不是按“总量”:
   %DLQ% 计数在 10 分钟内 > 0 就通知值班,量级用日累计单独看趋势。
3. 保留期与修复周期要匹配:需要更长窗口就调 fileReservedTime 并核算盘量,
   或者用导出方案(上一条)把死信搬到长期存储。
4. 修复根因后按“重构消息 + 幂等消费”回灌,
   回灌消息带原始业务键,处理逻辑必须能识别已处理过的记录。

复盘要点:判据是“死信在产生后 1 小时内一定有人看到并归档”。把“死信必须存在长期存储”写进消费组上线检查项;根因未修而死信持续产生,说明重试与告警的分工有问题——重试是兜底,不是修复。

二、用 resetOffsetByTime “重投死信”,结果什么都没发生 ​

现象:一次故障后运维按网上教程执行 mqadmin resetOffsetByTime -g account-consumer -s now,控制台显示执行成功,团队记录“已重投”。第二天对账发现这批消息仍未处理,且这次操作让消费组从 1 小时前的位点开始重放,下游收到大量重复请求。

排查:这个命令的作用域是原 Topic 的位点,不是 DLQ。

定位层取证据判定
命令语义resetOffsetByTime 只重置“原 Topic 上某消费组”的位点不会把 DLQ 消息搬回原 Topic,也不会让组去消费 DLQ
是否重置过早位点重置到 1 小时前 → 原 Topic 已处理的消息被重放重复请求的直接来源
DLQ 现状%DLQ%<group> 里消息仍在说明死信从未被处理
操作记录命令、时间、执行人、预期与实际结果缺记录就无法复盘
幂等能力重放时下游是否会二次生效决定该操作能否安全执行
text
正确的处置路径:
1. 处理死信:由死信消费者导出,修复根因后用 mqadmin 或程序把消息重新投递到原 Topic
   (作为新消息发送,保留业务键),下游靠幂等收敛。
2. 位点重置只在“确实需要重放原 Topic”时使用,
   且必须先停消费者、先 --dry-run 看影响区间,再执行。
3. 两类操作不要混称“重投”:一个是搬运死信,一个是回拨位点,
   后者会导致重复消费,前者导致延迟处理。
bash
# 位点操作的正确顺序:停消费 → 试运行 → 执行 → 恢复消费
sh mqadmin consumerProgress -n $NS -g account-consumer          # 先看当前位点与积压
sh mqadmin resetOffsetByTime -n $NS -g account-consumer -t OrderTopic -s 2026-10-20#09:00:00 -j   # 试运行(JSON 输出影响范围)
# 确认区间正确、消费者已下线,才去掉 -j 执行

复盘要点:判据是“所有位点变更必须有 dry-run 输出留档,且执行时消费者已停”。运维手册要写清“死信处理”和“位点重置”是两条不同流程,用不同标题,不共用“重投”这个词——命名歧义本身就是事故来源。

三、16 次阶梯重试把下游打得更慢 ​

现象:风控接口限流后返回业务异常,消费端把它当可重试抛出 RECONSUME_LATER。默认最多 16 次重试、累计约 4 小时 46 分,结果是同一条消息在 5 小时内被反复投递,风控侧 QPS 从平时 200 涨到 900(经验值,需校准),限流窗口迟迟打不开,其他调用方也被牵连。

排查:阶梯重试对“下游过载”是放大器,不是解法。

定位层取证据判定
重试次数与延迟消息的 reconsumeTimes 与 delayLevel(第 N 次对应 N+2)长时间跨度持续重投即阶梯在跑
异常类型分布消费失败的异常分类限流/降级类异常被当成可重试
下游 QPS 曲线重试流量占下游总流量的比例超过 30% 即“自己在打自己”(阈值经验值,需校准)
幂等是否具备重投时下游是否重复生效无幂等则重试还会造成副作用
熔断是否生效消费端是否对下游做快速失败无熔断时每条消息都独立撞墙
第 1..16 次重试延迟:10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
累计 17140s ≈ 4 小时 46 分,不是 16 × 固定间隔

对症做法:

java
// 1. 按异常类型决定语义:限流/拒绝类直接快速失败进 DLQ,不占用 16 次阶梯
public ConsumeConcurrentlyStatus consume(List<MessageExt> msgs, ConsumeConcurrentlyContext ctx) {
    for (MessageExt m : msgs) {
        try {
            riskClient.check(m);
        } catch (DownstreamRejectException e) {       // 明确不可重试
            dlqWriter.sendNow(m, e);                   // 主动进死信,保留原因
            metrics.fastFail("risk.reject");
            continue;                                  // 返回 SUCCESS,不再重投
        } catch (IOException | TimeoutException e) {   // 网络/超时类可重试
            return ConsumeConcurrentlyStatus.RECONSUME_LATER;
        }
    }
    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
java
// 2. 缩短重试预算,让“不可恢复”尽快显式化
consumer.setMaxReconsumeTimes(3);        // 默认 16 对在线业务太长
text
3. 消费端加熔断:下游持续失败时暂停整个消费组(或降低拉取速度),
   而不是让每条消息各自重试 —— 拉取暂停比阶梯重投温和得多。
4. 顺序消费场景另有一套:不能投重试 Topic,只能原地重试并阻塞整条 Queue,
   且部分版本 maxReconsumeTimes 默认可视为不限制,必须显式设上限。

复盘要点:判据是“重试流量占下游请求的比例”和“按异常类型分流的覆盖率”:可重试与不可重试必须在消费代码里显式区分,否则 16 次阶梯会变成默认的“自我 DDoS”。把 maxReconsumeTimes 与异常分类写进消费 SDK 模板,比逐个服务口头约定有效。

最小上手 ​

造一条必然失败的消息,并把重试上限调到 2,快速看到 DLQ:

java
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("cg-dlq-demo");
consumer.setNamesrvAddr("127.0.0.1:9876");
consumer.setMaxReconsumeTimes(2);          // 演示用;生产按业务设置
consumer.subscribe("t-dlq", "*");
consumer.registerMessageListener((MessageListenerConcurrently) (msgs, ctx) -> {
    System.out.println("fail once: " + msgs.get(0).getMsgId());
    return ConsumeConcurrentlyStatus.RECONSUME_LATER;   // 永远失败
});
consumer.start();

发送一条消息后观察:

bash
# 1. 重试 Topic:应有消息,延迟级别随重试次数上升
sh bin/mqadmin topicStatus -n 127.0.0.1:9876 -t "%RETRY%cg-dlq-demo"

# 2. 死信 Topic:重试耗尽后出现消息
sh bin/mqadmin topicStatus -n 127.0.0.1:9876 -t "%DLQ%cg-dlq-demo"

# 3. 按 key 查消息(发送时 setKeys)
sh bin/mqadmin queryMsgByKey -n 127.0.0.1:9876 -t "%DLQ%cg-dlq-demo" -k order-1

# 4. 看消费组积压;DLQ 不在这个组的正常进度里
sh bin/mqadmin consumerProgress -n 127.0.0.1:9876 -g cg-dlq-demo

生产动作 ​

  • 监控 %DLQ% 的 maxOffset 与增长速度。DLQ 上涨说明存在永久性失败,不是「重试还没到」。
  • 给顺序消费显式设置 maxReconsumeTimes,并接受「达到上限前整条 Queue 停住」的事实;不要用无限重试掩盖下游故障。
  • 区分可重试与不可重试错误:参数校验失败、消息格式错误应立即记录到业务失败表并返回 CONSUME_SUCCESS,不要浪费 16 次重试;只有超时、限流、死锁这类 transient 错误才返回 RECONSUME_LATER。
  • DLQ 处理要有责任人:每条死信要么修复后重发,要么标记丢弃,不能长期堆着。
  • 重发必须幂等:DLQ 重发会与原消息、重试消息产生重复,消费端用唯一键或状态机去重。
  • 不要用 resetOffsetByTime 处理 DLQ。它只影响原 Topic 的消费位点,而且会让整个组回退,产生大量重复消费。

边界 ​

  • DLQ 保留 72 小时;超过保留时间会被文件清理删掉,想留证据要提前导出。
  • DLQ 不是「自动恢复队列」。消息进去后不会自己回到原 Topic,必须人工重发或写消费者搬运。
  • 顺序消费不是「不重试」,而是「本地重试、阻塞队列」;默认上限可能很大,必须显式配置。
  • 重试次数可调,但总时长会随延迟级别变化;不要按固定间隔估算。
  • 重试不能解决所有失败。下游长时间不可用时,重试只会把压力延后;要配合熔断、降级和积压告警。

讲清楚 ​

RocketMQ 的失败兜底分两条路。并发消费失败后,消息被发到 %RETRY% 开头的重试 Topic,按 10 秒、30 秒、1 分钟一直到 2 小时的阶梯重试,默认 16 次,总共约 4 小时 46 分;这期间原 Queue 的位点继续走,不阻塞后面的消息。顺序消费不能打乱顺序,所以失败后在本地原地重试,默认每秒一次,一直到 maxReconsumeTimes 上限,这期间整条 Queue 停住。

两条路走到上限都会进 %DLQ% 加消费组名,保留时间和普通消息一样,默认 72 小时。DLQ 不是自动恢复队列,消息不会自己回去,要靠控制台重发或写一个消费者搬运。resetOffsetByTime 只能重置原 Topic 的位点,不能重投 DLQ,这点最容易搞错。

工程上要做三件事:给顺序消费设上限,区分可重试和不可重试错误,监控 DLQ 并指定处理人。重试是给 transient 故障留时间,不是给永久故障做遮羞布。

把一门技术讲透到既懂也会用