Hai salvato l'ordine, ma gli articoli dell'ordine non sono mai arrivati al database. O forse il contatore dello stock è diminuito, il gateway di pagamento ha restituito un timeout, e ora il cliente ha un addebito sulla carta ma nessun record dell'ordine. Questi scenari sembrano casi limite finché non lo diventano. In un'applicazione trafficata, diventano mal di testa quotidiani.
La causa principale è quasi sempre la stessa: una sequenza di scritture nel database trattate come eventi separati e isolati. Quando uno fallisce, gli altri rimangono indietro. La soluzione è una transazione del database e, in Laravel, lo strumento è DB::transaction().
Cosa promette effettivamente una transazione
Una transazione del database lega più operazioni in un'unica unità di lavoro. Il motore del database garantisce che tutto ciò che è all'interno venga confermato permanentemente (commit) o annullato completamente (rollback). Non c'è una via di mezzo.
Pensa a un bonifico bancario. Il sistema deve addebitare un conto e accreditare un altro. Se l'accredito fallisce dopo l'addebito, il denaro non svanisce semplicemente nel nulla. La banca storna l'addebito. Quello storno è il rollback. Se entrambi i passaggi hanno successo, il trasferimento viene confermato (commit), il che significa che i nuovi saldi sono salvati in modo duraturo.
Questo comportamento "tutto o niente" è ciò che mantiene la coerenza dei tuoi dati. Senza di esso, i fallimenti parziali disperdono record scritti a metà nelle tue tabelle, e quei record marciscono nel database perché nessun processo automatizzato sa come pulirli in sicurezza.
Come Laravel gestisce il collegamento
In SQL puro, dovresti scrivere tu stesso BEGIN, COMMIT e ROLLBACK, ricordandoti di catturare ogni possibile errore per non lasciare mai una transazione appesa. Laravel racchiude tutto questo codice ripetitivo in un unico metodo.
Passi una closure a DB::transaction(). Laravel avvia la transazione, esegue il tuo codice e, se la closure termina senza lanciare un'eccezione, esegue il commit automaticamente. Se qualcosa fallisce, Laravel cattura l'eccezione, esegue il rollback di tutto e rilancia l'errore in modo che il logging e la gestione degli errori funzionino come previsto.
use Illuminate\Support\Facades\DB;
DB::transaction(function () {
$order = Order::create([/* ... */]);
foreach ($cart->items as $item) {
OrderItem::create([
'order_id' => $order->id,
'product_id' => $item->product_id,
'quantity' => $item->quantity,
]);
Product::find($item->product_id)
->decrement('stock', $item->quantity);
}
Payment::create([
'order_id' => $order->id,
'amount' => $cart->total,
'status' => 'completed',
]);
});
Se l'inserimento del pagamento fallisce perché manca una chiave esterna o la connessione al database cade, l'ordine, gli articoli dell'ordine e le variazioni di stock vengono tutti annullati. Non ti ritroverai con uno stock svanito senza motivo, né con un ordine che richiede una spedizione che non è mai stata pagata.
Dove questo ti salva
Alcuni flussi di lavoro dipendono assolutamente dalla sicurezza transazionale. L'esempio del checkout è il più ovvio, ma il pattern si presenta ovunque.
Registrazione utente. Creare una riga utente, poi una riga profilo, quindi le impostazioni predefinite. Se l'inserimento del profilo fallisce a causa di un caso limite di validazione, un utente senza profilo diventa un account fantasma. Qualsiasi pagina che presupponga che ogni utente abbia un profilo andrà in crash o mostrerà un'interfaccia utente interrotta.
Importazioni massive. Un caricamento CSV che inserisce cinquanta record non dovrebbe lasciarne venticinque indietro perché la riga ventisei aveva una data malformata. Avvolgere il batch in una transazione permette all'intero import fallire come un'unica unità pulita. L'amministratore corregge il file e riprova, invece di passare ore a cercare quali righe sono trapelate parzialmente.
Inventario e contabilità. Ogni volta che una tabella traccia una risorsa fisica e un'altra traccia denaro o crediti, le due devono muoversi insieme. Separarle invita ad audit che non coincidono e report che mentono.
Quando devi gestire manualmente
L'approccio con la closure copre la maggior parte dei casi, ma a volte serve più controllo. Una logica condizionale complessa all'interno di una service class, o la necessità di decidere a runtime se eseguire il commit, possono rendere scomoda una singola closure. In quei momenti, puoi gestire la transazione autonomamente:
DB::beginTransaction();
try {
// Run your operations
$order = Order::create([/* ... */]);
// ... more work ...
if ($someBusinessRulePasses) {
DB::commit();
} else {
DB::rollBack();
}
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
Nota l'ordine all'interno del blocco catch: prima il rollback, poi il rilancio dell'errore. Se lanci l'eccezione prima di eseguire il rollback, la transazione rimane aperta sulla connessione. Questo può bloccare le righe, causare deadlock su altre query o esaurire il pool di connessioni. Le transazioni manuali sono potenti, ma pongono su di te l'onere della pulizia.
Mantieni gli effetti collaterali fuori dalla transazione
Questa è la regola che mette in difficoltà i team in produzione. Una transazione può annullare solo il lavoro sul database. Non può annullare l'invio di un'email, eliminare un file da uno storage cloud o rimborsare un addebito tramite un'API di pagamento.
Se inserisci una chiamata Mail::send() all'interno della closure della transazione e il database esegue il rollback due righe dopo, quell'email arriverà comunque nella casella di posta del cliente. Il destinatario si ritroverà con una fattura per un ordine che non esiste nel tuo sistema. Lo stesso vale per le notifiche Slack, il caricamento di file su S3 o l'invio di webhook.
La sequenza corretta è:
- Completa la transazione e cattura tutti gli ID o i risultati di cui hai bisogno.
- Solo allora attiva gli effetti collaterali esterni.
Per esempio, metti in coda l'email di conferma dopo il commit, non all'interno di esso:
$order = DB::transaction(function () {
// database work only
return Order::create([/* ... */]);
});
// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);
C'è un secondo motivo per mantenere le chiamate esterne al di fuori della transazione: il tempo. Una transazione mantiene dei lock e tiene occupata una connessione al database. Attendere tre secondi per una risposta della Stripe API mentre si è all'interno di una transazione significa tre secondi di tempo di lock non necessari. Mantieni la transazione snella e veloce.
Perché questo bug si nasconde
Su una macchina di sviluppo con un singolo utente e un database locale, le inserzioni correlate hanno quasi sempre successo. La rete è stabile, il disco non si riempie mai e non c'è carico concorrente. Il codice sembra corretto perché di solito funziona.
La produzione è diversa. Due clienti inviano ordini nello stesso identico millisecondo. Un worker della coda si riavvia a metà lavoro durante un deployment. Un provider di pagamenti va in timeout per trenta secondi. Senza le transazioni, questi eventi creano record orfani e totali errati che sono difficili da tracciare. La parte peggiore è che i test delle funzionalità standard raramente li colgono, perché la modalità di errore dipende dai tempi e dall'infrastruttura, non da semplici errori logici.
Rendilo un riflesso
DB::transaction() non aggiunge complessità. Rimuove la complessità nascosta del dover pulire i dati parziali a posteriori. Se un gruppo di operazioni sul database è correlato, trattalo come tale fin dall'inizio. La tua applicazione rimarrà coerente sotto carico, i tuoi log di errore rimarranno puliti e il tuo database non diventerà un cimitero di record incompleti.
