為什麼不在 HTTP Request 裡做完所有事

// ❌ 在 request handler 裡發 email
app.post('/orders', async (req, res) => {
  const order = await orderService.create(req.body);
  await emailService.sendOrderConfirmation(order);  // 如果 email provider 慢,response 就慢
  await smsService.sendNotification(order);          // 如果 SMS API 掛了,整個 request 失敗
  res.json(order);
});

問題:

  1. Response time 受第三方影響:Email provider 慢了,用戶等的是整個下單流程
  2. 失敗要整個重來:SMS API 噴錯,要讓用戶重新下單嗎?
  3. Server 重啟就丟失:用戶下單後 server crash,那封確認信就沒了

正確做法

// ✅ 下單成功後把任務丟進 queue,立刻回傳
app.post('/orders', async (req, res) => {
  const order = await orderService.create(req.body);
 
  // 丟進 queue,立刻回傳
  await emailQueue.add('send-order-confirmation', { orderId: order.id });
  await smsQueue.add('send-notification', { orderId: order.id, userId: req.user.id });
 
  res.status(201).json(order);  // 不等 email / SMS 完成就回傳
});
 
// 背景 worker 處理
emailQueue.process('send-order-confirmation', async (job) => {
  const order = await orderRepo.findById(job.data.orderId);
  await emailService.sendOrderConfirmation(order);
});

Queue 的三個核心能力

Retry 策略

網路抖動、外部 API 臨時掛掉——這些是暫時性錯誤,retry 就好。Worker 不應該永遠 retry,也不應該只試一次。

// BullMQ(Node.js)
const emailQueue = new Queue('email', { connection: redis });
 
const worker = new Worker('email', async (job) => {
  await emailService.send(job.data);
}, {
  connection: redis,
  defaultJobOptions: {
    attempts: 5,                         // 最多 retry 5 次
    backoff: {
      type: 'exponential',
      delay: 1000,                       // 第 1 次 retry 等 1 秒,第 2 次 2 秒,...
    },
  }
});
 
worker.on('failed', (job, error) => {
  logger.error('Job failed', {
    jobId: job.id,
    jobName: job.name,
    attempts: job.attemptsMade,
    error: error.message,
  });
});

Exponential backoff 是標準做法:第 1 次失敗後等 1 秒重試,再失敗等 2 秒,再失敗等 4 秒——給外部服務時間恢復,同時避免連打。


Dead Letter Queue(DLQ)

Retry 到上限還失敗的 job,要去哪裡?

答案:Dead Letter Queue——一個存放「無法處理」的 job 的特殊 queue。

// BullMQ 設定 DLQ
const emailWorker = new Worker('email', async (job) => {
  await emailService.send(job.data);
}, {
  connection: redis,
});
 
emailWorker.on('failed', async (job, error) => {
  if (job.attemptsMade >= job.opts.attempts) {
    // 達到 retry 上限,送進 DLQ
    await deadLetterQueue.add('failed-email', {
      originalJob: job.data,
      error: error.message,
      failedAt: new Date().toISOString(),
      jobId: job.id,
    });
 
    // 通知工程師
    await alertService.send('Email job exhausted retries', { jobId: job.id });
  }
});

DLQ 的 job 需要人工介入處理:

  • 確認是程式 bug 還是資料問題
  • 修復後可以重新 enqueue(requeue)
  • 或標記為永久失敗(紀錄 + 補償操作)

沒有 DLQ 的後果:job 失敗超過 retry 次數後就消失——你不知道有多少 email 沒送到、多少通知沒發出。


Job 去重(Deduplication)

Queue 裡的另一個常見問題:同一個 job 被觸發多次,但只應該執行一次。

典型場景:

  • 用戶快速連點「發布」按鈕,觸發三次 send-notification job
  • 批次更新 10 個商品,每個都觸發 rebuild-search-index,但 index 只需要重建一次
  • Webhook 發送方 retry,你的系統把同一個事件放進 queue 兩次

BullMQ 的 jobId 去重

// 指定 jobId,相同 jobId 的 job 不會重複加進 queue
await searchQueue.add(
  'rebuild-index',
  { category: 'electronics' },
  {
    jobId: 'rebuild-index:electronics',  // 固定的 ID
    // 如果 queue 裡已有這個 jobId,新的 add 會被忽略
  }
);
 
// 批次更新 10 個商品時,10 次 add 只會有 1 個 job 在 queue
for (const product of updatedProducts) {
  await searchQueue.add(
    'rebuild-index',
    { category: product.category },
    { jobId: `rebuild-index:${product.category}` }  // 同 category 只有一個 job
  );
}

Redis SET NX 手動去重(不依賴 BullMQ jobId):

async function addJobOnce(queue: Queue, jobName: string, data: unknown, dedupeKey: string, ttlMs = 5000) {
  const lockKey = `job-dedupe:${dedupeKey}`;
  const acquired = await redis.set(lockKey, '1', 'NX', 'PX', ttlMs);
 
  if (!acquired) {
    // 這個 key 在 TTL 內已經有 job 在跑了,跳過
    return null;
  }
 
  return queue.add(jobName, data);
}
 
// 使用
await addJobOnce(
  notificationQueue,
  'send-push',
  { userId, message },
  `push:${userId}`,
  3000  // 3 秒內不重複觸發
);

去重和冪等(Idempotency)的差異:去重是在 enqueue 前擋掉重複;冪等是在 執行時確保重複執行結果相同。兩個都要——去重減少無謂的工,冪等保證萬一去重失效時結果還是正確的。


Job Priority

不是所有 job 都一樣重要:

// 高優先:密碼重設 email(使用者等著)
await emailQueue.add('password-reset', { userId }, { priority: 1 });
 
// 低優先:月報產生(不急)
await emailQueue.add('monthly-report', { month: '2026-03' }, { priority: 10 });

Priority 值越小,優先級越高(BullMQ 的慣例)。


什麼任務適合放 Queue

適合

  • Email / SMS / Push notification
  • 產生報表、匯出 CSV
  • 呼叫有 rate limit 的外部 API(要控制速度)
  • 圖片處理、影片轉檔
  • 資料同步(webhook 轉發、ETL)
  • 任何超過 1–2 秒的操作

不適合

  • 需要即時回傳結果的操作(client 在等 response)
  • 簡單的 DB 操作(加 queue 反而多一層複雜度)

各框架的 Queue 工具

語言/框架推薦工具特點
Node.jsBullMQ(Redis-based)最主流,有 UI dashboard(Bull Board)
PythonCelery(可接 Redis / RabbitMQ)Python 生態標準,與 Django / FastAPI 整合成熟
Java / SpringSpring Batch / RabbitMQ / Kafka企業級,支援分散式 job
LaravelLaravel Queue(內建,可接 Redis / SQS)與 Laravel 深度整合,Horizon 提供 dashboard
RailsSidekiq(Redis-based)社群最成熟,Active Job 介面統一

Crontab vs Queue

Article 24 有提到 crontab 的使用情境。Crontab 和 Queue 不是競爭關係:

Crontab → 定時觸發(每天凌晨 2 點產生報表)
Queue   → 事件觸發(使用者下單後發 email)

實際上,很多場景是Cron + Queue 的組合

// Cron 定時把大量 job 放進 queue,由多個 worker 並行處理
cron.schedule('0 2 * * *', async () => {
  // 取得所有需要月報的用戶
  const users = await userRepo.findSubscribers();
  for (const user of users) {
    // 不是在 cron 裡直接產生,而是丟進 queue
    await reportQueue.add('monthly-report', { userId: user.id, month: '2026-03' });
  }
});

延伸閱讀