Jedes Queueing-System, das du einsetzen wirst — SQS, RabbitMQ, BullMQ, Sidekiq, Pub/Sub — verspricht "at-least-once"-Zustellung. Keines verspricht exactly-once, denn exactly-once-Zustellung in einem verteilten System ist nachweislich unmöglich, ohne dass der Consumer mitspielt. Das Netzwerk kann einen Ack verschlucken. Ein Worker kann abstürzen, nachdem er die Arbeit erledigt, aber bevor er sie bestätigt hat. Ein Retry kann feuern, während der ursprüngliche Versuch noch läuft.
Die unbequeme Wahrheit: Die Zustellgarantie deiner Queue ist nicht dein Problem. Das Verhalten deines Job-Handlers bei doppelter Ausführung schon. Wenn du dafür designst, dass "das hier mehr als einmal läuft, manchmal sogar gleichzeitig", wird exactly-once-Verhalten erreichbar — obwohl exactly-once-Zustellung nie erreichbar sein wird.
Warum Retries immer Duplikate erzeugen
Ein typischer Job-Lebenszyklus sieht so aus: Nachricht abholen → verarbeiten → bestätigen. Der Fehlermodus, der naive Handler zum Einsturz bringt, ist die Lücke zwischen "verarbeiten" und "bestätigen".
// ❌ Bad: work and acknowledgment aren't atomic
async function processPayment(job: Job<PaymentPayload>) {
await chargeCard(job.data.customerId, job.data.amountCents);
await sendReceiptEmail(job.data.customerId);
// If the process crashes here, or the ack never reaches the broker,
// the queue redelivers this job — and the customer gets charged twice.
}Wenn der Worker direkt nach dem erfolgreichen chargeCard-Aufruf stirbt, aber bevor die Nachricht bestätigt wurde, werden die meisten Broker die Nachricht an einen anderen Worker erneut zustellen. Dieser Worker weiß nicht, dass die Abbuchung bereits durchgeführt wurde. Das ist kein hypothetischer Randfall — es ist das Standardverhalten jeder at-least-once-Queue unter Last, bei Deployments und Netzwerkpartitionen.
Idempotenz-Keys: das Fundament
Die Lösung besteht darin, jedem Job eine stabile Identität zu geben und diese Identität zu erfassen, bevor irgendetwas Irreversibles passiert. Das ist dasselbe Muster, das für idempotente API-Requests verwendet wird, nur angewendet auf Background-Verarbeitung.
-- ✅ Good: a dedupe table backed by a unique constraint
CREATE TABLE job_executions (
idempotency_key TEXT PRIMARY KEY,
job_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'processing',
result JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);// ✅ Good: claim the key atomically before doing side effects
async function processPayment(job: Job<PaymentPayload>) {
const key = job.data.idempotencyKey; // generated once, at job creation time
const claimed = await db.query(
`INSERT INTO job_executions (idempotency_key, job_type)
VALUES ($1, 'process_payment')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key`,
[key],
);
if (claimed.rowCount === 0) {
// Already claimed by this run or a previous attempt.
const existing = await db.query(
`SELECT status, result FROM job_executions WHERE idempotency_key = $1`,
[key],
);
if (existing.rows[0].status === "completed") {
return existing.rows[0].result; // safe to no-op, work already done
}
throw new RetryableError("Job already in progress, back off and retry");
}
const result = await chargeCard(job.data.customerId, job.data.amountCents);
await db.query(
`UPDATE job_executions SET status = 'completed', result = $2, completed_at = now()
WHERE idempotency_key = $1`,
[key, result],
);
await sendReceiptEmail(job.data.customerId, result);
return result;
}Die Zeile INSERT ... ON CONFLICT DO NOTHING trägt hier die ganze Last. Sie verwandelt die Frage "lief dieser Job schon" von einer Race Condition in eine einzige atomare Datenbankoperation. Der Unique-Constraint erledigt genau das, was Checks auf Anwendungsebene unter Nebenläufigkeit nicht sicher leisten können.
Der Idempotenz-Key muss einmalig bei der Job-Erstellung generiert werden und mit der Job-Payload mitreisen. Wenn du bei jedem Retry einen neuen Key generierst, hast du den gesamten Mechanismus ausgehebelt.
Locks lösen ein anderes Problem als Idempotenz
Es ist verlockend, zu einem Distributed Lock zu greifen (Redis SETNX, Postgres Advisory Locks, ZooKeeper) und anzunehmen, das reiche aus. Tut es nicht. Ein Lock verhindert gleichzeitige Ausführung; er tut nichts gegen sequenzielle erneute Ausführung nach einem Absturz.
// ❌ Bad: a lock only stops concurrency, not retries
async function runNightlyReport(jobId: string) {
const lockKey = `lock:report:${jobId}`;
const acquired = await redis.set(lockKey, "1", "NX", "EX", 300);
if (!acquired) return; // another worker is already on it
await generateAndSendReport(jobId);
await redis.del(lockKey);
// If the worker crashes after generateAndSendReport but before del,
// the lock expires after 300s and the NEXT retry runs the whole thing again.
}Locks und Idempotenz-Keys lösen komplementäre Probleme:
| Mechanismus | Verhindert | Verhindert nicht |
|---|---|---|
| Distributed Lock | Zwei Worker verarbeiten denselben Job gleichzeitig | Dass derselbe Job nach einem Absturz oder Retry erneut verarbeitet wird |
| Idempotenz-Key + Unique-Constraint | Erneute Verarbeitung derselben logischen Operation, für immer | Zwei Worker, die kurz gegeneinander laufen, bevor der Constraint greift |
| Beides kombiniert | Gleichzeitige Ausführung und doppelte Seiteneffekte | Nichts Nennenswertes — das ist die eigentliche Lösung |
Nutze Locks, um verschwendete Arbeit und Konkurrenz zu reduzieren (warum zwei Worker um denselben Job wetteifern lassen, wenn einer sofort zurückweichen kann?). Nutze Idempotenz-Keys, um Korrektheit zu garantieren, selbst wenn der Lock fehlschlägt, abläuft oder umgangen wird. Behandle einen Lock niemals als eigenständige Korrektheitsgarantie — allein durch die TTL-Ablaufzeit ist das unsicher.
Seiteneffekte selbst idempotent machen
Es reicht nicht, festzuhalten, dass ein Job gelaufen ist, wenn der Seiteneffekt selbst — eine E-Mail, ein Webhook, ein Drittanbieter-API-Call — nicht von sich aus idempotent ist. Eine nicht idempotente Aktion in einen Idempotenz-Check einzuwickeln schützt dich nur, wenn der Check vor der Aktion und atomar erfolgt.
// ✅ Good: push idempotency down to the external call when the API supports it
async function chargeCard(customerId: string, amountCents: number) {
return stripe.charges.create(
{ customer: customerId, amount: amountCents, currency: "usd" },
{ idempotencyKey: `charge:${customerId}:${amountCents}:${dayBucket()}` },
);
}Die meisten Zahlungsdienstleister, E-Mail-Anbieter und modernen Webhook-Empfänger unterstützen eigene Idempotenz-Keys — nutze sie. Das gibt dir zwei unabhängige Schutzschichten: deine Job-Dedupe-Tabelle und die eigene Deduplizierung des nachgelagerten Systems. Wenn du das nachgelagerte System nicht kontrollierst (etwa einen Legacy-SOAP-Endpoint ohne Idempotenz-Unterstützung), wird das Transactional-Outbox-Pattern notwendig: Schreibe die Absicht in derselben Transaktion wie deine Business-Logik in eine Datenbanktabelle und lass einen separaten Relay-Prozess sie unter Nachverfolgung des Zustellstatus genau einmal zustellen.
Umgang mit dem "Processing"-Schwebezustand
Ein Job, der mitten in der Ausführung abstürzt, hinterlässt deine Dedupe-Zeile für immer im Zustand status = 'processing', wenn du das nicht berücksichtigst. Lass einen veralteten Claim Retries nicht auf unbestimmte Zeit blockieren.
// ✅ Good: expire stale claims so crashed jobs can be retried safely
async function reclaimStaleJobs() {
await db.query(`
UPDATE job_executions
SET status = 'failed'
WHERE status = 'processing'
AND created_at < now() - interval '10 minutes'
`);
}Wähle das Zeitfenster für "veraltet" basierend auf der realistischen Worst-Case-Laufzeit deines Jobs, nicht nach Bauchgefühl. Zu kurz, und du reklamierst Jobs zurück, die noch legitim laufen — das erzeugt echte doppelte Seiteneffekte. Zu lang, und ein abgestürzter Job blockiert den Fortschritt länger als nötig.
Auf Idempotenz testen, nicht nur auf Korrektheit
Standard-Unit-Tests prüfen, ob ein Job einmalig die richtige Ausgabe produziert. Sie prüfen nicht, was passiert, wenn du ihn zweimal ausführst, oder zweimal gleichzeitig. Füge das explizit hinzu:
test("processing the same job twice produces one charge", async () => {
const payload = { idempotencyKey: "test-key-1", customerId: "cus_123", amountCents: 500 };
await Promise.all([
processPayment({ data: payload } as Job<PaymentPayload>),
processPayment({ data: payload } as Job<PaymentPayload>),
]);
const charges = await getChargesForCustomer("cus_123");
expect(charges).toHaveLength(1);
});Wenn dieser Test nicht in deiner Suite für jeden Job steht, der Geld, Lagerbestand oder irreversible externe Aufrufe berührt, sollte er es sein. Er fängt genau die Klasse von Bug, die erst in Produktion, unter Last, Monate nach der Implementierung auftaucht.
Key Takeaways
- Exactly-once-Zustellung existiert nicht — entwirf Job-Handler, die unter at-least-once-Zustellung korrekt funktionieren.
- Nutze einen Unique-Constraint auf einem Idempotenz-Key als atomares Tor, bevor irgendein irreversibler Seiteneffekt ausgeführt wird.
- Locks reduzieren Konkurrenz und verschwendete Arbeit; sie garantieren keine Korrektheit nach einem Absturz. Verwechsle die beiden nicht.
- Verlagere Idempotenz zu externen Systemen (Zahlungsdienstleister, Webhook-Empfänger), wenn diese eigene Dedupe-Keys unterstützen.
- Reklamiere veraltete "Processing"-Claims mit einem Timeout, das auf die reale Worst-Case-Dauer deines Jobs abgestimmt ist, sonst werden abgestürzte Jobs nie erneut versucht.
- Schreibe explizite Tests, die einen Job zweimal ausführen — einmal sequenziell, einmal gleichzeitig — und prüfen, dass der Seiteneffekt genau einmal eingetreten ist.



