主题
死信队列与消费重试
一句话:消费失败不是直接丢,而是按模式走两条路——并发消费进
%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)。
| 第几次 | 延迟级别 | 间隔 |
|---|---|---|
| 1 | 3 | 10s |
| 2 | 4 | 30s |
| 3 | 5 | 1m |
| 4 | 6 | 2m |
| 5 | 7 | 3m |
| 6 | 8 | 4m |
| 7 | 9 | 5m |
| 8 | 10 | 6m |
| 9 | 11 | 7m |
| 10 | 12 | 8m |
| 11 | 13 | 9m |
| 12 | 14 | 10m |
| 13 | 15 | 20m |
| 14 | 16 | 30m |
| 15 | 17 | 1h |
| 16 | 18 | 2h |
默认最多 16 次,累计等待约 4 小时 46 分(10s + 30s + 1m + … + 2h = 17140s),不是「16 次 × 固定间隔」。第 16 次仍失败,消息进入死信 Topic %DLQ%{consumerGroup}。
maxReconsumeTimes 可以在 Consumer 上调整;把它设小能让消息更快进 DLQ,适合演示或明确不可重试的业务。
顺序消费:原地重试,阻塞队列
顺序消费不能把失败消息发到重试 Topic,否则会打乱顺序。它的做法是:
- 消费失败后不提交位点,锁住当前 Queue。
- 等待
suspendCurrentQueueTimeMillis(默认 1000ms)后重投给本地消费者。 - 重试次数达到
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。用它「重投死信」是错的。
可行做法:
- 控制台重发:在 RocketMQ Console 的死信页面选中消息,修改后重新发送到原 Topic(或目标 Topic)。这是最常用、最可控的方式。
- 写一个 DLQ 消费者:订阅
%DLQ%{group},把消息体读出后重新构造并发送到原 Topic,成功后提交 DLQ 位点。适合批量处理。 - 确认丢弃:业务确认消息已过期或无价值后,停止处理并让它在 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 故障留时间,不是给永久故障做遮羞布。