20 dic 2025·7 min di lettura

Pattern outbox in PostgreSQL per integrazioni API affidabili

Scopri il pattern outbox per memorizzare eventi in PostgreSQL e poi consegnarli ad API di terze parti con ritentativi, ordinamento e deduplicazione.

Pattern outbox in PostgreSQL per integrazioni API affidabili

Perché le integrazioni falliscono anche quando la tua app funziona

È comune vedere un'azione “riuscita” nella tua app mentre l'integrazione dietro le quinte fallisce silenziosamente. La scrittura sul database è veloce e affidabile. Una chiamata a un'API di terze parti no. Così si creano due mondi diversi: il tuo sistema dice che la modifica è avvenuta, ma il sistema esterno non ne è mai stato informato.

Un esempio tipico: un cliente effettua un ordine, la tua app lo salva in PostgreSQL e poi prova a notificare un corriere. Se il fornitore va in timeout per 20 secondi e la tua richiesta rinuncia, l'ordine è comunque reale, ma la spedizione non viene creata.

Gli utenti vivono questo come comportamenti confusi e incoerenti. Gli eventi mancanti sembrano “non è successo nulla”. Gli eventi duplicati sembrano “perché mi hanno addebitato due volte?”. Anche i team di supporto faticano perché è difficile capire se il problema fosse nella tua app, nella rete o nel partner.

I ritentativi aiutano, ma da soli non garantiscono correttezza. Se ritenti dopo un timeout, potresti inviare lo stesso evento due volte perché non sai se il partner ha ricevuto la prima richiesta. Se ritenti fuori ordine, potresti inviare “Ordine spedito” prima di “Ordine pagato”.

Questi problemi di solito derivano dalla concorrenza normale: più worker che processano in parallelo, più server dell'app che scrivono contemporaneamente, e code “best effort” dove i tempi cambiano sotto carico. I modi di fallimento sono prevedibili: API che cadono o rallentano, reti che perdono richieste, processi che crashano al momento sbagliato, e i retry che creano duplicati quando nulla impone l'idempotenza.

Il pattern outbox esiste perché questi fallimenti sono normali.

Cos'è il pattern outbox in termini semplici

Il pattern outbox è semplice: quando la tua app effettua una modifica importante (come creare un ordine), scrive anche un piccolo record “evento da inviare” in una tabella del database, nella stessa transazione. Se il commit del database riesce, sai che i dati di business e il record evento esistono insieme.

Dopo di ciò, un worker separato legge la tabella outbox e consegna quegli eventi alle API di terze parti. Se un'API è lenta, giù o va in timeout, la richiesta principale dell'utente continua a riuscire perché non aspetta la chiamata esterna.

Questo evita gli stati imbarazzanti che ottieni chiamando un'API dentro l'handler della richiesta:

  • L'ordine è salvato, ma la chiamata API fallisce.
  • La chiamata API riesce, ma la tua app crasha prima di salvare l'ordine.
  • L'utente ritenta, e tu invii la stessa cosa due volte.

Il pattern outbox aiuta principalmente con eventi persi, fallimenti parziali (database ok, API esterna no), invii doppi accidentali e ritentativi più sicuri (puoi riprovare dopo senza indovinare).

Non risolve tutto. Se il payload è sbagliato, le regole di business sono errate o l'API di terze parti rifiuta i dati, servono comunque validazione, buona gestione degli errori e un modo per ispezionare e correggere gli eventi falliti.

Progettare una tabella outbox in PostgreSQL

Una buona tabella outbox è volutamente noiosa. Deve essere facile da scrivere, facile da leggere e difficile da usare male.

Ecco uno schema pratico di base che puoi adattare:

create table outbox_events (
  id            bigserial primary key,
  aggregate_id  text not null,
  event_type    text not null,
  payload       jsonb not null,
  status        text not null default 'pending',
  created_at    timestamptz not null default now(),
  available_at  timestamptz not null default now(),
  attempts      int not null default 0,
  locked_at     timestamptz,
  locked_by     text,
  meta          jsonb not null default '{}'::jsonb
);

