0Pricing
Node.js Backend Development Bootcamp · 课时

确认、死信队列与重试

通过手动确认保证投递,处理有害消息,并实现重试和死信流程。

确认、死信队列与重试 是 CoddyKit 上的免费 Node.js Backend Development Bootcamp 课时。 这是第 3 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Node.js Backend Development Bootcamp 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Node.js Backend Development Bootcamp 课程共包含 4 节课。

本课时的部分内容尚未翻译,以英文显示。

Why Acknowledgements Matter

By default, when a consumer receives a message from RabbitMQ it must tell the broker whether the message was processed successfully. This signal is called an acknowledgement (ack).

If you enable auto-ack, RabbitMQ deletes a message the instant it is delivered. If your worker then crashes mid-processing, the message is gone forever. With manual acks, the broker keeps the message until you confirm it, and re-delivers it if the consumer dies.

  • auto-ack: fast, but at-most-once delivery (can lose messages)
  • manual ack: safe, gives you at-least-once delivery

For any job that does real work (charging a card, sending email, writing to a DB), you almost always want manual acks.

Consuming With Manual Acks

Using the amqplib library, you pass { noAck: false } to channel.consume to enable manual acknowledgement. After your handler finishes successfully, you call channel.ack(msg).

Until you ack, RabbitMQ considers the message unacked and will redeliver it if the channel or connection closes.

const amqp = require('amqplib');

async function start() {
  const conn = await amqp.connect('amqp://localhost');
  const channel = await conn.createChannel();
  const queue = 'orders';

  await channel.assertQueue(queue, { durable: true });

  channel.consume(queue, async (msg) => {
    if (msg === null) return;
    const order = JSON.parse(msg.content.toString());
    console.log('Processing order', order.id);
    // ... do real work here ...
    channel.ack(msg); // confirm success
  }, { noAck: false });
}

start();

ack, nack, and reject

RabbitMQ gives you three ways to respond to a delivered message:

  • channel.ack(msg) — success, remove the message
  • channel.nack(msg, false, requeue) — failure; if requeue=true the message goes back to the queue, if false it is discarded (or dead-lettered)
  • channel.reject(msg, requeue) — like nack but for a single message only

The second argument of nack is allUpTo. Set it to false to act only on the current message; true nacks every unacked message up to this one.

Key decision: requeue=true retries immediately and can cause infinite hot-loops for poison messages. Prefer requeue=false plus a dead-letter strategy.

// Failure with no requeue -> message is dropped or dead-lettered
channel.nack(msg, false, false);

// Failure with requeue -> message returns to the front of the queue
channel.nack(msg, false, true);

// reject only ever affects this one message
channel.reject(msg, false);

Prefetch: Controlling In-Flight Work

Without limits, RabbitMQ pushes as many messages as it can to a consumer, all sitting unacked in memory. That can overwhelm a slow worker.

channel.prefetch(n) sets the maximum number of unacknowledged messages a consumer may hold at once. With manual acks, a worker with prefetch(1) processes one message, acks it, then receives the next — giving fair, back-pressured dispatch.

const channel = await conn.createChannel();
await channel.assertQueue('orders', { durable: true });

// Only one unacked message at a time per consumer
await channel.prefetch(1);

channel.consume('orders', async (msg) => {
  await handle(msg);
  channel.ack(msg);
}, { noAck: false });

What Is a Poison Message?

A poison message is one that always fails processing — bad JSON, a reference to a deleted record, an unrecoverable validation error. If you simply nack(msg, false, true) it, the message requeues, gets redelivered, fails again, and loops forever, pinning your CPU.

You need a way to give up after a few tries and move the message somewhere safe for inspection. That somewhere is a Dead-Letter Queue (DLQ).

  • Stop retrying after N attempts
  • Move the message out of the main flow
  • Keep it for debugging / manual replay

Dead-Letter Exchanges

RabbitMQ can automatically route a message to another exchange when it is dead-lettered. A message is dead-lettered when:

  • it is nacked/rejected with requeue=false, or
  • its TTL expires, or
  • the queue exceeds its max length

You configure this with the queue arguments x-dead-letter-exchange (required) and optionally x-dead-letter-routing-key. Bind a queue to that exchange and dead messages land there.

// Main queue routes failures to the 'dlx' exchange
await channel.assertExchange('dlx', 'direct', { durable: true });
await channel.assertQueue('orders.dlq', { durable: true });
await channel.bindQueue('orders.dlq', 'dlx', 'orders.dead');

await channel.assertQueue('orders', {
  durable: true,
  arguments: {
    'x-dead-letter-exchange': 'dlx',
    'x-dead-letter-routing-key': 'orders.dead'
  }
});

Sending a Failure to the DLQ

Once the orders queue has a dead-letter exchange configured, dropping a message there is simply a matter of nacking without requeue.

The broker handles the routing for you — you never publish to the DLQ manually. This keeps your consumer logic clean: process, and on unrecoverable failure, let RabbitMQ dead-letter it.

channel.consume('orders', async (msg) => {
  try {
    const order = JSON.parse(msg.content.toString());
    await processOrder(order);
    channel.ack(msg);
  } catch (err) {
    console.error('Unrecoverable failure:', err.message);
    // requeue=false -> RabbitMQ dead-letters to orders.dlq
    channel.nack(msg, false, false);
  }
}, { noAck: false });

Counting Retries With Headers

Often you want to retry a few times before giving up — a transient DB timeout might succeed on the second try. RabbitMQ has no built-in retry counter, so you track attempts yourself.

When a message is dead-lettered, RabbitMQ adds an x-death header array describing each dead-letter event, including a count. You can read it to decide whether to retry or to route to the final DLQ.

