Wenn du Redis nur für den Job-Trigger startest, schmeißt du ihn wieder raus: Postgres pg_notify als Worker-Queue
Unser cinema-orch-Worker musste neue Render-Jobs sofort sehen — 5-Sekunden-Polling war zu langsam und lief die Postgres-Load in den fünfstelligen QPS-Bereich. Redis dazuzunehmen war der offensichtliche Reflex. Wir haben stattdessen \`LISTEN/NOTIFY\` genommen, die Latenz auf < 200 ms gedrückt und eine ganze Komponente aus dem Stack rausgehalten.
Der Worker in unserem cinema-orch (Sprint 3.2) hat einen sehr klaren Job: sobald ein User einen neuen Render-Auftrag über die Next.js-App abschickt und der Estimate-Check durch ist, muss der Worker den Job aus der jobs-Tabelle rausholen, den Pod spawnen und den Frame-Loop starten. Latenz-Ziel: unter 1 Sekunde von „User klickt Render" bis „Pod ist provisioniert".
Die naive Lösung ist Polling — alle 5 Sekunden SELECT ... WHERE state = 'queued' ORDER BY created_at LIMIT 1. Funktioniert, hat aber zwei Probleme: die Latenz liegt im Median bei 2.5 Sekunden (halbes Polling-Intervall), und die Postgres-Load pro Worker × Polling-Frequenz × Concurrent-Workers wird bei 20 Workern und 5-Sekunden-Interval zu 4 QPS Baseline-Load. Fügst du weitere Worker-Kategorien hinzu (Render-Worker, Upload-Worker, Cleanup-Worker), sind das schnell 15–30 QPS ohne echten User-Verkehr. Auf einem Hetzner-CX22 mit einer 2-vCPU-Postgres ist das die Hälfte des IO-Budgets.
Der Reflex im deutschen SaaS-Ökosystem ist dann: Redis dazunehmen. Redis Pub/Sub für die Worker-Notifications, Postgres nur noch als Persistenz-Layer. Wir haben das nicht gemacht.
Postgres kann Worker-Queues von Haus aus
Postgres hat LISTEN und NOTIFY seit Version 7.4 (2003). Die zwei Befehle machen genau das, wofür man normalerweise Redis Pub/Sub startet: ein Client subscribed sich auf einen Channel, ein anderer Client sendet ein Signal an den Channel, und Postgres liefert das Signal an alle Subscriber. Payload-Size ist 8 KB (bei Standard-Postgres-Config).
Der cinema-orch-Worker macht in Python mit asyncpg:
async def wait_for_next_job(conn):
await conn.execute("LISTEN new_render_job")
while True:
# blockt bis eine NOTIFY reinkommt
await conn.connection.notifies.get()
job = await pick_next_queued_job(conn)
if job:
return job
Auf der Next.js-App-Seite, direkt nach dem erfolgreichen Insert des neuen Jobs:
await db.execute(sql`
INSERT INTO jobs (user_id, state, ...) VALUES (${userId}, 'queued', ...);
NOTIFY new_render_job;
`);
Zwei Bemerkungen zum SQL: NOTIFY und INSERT müssen in derselben Transaction sein, sonst kann der Worker den Notify empfangen bevor der Insert committed ist. Und die Payload kann optional ein Payload-String enthalten (NOTIFY new_render_job, 'job_id=42'), aber wir übergeben nur den Channel — der Worker holt den Job selbst mit SELECT ... FOR UPDATE SKIP LOCKED.
Die drei Trade-offs die man kennen muss
1. Notify-Delivery ist at-most-once. Postgres speichert Notifies nicht persistent. Wenn der Worker gerade nicht listened (Restart, Netzwerk-Partition), verpasst er das Signal. Fix: der Worker macht nach einem Reconnect immer erst einmal einen Full-Scan der jobs-Tabelle für state = 'queued'-Rows, bevor er auf neue Notifies wartet. So ist ein verpasster Notify egal.
2. Payload-Size ist 8 KB. Wenn du ganze Job-Objekte per Notify verschicken willst, klappt das nicht. Wir schicken ohnehin nur den Channel — der Worker holt sich die Details aus der Tabelle.
3. Ein Notify ist ein Broadcast, nicht ein Pop. Alle Worker die auf demselben Channel listenen sehen das Signal. Wenn du sicherstellen willst dass nur einer den Job nimmt, brauchst du SELECT ... FOR UPDATE SKIP LOCKED beim Pick — Postgres locked die Row auf DB-Ebene, andere Worker sehen sie beim nächsten SELECT als „skipped".
Unser cinema-orch hat einen einzelnen Worker pro Server-Instanz, deshalb ist SKIP LOCKED bei uns nicht mal notwendig — aber es ist die Standard-Empfehlung wenn du je horizontal skalierst.
Die Zahlen nach dem Umbau
- Median-Latenz „User klickt" bis „Pod spawnt": 5.2 s → 620 ms. Der größte Anteil sind jetzt die 400 ms Runpod-Pod-Provisioning, nicht mehr die Polling-Wartezeit.
- Postgres-Load Baseline ohne echten User-Verkehr: 4 QPS → 0.1 QPS (nur Heartbeat-Ticks).
- Kein Redis im Stack. Ein weniger Docker-Container, ein weniger Backup-Ziel, eine weniger Prozess-Kategorie im Monitoring.
Wann Redis trotzdem stimmt
Wenn du Delayed Jobs brauchst (SCHEDULE FOR '2026-08-15T09:00:00Z'), einen Verzögerten-Retry mit exponentieller Backoff-Kurve, oder komplexe Priority-Queues mit Weighted-Round-Robin. LISTEN/NOTIFY gibt dir keine Delayed-Jobs. Für Sofort-Jobs mit einfacher FIFO-Semantik ist Postgres genug.
Wenn du > 1000 Notifies pro Sekunde brauchst — auch dann. Postgres NOTIFY hat einen Overhead pro Notify-Emit, ab 1k/s wird das sichtbar. Bei uns sind das aktuell < 10 Notifies pro Minute. Kein Grund, ein zweites Storage-System zu betreiben.
Meta-Lektion
Der deutsche SaaS-Reflex „Redis dazunehmen" kommt aus dem Zeitalter als Postgres wirklich nur eine Datenbank war. Post-2015 ist Postgres eine Message-Bus + KV-Store + Search-Engine + JSON-Store + Zeit-Serie in einem — und alle diese Features sind gut genug für die 90 % Use-Case. Wenn du keine spezifische Anforderung hast die Postgres nicht kann, ist es billiger und einfacher, einen Server zu betreiben statt zwei.
Der komplette Setup ist in unserem ZER0ONE Lab Cinema Render live und läuft seit 4 Wochen im Beta ohne einen einzigen Job-Miss. Wenn du für dein eigenes Backend eine LISTEN/NOTIFY-basierte Job-Queue evaluierst und Fragen hast — [email protected], gerne 30 Minuten kostenlos.
Ähnlicher Brand bei dir?
Wir haben vermutlich schon etwas ähnliches gesehen. Sprich mit uns.
Gespräch starten→