Scegliere un ID

Usare bigserial (o bigint) mantiene semplice l'ordinamento e gli indici veloci. Le UUID sono ottime per l'unicità tra sistemi, ma non si ordinano per creazione, il che può rendere il polling meno prevedibile e gli indici più pesanti.

Un compromesso comune è: tieni id come bigint per l'ordinamento e aggiungi un event_uuid separato se ti serve un identificatore stabile da condividere tra servizi.

Indici che contano

Il tuo worker interrogherà gli stessi pattern tutto il giorno. La maggior parte dei sistemi ha bisogno di:

  • Un indice come (status, available_at, id) per prendere i prossimi eventi pending in ordine.
  • Un indice su (locked_at) se prevedi di scadere lock vecchi.
  • Un indice come (aggregate_id, id) se qualche volta consegni per aggregate in ordine.

Mantieni i payload stabili

Tieni i payload piccoli e prevedibili. Conserva ciò di cui il ricevitore ha davvero bisogno, non l'intera riga. Aggiungi una versione esplicita (per esempio, in meta) così puoi evolvere i campi in sicurezza.

Usa meta per routing e contesto di debug come tenant ID, correlation ID, trace ID e una chiave di dedup. Quel contesto extra ripaga quando il supporto deve rispondere a “che è successo a questo ordine?”

Come memorizzare gli eventi in modo sicuro insieme alla scrittura di business

La regola più importante è semplice: scrivi i dati di business e l'evento outbox nella stessa transazione del database. Se la transazione committa, esistono entrambi. Se viene rollbackata, nessuno dei due esiste.

Esempio: un cliente piazza un ordine. In una sola transazione inserisci la riga dell'ordine, le righe degli articoli e una riga outbox come order.created. Se un passaggio fallisce, non vuoi che un evento “created” sfugga nel mondo.

Un evento o molti?

Inizia con un evento per azione di business quando puoi. È più facile da capire e meno costoso da processare. Dividi in più eventi solo quando consumatori diversi hanno davvero bisogno di tempi o payload differenti (per esempio, order.created per fulfillment e payment.requested per billing). Generare molti eventi per un singolo click aumenta ritentativi, problemi di ordinamento e gestione dei duplicati.

Quale payload salvare?

Solitamente scegli tra:

  • Snapshot: salva i campi chiave come erano al momento dell'azione (totale ordine, valuta, customer ID). Questo evita letture extra dopo e mantiene il messaggio stabile.
  • Reference ID: salva solo l'ID dell'ordine e lascia che il worker carichi i dettagli dopo. Questo mantiene l'outbox piccolo, ma aggiunge letture e può cambiare se l'ordine viene modificato.

Un compromesso pratico è identificatori più un piccolo snapshot dei valori critici. Aiuta i riceventi ad agire velocemente e aiuta te nel debug.

Mantieni il boundary della transazione stretto. Non chiamare API di terze parti dentro la stessa transazione.

Consegnare eventi alle API di terze parti: il loop del worker

Gestisci l'ordinamento nel modo pratico
Mantieni l'ordine degli eventi per cliente o ordine senza rallentare tutto il sistema.
Crea Progetto

Una volta che gli eventi sono nella outbox, hai bisogno di un worker che li legga e chiami l'API di terze parti. Questa è la parte che trasforma il pattern in un'integrazione affidabile.

Il polling è di solito l'opzione più semplice. LISTEN/NOTIFY può ridurre la latenza, ma aggiunge parti mobili e ha comunque bisogno di un fallback quando le notifiche vengono perse o il worker si riavvia. Per la maggior parte dei team, un polling costante con un batch piccolo è più facile da gestire e debuggare.

Come claimare le righe in sicurezza

Il worker dovrebbe claimare le righe così due worker non processano lo stesso evento contemporaneamente. In PostgreSQL, l'approccio comune è selezionare un batch usando lock di riga e SKIP LOCKED, poi marcarli come in progresso.

