Zum Inhalt springen

Muster für die Verarbeitung von Hintergrund-Jobs

Nicht alles gehört in den Request-Response-Zyklus — Queues, Worker, Retries und Dead-Letter-Muster für eine zuverlässige Hintergrundverarbeitung.

3 Min. Lesezeit
Architekturdiagramm einer Job-Warteschlange mit Produzenten, Warteschlange, Workern und Dead-Letter-Queue

Bestätigungs-E-Mails versenden, PDF-Berichte erzeugen, hochgeladene Bilder verarbeiten, Daten mit Drittanbieter-APIs synchronisieren — nichts davon gehört in den Request-Response-Zyklus. Diese Aufgaben sind langsam und unzuverlässig, und ihr Fehlschlag sollte die Aktion des Nutzers nicht blockieren. Die Verarbeitung von Hintergrund-Jobs verlagert diese Arbeit in eine separate Pipeline, in der sie unabhängig wiederholt, gedrosselt und überwacht werden kann.

Warum nicht einfach setTimeout verwenden?

tstypescript
// ❌ "Background" processing that isn't actually reliable
app.post("/api/orders", async (req, res) => {
  const order = await createOrder(req.body);
 
  // This runs in the same process — if the server crashes, the email is lost
  setTimeout(async () => {
    await sendConfirmationEmail(order);
    await updateInventory(order);
    await notifyWarehouse(order);
  }, 0);
 
  res.json(order);
});

Wenn der Server neu startet, sind diese Callbacks weg. Kein Retry, keine Transparenz, keine Garantie, dass die Arbeit abgeschlossen wird. Eine richtige Job-Queue persistiert die Arbeit und übersteht Abstürze.

Das Job-Queue-Muster

Eine Job-Queue besteht aus drei Komponenten: Produzenten (stellen Arbeit ein), ein persistentes Speichersystem (hält Jobs vor) und Konsumenten/Worker (verarbeiten Jobs).

tstypescript
// Producer: enqueue a job after the main operation
import { Queue } from "bullmq";
import { Redis } from "ioredis";
 
const connection = new Redis(process.env.REDIS_URL);
const emailQueue = new Queue("email", { connection });
 
app.post("/api/orders", async (req, res) => {
  const order = await createOrder(req.body);
 
  // Job is persisted in Redis — survives server restarts
  await emailQueue.add("order-confirmation", {
    orderId: order.id,
    customerEmail: order.customerEmail,
    orderTotal: order.total,
  });
 
  res.json(order);
});
tstypescript
// Consumer: process jobs from the queue
import { Worker } from "bullmq";
 
const emailWorker = new Worker(
  "email",
  async (job) => {
    switch (job.name) {
      case "order-confirmation":
        await sendConfirmationEmail(
          job.data.customerEmail,
          job.data.orderId,
          job.data.orderTotal,
        );
        break;
      case "shipping-notification":
        await sendShippingEmail(job.data);
        break;
    }
  },
  {
    connection,
    concurrency: 5, // Process 5 jobs simultaneously
  },
);
 
emailWorker.on("completed", (job) => {
  console.log(`Job ${job.id} completed`);
});
 
emailWorker.on("failed", (job, error) => {
  console.error(`Job ${job?.id} failed:`, error.message);
});

Retry-Strategien

Jobs schlagen fehl. APIs laufen in Timeouts, Dienste fallen aus, Rate-Limits werden erreicht. Eine gute Retry-Strategie behandelt vorübergehende Fehler, ohne permanente Fehler noch schlimmer zu machen.

tstypescript
// Exponential backoff with jitter
await emailQueue.add(
  "order-confirmation",
  { orderId: order.id },
  {
    attempts: 5,
    backoff: {
      type: "exponential",
      delay: 2000, // 2s, 4s, 8s, 16s, 32s
    },
  },
);
tstypescript
// ❌ Retrying non-retryable errors wastes resources
async function processJob(job: Job) {
  try {
    await callExternalAPI(job.data);
  } catch (error) {
    // Retrying a 400 Bad Request will never succeed
    throw error; // All errors get retried
  }
}
 
