2026-06-19
Outbox transazionale su Postgres: come non perdere un evento nemmeno quando il server crasha
Pattern outbox su Postgres: scrivi eventi nella stessa transazione dei dati business, pubblica con worker SKIP LOCKED, at-least-once e idempotenza lato consumer.
Il pattern outbox transazionale rende atomica rispetto al database l’emissione di eventi (webhook, notifiche, messaggi verso una coda): scrivi l’evento in una tabella outbox nella stessa transazione dei dati business e un worker lo pubblica dopo. È uno dei pattern che usiamo in Snowinch quando un sistema non può permettersi di perdere ordini, pagamenti o aggiornamenti di stato, anche con crash, restart o timeout di rete.
Il problema che sembra non esistere finché non esiste
Hai un endpoint che riceve un ordine. Salvi l’ordine sul database, poi invii un messaggio alla coda (o chiami un webhook, o pubblichi un evento). Se il processo crasha tra le due operazioni, l’ordine è salvato ma l’evento non è mai partito. Nessun errore visibile, nessun retry automatico, solo un evento silenziosamente perso.
L’approccio opposto, emettere l’evento prima di salvare, ha lo stesso problema invertito: l’evento parte ma il salvataggio fallisce.
Il punto critico è che le due operazioni sono su sistemi separati. Il database ha le transazioni ACID; la coda, il broker, il servizio di notifica esterno non partecipano a quella transazione. Non esiste un two-phase commit praticabile in questo contesto, e anche se esistesse non vorresti usarlo.
È il pattern che emerge più spesso quando un sistema cresce oltre il prototipo e inizia a gestire dati che hanno conseguenze reali.
La soluzione: scrivere tutto sul database, pubblicare dopo
L’outbox transazionale funziona così: invece di emettere l’evento direttamente verso il sistema esterno, lo scrivi in una tabella outbox nella stessa transazione in cui scrivi i dati business. Un worker separato legge la tabella e pubblica gli eventi verso la destinazione finale.
Le due operazioni diventano atomiche rispetto al database, o entrambe avvengono, o nessuna delle due. Il worker può fallire, andare in timeout, essere riavviato: riprende da dove si era fermato, perché gli eventi non pubblicati sono ancora nella tabella.
Schema minimo
CREATE TABLE outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz, -- null = da pubblicare
topic text NOT NULL, -- es. 'order.created'
payload jsonb NOT NULL,
attempts int NOT NULL DEFAULT 0,
last_error text
);
published_at null indica che l’evento è in attesa. Il worker lo marca dopo la pubblicazione confermata. attempts e last_error servono per il monitoraggio e per la logica di backoff, non sono decorativi.
Scrittura nella transazione business
// Tutto in una singola transazione: o entrambe le scritture avvengono, o nessuna
await db.transaction(async (trx) => {
const order = await trx('orders').insert({
user_id: userId,
total: orderTotal,
status: 'confirmed',
}).returning('*');
await trx('outbox').insert({
topic: 'order.created',
payload: {
order_id: order[0].id,
user_id: userId,
total: orderTotal,
},
});
});
Il sistema esterno, coda, webhook, notifica, non viene toccato qui. La transazione è breve e non dipende dalla disponibilità di nessun servizio esterno.
Il worker: SKIP LOCKED per concorrenza sicura
Il worker deve essere idempotente e funzionare correttamente anche con più istanze in parallelo. SKIP LOCKED è la primitiva Postgres che permette a più worker di pescare righe diverse senza collisioni e senza bloccarsi a vicenda.
-- Pesca fino a 10 eventi non ancora pubblicati, saltando quelli già in lavorazione
SELECT * FROM outbox
WHERE published_at IS NULL
ORDER BY created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 10;
async function processOutbox() {
await db.transaction(async (trx) => {
const events = await trx.raw(`
SELECT * FROM outbox
WHERE published_at IS NULL
ORDER BY created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 10
`).then(r => r.rows);
for (const event of events) {
try {
await publishToExternalSystem(event.topic, event.payload);
await trx('outbox')
.where({ id: event.id })
.update({ published_at: new Date() });
} catch (err) {
// Non si rilancia: l'evento resta in coda, il worker continua
await trx('outbox')
.where({ id: event.id })
.update({
attempts: trx.raw('attempts + 1'),
last_error: String(err),
});
}
}
});
}
Un dettaglio importante: il FOR UPDATE SKIP LOCKED deve stare dentro la stessa transazione che poi aggiorna published_at. Se la transazione del worker non va a buon fine, il lock viene rilasciato e l’evento rimane disponibile per il prossimo ciclo. Tieni la chiamata esterna il più breve possibile: stai tenendo un lock di riga finché la destinazione non risponde.
Frequenza e backoff
Il worker gira su un intervallo, ogni 5 secondi, ogni 30, dipende dalla latenza accettabile per il sistema. Non è un cron in senso stretto: puoi usare un semplice setInterval in un processo Node long-running, oppure un job schedulato con la stessa logica descritta nell’articolo sui cron serverless con GitHub Actions se preferisci non tenere un processo sempre attivo.
Per gli eventi con molti tentativi falliti, aggiungi una condizione di backoff:
WHERE published_at IS NULL
AND (attempts = 0 OR created_at < now() - (attempts * interval '1 minute'))
Questo evita di riprovare continuamente un evento che fallisce per un problema strutturale, un topic inesistente, un payload malformato, un servizio esterno definitivamente down.
Pulizia
Gli eventi pubblicati possono accumularsi. Una finestra di retention ragionevole, 7 giorni, 30 giorni, dipende dal dominio, e un job periodico di pulizia:
DELETE FROM outbox
WHERE published_at IS NOT NULL
AND published_at < now() - interval '30 days';
Non cancellare eventi non pubblicati con criteri temporali: se sono ancora lì, c’è un motivo.
Cosa questo pattern non risolve
L’outbox garantisce che l’evento venga emesso almeno una volta, non esattamente una volta. Il sistema di destinazione deve essere in grado di gestire duplicati, o devi implementare idempotenza lato consumer. Su questo tema, l’articolo sui webhook Stripe e idempotenza copre il lato consumer in dettaglio.
Il pattern non sostituisce una coda dedicata (RabbitMQ, SQS, Kafka) per volumi alti o requisiti di fan-out complessi. È la soluzione giusta per sistemi che già usano Postgres come database primario e non vogliono introdurre infrastruttura aggiuntiva per gestire l’affidabilità degli eventi, che è la maggior parte dei casi fino a un certo volume.
Non copre il caso in cui il sistema esterno richiede esattamente un delivery senza duplicati e non supporta idempotency key. In quel caso serve una coordinazione più sofisticata.
Sintesi operativa
- Scrivi l’evento nella tabella
outboxnella stessa transazione dei dati business, mai in due operazioni separate. - Il worker usa
FOR UPDATE SKIP LOCKEDper la concorrenza sicura tra istanze multiple. - Marca
published_atsolo dopo conferma della pubblicazione, dentro la stessa transazione del lock. - Gestisci i fallimenti con
attemptse backoff, non rilanciare eccezioni che bloccano il worker. - Implementa idempotenza lato consumer: l’outbox garantisce at-least-once, non exactly-once.
- Aggiungi un job di pulizia sugli eventi pubblicati, la tabella non deve crescere indefinitamente.