Un flusso di status pratico è:

  • pending: pronto da inviare
  • processing: bloccato da un worker (usa locked_by e locked_at)
  • sent: consegnato con successo
  • failed: fermato dopo tentativi massimi (o messo da parte per revisione manuale)

Tieni i batch piccoli per essere gentile con il database. Un batch da 10 a 100 righe, eseguito ogni 1-5 secondi, è un punto di partenza comune.

Quando una chiamata riesce, marca la riga sent. Quando fallisce, incrementa attempts, imposta available_at in un orario futuro (backoff), svuota il lock e ritorna a pending.

Log che aiutano (senza esporre segreti)

Buoni log rendono i fallimenti azionabili. Logga l'id dell'outbox, il tipo evento, il nome della destinazione, il conteggio dei tentativi, i tempi e lo status HTTP o la classe di errore. Evita corpi delle richieste, header di auth e risposte complete. Se ti serve correlazione, conserva un ID richiesta sicuro o un hash invece del payload raw.

Regole di ordinamento che funzionano nei sistemi reali

Implementa il pattern in modo visuale
Usa la logica di business visuale per scrivere dati e accodare eventi in un'unica transazione.
Crea Backend

Molti team iniziano con “invia gli eventi nello stesso ordine in cui li abbiamo creati”. Il problema è che “lo stesso ordine” raramente è globale. Se forzi una coda globale, un singolo cliente lento o un'API instabile può bloccare tutti.

Una regola pratica è: preserva l'ordine per gruppo, non per l'intero sistema. Scegli una chiave di raggruppamento che rispecchi come il mondo esterno pensa i tuoi dati, come customer_id, account_id o un aggregate_id come order_id. Poi garantisci l'ordinamento all'interno di ogni gruppo permettendo l'esecuzione in parallelo tra gruppi diversi.

Worker paralleli senza rompere l'ordine

Esegui più worker, ma assicurati che due worker non processino lo stesso gruppo nello stesso momento. L'approccio usuale è consegnare sempre l'evento pendente più vecchio per un dato aggregate_id e permettere parallelismo tra aggregate diversi.

Mantieni le regole di claim semplici:

  • Consegna solo il più antico evento pending per gruppo.
  • Permetti parallelismo tra gruppi, non all'interno di uno stesso gruppo.
  • Claima un evento, invialo, aggiorna lo status, poi vai avanti.

Quando un evento blocca gli altri

Prima o poi un evento “velenoso” fallirà per ore (payload errato, token revocato, provider giù). Se applichi rigidamente l'ordine per gruppo, gli eventi successivi in quel gruppo dovrebbero aspettare, ma gli altri gruppi devono proseguire.

Un compromesso funzionante è limitare i retry per evento. Dopo di che, marcalo failed e metti in pausa solo quel gruppo finché qualcuno non risolve la causa. Questo evita che un cliente rotto rallenti tutti gli altri.

Ritentativi senza peggiorare le cose

I retry sono il punto in cui un buon setup outbox diventa affidabile o rumoroso. L'obiettivo è semplice: riprovare quando ha senso e fermarsi rapidamente quando non ha senso.

Usa backoff esponenziale e un tetto massimo. Per esempio: 1 minuto, 2 minuti, 4 minuti, 8 minuti, poi fermati (o continua con un delay massimo come 15 minuti). Imposta sempre un numero massimo di tentativi così un evento cattivo non può intasare il sistema per sempre.

Non tutti i fallimenti vanno ritentati. Mantieni regole chiare:

  • Retry: timeout di rete, reset di connessione, problemi DNS e risposte HTTP 429 o 5xx.
  • Non ritentare: HTTP 400 (bad request), 401/403 (problemi di auth), 404 (endpoint sbagliato) o errori di validazione rilevabili prima di inviare.

Conserva lo stato del retry nella riga outbox. Incrementa attempts, imposta available_at per il prossimo tentativo e registra un breve sommario di errore sicuro (codice status, classe di errore, messaggio troncato). Non salvare payload completi o dati sensibili nei campi di errore.

I rate limit richiedono gestione speciale. Se ricevi HTTP 429, rispetta Retry-After se presente. Altrimenti, fai backoff più aggressivo per evitare uno storm di retry.

Deduplicazione e basi dell'idempotenza

Crea una outbox in pochi minuti
Modella la tua tabella outbox nel Data Designer e invia eventi senza bloccare il checkout.
Inizia a costruire

Se costruisci integrazioni affidabili, assumi che lo stesso evento possa essere inviato due volte. Un worker può crashare dopo la chiamata HTTP ma prima di registrare il successo. Un timeout può nascondere un successo. Un retry può sovrapporsi con un primo tentativo lento. Il pattern outbox riduce gli eventi persi, ma non previene i duplicati di per sé.

L'approccio più sicuro è l'idempotenza: consegne ripetute producono lo stesso risultato di una sola consegna. Quando chiami un'API di terze parti, includi una chiave di idempotenza che rimane stabile per quell'evento e quella destinazione. Molte API supportano un header; se non lo fanno, metti la chiave nel body della richiesta.

Una chiave semplice è destinazione più ID evento. Per un evento con ID evt_123, usa sempre qualcosa come destA:evt_123.

Da parte tua, previeni invii doppi tenendo un log di delivery outbound e imponendo una regola unica come (destination, event_id). Anche se due worker gareggiano, solo uno potrà creare il record “stiamo inviando questo”.

Anche i webhook duplicano

Se ricevi callback webhook (come “consegna confermata” o “stato aggiornato”), trattali allo stesso modo. I provider ritentano e puoi vedere lo stesso payload ripetuto. Conserva gli ID webhook processati, o calcola un hash stabile dall'ID messaggio del provider e rifiuta i duplicati.

Quanto tempo conservare i dati

Tieni le righe outbox finché non hai registrato il successo (o un fallimento finale accettato). Conserva i log di delivery più a lungo, perché sono la tua traccia di audit quando qualcuno chiede “l'abbiamo inviato?”.

Un approccio comune:

  • Righe outbox: elimina o archivia dopo il successo più una breve finestra di sicurezza (giorni).
  • Log di delivery: conserva per settimane o mesi, in base a compliance e bisogni di supporto.
  • Chiavi di idempotenza: conserva almeno quanto possono avvenire i retry (e più a lungo per duplicati webhook).

Passo dopo passo: implementare il pattern outbox

Decidi cosa pubblicherai. Mantieni gli eventi piccoli, focalizzati e facili da rieseguire. Una buona regola è un fatto di business per evento, con abbastanza dati perché il ricevente possa agire.

Costruisci le fondamenta

Scegli nomi evento chiari (per esempio, order.created, order.paid) e versione lo schema del payload (come v1, v2). La versioning ti permette di aggiungere campi senza rompere i consumer più vecchi.

Crea la tabella outbox PostgreSQL e aggiungi indici per le query che il worker eseguirà più spesso, specialmente (status, available_at, id).

Aggiorna il flusso di scrittura così che la modifica di business e l'inserimento in outbox avvengano nella stessa transazione. Questa è la garanzia fondamentale.

Aggiungi consegna e controllo

Un piano di implementazione semplice:

  • Definisci tipi di evento e versioni payload che puoi supportare a lungo termine.
  • Crea la tabella outbox e gli indici.
  • Inserisci una riga outbox insieme alla modifica dei dati principali.
  • Costruisci un worker che claimi righe, invochi l'API terza e poi aggiorni lo stato.
  • Aggiungi la schedulazione dei retry con backoff e uno stato failed quando i tentativi sono esauriti.

Aggiungi metriche di base così noti i problemi presto: lag (età del più vecchio evento non inviato), rate di invio e tasso di errori.

Un esempio semplice: inviare eventi d'ordine a servizi esterni

Mantieni un'uscita verso il codice sorgente
Esporta Go, Vue3 e Kotlin o SwiftUI reali quando hai bisogno del controllo completo.
Genera codice

