Vai al contenuto
DedicatedPHP Contatto

Come progettare una transactional outbox in PHP

Il pattern transactional outbox in PHP evita di perdere eventi tra una transazione di business e una coda. Scopri come elaborarli, ritentarli e monitorarli.

Diagramma concettuale di una transazione PHP che salva una modifica di business e un evento in una tabella outbox per la pubblicazione asincrona

Un’applicazione può salvare correttamente un ordine e, nonostante ciò, non notificare il sistema di pagamento, il servizio di inventario o una coda di job. Può anche pubblicare la notifica e fallire prima di confermare l’ordine. In entrambi i casi, i sistemi finiscono per essere incoerenti. Il pattern transactional outbox in PHP risolve questo problema di doppia scrittura facendo sì che la modifica di business e la registrazione dell’evento vengano confermate insieme nella stessa transazione del database.

La outbox non rende la consegna istantanea e non garantisce, da sola, che un consumer elabori un evento una sola volta. Il suo obiettivo è fare in modo che un evento confermato non dipenda da una seconda operazione di scrittura, che potrebbe fallire separatamente. Per applicare correttamente il pattern, occorre progettare anche la lettura concorrente, i tentativi, l’idempotenza e la gestione operativa quotidiana.

Il problema di scrivere nel database e pubblicare un evento

Il problema di scrivere nel database e pubblicare un evento — guía visual de DedicatedPHP

Supponiamo che un’operazione confermi un acquisto e poi pubblichi OrderPlaced in una coda. Se il processo PHP termina tra le due azioni, l’acquisto esiste, ma nessuno riceve l’evento. Se l’evento viene pubblicato per primo e la transazione del database viene annullata, il consumer potrebbe agire su un acquisto inesistente.

In genere, una transazione SQL non comprende anche una chiamata a una coda, a un’API esterna o a un broker. Cercare di coordinare manualmente le due operazioni non elimina la finestra di errore: cambia soltanto l’ordine in cui si presenta. Il pattern transactional outbox evita la doppia scrittura non atomica includendo in un’unica transazione la modifica di business e una riga che rappresenta l’evento da pubblicare.

Struttura minima della tabella outbox

La tabella deve contenere informazioni sufficienti perché un altro processo possa identificare, consegnare e tracciare l’evento. Una prima struttura può includere:

  • Identificatore univoco: stabile e utilizzabile come chiave di idempotenza, per esempio un UUID.
  • Tipo di evento: come OrderPlaced, idealmente con una versione dello schema se il formato evolve.
  • Payload: dati serializzati necessari al consumer, senza informazioni sensibili non necessarie.
  • Data di creazione: utile per ordinare gli eventi, misurarne l’età e diagnosticare i ritardi.
  • Stato e tracciamento: per esempio, in attesa, assegnato o pubblicato; numero di tentativi, ultimo errore e data dell’ultimo tentativo.
  • Dati di assegnazione: identificatore del worker e scadenza della concessione, se si usa un lease per coordinare i consumer.

Lo schema concreto dipende dal motore del database e dal volume. Aggiungi indici che supportino la ricerca degli eventi in attesa ed evita di caricare l’intera tabella per trovare il lavoro da svolgere. Definisci anche per quanto tempo conservare le righe pubblicate e quelle in errore: non trasformare la outbox per impostazione predefinita in un archivio senza scadenza.

Salvare i dati di business e l’evento in un’unica transazione

In PHP, il codice che modifica lo stato di business e aggiunge l’evento deve usare la stessa connessione e la stessa transazione. Questo esempio mostra la sequenza, non uno schema SQL universale:

$pdo->beginTransaction();

try {
    // Inserire o aggiornare l’ordine usando questa connessione.
    $orderId = saveOrder($pdo, $orderData);

    $eventId = generateUuid();
    $payload = json_encode([
        'event_id' => $eventId,
        'order_id' => $orderId,
    ], JSON_THROW_ON_ERROR);

    $statement = $pdo->prepare(
        'INSERT INTO outbox_events (id, event_type, payload, status, created_at)
         VALUES (:id, :type, :payload, :status, CURRENT_TIMESTAMP)'
    );
    $statement->execute([
        'id' => $eventId,
        'type' => 'OrderPlaced',
        'payload' => $payload,
        'status' => 'pending',
    ]);

    $pdo->commit();
} catch (Throwable $error) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    throw $error;
}

Se la transazione viene confermata, entrambi i record vengono salvati; se fallisce, nessuno dei due deve considerarsi confermato. Non pubblicare il messaggio sul broker all’interno della transazione pensando che ciò renda atomica l’operazione: la chiamata esterna non partecipa al commit SQL.

L’UUID dell’esempio identifica l’evento creato in quella transazione, ma da solo non evita i duplicati se viene ritentata l’intera richiesta di business. Se questo rischio è rilevante — per esempio, una richiesta può essere ripetuta dopo un timeout — usa una chiave di idempotenza per la richiesta oppure un vincolo di unicità sull’operazione di business. Quando rilevi la ripetizione, recupera il risultato e l’evento già associati all’operazione originale, invece di creare un altro evento con un UUID diverso.

Elaborare gli eventi in attesa gestendo concorrenza e recupero

Un worker interroga un lotto limitato di eventi idonei, li assegna e li pubblica sulla destinazione. L’assegnazione deve impedire che più worker elaborino contemporaneamente lo stesso evento oppure, almeno, consentire di gestire la duplicazione in modo sicuro. Le opzioni dipendono dal motore: lock sulle righe, aggiornamento condizionale dello stato o lease con scadenza. Non esiste una query portabile che risolva correttamente tutti i casi: verifica la semantica di lock e isolamento del database utilizzato.