function deathCount(msg) {
  const xDeath = msg.properties.headers && msg.properties.headers['x-death'];
  if (!Array.isArray(xDeath) || xDeath.length === 0) return 0;
  // sum counts across dead-letter events for this reason
  return xDeath.reduce((sum, d) => sum + (d.count || 0), 0);
}

const attempts = deathCount(msg);
if (attempts >= 3) {
  channel.nack(msg, false, false); // give up -> parking DLQ
} else {
  // route back for another attempt
}

Delayed Retries With a TTL Wait Queue

Immediate requeue retries instantly, which is bad for transient errors that need time to clear. A common pattern is a retry queue with a TTL that dead-letters back to the main queue.

Flow: main queue fails → message goes to orders.retry which has x-message-ttl (e.g. 5s) and a dead-letter exchange pointing back to orders. After the TTL expires, RabbitMQ moves it back automatically — giving a built-in delay between attempts.

// Retry queue: holds messages for 5s, then dead-letters back to main
await channel.assertQueue('orders.retry', {
  durable: true,
  arguments: {
    'x-message-ttl': 5000,
    'x-dead-letter-exchange': '',          // default exchange
    'x-dead-letter-routing-key': 'orders'  // back to main queue
  }
});

// On a retryable failure, publish into the wait queue instead of nacking
channel.sendToQueue('orders.retry', msg.content, {
  persistent: true,
  headers: msg.properties.headers
});
channel.ack(msg); // ack the original; the copy is now waiting

Putting the Retry Logic Together

Here is the decision flow a robust consumer follows on failure:

  • Parse/validate fails permanently → dead-letter to the parking DLQ immediately
  • Transient error and attempts < max → push to the retry (TTL) queue for a delayed re-attempt
  • Transient error but attempts ≥ max → give up, send to the parking DLQ

This bounds total work, avoids hot-loops, and preserves failed messages for inspection or manual replay.

const MAX_RETRIES = 3;

channel.consume('orders', async (msg) => {
  let order;
  try {
    order = JSON.parse(msg.content.toString());
  } catch (e) {
    return channel.nack(msg, false, false); // poison -> parking DLQ
  }

  try {
    await processOrder(order);
    channel.ack(msg);
  } catch (err) {
    const attempts = retryHeader(msg);
    if (attempts >= MAX_RETRIES) {
      channel.nack(msg, false, false);    // exhausted -> parking DLQ
    } else {
      channel.sendToQueue('orders.retry', msg.content, {
        persistent: true,
        headers: { ...msg.properties.headers, 'x-retries': attempts + 1 }
      });
      channel.ack(msg);                    // ack original; retry is queued
    }
  }
}, { noAck: false });

A Runnable Retry Simulation

The RabbitMQ behavior needs a broker, but the retry decision logic is plain JavaScript you can reason about and test in isolation. Below is a self-contained simulation of the ack / retry / dead-letter decision an order consumer makes.

It demonstrates the core idea: succeed and ack, retry while under the limit, and park to the DLQ once retries are exhausted.

const MAX_RETRIES = 3;

function decide(message) {
  // simulate processing: 'good' succeeds, 'bad-json' is poison, else transient
  if (message.kind === 'good') return { action: 'ack' };
  if (message.kind === 'bad-json') return { action: 'dlq', reason: 'poison' };

  const attempts = message.retries || 0;
  if (attempts >= MAX_RETRIES) return { action: 'dlq', reason: 'exhausted' };
  return { action: 'retry', retries: attempts + 1 };
}

const inbox = [
  { id: 1, kind: 'good' },
  { id: 2, kind: 'bad-json' },
  { id: 3, kind: 'transient', retries: 0 },
  { id: 4, kind: 'transient', retries: 3 }
];

for (const msg of inbox) {
  const result = decide(msg);
  console.log('msg ' + msg.id + ' ->', JSON.stringify(result));
}

Quick Check

You have a consumer using manual acks. A message contains malformed JSON that can never be parsed. You want to stop it from looping forever while keeping it for later inspection.

Recap

You learned how to make RabbitMQ delivery reliable and resilient:

  • Manual acks (noAck: false + channel.ack) give at-least-once delivery so a crash never loses a message.
  • nack/reject with requeue=false hands a failed message to a dead-letter exchange instead of looping forever.
  • prefetch(n) back-pressures slow workers by capping unacked messages.
  • Dead-letter exchanges (x-dead-letter-exchange) auto-route failures to a DLQ for inspection and replay.
  • Retries use a TTL wait queue that dead-letters back to the main queue, with an attempt counter (via x-death or a custom header) to cap retries before parking the message.

Together these patterns give you bounded, observable, lossless message processing.

常见问题解答

「确认、死信队列与重试」课时是免费的吗?

是的 — 「确认、死信队列与重试」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Node.js Backend Development Bootcamp 课程的其余内容,请升级到 CoddyKit PRO。 Node.js Backend Development Bootcamp 课程共包含 4 节课。

「确认、死信队列与重试」这节课中我会学到什么?

通过手动确认保证投递,处理有害消息,并实现重试和死信流程。 你通过在浏览器中直接运行的动手代码来练习 Node.js Backend Development Bootcamp,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Node.js Backend Development Bootcamp 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Node.js Backend Development Bootcamp 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 3 节课,共 4 节。

「确认、死信队列与重试」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Node.js Backend Development Bootcamp 课中编写并运行代码吗?

能。每节 Node.js Backend Development Bootcamp 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 生产者、消费者与 AMQP 模型
  2. 交换器类型:直连、主题、扇出与标头
  3. 确认、死信队列与重试
  4. 工作队列、预取与竞争消费者
← 返回 Node.js Backend Development Bootcamp