Un cliente piazza un ordine nella tua app. Due cose devono succedere fuori dal tuo sistema: il provider di billing deve addebitare la carta e il corriere deve creare una spedizione.

Con il pattern outbox, non chiami quelle API dentro la richiesta di checkout. Invece, salvi l'ordine e una riga outbox nella stessa transazione PostgreSQL, così non ti ritrovi mai con “ordine salvato, ma nessuna notifica” (o il contrario).

Una riga outbox tipica per un evento d'ordine potrebbe includere un aggregate_id (l'ID ordine), un event_type come order.created e un payload JSONB con totali, articoli e dettagli di destinazione.

Un worker prende poi le righe pending e chiama i servizi esterni (in un ordine definito o emettendo eventi separati come payment.requested e shipment.requested). Se un provider è giù, il worker registra il tentativo, programma il prossimo retry spostando available_at nel futuro e continua. L'ordine esiste comunque, e l'evento verrà ritentato dopo senza bloccare nuovi checkout.

L'ordinamento è di solito “per ordine” o “per cliente”. Assicurati che eventi con lo stesso aggregate_id siano processati uno alla volta così order.paid non arrivi prima di order.created.

La deduplicazione ti evita di addebitare due volte o creare due spedizioni. Invia una chiave di idempotenza quando il terzo la supporta e conserva un record di delivery per la destinazione così un retry dopo un timeout non provoca una seconda azione.

Controlli rapidi prima del rilascio

Distribuisci dove lavora il tuo team
Distribuisci su AppMaster Cloud o sul tuo AWS, Azure o Google Cloud.
Prova AppMaster

Prima di affidare a un'integrazione il movimento di denaro, notifiche clienti o sincronizzazioni dati, testa gli edge: crash, retry, duplicati e più worker.

Controlli che catturano i fallimenti comuni:

  • Conferma che la riga outbox viene creata nella stessa transazione della modifica di business.
  • Verifica che il sender sia sicuro da eseguire in più istanze. Due worker non dovrebbero inviare lo stesso evento nello stesso momento.
  • Se l'ordinamento conta, definisci la regola in una frase e applicala con una chiave stabile.
  • Per ogni destinazione, decidi come prevenire duplicati e come provare “l'abbiamo inviato”.
  • Definisci l'uscita: dopo N tentativi, sposta l'evento in failed, conserva l'ultimo sommario di errore e fornisci una semplice azione di reprocess.

Un reality check: Stripe può accettare una richiesta ma il tuo worker può crashare prima di salvare il successo. Senza idempotenza, un retry può causare un doppio addebito. Con idempotenza più un record di delivery salvato, il retry diventa sicuro.

Prossimi passi: rollout senza interrompere l'app

Il rollout è dove i progetti outbox di solito riescono o si bloccano. Mantienilo piccolo all'inizio così osservi il comportamento reale senza mettere a rischio l'intero layer di integrazione.

Inizia con un'integrazione e un tipo di evento. Per esempio, invia solo order.created a un singolo vendor API mentre tutto il resto rimane com'è. Questo ti dà una baseline pulita per throughput, latenza e tassi di fallimento.

Rendi i problemi visibili presto. Aggiungi dashboard e alert per il lag dell'outbox (quanti eventi aspettano e quanto è vecchio il più vecchio) e il tasso di fallimento (quanti sono bloccati in retry). Se puoi rispondere a “siamo in ritardo adesso?” in 10 secondi, catturerai i problemi prima degli utenti.

Avere un piano sicuro di reprocess prima del primo incidente. Decidi cosa significa “reprocessare”: ritentare lo stesso payload, ricostruire il payload dai dati correnti o inviarlo per revisione manuale. Documenta quali casi è sicuro reinviare e quali richiedono controllo umano.

Se stai costruendo questo con una piattaforma no-code come AppMaster (appmaster.io), la stessa struttura si applica: scrivi i dati di business e una riga outbox insieme in PostgreSQL, poi esegui un processo backend separato per consegnare, ritentare e marcare gli eventi come inviati o falliti.

