消息队列死信堆积怎么排查和清空

SQS / RabbitMQ / Kafka 的 DLQ 不停涨。先抽样分类失败、修毒丸消息根因、收紧重试预算,再把安全消息回放回去。

SQS 死信队列上周还只有 3 条消息,今天就到了 8000。或者 RabbitMQ 的死信交换机(DLX)在不断积压。或者 Kafka 死信 topic 没边界。每一次激增都对应一类消费者处理不了的消息:schema 不匹配、下游超时、payload 畸形、或者逻辑 bug。任 DLQ 静悄悄涨下去,就是在丢业务;一旦消息撞上队列留存上限(SQS 默认 4 天、最长 14 天),就彻底丢了。

最快的稳定路径:先不删消息、抽 10 条样本,把失败分类(schema / 超时 / 畸形 / 业务逻辑),修掉那一个根因,把 maxReceiveCount 收到 5,再把安全的消息回放回去。在搞清楚为什么失败之前不要批量回放——它们会原样弹回 DLQ。

下面会逐类细讲,但顺序始终一样:先抽样,再修根因,最后才回放。

第一步:先判断你在哪个桶里

动任何东西之前先抽样。每一类失败的修法完全不同,而且绝大多数 DLQ 暴涨都只是单独一类。

样本里的信号大概率原因对应步骤
每条失败消息都是同一个 schema 版本,多/少了一个字段生产者和消费者 schema 漂移Step 2
失败元数据里有 timeoutETIMEDOUT503;增长跟下游延迟同步下游 API 变慢或挂了Step 3
同一个 payload hash 反复出现;消费者崩溃循环;ApproximateReceiveCount 接近上限单条毒丸消息Step 6
消息年龄参差,maxReceiveCount 超过 10,真问题几天后才冒头重试预算太宽Step 4
DLQ 涨了几周都没人收到 page没有告警Step 7
DLQ 增长时间跟消费者 scale-in 事件吻合事故期间消费者被缩容见下方说明

六个原因,按踩坑频率排序

  1. 生产者和消费者 schema 漂移。 生产者加了个新必填字段,消费者解析在旧代码路径上抛异常。消费者没发版之前,每条新消息都失败。判断:所有 DLQ 样本都来自同一个生产者版本,多了一个消费者解析不了的字段。
  2. 下游 API 在超时。 消费者要扇出到一个第三方 API,对方开始变慢。每条消息超时、重试 N 次、进 DLQ。判断:DLQ 增长跟下游延迟同步;元数据里的失败原因写着 timeout。
  3. 单条毒丸消息。 一条畸形消息让消费者进程崩;消费者重启,又拿到同一条,又崩。判断:消费者日志反复崩在同一个 payload hash 上;SQS ApproximateReceiveCount 接近上限。
  4. 重试预算太宽,掩盖了真正的失败。 maxReceiveCount = 100 意味着一条坏消息要试 100 次才进 DLQ,真问题几天后才被发现。SQS 默认是 10,有效范围 11000(截至 2026 年 6 月)。超过 10 通常就是个信号。
  5. DLQ 没监控也没告警。 DLQ 已经涨了几周,没人收到 page。等有人注意到,已经积了 5 万条。
  6. 事故期间消费者被缩容。 低 CPU 时自动伸缩把消费者数量降下来;主队列堆积,visibility 超时,消息重入队,最终越过 DLQ 阈值。判断:DLQ 增长时间戳跟消费者 scale-in 事件吻合。修法是改伸缩策略(按队列深度 / ApproximateNumberOfMessagesVisible 伸缩,而不是按 CPU),积压交给 Step 5 的回放处理。

动手前先确认

  • 把 DLQ 深度和增长率做个快照;抓 10 条样本。
  • 找到 DLQ 归属的消费者服务。
  • 确认处理是否幂等(能安全重放)还是不能(重放有风险)。
  • 重放前按失败类别打标。
  • 需要修 schema 时跟生产者团队同步。

需要收集的信息

  • DLQ 大小、增长速率、最老消息的年龄。
  • 10-20 条样本消息的 body + 元数据(ApproximateReceiveCountApproximateFirstReceiveTimestamp)。
  • 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 里的 maxReceiveCount1035
RabbitMQ(quorum)x-delivery-limit 队列参数20(自 4.0 起)5
Kafka(Spring)@RetryableTopic 里的 attempts335

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-0orders-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 optimizedCustom 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 条/秒;回放后的消息会拿到新的 messageIDenqueueTime,留存周期会重置;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 任务状态到 COMPLETEDaws 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,有效范围 11000(截至 2026 年 6 月)。大多数服务 3 到 5 才是合适值。

为什么回放回来的 SQS 消息看着像全新的? 原生 DLQ redrive 会给每条消息分配新的 messageIDenqueueTime,并重置留存周期;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