Redis ist der Standard-Broker für die meisten neuen Celery-Projekte, und das aus gutem Grund — es steckt ohnehin schon fürs Caching im Stack, es ist schnell, und einen Worker dagegen laufen zu lassen braucht etwa vier Zeilen Config. Was dieses einfache Setup verbirgt: Redis wurde nie so als Message Broker entworfen, wie RabbitMQ es wurde. Celerys Redis-Transport ist eine Kompatibilitätsschicht, gebaut auf Redis’ universellen Datenstrukturen, und mehrere seiner Verhaltensweisen — vor allem rund um Zustellgarantien — sind emulierte Annäherungen an das, was ein echter AMQP-Broker nativ leistet, keine erstklassigen Features von Redis selbst.
Meistens spielt diese Lücke keine Rolle. Sie spielt eine große Rolle, sobald ein Task zum ersten Mal ohne sichtbaren Grund doppelt läuft, oder ein Worker bei null CPU sitzt, während sich eine Queue mit Tausenden ausstehenden Nachrichten staut, oder ein Batch von Tasks, der letzte Woche hätte feuern sollen, einfach nie feuert. Nichts davon sind Bugs in Celery. Es sind die spezifischen, gut dokumentierten Konsequenzen einer Handvoll Einstellungen, deren Defaults auf ein anderes Szenario zugeschnitten sind als das, das man tatsächlich betreibt — und sobald man weiß, wo man hinschauen muss, ist jede einzelne davon in einer Config-Datei behebbar.
Das lernst du:
- Warum Redis als Celery-Broker echte Message-Queue-Semantik nur annähert, und wo diese Annäherung bricht
- Wie eine falsch konfigurierte Celery-Redis-Visibility-Timeout dazu führt, dass Tasks bei lang laufender Arbeit doppelt ausgeführt werden
- Was der Celery-Prefetch-Multiplier tatsächlich steuert, und warum der Default bei gemischten Task-Dauern zu Worker-Hoarding führt
- Warum Tasks, die weit in der Zukunft geplant sind, still aus der Queue verschwinden können
- Wie die
maxmemory-policy-Einstellung von Redis unter Speicherdruck leise Tasks aus der Queue evicted - Eine praktische Checkliste, um verschwindende Celery-Tasks zu diagnostizieren, wenn die Logs keine offensichtliche Ursache zeigen
Inhaltsverzeichnis
- Warum Redis kein echter Message Broker ist
- Visibility Timeout und erneute Task-Zustellung
- Prefetch Multiplier und Worker-Hoarding
- Weit in der Zukunft liegende ETAs und das Reservierungsproblem
- maxmemory-policy: Die Einstellung, die deine Queue auffrisst
- Verschwindende Celery-Tasks diagnostizieren
- Häufige Fallstricke
Warum Redis kein echter Message Broker ist
RabbitMQ spricht AMQP nativ: Acknowledgments, Redelivery pro Nachricht und Consumer-Prefetch sind Primitive auf Protokollebene, die der Broker selbst durchsetzt. Redis hat davon nichts eingebaut — es ist ein Key-Value-Store mit List-, Set- und Sorted-Set-Datenstrukturen. Celerys redis-Transport baut Queue-Semantik auf Redis-Listen auf (BRPOP zum Konsumieren) und verwendet ein separates, nach Score sortiertes „unacked"-Sorted-Set, um In-Flight-Nachrichten zu verfolgen, die noch nicht bestätigt wurden.
Dieses „unacked"-Set ist der Kern fast jeder Tücke in diesem Beitrag. Da Redis kein natives Konzept für „dieser Consumer arbeitet noch an dieser Nachricht, gib sie niemand anderem" hat, muss Celery es simulieren: Wenn ein Worker einen Task zieht, verschiebt Celery eine Kopie mit Zeitstempel in das unacked-Set. Ein periodischer Sweep (visibility_timeout Sekunden später) prüft dieses Set und geht, falls eine Nachricht bis dahin nicht bestätigt wurde, davon aus, dass der Worker gestorben ist, und legt sie zur erneuten Zustellung zurück in die Hauptqueue. Das ist ein vernünftiges Design — aber es ist eine Vermutung auf Basis verstrichener Zeit, kein echtes Signal vom Worker darüber, ob er noch lebt und arbeitet. Jede der folgenden Tücken entsteht dadurch, dass diese Vermutung auf eine spezifische, vorhersagbare Weise falsch liegt.
Steht dein Team noch vor der Broker-Entscheidung, statt bereits fest auf Redis festgelegt zu sein, lohnt es sich, das direkt gegen die Alternativen abzuwägen — sieh dir dazu den Broker-Vergleich bei der Wahl einer Python-Task-Queue an, wie sich Redis, RabbitMQ und SQS bei Zustellgarantien tatsächlich unterscheiden, bevor man so tief in der Produktion steckt, dass ein Wechsel teuer wird.
Visibility Timeout und erneute Task-Zustellung
visibility_timeout (gesetzt über broker_transport_options) legt fest, wie lange Celery wartet, bevor es annimmt, dass der Worker einer unbestätigten Nachricht gestorben ist, und sie zurück in die Queue legt:
app.conf.broker_transport_options = {"visibility_timeout": 3600} # seconds
Der Default liegt bei aktuellen Celery-Versionen bei 3600 Sekunden (eine Stunde) — für die meisten Workloads in Ordnung, aber aktiv gefährlich für zwei konkrete Fälle: Tasks, die legitim länger brauchen als der konfigurierte Timeout, und Tasks, die mit task_acks_late = True konfiguriert sind (was die meisten Produktions-Deployments laut der vorherigen Idempotenz-Diskussion eigentlich laufen lassen sollten).
So sieht die Fehlersequenz aus: Ein Task startet, braucht 90 Minuten, um einen großen Export abzuschließen, und der Visibility Timeout ist auf 3600 Sekunden gesetzt. Nach einer Stunde entscheidet Celerys Sweep, dass der Worker gestorben sein muss — es wurde kein Ack empfangen — und liefert die Nachricht erneut an einen zweiten Worker aus. Jetzt laufen zwei Worker gleichzeitig denselben 90-minütigen Export, und welcher auch immer als zweiter fertig wird, überschreibt entweder die Ausgabe des ersten oder dupliziert, welchen Seiteneffekt der Task auch immer erzeugt. Das ist ein Lehrbuchfall von einem Celery-Task, der doppelt läuft, der nichts mit einem tatsächlichen Absturz zu tun hat — der ursprüngliche Worker war die ganze Zeit über in Ordnung, nur langsamer, als der Timeout angenommen hat.
Der Fix ist mechanisch, sobald man weiß, wonach man suchen muss: visibility_timeout komfortabel über der Worst-Case-Dauer des am längsten laufenden Tasks setzen, nicht über seinem Durchschnitt.
# If your slowest task can take up to 2 hours, don't set this to 1 hour.
app.conf.broker_transport_options = {"visibility_timeout": 10800} # 3 hours
Der Trade-off ist real, nicht kostenlos: Ein längerer Visibility Timeout bedeutet auch, dass der Task eines tatsächlich abgestürzten Workers länger wartet, bevor er erneut zugestellt wird — stirbt ein Worker eine Minute nach Start eines Tasks mit einem 3-Stunden-Timeout, bleibt dieser Task bis zu 3 Stunden unverarbeitet liegen, bevor irgendjemand (Celery eingeschlossen) es bemerkt und ihn erneut einreiht. Es gibt keinen einzigen richtigen Wert; es gibt einen Wert, der zur tatsächlichen Verteilung der Task-Dauern passt, und es lohnt sich, ihn pro Queue zu setzen, wenn der eigene Workload schnelle und langsame Tasks mischt, statt eine einzige globale Zahl zu wählen, die für die Hälfte davon falsch ist.
Prefetch Multiplier und Worker-Hoarding
worker_prefetch_multiplier legt fest, wie viele Tasks jeder Worker-Prozess auf einmal aus der Queue reserviert, als Vielfaches seiner Concurrency. Der Default ist 4 — das bedeutet, ein Worker mit Concurrency 8 greift sich im Voraus bis zu 32 Tasks und hält sie im Speicher, während er seinen eigenen lokalen Rückstau abarbeitet, bevor er Redis um mehr bittet.
Dieser Default wurde für eine Welt vieler kleiner, schneller Tasks mit ungefähr einheitlicher Dauer getunt, in der Prefetching den Round-Trip-Overhead reduziert, den Broker einzeln nacheinander nach dem nächsten Task zu fragen. Er schadet aktiv, sobald die Dauer der eigenen Tasks stark variiert. Stell dir eine Queue mit einer Mischung aus Tasks vor, die 100ms brauchen, und Tasks, die 10 Minuten brauchen: Ein Worker prefetcht 32 Tasks, von denen mehrere zufällig zur 10-Minuten-Sorte gehören, und jetzt sitzen diese Tasks reserviert im lokalen Puffer dieses Workers — unsichtbar für jeden anderen untätigen Worker —, während der zugewiesene Worker sich seriell durch seine Queue arbeitet. Andere Worker bleiben untätig, weil diese Arbeit, soweit es Redis betrifft, bereits vergeben ist.
Das ist das klassische Celery-Prefetch-Multiplier-Hoarding-Problem, und von außen sieht es exakt wie das Symptom „verschwindende Tasks" aus: Die vom Monitoring gemeldete Queue-Länge sinkt (die Tasks wurden prefetcht, also sind sie technisch „draußen" aus der Queue), aber der Durchsatz steigt nicht entsprechend, weil sie hinter langsamen Tasks auf einem Worker feststecken, der nicht frei ist, um neue Arbeit aufzunehmen.
# Global default — fine for short, uniform tasks
app.conf.worker_prefetch_multiplier = 4
# For queues with long-running or highly variable-duration tasks
app.conf.worker_prefetch_multiplier = 1
worker_prefetch_multiplier = 1 zu setzen bedeutet, dass ein Worker jeweils nur einen Task pro Concurrency-Slot reserviert — näher an echtem Fair-Dispatch-Verhalten, auf Kosten etwas mehr Round-Trips zum Broker pro Task, was für so gut wie jeden Workload mit lang laufenden Tasks ein Preis ist, den es sich zu zahlen lohnt. Mischt man Task-Dauern in einem einzigen Deployment, sollte man die lang laufenden Tasks in eine dedizierte Queue mit eigenem Worker-Pool routen, der auf prefetch_multiplier=1 gesetzt ist, und den Default für einen separaten Pool höher lassen, der kurze, einheitliche Tasks bearbeitet — beide Muster auf einem Worker-Pool mit einer einzigen Prefetch-Einstellung zu mischen, ist überhaupt erst die Ursache des Hoardings.
Weit in der Zukunft liegende ETAs und das Reservierungsproblem
Einen Task für einen bestimmten zukünftigen Zeitpunkt zu planen — my_task.apply_async(eta=some_datetime) — funktioniert auf Redis anders, als es konzeptionell wirkt: Celery lässt die Nachricht nicht einfach unangetastet in der Hauptqueue liegen, bis die ETA erreicht ist. Je nach Konfiguration und Worker-Verhalten kann ein ETA-Task schon weit vor seiner Ausführungszeit von einem Worker reserviert und im Speicher dieses Workers gehalten werden, wo er gegen dessen Prefetch-Limit zählt, bis der geplante Moment eintritt.
Für einen Task, der wenige Minuten im Voraus geplant ist, ist das unsichtbar. Für einen Task, der Tage oder Wochen im Voraus geplant ist — eine Erinnerungs-E-Mail, eine Prüfung der Abo-Verlängerung — wird es zu einem echten Problem: Diese Reservierung blockiert für die gesamte Wartezeit einen Slot im Prefetch-Puffer des Workers, und startet dieser Worker neu (ein Deploy, ein Absturz, ein Autoscaling-Event), bevor die ETA erreicht ist, kann der reservierte Task aus dem lokalen Zustand dieses Workers verloren gehen — abhängig davon, genau wann und wie der Neustart relativ zu Redis’ eigener Nachverfolgung stattfindet. Der Nettoeffekt sieht identisch damit aus, dass ein Task einfach verschwindet — nichts in den Logs weist darauf hin, dass „das hier korrekt geplant und dann während eines routinemäßigen Deploys verloren wurde", weil aus Sicht der Anwendung apply_async erfolgreich war und eine Task-ID zurückgegeben hat.
Die praktische Abhilfe besteht darin, sich nicht auf Celerys eigenen ETA-Mechanismus zu verlassen, wenn etwas mehr als etwa eine Stunde im Voraus geplant wird. Für wirklich periodische Arbeit celery beat nutzen, oder — für einmalige, weit in der Zukunft liegende Planung — die Absicht in einer eigenen Datenbanktabelle mit einer scheduled_for-Spalte persistieren und mit einem periodischen Beat-Task fällige Zeilen abfragen und just-in-time einreihen, statt eine eine Woche entfernte ETA direkt an den Broker zu übergeben und darauf zu vertrauen, dass ein Worker sie sieben Tage später noch unverändert hält.
# Fragile: a worker restart between now and next month can lose this
send_renewal_reminder.apply_async(args=[user_id], eta=next_month)
# Sturdier: durable intent in your own database, dispatched just-in-time
class ScheduledReminder(db.Model):
user_id = db.Column(db.Integer, nullable=False)
scheduled_for = db.Column(db.DateTime, nullable=False)
dispatched = db.Column(db.Boolean, default=False)
@app.task
def dispatch_due_reminders():
due = ScheduledReminder.query.filter(
ScheduledReminder.scheduled_for <= datetime.utcnow(),
ScheduledReminder.dispatched.is_(False),
).all()
for reminder in due:
send_renewal_reminder.delay(reminder.user_id)
reminder.dispatched = True
db.session.commit()
maxmemory-policy: Die Einstellung, die deine Queue auffrisst
Das ist die Tücke, die die meiste Verwirrung stiftet, weil es sich überhaupt nicht um eine Celery-Einstellung handelt — es ist eine Redis-Server-Konfiguration, die die meisten Teams aus einem Caching-Use-Case übernehmen und nie wieder überprüfen, wenn dieselbe Redis-Instanz auch zum Celery-Broker wird.
maxmemory-policy sagt Redis, was zu tun ist, wenn die konfigurierte Speichergrenze erreicht wird. Für einen reinen Cache ist allkeys-lru (den am längsten nicht genutzten Key evicten, unabhängig vom Typ) ein völlig vernünftiger, gängiger Default — gecachte Daten sind per Definition entbehrlich, und Eviction ist das beabsichtigte Verhalten. Das Problem ist, dass Celerys Queue-Daten — die Listen und Sorted Sets, die ausstehende und unacked Task-Nachrichten enthalten — als ganz normale Redis-Keys ohne besonderen Schutz existieren. Wird dieselbe Redis-Instanz zwischen der Anwendungs-Cache und dem Celery-Broker geteilt und baut sich Speicherdruck auf, evicted allkeys-lru in der Queue liegende, noch nicht ausgeführte Task-Nachrichten genauso bereitwillig wie einen abgestandenen Cache-Eintrag. Die Tasks schlagen nicht fehl. Sie werfen keinen Fehler. Sie sind einfach weg — evicted auf dieselbe Art wie ein alter Cache-Key, weil es aus Redis’ Sicht keinen Unterschied zwischen beiden gibt.
# redis.conf — check this if Redis is shared between cache and Celery broker
maxmemory-policy allkeys-lru # dangerous if this Redis also holds queue data
Der Fix ist einer von zwei Ansätzen, beide legitim: eine separate, dedizierte Redis-Instanz für Celery mit maxmemory-policy noeviction betreiben (Redis gibt bei vollem Speicher einen Fehler auf Writes zurück, statt Daten still zu löschen — was das Kapazitätsproblem als lauten Fehlschlag statt als stillen Datenverlust sichtbar macht), oder, falls eine gemeinsam genutzte Instanz unvermeidbar ist, einen separaten logischen Datenbankindex verwenden (redis://localhost:6379/1 für Celery vs. /0 für den Cache) und sicherstellen, dass maxmemory-policy auf etwas Eviction-sicheres für den Queue-Use-Case gesetzt ist — angesichts dessen, dass Redis’ Eviction-Policy instanzweit gilt, nicht pro logischer Datenbank: Die SELECT-artige Datenbanktrennung isoliert nur Keyspaces, nicht das Verhalten unter Speicherdruck.
# celeryconfig.py — dedicated logical DB, separate from application cache
app.conf.broker_url = "redis://localhost:6379/1"
app.conf.result_backend = "redis://localhost:6379/2"
# On the dedicated broker instance (or if sharing is unavoidable, understand
# this applies to the whole instance, not just the Celery-owned keys):
maxmemory-policy noeviction
Das lohnt sich zu prüfen, selbst wenn man selbst nie die Redis-Konfiguration angefasst hat — eine gemeinsam genutzte Redis-Instanz, die von Infrastruktur-Tooling oder einem Managed-Cloud-Service bereitgestellt wird, kommt oft mit einem LRU-freundlichen, auf Caching getunten Default, unter der vernünftigen Annahme, dass das, was sie nutzt, ein Cache ist. Wird Celery später auf dieselbe Instanz gerichtet, überprüft niemand diese Annahme zwangsläufig noch einmal.
Verschwindende Celery-Tasks diagnostizieren
Wenn Tasks wirklich zu verschwinden scheinen — kein Fehler, kein Dead-Letter-Eintrag, keine Spur in den Logs —, geht diese Punkte der Reihe nach durch, da sie die überwältigende Mehrheit der realen Fälle abdecken:
maxmemory-policyauf der tatsächlichen Broker-Instanz prüfen.redis-cli config get maxmemory-policy— steht sie auf etwas anderem alsnoevictionund diese Redis-Instanz macht noch etwas anderes als Celery, ist das der erste Verdächtige.- Redis-Speichernutzung gegen
maxmemoryprüfen.redis-cli info memory— liegtused_memorynahe anmaxmemory, findet aktiv Eviction statt, selbst wenn man die Policy noch nicht bestätigt hat. - Prüfen, ob die „verschwundenen" Tasks ETA-/Countdown-Tasks waren. Falls ja, das Reservierungsproblem bei weit in der Zukunft liegenden Tasks vermuten, besonders wenn während des Wartefensters ein Worker-Neustart oder Deploy stattgefunden hat.
- Prefetch-Einstellungen gegen die Varianz der Task-Dauer prüfen. Eine Queue, die mit untätigen Workern und wachsendem Rückstau blockiert wirkt, zeigt das Hoarding-Muster, kein Verschwinden —
celery inspect activeundcelery inspect reservedzeigen Tasks, die reserviert auf bestimmten Workern sitzen, statt tatsächlich verloren zu sein. visibility_timeoutgegen die tatsächlichen Task-Dauern prüfen, wenn Duplikate statt Verluste auftreten — ein Task, der scheinbar doppelt läuft, ist das Spiegelbild derselben Problemklasse, kein eigenständiges Problem.
Die meisten „Verschwinden"-Meldungen erweisen sich als eine der ersten drei Ursachen; die meisten „Duplikat"-Meldungen lassen sich auf die vierte zurückführen. Nur sehr wenige sind Celery-Bugs im wörtlichen Sinn — fast alle sind Default-Einstellungen, die nie an die konkrete Form des Workloads angepasst wurden, der tatsächlich gegen sie läuft.
Es lohnt sich außerdem, die langweiligen Erklärungen auszuschließen, bevor man in die Broker-Internas eintaucht: bestätigen, dass der Task überhaupt tatsächlich eingereiht wurde (ein stiller Serialisierungsfehler auf Producer-Seite kann apply_async erfolgreich aussehen lassen, während gar nichts bei Redis ankommt), und bestätigen, dass man dieselbe Redis-Instanz und dieselbe logische Datenbank inspiziert, gegen die der Worker tatsächlich konfiguriert ist — redis-cli während einer Untersuchung auf den falschen Host oder DB-Index zu richten, kommt häufig genug vor, besonders in Setups, in denen sich Cache und Broker einen Host teilen, aber unterschiedliche Datenbankindizes nutzen, dass es sich lohnt, CELERY_BROKER_URL gegen den Connection-String gegenzuprüfen, den die eigene Debugging-Session tatsächlich verwendet, bevor man auf etwas Exotischeres als Ursache schließt.
Häufige Fallstricke
Fehler: visibility_timeout bei lang laufenden Tasks auf dem Default belassen. Tasks, die länger als der Timeout brauchen, werden erneut zugestellt und laufen doppelt. Lösung: ihn über der Worst-Case-Dauer des langsamsten Tasks setzen, nicht über seinem Durchschnitt, und lange Tasks in eine Queue mit einem speziell dafür abgestimmten Timeout routen.
Fehler: den Default-Prefetch-Multiplier bei Tasks mit gemischter Dauer verwenden. Lange Tasks werden im Puffer eines Workers gehortet, während andere untätig bleiben. Lösung: worker_prefetch_multiplier = 1 für Queues mit variabler oder langer Task-Dauer setzen und sie von Queues mit kurzer, einheitlicher Dauer isolieren.
Fehler: weit in der Zukunft liegende Arbeit direkt mit eta=/countdown= planen. Lange gehaltene Reservierungen können über Worker-Neustarts hinweg verloren gehen. Lösung: celery beat plus eine eigene durable Scheduling-Tabelle für alles verwenden, das mehr als etwa eine Stunde in der Zukunft liegt.
Fehler: eine auf Caching getunte Redis-Instanz mit Celery teilen, ohne maxmemory-policy zu prüfen. allkeys-lru evicted Tasks in der Queue genauso wie abgestandene Cache-Einträge. Lösung: eine eigene Redis-Instanz für den Broker mit noeviction reservieren oder zumindest bestätigen, dass die Eviction-Policy der gemeinsam genutzten Instanz für Queue-Daten sicher ist.
Fehler: annehmen, „verschwindende" und „doppelte" Tasks seien unabhängige Probleme. Sie haben oft dieselbe Ursache — eine von Redis emulierte Zustellgarantie, die auf verstrichene Zeit statt auf ein echtes Signal vom Worker reagiert —, die je nach Timing als entgegengesetzte Symptome auftritt. Lösung: Visibility Timeout, Prefetch, ETA-Handling und maxmemory-policy als eine zusammenhängende Checkliste durcharbeiten, nicht als vier unabhängige Spuren.
Keine dieser Tücken bedeutet, dass Redis unzuverlässig ist, und keine bedeutet, dass Celery buggy ist — es sind die sichtbaren Nähte davon, dass Redis eine Rolle spielt, für die es ursprünglich nicht gebaut wurde, zusammengehalten von Einstellungen, die mit Defaults ausgeliefert werden, die auf einen generischen Workload getunt sind statt auf den eigenen. Hat man Visibility Timeout, Prefetch, weit in der Zukunft liegende ETAs und maxmemory-policy einmal durchgearbeitet, hört „Tasks sind verschwunden und niemand weiß warum" auf, ein Rätsel zu sein, und wird zu einer Fünf-Minuten-Checkliste.