Una strategia comune consiste nell’assegnare le righe per un periodo limitato, pubblicare al di fuori di una transazione lunga e contrassegnarle come pubblicate dopo aver ricevuto la conferma della destinazione. Se il processo si interrompe dopo la pubblicazione ma prima dell’aggiornamento dello stato, il lease scade e un altro worker può pubblicare di nuovo l’evento. Se lo stato viene impostato su pubblicato prima della pubblicazione e il processo si interrompe, l’evento potrebbe andare perso. Per questo i tentativi di pubblicazione possono offrire una semantica at least once, non esattamente una volta: possono produrre duplicati, ma non garantiscono che la destinazione riceva l’evento. La consegna effettiva dipende dalla policy di retry e dal fatto che la destinazione torni disponibile prima che i tentativi si esauriscano, oppure che si intervenga per recuperare l’evento.

Usa lotti piccoli o configurabili e limita la durata di ogni esecuzione. Mantenere i lock SQL mentre si attende una rete esterna può degradare le prestazioni del database. Dimensioni del lotto, durata del lease e frequenza di polling vanno regolate sulla base delle misurazioni: un lotto grande non migliora sempre le prestazioni e può aumentare la latenza o rendere più difficile il recupero.

Idempotenza, tentativi ed eventi bloccati

Il consumer deve tollerare la ricezione dello stesso evento più di una volta. Per un effetto di business sullo stesso database, può registrare l’identificatore dell’evento in una tabella degli eventi elaborati con un vincolo univoco e applicare l’effetto nella stessa transazione in cui registra l’identificatore. In questo modo, se la transazione viene confermata, identificatore ed effetto risultano registrati insieme; se fallisce, è possibile ritentare senza lasciare che uno dei due risulti confermato senza l’altro.

Questo meccanismo non rende automaticamente idempotenti gli effetti esterni non transazionali. Se il consumer chiama un’API, invia un’email o esegue un’altra azione al di fuori del proprio database, un’interruzione tra l’effetto esterno e la registrazione locale può causare una ripetizione. Usa una chiave di idempotenza supportata dalla destinazione, un meccanismo di coordinamento specifico o un adeguato flusso di compensazione; se non è disponibile nessuna di queste opzioni, riconosci e gestisci esplicitamente il rischio di duplicazione.

Applica i tentativi con attese crescenti e limiti ragionevoli, distinguendo gli errori transitori — per esempio, un timeout — da quelli permanenti, come un payload non valido. Registra l’ultimo errore in modo utile per le indagini, senza però riversare nei log segreti o dati personali non necessari. Quando i tentativi si esauriscono, mantieni l’evento in uno stato di errore visibile: non eliminarlo in silenzio.

Il recupero deve essere esplicito: occorre individuare la causa, correggerla e decidere se ritentare lo stesso evento o se è necessario intervenire. Prima di riprendere l’elaborazione, verifica che il consumer sia idempotente. Un pulsante «Rielabora» senza controlli può ripetere effetti esterni o alterare l’ordine previsto. Documenta chi può usarlo e quali operazioni vengono registrate.

Monitoraggio, conservazione e protezione dei dati

Monitora il numero di eventi in attesa, l’età dell’evento in attesa più vecchio, i tentativi falliti, il tasso di pubblicazione e il tempo che intercorre tra creazione e consegna. Una coda in crescita può indicare un’indisponibilità della destinazione, consumer fermi, errori persistenti o capacità insufficiente. Un allarme basato solo sul numero totale può nascondere i ritardi: l’età aiuta a capire se il lavoro sta procedendo.

La tabella crescerà con ogni evento. Definisci una policy di conservazione per gli eventi pubblicati e quelli in errore ed elimina le righe in lotti, così da evitare operazioni di manutenzione troppo onerose. Prima di eliminare i dati, conserva quanto serve per audit, supporto o indagini, in base ai requisiti di business. Limita l’accesso al payload, cifra o minimizza i dati sensibili quando opportuno e definisci una policy di cancellazione coerente con la conservazione.

Quando usare la outbox e quando evitare questa complessità

Quando usare la outbox e quando evitare questa complessità — guía visual de DedicatedPHP

La outbox è utile quando una conferma di business deve avviare un’azione asincrona affidabile, sono possibili errori tra sistemi e la perdita della notifica avrebbe conseguenze. È particolarmente utile se l’applicazione dispone già di un database transazionale e deve integrarsi con una coda o con servizi esterni senza legare la disponibilità di questi ultimi a ogni richiesta.

Potrebbe non essere necessaria per attività sacrificabili o ricostruibili, quando è sufficiente interrogare periodicamente lo stato di business oppure quando la coda e la persistenza offrono una transazione integrata adeguata al flusso. Valuta questa alternativa considerando i suoi limiti effettivi: non dare per scontato che due servizi siano atomici solo perché fanno parte dello stesso prodotto.

Il pattern comporta anche dei costi: una tabella aggiuntiva, un worker, tentativi, gestione degli stati, conservazione e supporto operativo. Evita di aggiungere un sistema complesso di stati prima di conoscere le esigenze. Inizia con identificatori, stato, date, tentativi e un’assegnazione sicura; amplia il design solo in presenza di requisiti concreti di ordinamento, volume, audit o recupero. La scelta corretta è l’opzione più semplice che preservi la coerenza necessaria e permetta di rilevare e risolvere gli errori.

Vuoi applicare queste idee al tuo progetto?Parliamo della tua piattaforma PHP.
Visualizza il servizio correlato