SQS 死信队列上周还只有 3 条消息,今天就到了 8000。或者 RabbitMQ 的死信交换机(DLX)在不断积压。或者 Kafka 死信 topic 没边界。每一次激增都对应一类消费者处理不了的消息:schema 不匹配、下游超时、payload 畸形、或者逻辑 bug。任 DLQ 静悄悄涨下去,就是在丢业务;一旦消息撞上队列留存上限(SQS 默认 4 天、最长 14 天),就彻底丢了。
最快的稳定路径:先不删消息、抽 10 条样本,把失败分类(schema / 超时 / 畸形 / 业务逻辑),修掉那一个根因,把 maxReceiveCount 收到 5,再把安全的消息回放回去。在搞清楚为什么失败之前不要批量回放——它们会原样弹回 DLQ。
下面会逐类细讲,但顺序始终一样:先抽样,再修根因,最后才回放。
第一步:先判断你在哪个桶里
动任何东西之前先抽样。每一类失败的修法完全不同,而且绝大多数 DLQ 暴涨都只是单独一类。
| 样本里的信号 | 大概率原因 | 对应步骤 |
|---|---|---|
| 每条失败消息都是同一个 schema 版本,多/少了一个字段 | 生产者和消费者 schema 漂移 | Step 2 |
失败元数据里有 timeout、ETIMEDOUT、503;增长跟下游延迟同步 | 下游 API 变慢或挂了 | Step 3 |
同一个 payload hash 反复出现;消费者崩溃循环;ApproximateReceiveCount 接近上限 | 单条毒丸消息 | Step 6 |
消息年龄参差,maxReceiveCount 超过 10,真问题几天后才冒头 | 重试预算太宽 | Step 4 |
| DLQ 涨了几周都没人收到 page | 没有告警 | Step 7 |
| DLQ 增长时间跟消费者 scale-in 事件吻合 | 事故期间消费者被缩容 | 见下方说明 |
六个原因,按踩坑频率排序
- 生产者和消费者 schema 漂移。 生产者加了个新必填字段,消费者解析在旧代码路径上抛异常。消费者没发版之前,每条新消息都失败。判断:所有 DLQ 样本都来自同一个生产者版本,多了一个消费者解析不了的字段。
- 下游 API 在超时。 消费者要扇出到一个第三方 API,对方开始变慢。每条消息超时、重试 N 次、进 DLQ。判断:DLQ 增长跟下游延迟同步;元数据里的失败原因写着 timeout。
- 单条毒丸消息。 一条畸形消息让消费者进程崩;消费者重启,又拿到同一条,又崩。判断:消费者日志反复崩在同一个 payload hash 上;SQS
ApproximateReceiveCount接近上限。 - 重试预算太宽,掩盖了真正的失败。
maxReceiveCount = 100意味着一条坏消息要试 100 次才进 DLQ,真问题几天后才被发现。SQS 默认是10,有效范围1到1000(截至 2026 年 6 月)。超过 10 通常就是个信号。 - DLQ 没监控也没告警。 DLQ 已经涨了几周,没人收到 page。等有人注意到,已经积了 5 万条。
- 事故期间消费者被缩容。 低 CPU 时自动伸缩把消费者数量降下来;主队列堆积,visibility 超时,消息重入队,最终越过 DLQ 阈值。判断:DLQ 增长时间戳跟消费者 scale-in 事件吻合。修法是改伸缩策略(按队列深度 /
ApproximateNumberOfMessagesVisible伸缩,而不是按 CPU),积压交给 Step 5 的回放处理。
动手前先确认
- 把 DLQ 深度和增长率做个快照;抓 10 条样本。
- 找到 DLQ 归属的消费者服务。
- 确认处理是否幂等(能安全重放)还是不能(重放有风险)。
- 重放前按失败类别打标。
- 需要修 schema 时跟生产者团队同步。
需要收集的信息
- DLQ 大小、增长速率、最老消息的年龄。
- 10-20 条样本消息的 body + 元数据(
ApproximateReceiveCount、ApproximateFirstReceiveTimestamp)。 - DLQ 开始增长那段时间的消费者日志。
- 生产者最近的发布。
- 这段时间下游服务的健康度。
分步修复
Step 1:抽样并分类
接收但不删除,让消息留在 DLQ 里供你检查。把 visibility timeout 设得够长,能读完这 10 条;又够短,万一你走开了它们会重新出现。
# SQS:接收但不删除
aws sqs receive-message \
--queue-url "$DLQ_URL" \
--max-number-of-messages 10 \
--visibility-timeout 30 \
--attribute-names All \
--message-attribute-names All > sample.json
每条样本归类:
- Schema 不匹配(特定生产者版本、缺字段或多字段)
- 下游超时(错误元数据里有提)
- JSON / 编码畸形(parse error)
- 业务逻辑失败(校验有意拒绝)
- 未知(读 payload)
每一类修法不同。注意:这里设较长的 --visibility-timeout 并不会消耗 maxReceiveCount 的一次投递次数,除非该消息之后在真实消费者里处理失败。
Step 2:修 schema 漂移
接收时要宽容。新字段标可选并给合理默认值,让新旧生产者版本都能解析。
import { z } from 'zod';
const MessageSchema = z.object({
id: z.string(),
userId: z.string(),
// 新字段:可选 + 合理默认值
source: z.string().optional().default('unknown'),
// 旧字段:向后兼容
type: z.string(),
});
function parse(raw: string) {
try {
return MessageSchema.parse(JSON.parse(raw));
} catch (err) {
metrics.counter('mq_parse_failure').inc({ reason: String(err) });
throw err;
}
}
加字段时消费者先发。新字段一开始全部标可选,等所有消费者实例都升到新版本后,再改成必填。
Step 3:下游调用加单条超时
没有单调用超时时,一个慢下游会把整个消费者池堵死,把一次延迟抖动变成 DLQ 暴涨。
async function processMessage(msg: Message) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
try {
await fetch(downstream, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
把单调用超时压得明显低于队列的 visibility timeout,否则第一次还没跑完,broker 就会重投同一条消息,导致重复处理。
Step 4:收紧重试预算
收紧 redrive 策略,让一条坏消息几次就进 DLQ,而不是一百次。每种 broker 的旋钮和默认值都不一样(截至 2026 年 6 月):
| Broker | 重试预算设置 | 默认值 | 推荐值 |
|---|---|---|---|
| SQS | 源队列 RedrivePolicy 里的 maxReceiveCount | 10 | 3 到 5 |
| RabbitMQ(quorum) | x-delivery-limit 队列参数 | 20(自 4.0 起) | 5 |
| Kafka(Spring) | @RetryableTopic 里的 attempts | 3 | 3 到 5 |
SQS 的设置在「源队列」上,不在 DLQ 上。
# SQS:在「源队列」上收紧 redrive 策略
aws sqs set-queue-attributes \
--queue-url "$MAIN_URL" \
--attributes '{
"RedrivePolicy": "{\"deadLetterTargetArn\":\"<dlq-arn>\",\"maxReceiveCount\":\"5\"}",
"VisibilityTimeout": "60"
}'
5 次是合理默认值。失败 5 次后进 DLQ + 告警是正确路径。记住 maxReceiveCount 数的是接收次数,不是墙上时钟的重试次数,所以它跟你的 visibility timeout 是耦合的。
RabbitMQ(quorum 队列,自 RabbitMQ 4.0 起的默认队列类型):quorum 队列会强制一个 delivery-limit(RabbitMQ 4.x 默认 20,截至 2026 年 6 月);一旦某条消息的投递计数超过它,消息就被丢弃,或在配了 DLX 时被死信。把它显式调低:
channel.assertQueue('main', {
durable: true,
arguments: {
'x-queue-type': 'quorum',
'x-delivery-limit': 5,
'x-dead-letter-exchange': 'dlx',
'x-dead-letter-routing-key': 'failed',
},
});
如果你需要 broker 反复重试死信直到 DLX 确认收到,把 x-dead-letter-strategy 设成 at-least-once(默认是 at-most-once,它可能静默丢掉被死信的消息)。at-least-once 需要把 overflow 设成 reject-publish 并给源队列配 max-length;用默认的 drop-head 溢出策略时,它会静默退回 at-most-once。
Kafka 没有内建 DLQ——用 retry-topic 加死信 topic 的模式(例如 Spring Kafka 的 @RetryableTopic 配 @DltHandler):在默认 attempts = 3(一次原始投递加两次重试)下,失败记录依次流过 orders-retry-0 和 orders-retry-1,退避时间指数递增,最后进 orders-dlt。退避要带 jitter,免得一次下游抖动触发同步的重试风暴。
Step 5:根因修好后回放安全消息
在 SQS 上,除非你需要改写消息,否则别手写复制循环。用原生的 DLQ redrive,它把消息从 DLQ 移回源队列(或同类型的自定义目标),还能限速。
SQS 控制台里:打开你配成死信队列的那个队列,点 Start DLQ redrive,在 Message destination 下选 Redrive to source queue(s)(或选 Redrive to custom destination 填 ARN),把 Velocity control 设成 System optimized 或 Custom max velocity(上限 500 条/秒),然后点 Redrive messages。要中止就点 Cancel DLQ redrive。
CLI / SDK 用 StartMessageMoveTask(用 ListMessageMoveTasks 跟踪,用 CancelMessageMoveTask 中止):
# SQS 原生回放:DLQ -> 它的源队列,限速 50 条/秒
aws sqs start-message-move-task \
--source-arn "<dlq-arn>" \
--max-number-of-messages-per-second 50
截至 2026 年 6 月的注意点(见 SQS DLQ redrive 文档):一个 redrive 任务最长跑 36 小时;每个账号最多 100 个活跃 redrive 任务;自定义最大速率上限为 500 条/秒;回放后的消息会拿到新的 messageID 和 enqueueTime,留存周期会重置;redrive 过程中 SQS 不能过滤或改写消息。先用很低的速率起步,一边盯源队列一边往上调。
如果你确实需要在回放时改写或过滤(或者你不在 SQS 上),就自己排空 DLQ,并先验证一小批:
import { SQSClient, ReceiveMessageCommand, SendMessageCommand, DeleteMessageCommand } from '@aws-sdk/client-sqs';
async function replayDLQ(client: SQSClient, dlqUrl: string, mainUrl: string) {
while (true) {
const { Messages } = await client.send(new ReceiveMessageCommand({
QueueUrl: dlqUrl,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 5,
}));
if (!Messages || Messages.length === 0) break;
for (const m of Messages) {
await client.send(new SendMessageCommand({
QueueUrl: mainUrl,
MessageBody: m.Body!,
MessageAttributes: m.MessageAttributes,
}));
await client.send(new DeleteMessageCommand({
QueueUrl: dlqUrl,
ReceiptHandle: m.ReceiptHandle!,
}));
}
}
}
确认根因修好之后再回放。先回放 10 条、看着它们成功,再回放其余的。
Step 6:把毒丸消息隔离
对于确定性失败(一条永远会让解析器崩的 payload),别让它循环。接收几次后就把它路由到单独的隔离队列,让它别再毒害真实链路。
// 跟踪接收次数;超过 N 次进永久隔离
if (Number(msg.attributes.ApproximateReceiveCount) > 3) {
await sendToQuarantine(msg);
return;
}
隔离队列不自动重试。运维人工看了再决定。Step 1 里的「未知」桶就该放这儿。
Step 7:加 DLQ 深度告警
DLQ 绝不该静悄悄涨满。哪怕只卡了一条消息,也意味着有东西需要查。
# CloudWatch alarm
alarm_name: dlq-depth-high
metric: ApproximateNumberOfMessagesVisible
queue: my-service-dlq
threshold: 1
period: 300
comparison: GreaterThanThreshold
DLQ 持续 5 分钟超过 1 条就 page on-call。(有些团队把阈值设成 5 之类的小数字来吸收已知的瞬时噪声,但目标始终是 > 0 就追查。)RabbitMQ 用 management 对 DLX 目标队列做告警;Kafka 对 *-dlt topic 的消费组 lag 和消息数做告警。
怎么确认修好了
- 回放后 DLQ 深度回到基线(一般是 0)。
- redrive 任务状态到
COMPLETED(aws sqs list-message-move-tasks --source-arn <dlq-arn>)。 - 24 小时内消费者错误率稳在 0.1% 以下。
- 抽 10 条新处理的消息,确认解析路径对新旧生产者 schema 都有效。
- 下游延迟 p99 稳在你 Step 3 设的单调用超时之内。
长期预防
- 生产环境每个队列都必须有 DLQ 告警,阈值压到接近 0。
- 消费者 schema 默认可选字段;生产者后发。
- 全公司统一重试预算:3-5 次(
maxReceiveCount/x-delivery-limit)后进 DLQ。 - 季度复盘 DLQ:任何 DLQ 大于 0 都要追查。
- 处理默认幂等(用以消息 ID 为键的幂等 key),让回放永远安全。
- 消费者按队列深度伸缩,别按 CPU,免得事故期间消费者池被悄悄饿死。
容易踩的坑
- 提
maxReceiveCount来「争取时间」——只是延后看到真问题。 - 没修根因就回放 DLQ——同样的消息立刻回来。
- 没有幂等 key,回放会重复处理。
- 忘了 SQS redrive 会重置
messageID和留存时钟,破坏下游的去重假设。 - 在 RabbitMQ 上指望
at-least-once死信能配默认的drop-head溢出工作——它会静默退化成 at-most-once。 - DLQ 一直晾到季度末;消息撞上留存上限就丢了。
FAQ
看不懂的 DLQ 消息能直接删吗? 不能。挪到隔离队列里慢慢看。删了就丢掉了找 bug 需要的信号。
DLQ 多久排一次? 理想是从不——目标是 DLQ 永远为 0。一涨就当天修,修上线后再回放。
能不能让 DLQ 自动回放? 谨慎情况下可以:只针对已经根因清楚的失败类别,比如下游故障已经恢复。未知失败永远别自动回放,没有幂等处理也别自动回放。
SQS maxReceiveCount 的默认值和范围是多少? 默认是 10,有效范围 1 到 1000(截至 2026 年 6 月)。大多数服务 3 到 5 才是合适值。
为什么回放回来的 SQS 消息看着像全新的? 原生 DLQ redrive 会给每条消息分配新的 messageID 和 enqueueTime,并重置留存周期;SQS 把它们当新消息。如果下游有任何东西依赖消息年龄或 ID,要把这点考虑进去。
调大 visibility timeout 能解决 DLQ 堆积吗? 只有当原因确实是「消费者每条消息需要更多时间」时才行。如果原因是毒丸消息或 schema 漂移,调长 visibility timeout 只是放慢出血速度。先用上面的表格诊断。
RabbitMQ 或 Kafka 上有没有 SQS redrive 的等价物? 都没有一键 redrive。RabbitMQ 上用 Shovel 插件(或 rabbitmqadmin)把 DLX 目标队列里的消息搬回主交换机。Kafka 上写个小的 consumer/producer,读 *-dlt 再发回源 topic——也就是 Step 5 那个转换循环的写法,按你的客户端改一下。两种情况都要先修根因。
怎么确认生产者/消费者 schema 不匹配真的修好了? 消费者修复上线后,回放 10 条消息,确认它们离开 DLQ 且几分钟内不再回来。然后抽 10 条新生产的消息,检查解析路径对新旧两个 schema 版本都接受(Step 2 把字段标可选的做法,正是让两者都能过的关键)。
相关阅读
标签: #后端 #排查 #message-queue