// ✅ Distinguish retryable from permanent failures
async function processJob(job: Job) {
  try {
    await callExternalAPI(job.data);
  } catch (error) {
    if (error.status === 429 || error.status >= 500) {
      throw error; // Retryable — network/server issue
    }
    // Permanent failure — don't retry, move to dead letter
    throw new UnrecoverableError(error.message);
  }
}
FehlertypBeispielAktion
VorübergehendTimeout, 503, Verbindung verweigertRetry mit Backoff
Rate-Limit429 Too Many RequestsRetry mit längerer Verzögerung
Permanent400 Bad Request, ungültige DatenDead-Letter-Queue
BugNicht abgefangener TypeErrorDead-Letter-Queue + Alarm

Dead-Letter-Queues

Wenn ein Job alle Retries ausgeschöpft hat, wandert er zur Untersuchung in eine Dead-Letter-Queue. Verwirf fehlgeschlagene Jobs niemals stillschweigend.

tstypescript
// Configure dead letter queue behavior
const orderQueue = new Queue("orders", {
  connection,
  defaultJobOptions: {
    attempts: 5,
    backoff: { type: "exponential", delay: 2000 },
    removeOnComplete: { age: 86400 }, // Keep completed for 24h
    removeOnFail: false, // Never auto-delete failed jobs
  },
});
 
// Monitor dead letter jobs
async function getDeadLetterJobs() {
  const failed = await orderQueue.getFailed(0, 100);
  return failed.map((job) => ({
    id: job.id,
    name: job.name,
    data: job.data,
    failedReason: job.failedReason,
    attemptsMade: job.attemptsMade,
    timestamp: job.timestamp,
  }));
}

Job-Planung

Manche Jobs müssen nach einem Zeitplan laufen — tägliche Berichte, stündliche Synchronisierungen, regelmäßige Aufräumarbeiten.

tstypescript
// Repeatable jobs with cron expressions
await reportQueue.add(
  "daily-revenue-report",
  {}, // Data can be empty for scheduled jobs
  {
    repeat: {
      pattern: "0 8 * * *", // Every day at 8 AM
      tz: "America/New_York",
    },
  },
);
 
await cleanupQueue.add(
  "expire-old-sessions",
  {},
  {
    repeat: {
      pattern: "*/15 * * * *", // Every 15 minutes
    },
  },
);

Idempotenz

Jobs können mehr als einmal zugestellt werden (At-least-once-Zustellung). Jeder Job-Handler muss dasselbe Ergebnis liefern, egal ob er einmal oder zehnmal läuft.

tstypescript
// ❌ Not idempotent — charging the customer multiple times
async function processPayment(job: Job) {
  await stripe.charges.create({
    amount: job.data.amount,
    customer: job.data.customerId,
  });
}
 
// ✅ Idempotent — uses an idempotency key to prevent duplicates
async function processPayment(job: Job) {
  await stripe.charges.create(
    {
      amount: job.data.amount,
      customer: job.data.customerId,
    },
    {
      idempotencyKey: `order-${job.data.orderId}`,
    },
  );
}

Verwende eindeutige Bezeichner (Bestell-ID, Job-ID) als Idempotenzschlüssel. Prüfe, ob die Arbeit bereits erledigt wurde, bevor du sie erneut ausführst.

Die wichtigsten Erkenntnisse

  1. Verlagere langsame, unzuverlässige Arbeit aus dem Request-Zyklus in Hintergrund-Jobs
  2. Persistiere Jobs in einem dauerhaften Speicher (Redis, PostgreSQL) — Verarbeitung im Arbeitsspeicher ist unzuverlässig
  3. Wiederhole mit exponentiellem Backoff bei vorübergehenden Fehlern, nutze Dead-Letter bei permanenten
  4. Jeder Job-Handler muss idempotent sein — Jobs können mehr als einmal zugestellt werden
  5. Verwirf fehlgeschlagene Jobs niemals stillschweigend — Dead-Letter-Queues bewahren sie für die Untersuchung auf
  6. Überwache Queue-Tiefe und Verarbeitungslatenz — wachsende Queues signalisieren Kapazitätsprobleme
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX