Blogs / Celery-Tasks idempotent machen

Celery-Tasks idempotent machen

Veröffentlicht
9. September 2026
Autor
Faizan Nadeem
Tags
Celery Python Distributed Systems Redis
Ein Stempel liegt auf einem gestempelten Blatt Papier
Foto von Valeria Reverdo auf Unsplash

Das Support-Ticket liest sich meistens gleich: „Kunde sagt, er wurde für eine Bestellung doppelt belastet.“ Jemand zieht die Logs, findet einen einzigen API-Call, ein einziges charge_customer.delay(order_id) — und stellt dann fest, dass der Task trotzdem zweimal lief, zwölf Sekunden auseinander, auf zwei verschiedenen Workern. Niemand hat einen Bug geschrieben, der den Call dupliziert hat. Das System hat exakt so funktioniert, wie es entworfen wurde. Genau das ist der Punkt, an dem die meisten stolpern: Ein Celery-Task, der zweimal läuft, ist kein Fehlverhalten, sondern das dokumentierte, beabsichtigte Verhalten von At-least-once-Delivery — und es wird jeder Celery-Deployment passieren, die lange genug läuft.

Der Fix besteht nicht darin, die „Race Condition“ zu finden und zu patchen — in der Regel gibt es dort keinen Bug, auf den man zeigen könnte. Der Fix besteht darin zu akzeptieren, dass jeder Task für dieselbe logische Arbeitseinheit mehr als einmal ausgeführt werden kann, und den Task selbst so zu entwerfen, dass ein zweifacher Lauf denselben Endzustand erzeugt wie ein einfacher. Genau das bedeutet Idempotenz in diesem Kontext: nicht „das passiert nie“, sondern „es ist sicher, wenn es passiert“.

Das lernst du:

  • Warum ein doppelt laufender Celery-Task erwartetes Verhalten ist, kein Bug, sobald man acks_late und Broker-Redelivery versteht
  • Die realen Ursachen für doppelte Task-Ausführung bei Celery — Worker-Abstürze, Visibility-Timeouts, Netzwerkpartitionen und Retries
  • Wie man Idempotency Keys entwirft, die doppelte Task-Ausführung zu einem No-op machen
  • Wie man einen Distributed Lock mit Redis SET NX EX implementiert, um überlappende Ausführung zu verhindern
  • Idempotenz auf Datenbankebene mittels Unique-Constraints als zweite Verteidigungslinie
  • Wie man mit von Natur aus nicht-idempotenten Seiteneffekten wie E-Mail-Versand oder Payment-API-Aufrufen umgeht
  • Wie man tatsächlich testet, dass ein Task idempotent ist, statt es nur anzunehmen

Inhaltsverzeichnis

  1. Warum At-least-once-Delivery doppelte Ausführung bedeutet
  2. Die realen Ursachen doppelter Ausführung
  3. Idempotency Keys: das Kernmuster
  4. Distributed Locks mit SET NX EX
  5. Idempotenz auf Datenbankebene als zweite Verteidigungslinie
  6. Umgang mit nicht-idempotenten Seiteneffekten
  7. Idempotenz testen
  8. Häufige Fallstricke

Warum At-least-once-Delivery doppelte Ausführung bedeutet

Celery liefert, wie jede gängige Python-Task-Queue, standardmäßig At-least-once-Delivery, sobald es auf Durability konfiguriert ist. Diese Formulierung steckt voller Bedeutung, über die die meisten hinweglesen: At-least-once garantiert, dass ein Task ein- oder mehrmals versucht wird — niemals null Mal —, garantiert aber ausdrücklich keine exakt einmalige Ausführung. „Mindestens“ schließt „mehr als einmal“ mit ein.

Der Mechanismus dahinter ist task_acks_late. Ist die Option deaktiviert (Celerys historischer Default), bestätigt ein Worker eine Nachricht in dem Moment, in dem er den Task startet — noch bevor der Funktionskörper überhaupt gelaufen ist. Stirbt der Worker-Prozess mitten in der Ausführung — OOM-Kill, Deploy, harter Absturz —, ist die Nachricht bereits bestätigt und verschwunden. Der Task wird stillschweigend nie fertig und nie erneut versucht. Das ist ein Risiko für Datenverlust, kein Risiko für Duplizierung.

Setzt man task_acks_late = True, kippt der Trade-off: Der Broker entfernt die Nachricht erst, wenn der Task erfolgreich zurückkehrt. Stirbt der Worker, nachdem die eigentliche Arbeit fertig ist, aber bevor diese Bestätigung den Broker erreicht — ein Netzwerk-Hänger, ein Prozess, der in der halben Sekunde nach dem Return-Statement gekillt wird —, hat der Broker keine Möglichkeit zu wissen, dass die Arbeit bereits passiert ist. Er liefert die Nachricht erneut an einen anderen Worker aus. Der Seiteneffekt (die Belastung, die E-Mail, der Zeileninsert) passiert erneut.

# celeryconfig.py
task_acks_late = True
task_reject_on_worker_lost = True
worker_prefetch_multiplier = 1

Das ist der richtige Default für alles, wo ein stillschweigend verlorener Task schlimmer ist als einer, der doppelt läuft — was auf die meisten Produktions-Workloads zutrifft. Aber es bedeutet, dass doppelte Ausführung kein Randfall ist, den man vielleicht mal trifft, sondern ein Szenario, das man sich mit jeder Entscheidung für „Tasks nicht verlieren“ statt „Tasks nie wiederholen“ explizit einhandelt. Man kann nicht beides ohne zusätzliche Arbeit auf Anwendungsebene haben, weil kein Message Broker „Task fertig“ atomar mit „dein Seiteneffekt ist committed“ über eine Netzwerkgrenze hinweg koppeln kann.

Die realen Ursachen doppelter Ausführung

Den Mechanismus zu kennen hilft, aber es hilft mehr zu wissen, was in einem laufenden System tatsächlich dazu führt, denn „das kommt praktisch nie vor“ ist eine verbreitete und falsche Annahme:

  • Worker stürzt mitten im Task ab. OOM-Kills, ein SIGKILL vom Orchestrator während eines Deploys, oder ein harter Prozessabsturz nach dem Seiteneffekt, aber vor dem Ack.
  • Der Visibility-Timeout des Brokers läuft ab. Mit Redis als Broker wird eine In-Flight-(unbestätigte)-Nachricht nach visibility_timeout Sekunden erneut zustellbar — unabhängig davon, ob der ursprüngliche Worker noch daran arbeitet. Ein Task, der legitim länger läuft als der konfigurierte Timeout, wird an einen zweiten Worker ausgeliefert, während der erste ihn noch bearbeitet.
  • Manuelle Retries plus Teilerfolg. Ein Task wirft eine Exception, nachdem sein Seiteneffekt bereits committed wurde (z. B. der Datenbank-Write war erfolgreich, aber ein nachfolgender Notification-Call lief in einen Timeout und warf einen Fehler), und autoretry_for wiederholt die gesamte Funktion — inklusive des Teils, der bereits erfolgreich war. Genau deshalb verdient Retry-Backoff- und Dead-Letter-Queue-Design dieselbe bewusste Behandlung wie die Idempotenz selbst.
  • Netzwerkpartitionen zwischen Worker und Broker. Das Ack wird gesendet, kommt aber wegen eines vorübergehenden Netzwerkproblems nie an; aus Sicht des Brokers ist das nicht von einem gestorbenen Worker zu unterscheiden.
  • Bedienfehler. Jemand spielt manuell eine Nachricht aus einer Dead-Letter-Queue erneut ab, ohne zu prüfen, ob sie vor ihrem Fehlschlag bereits teilweise erfolgreich war.

Keiner dieser Fälle erfordert einen Bug im eigenen Code. Es sind Eigenschaften davon, verteilte Worker gegen ein Netzwerk laufen zu lassen, das man nicht vollständig kontrolliert. Redis-basierte Celery-Deployments treffen das Visibility-Timeout-Szenario oft genug, dass es eine eigene, ausführliche Behandlung verdient — siehe den Abschnitt zu Visibility-Timeout-Redelivery für die Mechanik, wie ein Task rein durch einen zu kurz gesetzten Timeout zweimal aufgegriffen werden kann.

Idempotency Keys: das Kernmuster

Der Standard-Fix ist derselbe, den Payment-APIs wie Stripe populär gemacht haben: Man hängt jeder logischen Arbeitseinheit einen eindeutigen Idempotency Key an und prüft, bevor man irgendetwas mit Seiteneffekten tut, ob dieser Key bereits verarbeitet wurde.

import redis
from celery import shared_task

redis_client = redis.Redis(host="localhost", port=6379, db=2)

@shared_task(bind=True)
def charge_customer(self, idempotency_key: str, order_id: int, amount_cents: int):
    already_done = redis_client.get(f"idempotency:{idempotency_key}")
    if already_done:
        # Task already completed successfully — this is a redelivery, not new work.
        return already_done.decode()

    charge_id = payment_gateway.charge(order_id=order_id, amount_cents=amount_cents)

    # Only mark as done AFTER the side effect succeeds.
    redis_client.set(f"idempotency:{idempotency_key}", charge_id, ex=86400)
    return charge_id

Zwei Details sind hier wichtiger als der Happy Path. Erstens muss der Idempotency Key vom Aufrufer erzeugt werden, an dem Punkt, an dem die logische Entscheidung getroffen wird, die Arbeit auszuführen — nicht innerhalb des Tasks selbst. Erzeugt man den Key innerhalb von charge_customer, generiert ein erneut zugestellter Task jedes Mal einen frischen Key, und die ganze Prüfung ist wertlos. Man erzeugt ihn einmal, weiter oben, typischerweise als UUID, die an das Business-Event gebunden ist (f"order-charge-{order_id}" funktioniert, wenn eine Bestellung legitimerweise nur einmal belastet werden kann; eine frische UUID pro Checkout-Versuch funktioniert, wenn wiederholte Checkouts legitim separate Belastungen sind).

Zweitens muss der Write, der den Abschluss markiert, nach dem erfolgreichen Seiteneffekt passieren, nicht davor — würde man ihn zuerst als erledigt markieren und die eigentliche Belastung dann fehlschlagen, würde das stillschweigend echte Arbeit verschleiern, die nie stattgefunden hat. Diese Reihenfolge wirkt, so hingeschrieben, offensichtlich, ist aber genau der Bug, der in Idempotenz-Implementierungen auftaucht, die unter Deadline-Druck nachträglich eingebaut wurden.

Distributed Locks mit SET NX EX

Die Idempotency-Key-Prüfung oben behandelt sequenzielle Redelivery — Task lief, lief später erneut. Sie behandelt nicht gleichzeitige Redelivery, bei der zwei Worker die gleiche Nachricht nahezu im selben Moment aufgreifen und beide die „schon erledigt?“-Prüfung passieren, bevor einer von beiden den Abschluss-Marker geschrieben hat. Das ist eine Race Condition, die der Key allein nicht schließen kann; dafür braucht man einen Lock.

Redis’ SET key value NX EX seconds ist der Standard-Baustein dafür: Es setzt einen Key nur, wenn er noch nicht existiert (NX), mit automatischem Ablauf (EX), damit ein abgestürzter Worker den Lock nicht für immer hält.

import uuid
import redis
from celery import shared_task

redis_client = redis.Redis(host="localhost", port=6379, db=2)

def acquire_lock(lock_key: str, ttl_seconds: int = 30) -> str | None:
    token = str(uuid.uuid4())
    # NX: only set if not already held. EX: auto-expire so a dead worker
    # can't hold the lock forever.
    acquired = redis_client.set(lock_key, token, nx=True, ex=ttl_seconds)
    return token if acquired else None

def release_lock(lock_key: str, token: str) -> None:
    # Only release if we're still the holder — avoids releasing a lock
    # that expired and was re-acquired by someone else in the meantime.
    lua_script = """
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
    """
    redis_client.eval(lua_script, 1, lock_key, token)

@shared_task(bind=True)
def charge_customer(self, idempotency_key: str, order_id: int, amount_cents: int):
    lock_key = f"lock:charge:{idempotency_key}"
    token = acquire_lock(lock_key, ttl_seconds=30)
    if token is None:
        # Someone else is already processing this exact unit of work.
        # Retry later rather than proceeding concurrently.
        raise self.retry(countdown=5, max_retries=10)

    try:
        already_done = redis_client.get(f"idempotency:{idempotency_key}")
        if already_done:
            return already_done.decode()

        charge_id = payment_gateway.charge(order_id=order_id, amount_cents=amount_cents)
        redis_client.set(f"idempotency:{idempotency_key}", charge_id, ex=86400)
        return charge_id
    finally:
        release_lock(lock_key, token)

Die Lock-TTL muss die realistische Worst-Case-Ausführungszeit des Tasks komfortabel überschreiten — läuft sie ab, während der Task noch legitim läuft, kann ein zweiter Worker den Lock übernehmen, und man ist wieder bei einer Race Condition, nur einer selteneren. Die Compare-and-Delete-Logik von release_lock über ein Lua-Script ist aus demselben Grund wichtig: Ohne sie könnte ein Worker, dessen Lock bereits abgelaufen ist (und von jemand anderem übernommen wurde), beim Aufräumen den Lock des neuen Halters löschen — und damit den ganzen Sinn der Übung zunichtemachen.

Dieses Lock-plus-Key-Muster verallgemeinert sich gut über Payments hinaus: Jeder Task mit einem externen, nicht-transaktionalen Seiteneffekt (eine Benachrichtigung senden, eine Drittanbieter-API aufrufen, in ein System schreiben, das man nicht kontrolliert) profitiert von derselben zweischichtigen Verteidigung.

Eine Anmerkung zu Single-Instance-Redis-Locks

Das SET NX EX-Muster oben setzt eine einzelne Redis-Instanz voraus (oder einen Primary, gegen den man konsistent liest und schreibt), und diese Annahme ist es wert, explizit ausgesprochen zu werden, statt sie zu überlesen. Failt Redis zu einem Replica über, bevor sich der Lock-Key repliziert hat — Redis-Replikation ist standardmäßig asynchron —, kann ein zweiter Worker denselben Lock gegen den neuen Primary erwerben, während der erste Worker noch glaubt, ihn zu halten. Für die meisten Task-Queue-Anwendungsfälle ist das ein akzeptables, gut verstandenes Risiko: Das Fehlerfenster ist klein, und die Idempotency-Key-Prüfung plus der nachgelagerte Datenbank-Unique-Constraint fangen auf, was der Lock durchlässt. Wenn die eigene Workload diese Lücke wirklich nicht tolerieren kann — etwa bei sehr hochwertigen Finanzoperationen —, lohnt sich ein Blick auf den Redlock-Algorithmus, der den Lock gegen ein Quorum unabhängiger Redis-Instanzen statt gegen eine einzige erwirbt, auf Kosten deutlich höherer operativer Komplexität. Für die überwiegende Mehrheit der Celery-Workloads ist eine einzelne, gut konfigurierte Redis-Instanz mit einer sinnvollen TTL die pragmatische Wahl, und standardmäßig zu Redlock zu greifen löst meist ein Problem, das man noch gar nicht hat.

Idempotenz auf Datenbankebene als zweite Verteidigungslinie

Redis-basierte Locks und Keys sind schnell, aber nicht unfehlbar — Redis kann neu starten, Keys können unter Speicherdruck etwas früher als beabsichtigt ablaufen, und die TTL eines Locks ist immer eine Vermutung über das Worst-Case-Timing. Die robusteste Idempotenz-Garantie ist, wenn der Seiteneffekt ein Datenbank-Write ist, ein Unique-Constraint, den die Datenbank durchsetzt, unabhängig davon, was auf Anwendungsebene schiefgegangen ist.

CREATE TABLE charges (
    id SERIAL PRIMARY KEY,
    idempotency_key VARCHAR(255) UNIQUE NOT NULL,
    order_id INTEGER NOT NULL,
    amount_cents INTEGER NOT NULL,
    gateway_charge_id VARCHAR(255) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now()
);
from sqlalchemy.exc import IntegrityError

@shared_task(bind=True)
def charge_customer(self, idempotency_key: str, order_id: int, amount_cents: int):
    charge_id = payment_gateway.charge(order_id=order_id, amount_cents=amount_cents)
    try:
        db.session.add(Charge(
            idempotency_key=idempotency_key,
            order_id=order_id,
            amount_cents=amount_cents,
            gateway_charge_id=charge_id,
        ))
        db.session.commit()
    except IntegrityError:
        # Row already exists for this key — a duplicate delivery raced us
        # here. The charge already succeeded and was recorded; treat this
        # as success, not failure.
        db.session.rollback()
    return charge_id

Man beachte: Diese Version ruft das Payment-Gateway immer noch auf, bevor sie auf ein Duplikat prüft — das ist eine echte Lücke, denn ein tatsächlich gleichzeitiges Duplikat würde extern trotzdem doppelt belasten, obwohl die eigene Datenbank konsistent bleibt. Genau deshalb ist das eine zweite Verteidigungslinie unter dem Redis-Lock, kein Ersatz dafür: Der Lock verhindert, dass gleichzeitige Ausführung überhaupt zweimal beim externen Call ankommt, und der Unique-Constraint fängt die Fälle auf, in denen sich die TTL-Annahme des Locks als falsch herausstellt. Gürtel und Hosenträger, nicht Entweder-oder.

Umgang mit nicht-idempotenten Seiteneffekten

Manche Seiteneffekte lassen sich am Ausführungspunkt schlicht nicht idempotent machen — der Versand einer E-Mail ist der klassische Fall, denn es gibt keinen „erst prüfen“-Schritt, den die meisten transaktionalen E-Mail-Provider anbieten und der günstiger wäre, als sie einfach zu senden. Das Idempotency-Key-Muster funktioniert trotzdem, aber die ganze Last verlagert sich auf die Check-before-Send-Absicherung, da es kein externes System gibt, auf das man als zweite Prüfung zurückgreifen könnte, so wie ein Unique-Constraint einen Datenbank-Write absichert.

@shared_task(bind=True)
def send_order_confirmation(self, idempotency_key: str, order_id: int):
    lock_key = f"lock:email:{idempotency_key}"
    token = acquire_lock(lock_key, ttl_seconds=15)
    if token is None:
        raise self.retry(countdown=3, max_retries=5)

    try:
        sent_key = f"idempotency:email:{idempotency_key}"
        if redis_client.get(sent_key):
            return "already-sent"

        email_provider.send(to=get_order_email(order_id), template="order_confirmation")
        redis_client.set(sent_key, "1", ex=604800)
        return "sent"
    finally:
        release_lock(lock_key, token)

Für wirklich irreversible externe Aktionen ohne Idempotenz-Unterstützung seitens des Providers lautet die ehrliche Antwort, dass man Risiko managt, nicht eliminiert — das Ziel verschiebt sich dahin, Duplikate selten und folgenarm zu machen (eine doppelte Bestätigungs-E-Mail ist ein Ärgernis), statt anzunehmen, dass man die Wahrscheinlichkeit für jeden erdenklichen Seiteneffekt auf null drücken kann. Reserviere das strengste Locking und die längsten TTLs für die Seiteneffekte, bei denen ein Duplikat wirklich teuer ist — Payments, Bestandsabbuchungen, irreversible Zustandsübergänge —, und akzeptiere lockerere Garantien für die, bei denen es nur lästig ist.

Idempotenz testen

Idempotenz-Bugs schleichen sich leicht ein und werden im Code-Review leicht übersehen, weil der Happy-Path-Test — Task einmal aufrufen, das erwartete Ergebnis prüfen — identisch besteht, unabhängig davon, ob der Task tatsächlich idempotent ist. Der Test, der zählt, ruft ihn zweimal auf:

def test_charge_customer_is_idempotent():
    key = str(uuid.uuid4())
    result_1 = charge_customer.apply(args=[key, order.id, 1000]).get()
    result_2 = charge_customer.apply(args=[key, order.id, 1000]).get()

    assert result_1 == result_2
    assert payment_gateway_mock.charge.call_count == 1  # not called twice
    assert Charge.query.filter_by(idempotency_key=key).count() == 1

Eine stärkere Variante simuliert das tatsächliche Szenario gleichzeitiger Redelivery mit Threads oder multiprocessing, ruft den Task mit demselben Key gleichzeitig von zwei Workern aus auf und prüft, dass der externe Call trotzdem genau einmal feuert. Dieser Test ist aufwendiger zu schreiben und langsamer auszuführen, aber es ist der einzige, der tatsächlich den Lock-Pfad statt nur den sequenziellen Key-Check-Pfad ausübt — die meisten Idempotenz-Bugs, die es in Produktion schaffen, sind Concurrency-Bugs, keine sequenziellen Logikfehler, und ein rein sequenzieller Test läuft genau daran vorbei.

Häufige Fallstricke

Fehler: den Idempotency Key innerhalb des Tasks erzeugen. Das unterläuft das gesamte Muster — ein erneut zugestellter Task mit frisch erzeugtem Key sieht jedes Mal wie neue Arbeit aus. Lösung: den Key einmal erzeugen, an dem Punkt, an dem die Business-Entscheidung für die Arbeit fällt, und ihn dem Task als Argument mitgeben.

Fehler: Arbeit als erledigt markieren, bevor der Seiteneffekt tatsächlich erfolgreich war. Ein Absturz zwischen dem Schreiben des Markers und der eigentlichen Arbeit bedeutet, dass der Marker lügt. Lösung: den Abschluss-Marker immer erst schreiben, nachdem der Seiteneffekt erfolgreich war, niemals davor.

Fehler: sich auf einen Lock ohne Ablaufzeit verlassen, oder auf eine, die kürzer ist als die Worst-Case-Task-Dauer. Ein Lock ohne TTL macht aus einem abgestürzten Worker ein permanentes Deadlock; eine zu kurze TTL lässt einen zweiten Worker rein, während der erste noch legitim läuft. Lösung: die TTL großzügig über der p99-Dauer des Tasks ansetzen und Tasks überwachen, die sich ihr regelmäßig nähern.

Fehler: annehmen, der Unique-Constraint der Datenbank allein reiche aus. Er fängt doppelte Writes ab, aber bis er greift, kann ein externer, nicht-transaktionaler Call (eine Belastung, eine E-Mail) bereits zweimal passiert sein. Lösung: einen Redis-Lock vor den externen Call schalten und den Constraint als zweite Verteidigungslinie nutzen, nicht als einzige.

Fehler: nur den sequenziellen Fall testen. Die Race Condition bei gleichzeitiger Redelivery ist der Fall, der tatsächlich Incidents verursacht, und ein sequenzieller „zweimal nacheinander aufrufen“-Test findet ein falsch implementiertes Lock nicht. Lösung: mindestens einen Test schreiben, der den Task mit demselben Key gleichzeitig auslöst.

Idempotenz ist kein Feature, das man an einen Task anschraubt, nachdem er einen Incident verursacht hat — es ist eine Design-Vorgabe, die man von Anfang an in jeden Task mitnimmt, genauso wie man annimmt, dass ein Netzwerk-Call in einen Timeout laufen kann. At-least-once-Delivery verschwindet nicht, und es ist keine Celery-spezifische Einschränkung — es ist das, was jede durabile Task-Queue in der Python-Task-Queue-Landschaft verspricht, weil die Alternative (garantiertes Exactly-once) ohne Koordinationskosten, die niemand zahlen will, über eine Netzwerkgrenze hinweg schlicht nicht erreichbar ist. Baue von Tag eins an für Duplikate, und das 2-Uhr-nachts-Doppelbelastungs-Ticket wird zum Nicht-Ereignis statt zum Incident.

Faizan Nadeem
Autor
Faizan Nadeem

AI/GenAI engineer specialising in agentic RAG, LangGraph, and production LLM systems with Python and FastAPI. 2 years building AI agents, RAG pipelines, and the backends behind them.

Weitere Artikel