Hintergrundjobs planen ohne Cron-Kopfschmerzen: Muster
Lerne Muster zum Planen von Hintergrundjobs mit Workflows und einer Jobs-Tabelle, um Erinnerungen, tägliche Zusammenfassungen und Aufräumtasks zuverlässig auszuführen.

Warum Cron einfach wirkt — bis es das nicht mehr tut
Cron ist am ersten Tag großartig: eine Zeile schreiben, eine Uhrzeit wählen, vergessen. Für einen Server und eine Aufgabe funktioniert das oft.
Die Probleme tauchen auf, wenn du Planung für echtes Produktverhalten brauchst: Erinnerungen, tägliche Zusammenfassungen, Aufräumjobs oder Synchronisationen. Die meisten Geschichten über „verpasste Runs“ sind nicht so sehr Cron-Fehler. Es sind die Randbedingungen: ein Server-Neustart, ein Deploy, das die crontab überschreibt, ein Job, der länger läuft als erwartet, oder eine Uhr-/Zeitzonen-Abweichung. Und sobald mehrere App-Instanzen laufen, bekommst du das Gegenproblem: Duplikate, weil zwei Maschinen denken, sie müssten dieselbe Aufgabe ausführen.
Tests sind ein weiterer Schwachpunkt. Eine Cron-Zeile gibt dir keine saubere Möglichkeit, „was würde morgen um 9:00 passieren“ in einem reproduzierbaren Test auszuführen. Planung wird so zu manuellen Checks, Überraschungen in Produktion und Log-Suche.
Bevor du eine Methode wählst, kläre, was du überhaupt planst. Die meisten Hintergrundarbeiten fallen in wenige Kategorien:
- Erinnerungen (zu einer bestimmten Zeit senden, einmalig)
- Tägliche Zusammenfassungen (Daten aggregieren, dann senden)
- Aufräumaufgaben (löschen, archivieren, ablaufen)
- Periodische Syncs (Updates holen oder pushen)
Manchmal kannst du Planung ganz überspringen. Wenn etwas direkt beim Eintreten eines Events passieren kann (ein Nutzer meldet sich an, eine Zahlung gelingt, ein Ticket ändert seinen Status), ist ereignisgesteuerte Arbeit meist einfacher und zuverlässiger als zeitgesteuerte Arbeit.
Wenn Zeit nötig ist, hängt Zuverlässigkeit vor allem von Sichtbarkeit und Kontrolle ab. Du brauchst einen Ort, an dem steht, was laufen soll, was gelaufen ist und was fehlgeschlagen ist, plus eine sichere Möglichkeit, ohne Duplikate neu zu versuchen.
Das Grundmuster: Scheduler, Jobs-Tabelle, Worker
Eine einfache Möglichkeit, Cron-Kopfschmerzen zu vermeiden, ist die Aufteilung der Verantwortlichkeiten:
- Ein Scheduler entscheidet, was wann laufen sollte.
- Ein Worker führt die Arbeit aus.
Die Trennung hilft in zwei Punkten. Du kannst die Timing-Logik ändern, ohne die Geschäftslogik anzufassen, und die Geschäftslogik ändern, ohne den Zeitplan zu zerstören.
Eine Jobs-Tabelle wird zur Quelle der Wahrheit. Statt Zustände in einem Serverprozess oder in einer Cron-Zeile zu verstecken, ist jede Arbeitseinheit eine Reihe: was zu tun ist, für wen, wann sie laufen soll und was beim letzten Mal passiert ist. Wenn etwas schiefgeht, kannst du es inspizieren, neu versuchen oder abbrechen, ohne zu raten.
Ein typischer Ablauf sieht so aus:
- Der Scheduler scannt auf fällige Jobs (zum Beispiel
run_at <= nowundstatus = queued). - Er claimt einen Job, sodass nur ein Worker ihn nimmt.
- Ein Worker liest die Job-Details und führt die Aktion aus.
- Der Worker schreibt das Ergebnis zurück in dieselbe Zeile.
Die Kernidee ist, Arbeit resumierbar, nicht magisch zu machen. Wenn ein Worker mitten drin abstürzt, sollte die Job-Zeile weiterhin zeigen, was passiert ist und was als Nächstes zu tun ist.
Eine Jobs-Tabelle so gestalten, dass sie nützlich bleibt
Eine Jobs-Tabelle sollte zwei Fragen schnell beantworten: Was muss als Nächstes laufen, und was ist beim letzten Mal passiert?
Beginne mit einem kleinen Satz Felder, die Identität, Timing und Fortschritt abdecken:
- id, type: eine eindeutige ID plus ein kurzer Typ wie
send_reminderoderdaily_summary. - payload: validiertes JSON mit nur den Daten, die der Worker braucht (z. B.
user_id, nicht das ganze Nutzerobjekt). - run_at: wann der Job berechtigt ist zu laufen.
- status:
queued,running,succeeded,failed,canceled. - attempts: wird bei jedem Versuch erhöht.
Füge dann ein paar operative Spalten hinzu, die Konkurrenzsicherheit und Vorfallsbehandlung erleichtern. locked_at, locked_by und locked_until erlauben es einem Worker, einen Job zu claimen, damit er nicht doppelt läuft. last_error sollte eine kurze Nachricht (und optional ein Fehlercode) sein, nicht ein vollständiger Stack-Trace, der die Zeilen aufbläht.
Schließlich behalte Zeitstempel für Support und Reporting: created_at, updated_at und finished_at. Damit kannst du Fragen beantworten wie „Wie viele Erinnerungen sind heute fehlgeschlagen?“ ohne Logs zu durchsuchen.
Indexe sind wichtig, weil dein System ständig fragt: „Was kommt als Nächstes?“ Zwei, die sich meist lohnen:
(status, run_at)um fällige Jobs schnell zu finden(type, status)um eine Job-Familie zu inspizieren oder bei Problemen zu pausieren
Beim Payload: Bevorzuge kleines, fokussiertes JSON und validiere vor dem Einfügen. Speichere Identifikatoren und Parameter, nicht Snapshots von Geschäftsdaten. Behandle die Payload-Form wie ein API-Contract, damit ältere zwischengereihtete Jobs nach App-Änderungen noch laufen.
Job-Lifecycle: Status, Locking und Idempotenz
Ein Job-Runner bleibt zuverlässig, wenn jeder Job einem kleinen, vorhersagbaren Lebenszyklus folgt. Dieser Lebenszyklus ist dein Sicherheitsnetz, wenn zwei Worker gleichzeitig starten, ein Server mitten im Lauf neu startet oder du ohne Duplikate neu versuchen musst.
Eine einfache Zustandsmaschine ist meist ausreichend:
- queued: bereit zu laufen ab
run_at - running: von einem Worker beansprucht
- succeeded: fertig und soll nicht erneut laufen
- failed: mit Fehler beendet und braucht Aufmerksamkeit
- canceled: absichtlich gestoppt (z. B. Nutzer hat abbestellt)
Jobs claimen ohne Doppelarbeit
Um Duplikate zu verhindern, muss das Claimen atomar sein. Üblich ist ein Lock mit Timeout (eine Lease): Ein Worker claimt einen Job, indem er status=running setzt und locked_by sowie locked_until schreibt. Wenn der Worker abstürzt, verfällt die Lease und ein anderer Worker kann den Job wieder übernehmen.
Ein praktisches Regelwerk zum Claimen:
- claim nur queued-Jobs, deren
run_at <= nowist - setze
status,locked_byundlocked_untilin demselben Update - übernimm running-Jobs nur, wenn
locked_until < nowist - halte die Lease kurz und verlängere sie, falls der Job länger läuft
Idempotenz (die Gewohnheit, die dich rettet)
Idempotenz bedeutet: Wenn derselbe Job zweimal läuft, ist das Ergebnis weiterhin korrekt.
Das einfachste Werkzeug ist ein eindeutiger Schlüssel. Für eine tägliche Zusammenfassung kannst du z. B. einen Job pro Nutzer pro Tag erzwingen mit einem Schlüssel wie summary:user123:2026-01-25. Wenn ein doppeltes Insert passiert, verweist es auf denselben Job statt einen zweiten zu erzeugen.
Markiere Erfolg erst, wenn die Seiteneffekte wirklich abgeschlossen sind (E-Mail gesendet, Datensatz aktualisiert). Wenn du neu versuchst, darf der Retry-Pfad keine zweite E-Mail oder doppelte Schreibvorgänge erzeugen.
Retries und Fehlerbehandlung ohne Drama
Retries sind der Punkt, an dem Job-Systeme entweder verlässlich werden oder zu Lärmquellen. Das Ziel ist einfach: Versuche es erneut, wenn der Fehler wahrscheinlich temporär ist; stoppe, wenn er es nicht ist.
Eine Standard-Retry-Policy enthält meist:
- maximale Versuche (z. B. 5 Versuche insgesamt)
- eine Verzögerungsstrategie (fester Delay oder exponentielles Backoff)
- Abbruchbedingungen (bei „invalid input“-Fehlern nicht neu versuchen)
- Jitter (kleine Zufallsverschiebung, um Retry-Spikes zu vermeiden)
Statt einen neuen Status für Retries zu erfinden, kannst du oft queued wiederverwenden: Setze run_at auf die nächste Versuchzeit und stelle den Job zurück in die Queue. Das hält die Zustandsmaschine klein.
Wenn ein Job Teilerfolge machen kann, behandle das als normal. Speichere einen Checkpoint, sodass ein Retry sicher weitermachen kann — entweder im Job-Payload (z. B. last_processed_id) oder in einer verwandten Tabelle.
Beispiel: Ein täglicher Summary-Job generiert Nachrichten für 500 Nutzer. Wenn er bei Nutzer 320 scheitert, speichere die letzte erfolgreiche Nutzer-ID und starte beim Retry bei 321. Wenn du außerdem pro Nutzer/pro Tag ein summary_sent-Record speicherst, kann ein erneuter Lauf bereits erledigte Nutzer überspringen.
Logging, das wirklich hilft
Logge genug, um in Minuten debuggen zu können:
- Job-ID, Typ und Versuch-Nummer
- Schlüssel-Inputs (user/team id, Datumsbereich)
- Zeiten (started_at, finished_at, next run time)
- kurze Fehler-Zusammenfassung (plus Stack-Trace, falls vorhanden)
- Anzahl Seiteneffekte (gesendete E-Mails, geänderte Zeilen)
Schritt für Schritt: eine einfache Scheduler-Schleife bauen
Eine Scheduler-Schleife ist ein kleiner Prozess, der in festen Intervallen aufwacht, nach fälliger Arbeit schaut und sie übergibt. Das Ziel ist langweilige Zuverlässigkeit, nicht perfekte Timing-Genauigkeit. Für viele Apps reicht „alle Minute aufwachen“.
Wähle die Aufwachfrequenz nach der Zeitkritikalität der Jobs und der Last, die deine Datenbank verträgt. Wenn Erinnerungen nahezu in Echtzeit sein müssen, wecke alle 30 bis 60 Sekunden. Wenn tägliche Zusammenfassungen ein wenig schwanken dürfen, reichen alle 5 Minuten und sind günstiger.
Eine einfache Schleife:
- Aufwachen und aktuelle Zeit holen (UTC verwenden).
- Due-Jobs auswählen, wo
status = 'queued'undrun_at <= now. - Jobs sicher claimen, damit nur ein Worker sie nimmt.
- Jeden geclaimten Job an einen Worker übergeben.
- Schlafen bis zum nächsten Tick.
Der Claim-Schritt ist der Punkt, an dem viele Systeme brechen. Du willst einen Job als running markieren (und locked_by sowie locked_until speichern) in derselben Transaktion, in der er ausgewählt wird. Viele Datenbanken unterstützen SKIP LOCKED, sodass mehrere Scheduler parallel laufen können, ohne sich gegenseitig in die Quere zu kommen.
-- concept example
BEGIN;
SELECT id FROM jobs
WHERE status='queued' AND run_at <= NOW()
ORDER BY run_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
UPDATE jobs
SET status='running', locked_until=NOW() + INTERVAL '5 minutes'
WHERE id IN (...);
COMMIT;
Halte die Batch-Größe klein (z. B. 50 bis 200). Größere Batches können die Datenbank verlangsamen und Crashes schmerzhafter machen.
Wenn der Scheduler mitten in einer Batch abstürzt, rettet dich die Lease. Jobs, die in running hängen, werden nach locked_until wieder verfügbar. Dein Worker sollte idempotent sein, sodass ein reclaimter Job keine doppelten E-Mails oder doppelte Buchungen erzeugt.
Muster für Erinnerungen, tägliche Zusammenfassungen und Aufräumarbeiten
Die meisten Teams landen bei denselben drei Arten Hintergrundarbeit: zeitkritische Nachrichten, regelmäßige Reports und Aufräumjobs, die Speicher und Performance gesund halten. Dieselbe Jobs-Tabelle und Worker-Schleife kann alle drei Arten bedienen.
Erinnerungen
Für Erinnerungen speichere alles, was zum Senden nötig ist, in der Job-Zeile: für wen, welcher Kanal (E-Mail, SMS, Telegram, In-App), welches Template und die exakte Versandzeit. Der Worker sollte den Job ausführen können, ohne „herumzusuchen“.
Wenn viele Erinnerungen gleichzeitig fällig sind, füge Rate-Limiting hinzu. Begrenze Nachrichten pro Minute pro Kanal und lass überschüssige Jobs auf die nächste Runde warten.
Tägliche Zusammenfassungen
Daily Summaries scheitern oft, weil das Zeitfenster schwammig ist. Wähle eine stabile Cutoff-Zeit (z. B. 08:00 in der lokalen Zeit des Nutzers) und definiere das Fenster klar (z. B. „gestern 08:00 bis heute 08:00“). Speichere Cutoff und die Nutzer-Zeitzone im Job, damit erneute Läufe dieselben Ergebnisse liefern.
Halte jeden Summary-Job klein. Wenn er tausende Datensätze verarbeiten muss, teile ihn in Chunks auf (pro Team, pro Account oder nach ID-Bereichen) und füge Folge-Jobs ein.
Aufräumaufgaben
Aufräumen ist sicherer, wenn du „löschen“ von „archivieren“ trennst. Entscheide, was endgültig entfernt werden kann (temporäre Tokens, ausgelaufene Sessions) und was archiviert werden sollte (Audit-Logs, Rechnungen). Führe Aufräumarbeiten in vorhersehbaren Batches aus, um lange Locks und plötzliche Lastspitzen zu vermeiden.
Zeit und Zeitzonen: die unterschätzte Fehlerquelle
Viele Fehler sind Zeitfehler: Eine Erinnerung geht eine Stunde zu früh raus, eine Zusammenfassung überspringt Montag oder Aufräumen läuft zweimal.
Eine gute Standardeinstellung ist: Zeitstempel in UTC speichern und die Zeitzone des Nutzers separat speichern. run_at sollte ein einzelner UTC-Moment sein. Wenn ein Nutzer „9:00 Uhr meine Zeit“ sagt, wandle das beim Planen in UTC um.
Daylight Saving Time ist eine typische Falle. „Jeden Tag um 9:00“ ist nicht dasselbe wie „alle 24 Stunden“. Bei DST-Umstellungen hat 9:00 eine andere UTC-Zuordnung, und manche lokale Zeiten existieren nicht (Spring Forward) oder kommen zweimal vor (Fall Back). Die sichere Methode ist, bei jeder Neuplanung das nächste lokale Vorkommen neu zu berechnen und erst dann wieder in UTC zu konvertieren.
Für eine tägliche Zusammenfassung entscheide, was „ein Tag“ bedeutet, bevor du Code schreibst. Ein Kalender-Tag (Mitternacht bis Mitternacht in der Zeitzone des Nutzers) entspricht eher menschlichen Erwartungen. „Letzte 24 Stunden“ ist einfacher, driftet aber und kann überraschen.
Späte Daten sind unvermeidbar: Ein Event kommt nach einem Retry an oder eine Notiz wird ein paar Minuten nach Mitternacht hinzugefügt. Entscheide, ob späte Events zu „gestern“ gehören (mit einer Gnadenfrist) oder zu „heute“, und halte die Regel konsistent.
Ein praktischer Puffer kann Ausfälle verhindern:
- scan für Jobs, die bis zu 2–5 Minuten zuvor fällig waren
- mach den Job idempotent, damit erneute Läufe sicher sind
- zeichne den abgedeckten Zeitbereich im Payload auf, damit Summaries konsistent bleiben
Häufige Fehler, die zu verpassten oder doppelten Läufen führen
Die meisten Probleme entstehen durch ein paar vorhersehbare Annahmen.
Die größte ist, von „genau einmal“-Ausführung auszugehen. In echten Systemen starten Worker neu, Netzwerkaufrufe timeouten und Locks gehen verloren. Meistens hast du „mindestens einmal“-Lieferung, was Duplikate normal macht — dein Code muss damit umgehen können.
Ein weiterer Fehler ist, Effekte zuerst zu machen (E-Mail senden, Karte belasten), ohne ein Dedupe-Check. Eine einfache Schutzmaßnahme hilft oft: ein sent_at-Zeitstempel, ein eindeutiger Schlüssel wie (user_id, reminder_type, date) oder ein gespeicherter Dedupe-Token.
Sichtbarkeit ist die nächste Lücke. Wenn du nicht beantworten kannst „was hängt, seit wann und warum“, wirst du raten müssen. Die minimalen Daten, die du griffbereit halten solltest, sind Status, Attempt-Count, nächster geplanter Zeitpunkt, letzte Fehlermeldung und Worker-ID.
Die Fehler, die am häufigsten auftauchen:
- Jobs so designen, als liefen sie genau einmal, und dann von Duplikaten überrascht werden
- Seiteneffekte ohne Dedupe-Check schreiben
- einen riesigen Job laufen lassen, der alles versucht und mitten drin timeouts trifft
- ewig ohne Limit retryen
- grundlegende Queue-Visibility überspringen (keine klare Übersicht über Backlog, Fehler, lange laufende Items)
Ein konkretes Beispiel: Ein Daily-Summary-Job iteriert über 50.000 Nutzer und timed bei Nutzer 20.000 aus. Beim Retry startet er von vorn und sendet den ersten 20.000 Nutzern erneut Summaries, es sei denn, du trackst pro Nutzer Fertigstellung oder teilst den Job in pro-Nutzer-Jobs.
Quick-Checklist für ein verlässliches Job-System
Ein Job-Runner ist erst „fertig“, wenn du ihm um 2 Uhr nachts vertrauen kannst.
Stelle sicher, dass du hast:
- Queue-Visibility: Zählungen für queued vs running vs failed sowie der älteste queued-Job.
- Idempotenz per Default: Gehe davon aus, dass jeder Job zweimal laufen kann; nutze eindeutige Schlüssel oder „already processed“-Marker.
- Retry-Policy pro Job-Typ: Retries, Backoff und klare Abbruchbedingung.
- Konsistente Zeitspeicherung:
run_atin UTC speichern; nur beim Input und bei der Anzeige konvertieren. - Recoverable Locks: Eine Lease, damit Crashes Jobs nicht ewig als running lassen.
Begrenze außerdem Batch-Größe (wie viele Jobs du auf einmal claimst) und Worker-Concurrency (wie viele parallel laufen). Ohne Limits kann ein Spike die Datenbank überlasten oder andere Arbeit verhungern lassen.
Ein realistisches Beispiel: Erinnerungen und Summaries für ein kleines Team
Ein kleines SaaS-Tool hat 30 Kundenaccounts. Jeder Account will zwei Dinge: eine Erinnerung um 9:00 Uhr für offene Aufgaben und um 18:00 Uhr eine tägliche Zusammenfassung der Änderungen. Außerdem braucht das System wöchentliches Cleanup, damit die Datenbank nicht mit alten Logs und abgelaufenen Tokens vollläuft.
Sie nutzen eine Jobs-Tabelle plus einen Worker, der nach fälligen Jobs pollt. Wenn ein neuer Kunde sich anmeldet, plant das Backend den ersten Reminder und die ersten Summary-Runs basierend auf der Zeitzone des Kunden.
Jobs werden zu ein paar typischen Zeitpunkten erstellt: bei Signup (wiederkehrende Zeitpläne anlegen), bei bestimmten Events (one-off Notifications einreihen), beim Schedule-Tick (künftige Runs einfügen) und am Wartungstag (Cleanup einreihen).
An einem Dienstag hat der E-Mail-Anbieter um 8:59 Uhr eine temporäre Störung. Der Worker versucht, Erinnerungen zu senden, bekommt ein Timeout und reschedult diese Jobs mit Backoff (z. B. 2 Minuten, dann 10, dann 30), erhöht bei jedem Versuch attempts. Weil jeder Reminder-Job einen Idempotency-Key wie account_id + date + job_type hat, erzeugen Retries keine Duplikate, falls der Anbieter mitten in der Aktion wieder verfügbar wird.
Cleanup läuft wöchentlich in kleinen Batches, sodass es andere Arbeit nicht blockiert. Statt eine Million Zeilen in einem Job zu löschen, löscht es bis zu N Zeilen pro Lauf und reschedult sich so lange, bis es fertig ist.
Wenn ein Kunde sich beschwert „Ich habe meine Zusammenfassung nicht bekommen“, prüft das Team die Jobs-Tabelle für diesen Account und Tag: Job-Status, Attempt-Count, aktuelle Lock-Felder und die letzte Fehlermeldung des Providers. Das verwandelt „es hätte gesendet werden müssen“ in „hier ist genau, was passiert ist".
Nächste Schritte: implementieren, beobachten, dann skalieren
Wähle einen Job-Typ und baue ihn komplett durch, bevor du mehr hinzufügst. Ein einzelner Reminder-Job ist ein guter Starter, weil er alles berührt: Scheduling, Claimen fälliger Arbeit, Nachricht senden und Ergebnisse speichern.
Starte mit einer Version, der du vertraust:
- erstelle die Jobs-Tabelle und einen Worker, der einen Job-Typ verarbeitet
- füge eine Scheduler-Schleife hinzu, die fällige Jobs claimt und ausführt
- speichere genug Payload, um den Job ohne zusätzliches Herumsuchen auszuführen
- logge jeden Versuch und jedes Ergebnis, sodass „Ist das gelaufen?“ eine 10-Sekunden-Frage wird
- füge einen manuellen Rerun-Pfad für fehlgeschlagene Jobs hinzu, sodass Recovery kein Deploy erfordert
Wenn es läuft, mache es für Menschen sichtbar. Schon eine einfache Admin-Ansicht zahlt sich schnell aus: Jobs nach Status suchen, nach Zeit filtern, Payload inspizieren, einen hängenden Job abbrechen, einen bestimmten Job-ID neu ausführen.
Wenn du diese Art Scheduler- und Worker-Logik lieber mit visueller Backend-Logik bauen willst, kann AppMaster (appmaster.io) die Jobs-Tabelle in PostgreSQL modellieren und die Claim-Process-Update-Schleife als Business Process abbilden — dabei wird weiterhin echter Quellcode zur Bereitstellung generiert.


