Lerne das Cron + Datenbank-Muster kennen, um geplante Hintergrundjobs mit Retries, Sperren und Idempotenz auszuführen – ganz ohne ein komplettes Queue-System.

Die meisten Apps brauchen Arbeit, die später oder nach Plan erledigt wird: Follow-up-E-Mails senden, eine nächtliche Abrechnung prüfen, alte Datensätze aufräumen, einen Bericht neu aufbauen oder einen Cache aktualisieren.
Am Anfang ist es verlockend, ein komplettes Queue-System einzuführen, weil sich das wie der „richtige“ Weg für Background-Jobs anfühlt. Queues bringen aber zusätzliche Komponenten: einen weiteren Dienst zum Betreiben, Überwachen, Deployen und Debuggen. Für ein kleines Team (oder einen Solo-Gründer) kann dieses Mehr an Gewicht bremsend wirken.
Die eigentliche Frage lautet also: Wie führt man geplante Arbeit zuverlässig aus, ohne zusätzliche Infrastruktur aufzusetzen?
Ein häufiger erster Versuch ist simpel: einen Cron-Eintrag, der einen Endpunkt aufruft, und dieser Endpunkt führt die Arbeit aus. Das funktioniert — bis es nicht mehr funktioniert. Sobald du mehr als einen Server hast, ein Deploy zur falschen Zeit läuft oder ein Job länger braucht als erwartet, treten verwirrende Fehler auf.
Geplante Arbeit bricht meist auf vorhersehbare Weisen zusammen:
Das Cron + Datenbank-Muster ist ein Mittelweg. Du benutzt weiterhin Cron, um „aufzuwecken“, speicherst aber Job-Intent und Job-Status in der Datenbank, damit das System koordinieren, retries durchführen und aufzeichnen kann, was passiert ist.
Es passt gut, wenn du bereits eine Datenbank hast (häufig PostgreSQL), nur wenige Job-Typen beabsichtigst und vorhersehbares Verhalten bei minimalem Ops-Aufwand willst. Es ist außerdem eine natürliche Wahl für schnell gebaute Apps mit modernen Stacks (zum Beispiel ein React + Go + PostgreSQL-Setup).
Nicht geeignet ist das Muster, wenn du sehr hohen Durchsatz brauchst, lang laufende Jobs mit Streaming-Fortschritt, strikte Reihenfolge über viele Job-Typen oder starke Fan-out-Szenarien (Tausende Sub-Tasks pro Minute). In solchen Fällen rentiert sich meist ein echtes Queue-System mit dedizierten Worker-Instanzen.
Das Cron + Datenbank-Muster führt Hintergrundarbeit nach Zeitplan aus, ohne ein komplettes Queue-System zu betreiben. Cron (oder jeder Scheduler) weckt die Worker regelmäßig auf, aber Cron entscheidet nicht, was ausgeführt wird. Cron ist nur die Weckfunktion; die Datenbank entscheidet, welche Arbeiten fällig sind und stellt sicher, dass nur ein Worker jeden Job übernimmt.
Stell es dir wie eine gemeinsame Checkliste an einer Tafel vor. Cron ist die Person, die jede Minute hereinkommt und fragt: „Muss jetzt etwas erledigt werden?“ Die Datenbank ist die Tafel, die zeigt, was fällig ist, was bereits vergeben ist und was erledigt wurde.
Die Bestandteile sind einfach:
Beispiel: Du möchtest jeden Morgen Erinnerungsemails für Rechnungen senden, einen Cache alle 10 Minuten auffrischen und alte Sessions nachts aufräumen. Anstatt drei separate Cron-Befehle (jeweils mit eigenen Overlap- und Fehlermodi) speicherst du Job-Einträge an einem Ort. Cron startet denselben Worker-Prozess. Der Worker fragt Postgres: „Was ist jetzt fällig?“ und Postgres erlaubt dem Worker, sicher genau einen Job nach dem anderen zu claimen.
Das skaliert schrittweise. Du kannst mit einem Worker auf einem Server starten und später fünf Worker über mehrere Server laufen lassen. Der Vertrag bleibt derselbe: die Tabelle ist der Vertrag.
Die Denkweise ändert sich leicht: Cron ist nur der Weckruf. Die Datenbank ist der Verkehrspolizist, der entscheidet, was laufen darf, aufzeichnet, was passiert ist, und dir eine klare Historie gibt, wenn etwas schiefgeht.
Dieses Muster funktioniert am besten, wenn deine Datenbank die Quelle der Wahrheit dafür wird, was laufen soll, wann es laufen soll und was beim letzten Mal passiert ist. Das Schema ist nicht fancy, aber kleine Details (Sperr-Felder und die richtigen Indizes) machen einen großen Unterschied, wenn die Last wächst.
Zwei gängige Ansätze:
Wenn du erwartest, Fehler häufig zu debuggen, behalte die Historie. Wenn du das kleinstmögliche Setup willst, starte mit einer Tabelle und füge die Historie später hinzu.
Hier ein PostgreSQL-freundliches Layout. Wenn du in Go mit PostgreSQL baust, lassen sich diese Spalten gut auf Structs abbilden.
-- What should exist (the definition)
create table job_definitions (
id bigserial primary key,
job_type text not null,
payload jsonb not null default '{}'::jsonb,
schedule text, -- optional: cron-like text if you store it
max_attempts int not null default 5,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- What should run (each run / attempt group)
create table job_runs (
id bigserial primary key,
definition_id bigint references job_definitions(id),
job_type text not null,
payload jsonb not null default '{}'::jsonb,
run_at timestamptz not null,
status text not null, -- queued | running | succeeded | failed | dead
attempts int not null default 0,
max_attempts int not null default 5,
locked_by text,
locked_until timestamptz,
last_error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
Einige Details, die später Schmerzen sparen:
job_type als kurzen String, nach dem du routen kannst (z. B. send_invoice_emails).payload als jsonb, damit du es ohne Migrationen weiterentwickeln kannst.run_at ist deine „nächste Fälligkeitszeit“. Cron (oder ein Scheduler-Skript) setzt ihn, Worker konsumieren ihn.locked_by und locked_until erlauben es Workern, Jobs zu claimen, ohne sich gegenseitig auf die Füße zu treten.last_error sollte kurz und menschenlesbar sein. Stacktraces legst du anderswo ab, wenn du sie brauchst.Ohne Indizes scannen Worker zu viel. Starte mit:
(status, run_at)(locked_until)status in queued und failed)Diese Indizes halten die Abfrage „nächsten ausführbaren Job finden" schnell, selbst wenn die Tabelle wächst.
Das Ziel ist einfach: viele Worker können laufen, aber nur einer darf einen bestimmten Job greifen. Wenn zwei Worker dieselbe Zeile verarbeiten, bekommst du doppelte E-Mails, doppelte Abbuchungen oder unordentliche Daten.
Ein sicherer Ansatz ist, eine Job-Claim wie ein „Lease" zu behandeln. Der Worker markiert den Job für ein kurzes Zeitfenster als gesperrt. Wenn der Worker abstürzt, läuft das Lease ab und ein anderer Worker kann den Job übernehmen. Dafür ist locked_until gedacht.
Ohne Lease kann ein Worker einen Job sperren und ihn nie wieder freigeben (Prozess beendet, Server neugestartet, fehlgeschlagenes Deploy). Mit locked_until wird der Job nach Ablauf der Zeit wieder verfügbar.
Eine typische Regel lautet: Ein Job kann übernommen werden, wenn locked_until NULL ist oder locked_until <= now().
Der entscheidende Punkt ist, den Job in einer einzelnen Anweisung (oder in einer Transaktion) zu claimen. Du willst, dass die Datenbank der Schiedsrichter ist.
Hier ein übliches PostgreSQL-Pattern: Wähle einen fälligen Job, sperre ihn und gib ihn dem Worker zurück. (Dieses Beispiel verwendet eine einzelne jobs-Tabelle; dieselbe Idee gilt für job_runs.)
WITH next_job AS (
SELECT id
FROM jobs
WHERE status = 'queued'
AND run_at <= now()
AND (locked_until IS NULL OR locked_until <= now())
ORDER BY run_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running',
locked_until = now() + interval '2 minutes',
locked_by = $1,
attempts = attempts + 1,
updated_at = now()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;
Warum das funktioniert:
FOR UPDATE SKIP LOCKED lässt mehrere Worker konkurrieren, ohne einander zu blockieren.RETURNING übergibt die Zeile an den Worker, der das Rennen gewonnen hat.Setze das Lease länger als eine normale Ausführung, aber kurz genug, damit ein Absturz schnell wieder aufgefangen wird. Wenn die meisten Jobs in 10 Sekunden fertig sind, reicht ein 2-Minuten-Lease.
Für lange Tasks erneuere das Lease während der Arbeit (Heartbeat). Ein einfacher Ansatz: Erweitere locked_until alle 30 Sekunden, solange du den Job besitzt.
WHERE id = $job_id AND locked_by = $worker_id enthaltenDiese letzte Bedingung ist wichtig. Sie verhindert, dass ein Worker ein Lease für einen Job verlängert, den er nicht mehr besitzt.
Retries sind der Punkt, an dem dieses Muster entweder ruhig wirkt oder in ein lautes Chaos kippt. Das Ziel ist einfach: Wenn ein Job fehlschlägt, versuche es später noch einmal — auf eine Art, die du erklären, messen und beenden kannst.
Beginne damit, den Job-Status explizit und endlich zu machen: queued, running, succeeded, failed, dead. In der Praxis verwenden die meisten Teams failed für „fehlgeschlagen, aber wird nochmals versucht“ und dead für „fehlgeschlagen und wir haben aufgegeben“. Diese Unterscheidung verhindert Endlosschleifen.
Versuchszählung ist die zweite Schutzmaßnahme. Speichere attempts (wie oft du es versucht hast) und max_attempts (wie oft du erlaubst). Wenn ein Worker einen Fehler fängt, sollte er:
attempts inkrementierenfailed setzen, wenn attempts < max_attempts, sonst auf deadrun_at für den nächsten Versuch berechnen (nur für failed)Backoff ist einfach die Regel, die das nächste run_at bestimmt. Wähle eine Methode, dokumentiere sie und bleibe konsistent:
Jitter ist wichtig, wenn eine Abhängigkeit ausfällt und wiederkommt. Ohne Jitter retryen Hunderte Jobs gleichzeitig und schlagen wieder fehl.
Speichere genug Fehlerdetails, damit Failures sichtbar und debuggbar sind. Du brauchst kein vollständiges Logging-System, aber die Grundlagen:
last_error (kurze Nachricht, sicher im Admin-UI anzeigbar)error_code oder error_type (hilft beim Gruppieren)failed_at und next_run_atlast_stack (nur wenn du die Größe kontrollierst)Eine konkrete Regel, die gut funktioniert: markiere Jobs nach 10 Versuchen als dead und nutze exponentielles Backoff mit Jitter. Das lässt temporäre Fehler wiederholt versuchen, stoppt aber kaputte Jobs, die sonst Ressourcen verbrennen.
Idempotenz bedeutet, dass dein Job zweimal laufen kann und trotzdem dasselbe Endergebnis produziert. In diesem Muster ist das wichtig, weil dieselbe Zeile nach einem Absturz, Timeout oder Retry erneut gepickt werden kann. Wenn dein Job Rechnung verschicken ist, ist zweimal ausführen nicht harmlos.
Denk praktisch: Teile jeden Job in (1) Arbeit tun und (2) Effekt anwenden. Der Effekt soll genau einmal passieren, auch wenn die Arbeit mehrfach versucht wird.
Ein Idempotency-Key sollte von dem stammen, was der Job repräsentiert, nicht vom Worker-Versuch. Gute Keys sind stabil und leicht erklärbar, z. B. invoice_id, user_id + day oder report_name + report_date. Wenn zwei Job-Versuche dasselbe reale Ereignis behandeln, sollten sie denselben Key teilen.
Beispiel: „Täglichen Verkaufsbericht für 2026-01-14 erstellen" kann sales_report:2026-01-14 nutzen. „Rechnung 812 belasten" kann invoice_charge:812 nutzen.
Die einfachste Absicherung ist, PostgreSQL Duplikate ablehnen zu lassen. Speichere den Idempotency-Key an einer Stelle, die indiziert werden kann, und füge eine Unique-Constraint hinzu.
-- Example: ensure one logical job/effect per business key
ALTER TABLE jobs
ADD COLUMN idempotency_key text;
CREATE UNIQUE INDEX jobs_idempotency_key_uniq
ON jobs (idempotency_key)
WHERE idempotency_key IS NOT NULL;
Das verhindert, dass zwei Zeilen mit demselben Key gleichzeitig existieren. Wenn dein Design mehrere Zeilen erlaubt (für Historie), lege die Einzigartigkeit auf einer „effects“-Tabelle fest, z. B. sent_emails(idempotency_key) oder payments(idempotency_key).
Typische Nebeneffekte, die du schützen solltest:
sent_emails-Zeile mit einem Unique-Key an, oder speichere die Message-ID des Providers nach dem Senden.delivered_webhooks(event_id) und überspringe, wenn ein Eintrag existiert.file_generated-Eintrag mit Key (type, date).Wenn du auf einem Postgres-gestützten Stack baust (z. B. Go + PostgreSQL), sind diese Unique-Checks schnell und lassen sich nah an den Daten halten. Die Kernidee ist simpel: Retries sind normal, Duplikate sind optional.
Wähle eine unspektakuläre Laufzeit und bleibe dabei. Der Sinn des Cron + Datenbank-Musters ist weniger bewegliche Teile, daher reicht meist ein kleines Go-, Node- oder Python-Programm, das mit PostgreSQL spricht.
Erstelle die Tabellen und Indizes. Lege eine jobs-Tabelle an (plus gewünschte Lookup-Tabellen), indexiere run_at und füge einen Index hinzu, der deinem Worker hilft, verfügbare Jobs schnell zu finden (z. B. (status, run_at)).
Schreibe eine winzige Enqueue-Funktion. Deine App sollte eine Zeile einfügen, mit run_at auf now oder eine zukünftige Zeit gesetzt. Halte das Payload klein und vorhersehbar (IDs und ein Job-Typ, keine großen Blobs).
INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
running.WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'queued' AND run_at <= now()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs
SET status = 'running', started_at = now()
WHERE id IN (SELECT id FROM picked)
RETURNING *;
Verarbeite und finalisiere. Für jeden geclaimten Job: führe die Arbeit aus und aktualisiere dann zu done mit finished_at. Falls er fehlschlägt, speichere eine Fehlermeldung und setze ihn zurück auf queued mit neuem run_at (Backoff). Halte Finalisierungs-Updates klein und führe sie immer aus, selbst wenn dein Prozess herunterfährt.
Füge erklärbare Retry-Regeln hinzu. Nutze eine einfache Formel wie run_at = now() + (attempts^2) * interval '10 seconds' und stoppe nach max_attempts durch Setzen von status = 'dead'.
Du brauchst kein vollständiges Dashboard am ersten Tag, aber genug, um Probleme zu bemerken.
Wenn du bereits einen Go + PostgreSQL-Stack hast, passt das sauber zu einer einzelnen Worker-Binary plus Cron.
Stell dir eine kleine SaaS-App mit zwei geplanten Aufgaben vor:
Halte es einfach: eine PostgreSQL-Tabelle für Jobs und ein Worker, der jede Minute läuft (per Cron getriggert). Der Worker claimt fällige Jobs, führt sie aus und protokolliert Erfolg oder Fehler.
Jobs kannst du an mehreren Stellen enqueuen:
cleanup_nightly-Job für „heute“.send_weekly_report-Job für den nächsten Montag des Nutzers.send_weekly_report-Job, der sofort für einen bestimmten Datumsbereich läuft.Das Payload ist nur das Minimum, das der Worker braucht. Halte es klein, damit es leicht erneut versucht werden kann.
{
"type": "send_weekly_report",
"payload": {
"user_id": 12345,
"date_range": {
"from": "2026-01-01",
"to": "2026-01-07"
}
}
}
Ein Worker kann im schlimmsten Moment abstürzen: direkt nachdem er die E-Mail gesendet hat, aber bevor er den Job als „done" markiert. Beim Neustart kann derselbe Job wieder aufgenommen werden.
Um doppelte Sends zu verhindern, gib der Arbeit einen natürlichen Dedupe-Key und speichere ihn dort, wo die Datenbank ihn durchsetzen kann. Bei Wochenberichten ist ein guter Key (user_id, week_start_date). Bevor gesendet wird, zeichnet der Worker „Ich werde Bericht X senden" auf. Existiert dieser Eintrag bereits, wird das Senden übersprungen.
Das kann so einfach sein wie eine sent_reports-Tabelle mit Unique-Constraint auf (user_id, week_start_date), oder ein eindeutiger idempotency_key direkt im Job.
Sagen wir, dein E-Mail-Provider hat ein Timeout. Der Job schlägt fehl, also macht der Worker:
attempts hochzählenWenn er weiter fehlschlägt und das Limit (z. B. 10 Versuche) überschreitet, markiere ihn als dead. Entweder gelingt der Job irgendwann, oder er retried planbar und sicher; Idempotenz macht Retries unproblematisch.
Das Cron + Datenbank-Muster ist simpel, aber kleine Fehler können es in Duplikate, blockierte Arbeit oder unerwartete Last verwandeln. Die meisten Probleme treten nach dem ersten Crash, Deploy oder Lastspike auf.
Viele reale Vorfälle entstehen durch einige Fallen:
locked_until ignorieren. Wenn ein Worker nach dem Claim abstürzt, kann die Zeile „in Bearbeitung" bleiben. Ein Lease-Timestamp erlaubt es einem anderen Worker, den Job später sicher zu übernehmen.user_id, invoice_id oder einen File-Key) und lade den Rest beim Ausführen.Beispiel: Du sendest eine wöchentliche Rechnungs-E-Mail. Wenn der Worker nach dem Senden timet outet, aber bevor der Job als erledigt markiert wurde, kann derselbe Job erneut ausgeführt werden und eine doppelte E-Mail senden. Das ist normal für dieses Muster, sofern du nicht ein Guardrail hinzufügst (z. B. ein eindeutiges „E-Mail gesendet"-Ereignis nach Invoice-ID).
Vermeide es, Planung und Ausführung in derselben langen Transaktion zu mischen. Wenn du eine Transaktion offen hältst, während du Netzwerkaufrufe machst, hältst du Sperren länger als nötig und blockierst andere Worker.
Achte auf Zeitdifferenzen zwischen Maschinen. Nutze die Datenbankzeit (NOW() in PostgreSQL) als Quelle der Wahrheit für run_at und locked_until, nicht die Uhr des App-Servers.
Setze eine klare maximale Laufzeit. Wenn ein Job 30 Minuten dauern kann, mache das Lease länger und erneuere es bei Bedarf. Ansonsten könnte ein anderer Worker ihn mitten in der Ausführung übernehmen.
Halte deine Job-Tabelle gesund. Wenn erledigte Jobs ewig liegenbleiben, verlangsamen sich Abfragen und Lock-Contention steigt. Lege eine einfache Retention-Regel (archivieren oder löschen alter Zeilen) fest, bevor die Tabelle zu groß wird.
Bevor du dieses Muster ausrollst, prüfe die Basics. Eine kleine Auslassung hier verwandelt sich meist in blockierte Jobs, überraschende Duplikate oder einen Worker, der die DB überlastet.
run_at, status, attempts, locked_until und max_attempts (plus last_error oder Ähnliches, damit du sehen kannst, was passiert ist).invoice_id).dead markieren, wenn er nicht mehr wiederholt werden soll.max_attempts stoppen.Wenn das alles zutrifft, ist das Cron + Datenbank-Muster in der Regel stabil genug für reale Workloads.
Wenn die Checkliste passt, konzentriere dich auf den täglichen Betrieb.
run_at = now() und löscht den Lock) und „abbrechen" (setzt einen terminalen Status). Das spart Zeit bei Vorfällen.status, run_at).Wenn du das schnell umsetzen willst, kann Koder.ai (koder.ai) dir helfen, vom Schema zu einer deployten Go + PostgreSQL-App zu kommen, während du dich auf Locking, Retries und Idempotenz-Regeln konzentrierst.
Wenn du dieses Setup später überwachst und ausbaust, hast du das Job-Lifecycle-Verständnis, das sich auch gut auf ein echtes Queue-System übertragen lässt.