Entwurf von Hintergrund-Job-Warteschlangen
Ein Hintergrund-Job-System hat drei Fehlermodi, gegen die Sie von Tag eins an entwerfen müssen: verlorene Jobs, doppelte Ausführung und sich aufschaukelnde Wiederholungen. Wenn Sie das falsche Werkzeug wählen, verbringen Sie das nächste Jahr damit, alle drei zu beheben. Der wiederkehrende Branchenkonsens, sowohl in der BullMQ-, Sidekiq-, SQS- als auch in der Temporal-Dokumentation, ist, dass At-Least-Once die einzig ehrliche Zustellungssemantik ist – gestalten Sie jeden Handler idempotent und hören Sie auf, exakt einmalige Ausführung zu erzwingen. (Caduh — Queues 101)
Schnell zu Ihrem Problem:
- Jobs wurden bei Wiederholung zweimal ausgeführt → Idempotenz
- Worker stürzte mitten im Job ab, Job verschwand → Zuverlässiger Abruf / Sichtbarkeits-Timeout
- DLQ füllt sich, Alarme feuern → Fehlerklassifizierung + DLQ
- Redis Out-of-Memory während eines Rückstaus → Speicherhygiene
- "Sollte das stattdessen Temporal sein?" → Queue vs. Workflow
- Wiederholungsstürme treffen nachgelagerte Systeme → Backoff + Jitter
- Parallelität überschreitet eine Ratenbegrenzung → Parallelität + Begrenzer
Verwendung
- Entwurf eines neuen Hintergrund-Job-Systems in Node, Ruby, Python oder Go.
- Migration von
setTimeout/cron/ selbstgebauten Warteschlangen zu einem echten Broker. - Worker-Abstürze führen zu Jobverlusten.
- Wiederholungen in der Produktion verursachen doppelte Seiteneffekte.
- Auswahl zwischen BullMQ (Node + Redis), Sidekiq (Ruby + Redis), RQ / Celery / Dramatiq (Python), Asynq (Go + Redis), SQS (verwaltet) oder Temporal (dauerhafte Ausführung).
Kernfähigkeiten
Wählen Sie das passende Werkzeug für die Arbeit
| Sie haben | Greifen Sie zu | Grund |
|---|---|---|
| Kurze, nebenwirkungsbehaftete Jobs (E-Mail senden, Bild skalieren) in Node | BullMQ | Redis-nativ, ausgereifte DLQ, Flow-Muster, typisch ~5k Jobs/s/Knoten |
| Gleiches in Ruby | Sidekiq (Pro für Zuverlässigkeit) | Gleiches Muster; super_fetch in Pro bietet zuverlässigen Abruf via LMOVE (Sidekiq Wiki) |
| Mehrschritt-Workflows mit externen API-Fan-Outs, Laufzeiten von Stunden bis Tagen | Temporal | Dauerhafte Ausführung; Wiederholung aus Ereignisverlauf (Temporal-Dokumentation) |
| AWS-nativ, einfacher Fan-Out, möchte kein Redis betreiben | SQS + Lambda | Verwaltet mit At-Least-Once; Sichtbarkeits-Timeout und DLQ sind First-Class (AWS SQS DLQ) |
| Regionsübergreifend, Multi-Tenant-Isolation | Temporal oder SQS | Ein einzelnes Redis überlebt Multi-Region nicht sauber |
| Exakt einmalige Ausführung über DB + E-Mail + Zahlungen hinweg benötigt | Keins — entwerfen Sie für At-Least-Once + idempotente Handler | "Exakt einmalig" innerhalb des Brokers erstreckt sich nicht über Systeme hinweg (Caduh) |
Das versteckte Kriterium: Wie komplex ist die Wiederherstellungsgeschichte? Eine einfache BullMQ-Warteschlange mit Wiederholungen ist für "Quittung senden" in Ordnung. Ein 12-schrittiger Kauf-Workflow, der 24 Stunden auf einen Webhook wartet, ist ein Temporal-Workflow, nicht 12 verkettete Queue-Jobs.
Idempotenz
Die zwingende Eigenschaft. Die Dokumentation jeder gängigen Warteschlange beginnt damit. Von der BullMQ-Seite "Idempotent Jobs": "Es sollte keinen Unterschied für den Endzustand des Systems machen, ob ein Job bei seinem ersten Versuch erfolgreich abgeschlossen wird oder ob er zunächst fehlschlägt und bei einer Wiederholung erfolgreich ist." (BullMQ Idempotent Jobs)
Praktische Muster:
// 1. Idempotenz-Schlüssel, abgeleitet vom Geschäftsereignis, nicht von der Warteschlange.
// Verwenden Sie die upsteam event_id, falls vorhanden (Stripe event.id, Shopify hook id).
const job = await queue.add(
'send-receipt',
{ orderId, amount },
{
jobId: `send-receipt:${orderId}`, // BullMQ dedupliziert mittels jobId in der Warteschlange.
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { age: 3600, count: 1000 },
removeOnFail: { age: 86400 },
}
);
// 2. Im Handler ebenfalls am Ort des Seiteneffekts deduplizieren.
async function processSendReceipt(job) {
const { orderId, amount } = job.data;
// Wenn bereits gesendet, nichts tun.
const sent = await db.queryOne(
'INSERT INTO email_sends (idempotency_key, order_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING id',
[`send-receipt:${orderId}`, orderId]
);
if (!sent) return { skipped: 'already-sent' };
await emailProvider.send({ to: ..., subject: ..., body: render(amount) });
}
Die jobId dedupliziert Hinzufüge, während ein Duplikat noch in der Warteschlange ist. Der DB-Unique-Constraint dedupliziert über Worker-Abstürze, Wiederholungen und Wiederholungen hinweg. Beide sind erforderlich; jobId allein ist unzureichend, da BullMQ abgeschlossene Jobs entfernt und das Deduplizierungsfenster verschwindet.
Zuverlässiger Abruf und Sichtbarkeits-Timeout
Was passiert, wenn ein Worker einen Job abruft und abstürzt?
| System | Standardverhalten | Wiederherstellung |
|---|---|---|
| BullMQ | Job hat ein lockDuration (Standard 30 Sekunden). Der Stalled-Job-Checker stellt die Warteschlange nach Ablauf wieder zu ([BullMQ Produktion][bullmq-production]) | Worker MÜSSEN das Sperre verlängern oder innerhalb von lockDuration fertig sein; andernfalls läuft der Job zweimal |
| Sidekiq OSS | Pop ist nicht atomar; stirbt der Worker zwischen Pop und Verarbeitung, ist der Job verloren | Upgrade auf Sidekiq Pro super_fetch (Redis 6.2+ LMOVE zu einer privaten Arbeitswarteschlange) |
| Sidekiq Pro super_fetch | LMOVE zu einer prozessbezogenen Arbeitsliste. Heartbeat läuft nach 60 Sekunden ab; verwaiste Jobs werden eingesammelt und wieder in die Warteschlange gestellt (Sidekiq Wiki) | Integriert |
| SQS | Sichtbarkeits-Timeout (Standard 30 Sekunden, max. 12 Stunden). Nachricht erscheint nach Ablauf erneut, wenn nicht gelöscht | Setzen Sie das Timeout > p99-Handler-Latenz; verlängern Sie mit ChangeMessageVisibility für lange Jobs |
| Temporal | Aktivitäten haben Heartbeat + Wiederholungsrichtlinien. Der Workflow-Verlauf überlebt den Workertod | Wiederholung aus dem Verlauf; Sie müssen nicht darüber nachdenken |
Die Falle: lockDuration / Sichtbarkeits-Timeout kürzer als Handler-p99 → Job läuft zweimal. Länger als akzeptable Wiederherstellungszeit → abgestürzte Arbeit wartet zu lange. Messen Sie zuerst Ihren p99, setzen Sie das Timeout auf etwa das 3-fache und lassen Sie den Handler für wirklich lange Arbeiten einen Heartbeat senden / die Sperre verlängern.
Fehlerklassifizierung und DLQ
Nicht jeder Fehler sollte sich endlos wiederholen. Aus der SQS-Dokumentation: "Legen Sie die maximale Anzahl von Empfängen und die Redrive-Richtlinie fest, um Nachrichten an die DLQ zu senden, sobald der Schwellenwert überschritten ist." (AWS SQS DLQ)
// Vor dem Werfen klassifizieren. Permanente Fehler sollten sich NICHT wiederholen.
class PermanentError extends Error { constructor(m: string) { super(m); this.name = 'PermanentError'; } }
class TransientError extends Error { constructor(m: string) { super(m); this.name = 'TransientError'; } }
async function process(job) {
try {
const user = await db.user(job.data.userId);
if (!user) throw new PermanentError('user-not-found'); // → sofort zur DLQ
if (user.suspended) throw new PermanentError('user-suspended');
await externalApi.call(user);
} catch (e) {
if (e instanceof PermanentError) {
// Sofort zur DLQ verschieben — Wiederholung hilft nicht.
job.discard(); // BullMQ: verhindert Wiederholung
throw e;
}
throw e; // Netzwerk/Timeout → Wiederholung gemäß attempts-Richtlinie
}
}
Die DLQ benötigt dann:
- Einen Alarm bei steigender Anzahl (nicht nur ungleich Null — "DLQ hat 3 Dinge für immer" ist in Ordnung; "DLQ wuchs um 100/Minute" ist ein Feuer).
- Ein Wiedereinspielungswerkzeug (CLI oder Admin-UI), damit ein Mensch den Upstream-Fehler beheben, die Dead-Letter-Jobs wiedereinspielen und die DLQ leeren kann.
- Dashboards (siehe
grafana-dashboard-builder).
Backoff und Jitter
Exponentieller Backoff allein reicht nicht. Wenn 1000 Jobs alle um 12:00:00 fehlschlagen, weil ein nachgelagerter Dienst kurz ausgefallen ist, führt einfacher exponentieller Backoff dazu, dass alle 1000 genau um 12:00:01, 12:00:03, 12:00:07 wiederholen — eine thundering herd, die den nachgelagerten Dienst möglicherweise weiter unten hält. Fügen Sie Full Jitter hinzu:
// Full Jitter nach AWS-Art: delay = random(0, exp_backoff)
const baseDelay = Math.min(2 ** attempt * 1000, 30_000);
const delay = Math.random() * baseDelay;
Als empfohlene Baseline für BullMQ schlägt das Dokument "Going to Production" Wiederholungsintervalle zwischen 1s und 20s mit retryStrategy vor. ([BullMQ Produktion][bullmq-production])
Parallelität und Begrenzer
Die Parallelität pro Worker führt N Jobs parallel in einem Prozess aus. Mehrere Worker multiplizieren das. Ohne einen Begrenzer auf Warteschlangenebene können Sie keine aggregierten Aufrufe an einen nachgelagerten Dienst begrenzen:
// BullMQ — globale Ratenbegrenzung über alle Worker dieser Warteschlange.
const queue = new Queue('outbound-emails', {
connection,
limiter: { max: 100, duration: 1000 }, // 100 Emails/s über die gesamte Flotte
});
Die richtige Form: viele gleichzeitige Worker + ein Queue-Limiter für nachgelagerte Vertragsgrenzen. Sidekiq hat gleichwertige Ratenbegrenzer; SQS verwendet Lambda-Parallelität oder eine Ratenbegrenzung pro Nachricht.
Speicherhygiene
Der am häufigsten betonte Produktionsfehler bei BullMQ: maxmemory-policy noeviction auf der Redis-Instanz. Alles andere (allkeys-lru, volatile-lru) wird unter Druck stillschweigend Jobs aus der Warteschlange entfernen. ([BullMQ Produktion][bullmq-production])
Außerdem:
removeOnCompleteaggressiv (z. B.{ age: 3600, count: 1000 }).removeOnFailnoch aggressiver, sobald Sie zu einem DLQ-Muster gewechselt sind.- Aktivieren Sie Redis AOF-Persistenz —
appendfsync everysecist der dokumentierte Sweet Spot für BullMQ. ([BullMQ Produktion][bullmq-production]) - Worker
maxRetriesPerRequest: null, um zu verhindern, dass ioredis bei vorübergehenden Trennungen einen Fehler auslöst. ([BullMQ Produktion][bullmq-production]) - Auf der Queue-Seite:
enableOfflineQueue: false, damit der Producer schnell fehlschlägt, anstatt zu puffern.
Sanftes Herunterfahren
process.on('SIGTERM', async () => {
await worker.close(); // keine neuen Jobs annehmen; laufende Jobs beenden.
await connection.quit();
process.exit(0);
});
process.on('SIGINT', async () => { /* gleich */ });
Die BullMQ-Produktionsanleitung weist ausdrücklich darauf hin: Worker schließen, bevor der Prozess gestoppt wird; das standardmäßige Stalling-Timeout beträgt ~30s. ([BullMQ Produktion][bullmq-production])
Queue vs. Workflow Entscheidung
Ein nützlicher Rahmen aus der Temporal-Dokumentation: "Im Gegensatz zu Nachrichtenwarteschlangen, die Daten zwischen Diensten verschieben, orchestriert Temporal ganze Prozesse ... es weiß, wo Sie sich in einem Workflow befinden, was abgeschlossen ist, was aussteht und was wiederholt werden muss." (Temporal Blog)
flowchart TD
A[Hintergrundarbeit] --> B{Einzelner Seiteneffekt oder Pipeline?}
B -->|Einzel| C[BullMQ / Sidekiq / SQS]
B -->|Mehrschritt| D{Schritte dauern Sekunden oder Stunden+ ?}
D -->|Sekunden| E[BullMQ Flows / Sidekiq Batches]
D -->|Stunden+ oder Mensch im Kreislauf| F[Temporal / DBOS / Restate]
C --> G{Regionsübergreifend oder Multi-Tenant-Isolation nötig?}
G -->|Ja| H[SQS oder Temporal — Redis-Einzelcluster wird nicht funktionieren]
G -->|Nein| I[Mit Redis Cluster + AOF + noeviction betreiben]
Anti-Patterns
Idempotenz nur via Redis SET-NX
Symptom: Während eines Redis-Ausfalls oder -Neustarts wird derselbe Job zweimal ausgeführt und erzeugt doppelte Seiteneffekte (Abbuchung, E-Mail, Ticket).
Diagnose: Redis ist ein Cache; es kann verdrängt, neu gestartet oder partitioniert werden. SET-NX-Deduplizierung, die nur in Redis lebt, ist ein Best-Effort.
Behebung: Idempotenz-Schlüssel mit einem DB-Unique-Constraint auf dem Seiteneffekt-Datensatz (z. B. email_sends.idempotency_key). Die DB-Transaktion, die den Seiteneffekt erzeugt, IST das Deduplizierungs-Primitiv.
lockDuration kürzer als Handler-p99
Symptom: Einträge im Stalled-Job-Protokoll; derselbe Job wird von zwei Workern verarbeitet. Kunden beschweren sich.
Diagnose: Standardmäßiges lockDuration ist 30s; der Handler benötigt manchmal 45s; der zweite Worker nimmt ihn auf.
Behebung: Messen Sie p99, setzen Sie lockDuration auf etwa das 3-fache des p99 und rufen Sie job.extendLock() in langen Handlern auf. Oder teilen Sie die Arbeit auf.
Einfacher exponentieller Backoff (kein Jitter)
Symptom: Ein kleiner Ausfall eines nachgelagerten Dienstes wird zum Ausfall; 10k Jobs hämmern im Gleichschritt auf den sich erholenden Dienst ein. Diagnose: Ohne Jitter sind Wiederholungen korreliert. Behebung: Full-Jitter-Backoff. Begrenzen Sie die maximale Verzögerung, um die DLQ-Zeit bis zum Fehler zu begrenzen.
maxmemory-policy allkeys-lru auf Redis
Symptom: Warteschlange verliert unter Last auf mysteriöse Weise Jobs. Keine Fehler. ([BullMQ Produktion][bullmq-production])
Diagnose: Redis hat Job-Schlüssel verdrängt, um Platz zu schaffen.
Behebung: CONFIG SET maxmemory-policy noeviction UND dies beim Start prüfen. Worker sollten sich sonst weigern zu starten.
Vortäuschen exakt einmaliger Ausführung systemübergreifend
Symptom: Kunde wurde zweimal belastet; zweimal die gleiche E-Mail gesendet. Der Entwickler ist sich sicher, dass "der Broker exakt einmalig ist". Diagnose: Selbst ein FIFO/exakt-einmaliger Broker erstreckt seine Garantie nicht auf Ihre DB + Ihren E-Mail-Anbieter + Stripe + Hauptbuch. Behebung: Behandeln Sie die Warteschlange als At-Least-Once. Idempotenz an jeder Seiteneffekt-Grenze.
DLQ ohne Wiedereinspielungswerkzeug
Symptom: "Wir haben 3.200 Jobs in der DLQ. Wir wissen nicht, was darin ist." Die DLQ wird zu einem unbeaufsichtigten Friedhof. Diagnose: Die DLQ wurde erstellt, das Betriebswerkzeug nicht. Behebung: Wiedereinspielung nach ID, nach Zeitbereich, Leeren mit Bestätigung. CLI oder Admin-UI. Getestet.
Lang andauernde, blockierende Arbeit in der Ereignisschleife
Symptom: Der BullMQ-Worker-Prozess hängt; Heartbeats werden gestoppt; die Sperre läuft ab; der Job wird erneut ausgeführt.
Diagnose: CPU-gebundene Arbeit im selben Prozess wie der Queue-Heartbeat; die Ereignisschleife blockiert für > lockDuration.
Behebung: Starten Sie einen Kindprozess oder einen Worker-Thread für CPU-gebundene Arbeit. Oder führen Sie einen sandboxed Processor aus (BullMQ unterstützt die Prozess-Sandboxing pro Job).
Qualitätskriterien
- Test: Chaostest — töten Sie einen Worker mitten im Job; stellen Sie sicher, dass der Job erneut ausgeführt wird und der Seiteneffekt nicht dupliziert wird.
- Test: Wiederholungssturm-Test — lassen Sie den nachgelagerten Dienst für 60s ausfallen; stellen Sie sicher, dass Backoff + Jitter die Wiederholungen verteilen (keine Thundering Herd in den Metriken).
- Jeder Handler ist idempotent und verwendet einen stabilen Idempotenz-Schlüssel, der vom Geschäftsereignis abgeleitet ist.
- Ein DB-Unique-Constraint sichert jede In-Redis-Deduplizierung ab (jobId, SET-NX).
-
lockDuration/ Sichtbarkeits-Timeout ≥ 3× gemessene p99-Handler-Latenz, UND lange Handler verlängern die Sperre. - Permanente Fehler (
PermanentError-Klasse oder Äquivalent) umgehen die Wiederholung und gehen direkt zur DLQ. - DLQ-Alarme: alarmieren, wenn die Wachstumsrate > N pro Minute beträgt, nicht nur wenn der Zähler > 0 ist.
- Das DLQ-Wiedereinspielungswerkzeug existiert und ist getestet (einmalig + Bereich).
- Redis:
maxmemory-policy=noeviction, AOF aktiviert (appendfsync everysec), beim Start des Workers geprüft. ([BullMQ Produktion][bullmq-production]) -
removeOnCompleteundremoveOnFailkonfiguriert. Die Metrik zur Jobanzahl wird im