Il Transactional Outbox risolve un problema frequente nelle API PHP: salvare una modifica nel database e pubblicare il relativo evento senza rischiare che una delle due operazioni riesca da sola. La soluzione consiste nel registrare dati ed evento nella stessa transazione, poi inviare gli eventi pendenti con un processo separato. In questo modo un errore temporaneo del servizio esterno non cancella né perde la richiesta.
Sintassi rapida (TL;DR):
$pdo->beginTransaction();
try {
$orderId = $orders->create($pdo, $data);
$outbox->append($pdo, 'order.created', ['orderId' => $orderId]);
$pdo->commit();
} catch (Throwable $error) {
$pdo->rollBack();
throw $error;
} Quando usare il Transactional Outbox in PHP per API e microservizi
Un endpoint può dover inserire un ordine nel database e, subito dopo, inviare un messaggio a una coda o a un’API REST. Se il processo si interrompe tra le due operazioni, il database può contenere l’ordine senza che il sistema di notifiche lo sappia. Invertire l’ordine non risolve il problema: l’evento potrebbe essere pubblicato anche se la transazione SQL fallisce.
Una transazione PDO protegge le operazioni sul database, non quelle verso un broker o un servizio HTTP. L’Outbox registra quindi l’evento come una riga persistente insieme ai dati applicativi. Un worker legge le righe non ancora inviate, le pubblica e le contrassegna come completate. Il compromesso è la consegna at-least-once: in caso di arresto dopo la pubblicazione ma prima dell’aggiornamento, lo stesso evento può essere inviato di nuovo. Il consumatore deve essere idempotente.
Implementazione del Transactional Outbox in PHP 8.2 con PDO
Questo esempio crea un ordine e il relativo evento in SQLite, quindi esegue un dispatcher simulato. Richiede PHP 8.2 o superiore con l’estensione pdo_sqlite; il database in memoria rende l’esempio autonomo e non adatto a conservare dati dopo la chiusura del processo.
<?php
declare(strict_types=1);
final class OutboxRepository
{
public function append(PDO $pdo, string $type, array $payload): void
{
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$statement = $pdo->prepare(
'INSERT INTO outbox (event_type, payload) VALUES (:type, :payload)'
);
$statement->execute(['type' => $type, 'payload' => $json]);
}
/** @return list<array{id: int, event_type: string, payload: string}> */
public function pending(PDO $pdo): array
{
$statement = $pdo->query(
'SELECT id, event_type, payload FROM outbox
WHERE published_at IS NULL ORDER BY id LIMIT 100'
);
return $statement->fetchAll(PDO::FETCH_ASSOC);
}
public function markPublished(PDO $pdo, int $id): void
{
$statement = $pdo->prepare(
'UPDATE outbox SET published_at = CURRENT_TIMESTAMP
WHERE id = :id AND published_at IS NULL'
);
$statement->execute(['id' => $id]);
}
}
interface EventPublisher
{
public function publish(string $type, string $payload): void;
}
final class ConsolePublisher implements EventPublisher
{
public function publish(string $type, string $payload): void
{
echo "Pubblicato {$type}: {$payload}" . PHP_EOL;
}
}
$pdo = new PDO('sqlite::memory:', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec(
'CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_email TEXT NOT NULL
)'
);
$pdo->exec(
'CREATE TABLE outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
published_at TEXT NULL
)'
);
$outbox = new OutboxRepository();
$pdo->beginTransaction();
try {
$insertOrder = $pdo->prepare(
'INSERT INTO orders (customer_email) VALUES (:email)'
);
$insertOrder->execute(['email' => '[email protected]']);
$orderId = (int) $pdo->lastInsertId();
$outbox->append($pdo, 'order.created', ['orderId' => $orderId]);
$pdo->commit();
} catch (Throwable $error) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
$publisher = new ConsolePublisher();
foreach ($outbox->pending($pdo) as $event) {
$publisher->publish($event['event_type'], $event['payload']);
$outbox->markPublished($pdo, $event['id']);
} Output dell'esecuzione / Risultato atteso
Pubblicato order.created: {"orderId":1} Come funziona il pattern Outbox: transazioni, dispatcher e idempotenza
1. Inserimento atomico con PDO
La transazione comprende l’inserimento in orders e la chiamata a OutboxRepository::append(). Se una delle due operazioni genera un’eccezione, il blocco catch esegue il rollback. JSON_THROW_ON_ERROR evita di salvare silenziosamente un payload non serializzabile: un errore di codifica diventa un’eccezione e la transazione viene annullata.
2. Lettura degli eventi pendenti e gestione della memoria
pending() legge solo eventi con published_at IS NULL, in ordine di inserimento, e limita il lotto a 100 righe. Il limite evita di caricare l’intera tabella in memoria. In produzione, un worker può richiamare questa query a intervalli regolari oppure elaborare più lotti; un indice su published_at può aiutare quando la tabella cresce.
3. Pubblicazione, retry e consegna ripetuta
Il dispatcher marca l’evento come pubblicato solo dopo che publish() ritorna senza errori. Se il publisher lancia un’eccezione, la riga resta pendente e può essere riprovata. Se invece la pubblicazione riesce ma il processo si arresta prima di markPublished(), il retry può duplicare il messaggio. In un sistema reale il payload dovrebbe includere un identificatore evento stabile, usato dal consumatore per deduplicare le elaborazioni.
Confronto tra Transactional Outbox e invio diretto degli eventi in PHP
La scelta dipende dal costo della perdita di eventi, dalla frequenza delle richieste e dall’infrastruttura disponibile. L’invio diretto è più semplice, ma non rende atomica una chiamata HTTP con una scrittura SQL.
| Metodo / Approccio | Sintassi / Caratteristica | Performance / Complessità | Quando sceglierlo |
|---|---|---|---|
| Invio diretto | INSERT seguito da richiesta HTTP | Bassa complessità; rischio di inconsistenza | Effetti non critici o prototipi |
| Transactional Outbox | Dati ed evento nella stessa transazione SQL | Una scrittura aggiuntiva e un worker; retry gestibili | API, ordini e integrazioni affidabili |
| CDC (Change Data Capture) | Lettura del log delle modifiche del database | Minor polling applicativo; infrastruttura più complessa | Volumi elevati e piattaforme con strumenti CDC |
Errori comuni da evitare e best practice Outbox in produzione
- Inviare il messaggio prima del commit: un rollback SQL potrebbe lasciare un evento riferito a dati inesistenti. Inserire la riga Outbox nella stessa transazione applicativa.
- Assumere consegna exactly-once: il database e il broker non condividono una transazione distribuita. Usare identificatori evento e deduplicazione lato consumer.
- Elaborare lo stesso evento in più worker: una semplice SELECT non blocca la riga. In produzione usare una strategia di claim atomico o lock compatibile con il database e definire timeout per i claim scaduti.
- Ignorare errori e tentativi infiniti: registrare eccezioni con ID evento, contatore dei tentativi e timestamp; applicare backoff e una coda di quarantena per eventi definitivamente falliti.
- Costruire query con concatenazione di input: usare statement preparati come nell’esempio per prevenire SQL injection. Validare inoltre i dati prima della serializzazione e non includere segreti nei payload.
Domande frequenti sul Transactional Outbox in PHP
Il Transactional Outbox garantisce la consegna exactly-once?
No: garantisce che l’evento sia persistito insieme ai dati applicativi, ma il dispatcher può pubblicarlo più di una volta. La consegna è tipicamente at-least-once; il consumer deve riconoscere gli eventi già elaborati tramite un identificatore stabile.
Posso usare il pattern Outbox con MySQL e PostgreSQL invece di SQLite?
Sì. La struttura del pattern è indipendente dal database, ma SQL per claim, lock e aggiornamenti concorrenti varia tra motori. Per più worker, implementare il prelievo atomico degli eventi usando le funzionalità di concorrenza del database scelto.
Perché un evento rimane pendente dopo un errore del broker?
È il comportamento atteso: il codice aggiorna published_at solo dopo una pubblicazione riuscita. Il worker può riprovare in seguito; in produzione aggiungere logging, backoff e un limite ai tentativi per isolare gli eventi non recuperabili.
Documentazione PHP e riferimenti per PDO e transazioni
- Documentazione ufficiale PHP — riferimento completo sulle specifiche del linguaggio e della libreria standard.
- Transazioni PDO — gestione di commit e rollback in PHP.
- Statement preparati PDO — esecuzione parametrizzata delle query.