FAQ

Quando dovrei usare il pattern outbox invece di chiamare l'API direttamente?

Usa il pattern outbox quando un'azione dell'utente aggiorna il tuo database e deve innescare lavoro in un altro sistema. È più utile quando timeout, reti instabili o outage di terze parti possono creare situazioni del tipo “salvato nella nostra app, mancante nel loro sistema”.

Perché l'inserimento nell'outbox deve essere nella stessa transazione della scrittura di business?

Scrivere la riga di business e la riga outbox nella stessa transazione del database ti dà una garanzia semplice: o esistono entrambe, o nessuna delle due. Questo previene fallimenti parziali come “la chiamata API è riuscita ma l'ordine non è stato salvato” o “ordine salvato ma la chiamata API non è avvenuta”.

Quali campi dovrebbe includere una tabella outbox per essere pratica?

Un buon default è id, aggregate_id, event_type, payload, status, created_at, available_at, attempts, più campi di lock come locked_at e locked_by. Questo mantiene semplice l'invio, la schedulazione dei ritentativi e la concorrenza sicura senza complicare troppo la tabella.

Quali indici sono più importanti per una tabella outbox in PostgreSQL?

Una baseline comune è un indice su (status, available_at, id) così i worker possono recuperare velocemente il prossimo batch di eventi inviabili in ordine. Aggiungi altri indici solo quando davvero interroghi quei campi, perché indici extra rallentano le insert.

Il mio worker dovrebbe fare polling della tabella outbox o usare LISTEN/NOTIFY?

Il polling è l'approccio più semplice e prevedibile per la maggior parte dei team. Parti con batch piccoli e un intervallo breve, poi fai tuning in base al carico e al lag; puoi aggiungere ottimizzazioni in seguito, ma un loop semplice è più facile da debuggare quando qualcosa va storto.

Come evito che due worker inviino lo stesso evento outbox?

Claim delle righe usando lock a livello di riga così due worker non possono processare lo stesso evento contemporaneamente, tipicamente con SKIP LOCKED. Poi marca la riga come processing con timestamp e ID worker, inviala, e infine marca sent o ritorna a pending con un available_at futuro.

Qual è la strategia di retry più sicura per le consegne outbox?

Usa backoff esponenziale con un limite massimo di tentativi, e ritenta solo i fallimenti probabilmente temporanei. Timeout, errori di rete e HTTP 429/5xx sono buoni candidati per il retry; errori di validazione e la maggior parte dei 4xx devono essere trattati come finali finché non si correggono i dati o la configurazione.

Il pattern outbox garantisce una consegna esattamente una volta?

Non contare sull'esatta consegna una sola volta. Assumi che gli eventi possano essere inviati più volte, specialmente se un worker crasha dopo la chiamata HTTP ma prima di registrare il successo. Usa una chiave di idempotenza stabile per destinazione ed evento, e tieni un log delle consegne con una regola unica per (destination, event_id) così anche in caso di race solo una consegna viene registrata.

Come gestisco l'ordinamento senza rallentare tutto il sistema?

Default a preservare l'ordine all'interno di un gruppo, non globalmente. Usa una chiave di raggruppamento come aggregate_id (ID ordine) o customer_id, processa un evento alla volta per gruppo e permetti parallelismo tra gruppi diversi così un cliente lento non blocca tutti.

Cosa dovrei fare con un evento “velenoso” che continua a fallire?

Segna l'evento come failed dopo un numero massimo di tentativi, conserva un breve sommario di errore sicuro e ferma il processamento degli eventi successivi per lo stesso gruppo finché qualcuno non corregge la causa. Questo contiene l'impatto e evita retry infiniti mantenendo in movimento gli altri gruppi.

Facile da avviare
Creare qualcosa di straordinario

Sperimenta con AppMaster con un piano gratuito.
Quando sarai pronto potrai scegliere l'abbonamento appropriato.

Iniziare