Se avvii Redis solo per il job trigger, poi lo butti fuori di nuovo: Postgres pg_notify come worker queue
Il nostro worker di cinema-orch doveva vedere subito i nuovi render job — un polling ogni 5 secondi era troppo lento e faceva salire il carico Postgres nell’ordine delle cinque cifre in QPS. Aggiungere Redis era il riflesso ovvio. Abbiamo scelto invece \`LISTEN/NOTIFY\`, abbassato la latenza sotto i 200 ms e tenuto un intero componente fuori dallo stack.
Il worker del nostro cinema-orch (Sprint 3.2) ha un compito molto chiaro: non appena un utente invia un nuovo job di render attraverso l’app Next.js e il check di estimate passa, il worker deve prelevare il job dalla tabella `jobs`, avviare il pod e far partire il frame-loop. Target di latenza: sotto 1 secondo da „utente clicca Render“ a „pod provisionato“.
La soluzione naive è il polling — ogni 5 secondi `SELECT ... WHERE state = 'queued' ORDER BY created_at LIMIT 1`. Funziona, ma ha due problemi: la latenza mediana è di 2,5 secondi (metà dell’intervallo di polling), e il carico Postgres per worker × frequenza di polling × worker concorrenti diventa, con 20 worker e intervallo 5 secondi, un carico baseline di 4 QPS. Se aggiungi altre categorie di worker (render worker, upload worker, cleanup worker), sono facilmente 15–30 QPS senza traffico reale. Su un Hetzner CX22 con Postgres a 2 vCPU è metà del budget di I/O.
Il riflesso nell’ecosistema SaaS tedesco è a quel punto: aggiungere Redis. Redis Pub/Sub per le notification worker, Postgres solo come persistence layer. Non l’abbiamo fatto.
Postgres sa fare worker queue nativamente
Postgres ha `LISTEN` e `NOTIFY` dalla versione 7.4 (2003). Questi due comandi fanno esattamente ciò per cui di solito si avvia Redis Pub/Sub: un client si iscrive a un channel, un altro client invia un segnale al channel, e Postgres consegna il segnale a tutti gli subscriber. La payload size è di 8 KB (con config standard di Postgres).
Il worker cinema-orch fa in Python con asyncpg:
```python async def wait_for_next_job(conn): await conn.execute("LISTEN new_render_job") while True: # blocca finché arriva un NOTIFY await conn.connection.notifies.get() job = await pick_next_queued_job(conn) if job: return job ```
Sul lato dell’app Next.js, direttamente dopo l’insert riuscito del nuovo job:
```typescript await db.execute(sql` INSERT INTO jobs (user_id, state, ...) VALUES (${userId}, 'queued', ...); NOTIFY new_render_job; `); ```
Due osservazioni sul SQL: `NOTIFY` e `INSERT` devono stare nella stessa transaction, altrimenti il worker può ricevere il notify prima che l’insert sia committato. E la payload può contenere opzionalmente una payload string (`NOTIFY new_render_job, 'job_id=42'`), ma noi passiamo solo il channel — il worker recupera il job da sé con `SELECT ... FOR UPDATE SKIP LOCKED`.
I tre trade-off da conoscere
1. La consegna del notify è at-most-once. Postgres non salva i notify in modo persistente. Se il worker in quel momento non sta ascoltando (restart, network partition), perde il segnale. Fix: dopo un reconnect il worker fa sempre prima un full scan della tabella `jobs` per righe `state = 'queued'`, prima di mettersi in attesa di nuovi notify. Così un notify perso è irrilevante.
2. La payload size è 8 KB. Se vuoi spedire interi oggetti job via notify, non funziona. Noi passiamo comunque solo il channel — il worker recupera i dettagli dalla tabella.
3. Un notify è un broadcast, non un pop. Tutti i worker che ascoltano lo stesso channel vedono il segnale. Se vuoi garantire che solo uno prenda il job, ti serve `SELECT ... FOR UPDATE SKIP LOCKED` al pick — Postgres blocca la row a livello DB, gli altri worker la vedono come „skipped“ alla `SELECT` successiva.
Il nostro cinema-orch ha un singolo worker per istanza server, quindi `SKIP LOCKED` da noi non è nemmeno necessario — ma è la raccomandazione standard non appena si scala orizzontalmente.
I numeri dopo la riprogettazione
- Latenza mediana „utente clicca“ fino a „pod parte“: 5,2 s → 620 ms. La quota maggiore ora sono i 400 ms di provisioning del pod Runpod, non più l’attesa del polling.
- Carico Postgres baseline senza traffico reale: 4 QPS → 0,1 QPS (solo tick di heartbeat).
- Nessun Redis nello stack. Un container Docker in meno, un backup target in meno, una categoria di processo in meno nel monitoring.
Quando Redis ha comunque senso
Se ti servono Delayed Jobs (`SCHEDULE FOR '2026-08-15T09:00:00Z'`), retry differiti con curva di backoff esponenziale, oppure priority queue complesse con Weighted-Round-Robin. `LISTEN/NOTIFY` non ti dà i Delayed Jobs. Per job immediati con semantica FIFO semplice, Postgres è sufficiente.
Se ti servono > 1000 notify al secondo — anche allora. NOTIFY di Postgres ha un overhead per notify-emit, sopra 1k/s diventa visibile. Da noi al momento siamo sotto 10 notify al minuto. Nessun motivo per gestire un secondo storage system.
Meta-lezione
Il riflesso SaaS tedesco „aggiungiamo Redis“ viene dall’era in cui Postgres era davvero solo un database. Post-2015 Postgres è message bus + KV store + search engine + JSON store + time series in uno — e tutte queste feature sono sufficientemente buone per il 90 % dei casi d’uso. Se non hai un requisito specifico che Postgres non copre, gestire un solo server è più economico e più semplice che gestirne due.
Il setup completo è live nel nostro ZER0ONE Lab Cinema Render e gira da 4 settimane in beta senza un singolo job perso. Se stai valutando per il tuo backend una job queue basata su `LISTEN/NOTIFY` e hai domande — [email protected], volentieri 30 minuti gratis.
Stesso fuoco anche da te?
Probabilmente abbiamo già visto qualcosa di simile. Parliamone.
Iniziamo a parlarne→