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

Das Problem: geplante Arbeit ohne zusätzliche Infrastruktur
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:
- Doppelte Ausführungen: Zwei Server führen dieselbe Aufgabe aus, Rechnungen werden doppelt erstellt oder E-Mails doppelt gesendet.
- Verlorene Ausführungen: Ein Cron-Aufruf schlägt während eines Deploys fehl und niemand bemerkt es, bis Nutzer sich beschweren.
- Stille Fehler: Der Job wirft einmal einen Fehler und läuft dann nie wieder, weil es keinen Retry-Plan gibt.
- Teilweise Arbeit: Der Job stürzt mitten im Ablauf ab und hinterlässt Daten in einem merkwürdigen Zustand.
- Keine Prüfspur: Du kannst nicht beantworten „Wann lief das zuletzt?“ oder „Was ist letzte Nacht passiert?"
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.
Die Kernidee in einfachen Worten
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:
- Ein einzelner Scheduler-Trigger läuft häufig.
- Eine Jobs-Tabelle hält das "Was" und das "Wann" (Fälligkeitszeit), plus Status und Versuchszähler.
- Ein oder mehrere Worker poll-en die Tabelle, claim-en einen Job und führen die Arbeit aus.
- Das Claiming nutzt eine Datenbank-Sperre, damit nicht zwei Worker dieselbe Zeile übernehmen.
- Die Datenbank bleibt die Quelle der Wahrheit dafür, was lief, was fehlschlug und was erneut versucht werden soll.
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.
Die Jobs-Tabelle entwerfen (praktisches Schema)
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.
Eine oder zwei Tabellen?
Zwei gängige Ansätze:
- Eine kombinierte Tabelle, wenn dir nur der aktuelle Zustand jedes Jobs wichtig ist (einfacher, weniger Joins).
- Zwei Tabellen, wenn du eine saubere Trennung zwischen „was dieser Job ist" und „jedes Mal, wenn er lief" willst (bessere Historie, leichteres Debugging).
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.
Ein praktisches Schema (Zwei-Tabellen-Version)
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:
- Behalte
job_typeals kurzen String, nach dem du routen kannst (z. B.send_invoice_emails). - Speichere
payloadalsjsonb, damit du es ohne Migrationen weiterentwickeln kannst. run_atist deine „nächste Fälligkeitszeit“. Cron (oder ein Scheduler-Skript) setzt ihn, Worker konsumieren ihn.locked_byundlocked_untilerlauben es Workern, Jobs zu claimen, ohne sich gegenseitig auf die Füße zu treten.last_errorsollte kurz und menschenlesbar sein. Stacktraces legst du anderswo ab, wenn du sie brauchst.
Indizes, die du wollen wirst
Ohne Indizes scannen Worker zu viel. Starte mit:
- Ein Index, um fällige Arbeit schnell zu finden:
(status, run_at) - Ein Index, der hilft, abgelaufene Locks zu erkennen:
(locked_until) - Optional: ein partieller Index nur für aktive Arbeit (z. B.
statusinqueuedundfailed)
Diese Indizes halten die Abfrage „nächsten ausführbaren Job finden" schnell, selbst wenn die Tabelle wächst.
Jobs sicher sperren und claimen
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.
Nutze ein Lease, damit Abstürze Arbeit nicht für immer blockieren
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().
Claim-Jobs mit einem atomaren Update
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 LOCKEDlässt mehrere Worker konkurrieren, ohne einander zu blockieren.- Das Lease wird beim Claim gesetzt, sodass andere Worker es bis zum Ablauf ignorieren.
RETURNINGübergibt die Zeile an den Worker, der das Rennen gewonnen hat.
Wie lang sollte das Lease sein und wie erneuerst du es?
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.
- Lease-Länge: 5x bis 20x deiner typischen Job-Zeit
- Heartbeat-Intervall: 1/4 bis 1/2 der Lease-Länge
- Das Erneuerungs-Update sollte
WHERE id = $job_id AND locked_by = $worker_identhalten
Diese letzte Bedingung ist wichtig. Sie verhindert, dass ein Worker ein Lease für einen Job verlängert, den er nicht mehr besitzt.
Retries und Backoff, die sich vorhersehbar verhalten
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:
attemptsinkrementieren- den Zustand auf
failedsetzen, wennattempts < max_attempts, sonst aufdead run_atfür den nächsten Versuch berechnen (nur fürfailed)
Backoff ist einfach die Regel, die das nächste run_at bestimmt. Wähle eine Methode, dokumentiere sie und bleibe konsistent:
- Feste Verzögerung: immer 1 Minute warten
- Exponentiell: 1m, 2m, 4m, 8m
- Exponentiell mit Obergrenze: exponentiell, aber nie mehr als z. B. 30m
- Jitter hinzufügen: etwas zufällige Streuung, damit nicht alle Jobs zur gleichen Sekunde retryen
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_codeodererror_type(hilft beim Gruppieren)failed_atundnext_run_at- optional
last_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: Duplikate verhindern, selbst wenn ein Job erneut läuft
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.
Verwende einen Idempotency-Key, der ans Geschäftsereignis gebunden ist
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.
Erzwinge „nur einmal" mit Datenbank-Constraints
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:
- E-Mails: Lege vor dem Senden eine
sent_emails-Zeile mit einem Unique-Key an, oder speichere die Message-ID des Providers nach dem Senden. - Webhooks: Speichere
delivered_webhooks(event_id)und überspringe, wenn ein Eintrag existiert. - Payments: Nutze immer die Idempotency-Funktion des Payment-Providers plus einen eigenen Unique-Key in der DB.
- Dateischreibungen: Schreibe in einen temporären Namen und renne ihn dann um, oder speichere einen
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.
Schritt-für-Schritt: einen minimalen Worker und Scheduler bauen
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.
Baue es in fünf kleinen Schritten
-
Erstelle die Tabellen und Indizes. Lege eine
jobs-Tabelle an (plus gewünschte Lookup-Tabellen), indexiererun_atund 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_ataufnowoder 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);
- Implementiere die Claim-Schleife. Führe sie in einer Transaktion aus. Selektiere einige fällige Jobs, sperre sie so, dass andere Worker sie überspringen, und markiere sie in derselben Transaktion als
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
donemitfinished_at. Falls er fehlschlägt, speichere eine Fehlermeldung und setze ihn zurück aufqueuedmit neuemrun_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 nachmax_attemptsdurch Setzen vonstatus = 'dead'.
Grundlegende Sichtbarkeit hinzufügen
Du brauchst kein vollständiges Dashboard am ersten Tag, aber genug, um Probleme zu bemerken.
- Logge eine Zeile pro Job: claimed, succeeded, failed, retried, dead.
- Erzeuge eine einfache Admin-Query/View für „dead jobs“ und „alte running-Jobs".
- Alert bei Zählwerten (z. B. mehr als N dead jobs in der letzten Stunde).
Wenn du bereits einen Go + PostgreSQL-Stack hast, passt das sauber zu einer einzelnen Worker-Binary plus Cron.
Ein realistisches Beispiel, das du kopieren kannst
Stell dir eine kleine SaaS-App mit zwei geplanten Aufgaben vor:
- Eine nächtliche Bereinigung, die abgelaufene Sessions und alte temporäre Dateien entfernt.
- Eine wöchentliche „Dein Aktivitätsbericht“-E-Mail, die jedem Nutzer jeden Montagmorgen geschickt wird.
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.
Was wird enqueued und wann
Jobs kannst du an mehreren Stellen enqueuen:
- Täglich um 02:00: enqueue einen
cleanup_nightly-Job für „heute“. - Bei Signup: enqueue einen
send_weekly_report-Job für den nächsten Montag des Nutzers. - Nach einem Ereignis (z. B. „User klickte Export“): enqueue einen
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"
}
}
}
Wie Idempotenz doppelte Sends verhindert
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.
Wie ein Fehler aussieht (und wie er sich erholt)
Sagen wir, dein E-Mail-Provider hat ein Timeout. Der Job schlägt fehl, also macht der Worker:
attemptshochzählen- die Fehlermeldung für Debugging speichern
- den nächsten Versuch mit Backoff planen (z. B. +1 Min, +5 Min, +30 Min, +2 Std)
Wenn 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.
Häufige Fehler und Fallen
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.
Fehler, die Duplikate oder blockierte Jobs verursachen
Viele reale Vorfälle entstehen durch einige Fallen:
- Den gleichen Job von mehreren Cron-Einträgen ausführen, ohne Lease. Wenn zwei Server zur gleichen Minute tickern, können beide dieselbe Arbeit claimen, es sei denn, dein Claim-Schritt ist atomar und setzt eine Sperre (oder Lease) in derselben DB-Transaktion.
locked_untilignorieren. 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.- Sofortiges Retrying bei Fehlern. Wenn eine API down ist, erzeugen sofortige Retries Peaks, verbrauchen Rate-Limits und schlagen wieder fehl. Plane den nächsten Versuch in die Zukunft.
- „At least once" wie „exactly once" behandeln. Ein Job kann zweimal laufen (Timeouts, Worker-Neustarts, Netzwerkprobleme). Wenn zweimal ausführen schädlich ist, mache die Nebeneffekte wiederholungssicher.
- Riesige Payloads in der Job-Zeile speichern. Große JSON-Blobs blähen die Tabelle auf, verlangsamen Indizes und machen Sperren teurer. Speichere Referenzen (z. B.
user_id,invoice_idoder 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).
Weniger offensichtliche Fallstricke
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.
Schnell-Checkliste und nächste Schritte
Schnell-Checkliste
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.
- Deine Jobs-Tabelle hat das Wesentliche:
run_at,status,attempts,locked_untilundmax_attempts(pluslast_erroroder Ähnliches, damit du sehen kannst, was passiert ist). - Jeder Job kann sicher zweimal laufen. Wenn du dir nicht sicher bist, füge einen Idempotency-Key oder eine Unique-Regel auf der Seite des Nebeneffekts hinzu (z. B. eine Rechnung pro
invoice_id). - Es gibt einen klaren Ort, um Fehler zu beobachten und zu entscheiden: fehlgeschlagene Jobs ansehen, einen Job neu ausführen oder als
deadmarkieren, wenn er nicht mehr wiederholt werden soll. - Dein Lease-Timeout ist sinnvoll für die Arbeit. Es sollte lang genug für normale Runs sein, aber kurz genug, dass abgestürzte Worker nicht stundenlang Fortschritt blockieren.
- Retry-Backoff ist vorhersehbar. Er sollte wiederholte Fehler drosseln und nach
max_attemptsstoppen.
Wenn das alles zutrifft, ist das Cron + Datenbank-Muster in der Regel stabil genug für reale Workloads.
Nächste Schritte
Wenn die Checkliste passt, konzentriere dich auf den täglichen Betrieb.
- Füge zwei kleine Admin-Aktionen hinzu: „jetzt erneut versuchen" (setzt
run_at = now()und löscht den Lock) und „abbrechen" (setzt einen terminalen Status). Das spart Zeit bei Vorfällen. - Lass den Worker pro Job eine Logzeile schreiben: Job-Typ, Job-ID, Attempt-Nummer und Ergebnis. Füge Alerts für steigende Fehlerzahlen hinzu.
- Loadtest mit einem realistischen Spike: viele Jobs, die zur gleichen Minute geplant sind. Wenn das Claimen langsam wird, füge den richtigen Index hinzu (oft
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.
FAQ
Wann sollte ich Cron mit einer Datenbank für Hintergrund-Jobs verwenden?
Verwenden Sie dieses Muster, wenn Sie bereits eine Datenbank haben, nur eine überschaubare Anzahl von Jobtypen benötigen und geplante Aufgaben ohne den Betrieb einer separaten Queue ausführen möchten. Es eignet sich für Aufgaben wie E-Mail-Erinnerungen, Bereinigungen, Berichte und Cache-Aktualisierungen.
Was macht Cron in diesem Setup?
Cron sollte den Worker nur in einem regelmäßigen Intervall starten, oft einmal pro Minute. Die Datenbank entscheidet, welche Jobs fällig sind, speichert ihren Status und ermöglicht es einem Worker, jeden Job zu übernehmen.
Welche Felder sollte eine Jobs-Tabelle enthalten?
Speichern Sie den Jobtyp, eine kleine JSON-Nutzlast, den Fälligkeitszeitpunkt, den Status, die Anzahl der Versuche, das Wiederholungslimit, den Lock-Inhaber, den Ablauf des Locks und eine kurze Fehlermeldung. Diese Felder geben Workern genügend Informationen, um Jobs sicher auszuführen und wiederherzustellen.
Wie vermeiden mehrere Worker, denselben Job auszuführen?
Beanspruchen Sie Jobs in einer einzelnen Datenbanktransaktion mit Zeilensperren, etwa mit PostgreSQL FOR UPDATE SKIP LOCKED. Aktualisieren Sie die Zeile auf „running“ und setzen Sie ihre Lease, bevor der Worker die eigentliche Aufgabe startet.
Warum brauchen Hintergrund-Jobs ein Lease-Timeout?
Eine Lease ist eine temporäre Sperre, die mit locked_until gespeichert wird. Stürzt ein Worker ab, läuft die Lease ab und ein anderer Worker kann den Job übernehmen, statt dass er dauerhaft hängen bleibt.
Wie sollten fehlgeschlagene Jobs wiederholt werden?
Wiederholen Sie den Versuch später nach einer klaren Backoff-Regel, etwa nach 1 Minute, 2 Minuten, 4 Minuten und danach mit einer begrenzten Verzögerung und etwas Zufall. Nach der erlaubten Anzahl von Versuchen markieren Sie den Job als „dead“, damit er keine Worker-Zeit mehr verbraucht.
Was ist Idempotenz und warum ist sie wichtig?
Machen Sie die Nebenwirkung sicher wiederholbar. Verwenden Sie zum Beispiel einen stabilen Idempotenzschlüssel wie eine Rechnungs-ID oder eine Benutzer-ID plus Berichtsdatum und erzwingen Sie dann Eindeutigkeit in PostgreSQL oder beim externen Anbieter.
Garantiert dieses Muster eine genau einmalige Verarbeitung?
Nein. Ein Worker kann abstürzen, nachdem er eine E-Mail gesendet oder einen Zahlungsanbieter aufgerufen hat, aber bevor er den Erfolg speichert. Planen Sie mit mindestens einmaliger Verarbeitung und nutzen Sie Idempotenz, um doppelte Auswirkungen zu verhindern.
Sollte ein Worker während der Ausführung des Jobs eine Datenbanktransaktion offen halten?
Halten Sie Datenbanktransaktionen kurz. Beanspruchen Sie den Job, committen Sie, führen Sie Netzwerkaufrufe außerhalb der Transaktion aus und speichern Sie Erfolg oder Fehler anschließend in einem separaten Update. Lange Transaktionen halten Sperren und bremsen andere Worker.
Wie überwache ich Cron- und Datenbank-Jobs?
Überwachen Sie tote Jobs und lange laufende Jobs, protokollieren Sie jede Übernahme und jedes Ergebnis und lösen Sie einen Alarm aus, wenn die Fehler zunehmen. Ergänzen Sie einfache Admin-Aktionen, um einen Job sofort erneut auszuführen, ihn abzubrechen oder seinen letzten Fehler zu prüfen.