Perché un bonifico bancario richiede più di due UPDATE
Sposta 50€ da un conto a un altro. Nella versione naive bastano due istruzioni: sottrai dal conto A, aggiungi al conto B. Eseguile separatamente e, se il processo va in crash o la connessione cade tra le due, il denaro è sparito da A e non arriva mai su B. Il database ha fatto esattamente quello che gli è stato chiesto, due volte. Il business ha perso 50€ comunque.
Una transazione racchiude entrambe le istruzioni così che il database le tratti come un’unica unità: o accadono entrambe, o nessuna delle due. Questa garanzia, insieme ad altre tre, è quello che indica l’acronimo ACID. È il motivo per cui puoi fidarti di un database per gestire denaro o scorte di magazzino, dove “a metà” è peggio di “non fatto”.
Cosa garantiscono le quattro proprietà ACID
- Atomicità. Tutte le istruzioni di una transazione vanno a commit insieme, o nessuna lo fa. Se il processo va in crash dopo il debito ma prima dell’accredito, il database annulla anche il debito. Non c’è nessuno stato parziale da sistemare a mano.
- Consistenza. Una transazione può portare il database solo da uno stato valido a un altro. Se un vincolo
CHECKdice che un saldo non può essere negativo, nessuna transazione può fare commit di una riga che lo viola, indipendentemente da cosa ha provato a fare il codice dell’applicazione. - Isolamento. Le transazioni in esecuzione contemporaneamente non vedono il lavoro a metà delle altre. Quanto rigidamente viene applicato è una manopola, non una regola fissa: quella manopola è il livello di isolamento, trattato più sotto.
- Durabilità. Una volta che il database dichiara che una transazione ha fatto commit, quel dato sopravvive anche a un crash un secondo dopo. È a questo che serve il write-ahead log: la modifica è su disco (o replicata) prima che il commit ritorni.
Atomicità e durabilità sono compito del database, e i motori più diffusi le gestiscono correttamente. L’isolamento è quello su cui decidi tu. Un isolamento più rigido costa concorrenza, e quel costo si vede nei grafici di latenza in produzione.
Come eseguire una transazione con BEGIN, COMMIT e ROLLBACK
In PostgreSQL (e nella maggior parte dei database SQL, con piccole differenze di sintassi), una transazione è l’insieme delle istruzioni tra BEGIN e COMMIT:
| |
Se qualcosa va storto prima del COMMIT (una violazione di vincolo, o un controllo di business nel codice dell’applicazione), ROLLBACK annulla tutto quello che è successo da BEGIN in poi:
| |
Dopo il rollback, il saldo del conto 1 è esattamente quello che era prima che BEGIN venisse eseguito. Per una transazione lunga in cui vuoi annullare solo una parte del lavoro, un savepoint ti dà un rollback parziale:
| |
Fuori da un BEGIN esplicito, la maggior parte dei driver esegue ogni istruzione nella sua transazione auto-commit. Ogni istruzione è atomica di per sé, ma niente lega due istruzioni insieme. Salta BEGIN nel bonifico bancario e sei di nuovo al punto di partenza: 50€ persi.
Cosa prevengono i livelli di isolamento delle transazioni
Due transazioni concorrenti possono interferire tra loro in tre modi specifici, e i livelli di isolamento si definiscono in base a quali di questi permettono:
| Anomalia | Cosa succede |
|---|---|
| Dirty read | La transazione A legge una riga che B ha scritto ma non ha ancora fatto commit. Se B fa rollback, A ha letto un dato che non è mai esistito. |
| Non-repeatable read | A legge la stessa riga due volte nella stessa transazione e ottiene valori diversi, perché B ha fatto commit di una modifica nel frattempo. |
| Phantom read | A ri-esegue la stessa query WHERE due volte e ottiene un set di righe diverso, perché B ha inserito o eliminato una riga corrispondente nel frattempo. |
Lo standard SQL definisce quattro livelli attorno a queste tre anomalie:
| Livello di isolamento | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| Read Uncommitted | Possibile | Possibile | Possibile |
| Read Committed | Impedito | Possibile | Possibile |
| Repeatable Read | Impedito | Impedito | Possibile (permesso dallo standard) |
| Serializable | Impedito | Impedito | Impedito |
Raramente vorrai usare READ UNCOMMITTED. PostgreSQL non lo implementa nemmeno e silenziosamente esegue Read Committed al suo posto; MySQL invece lo rispetta e ti mostrerà dirty read. In pratica i due livelli che contano sono Read Committed e Repeatable Read, e i due principali database open source hanno default diversi:
- PostgreSQL usa come default Read Committed. Ogni istruzione dentro una transazione vede uno snapshot fresco preso all’avvio di quella istruzione, quindi due
SELECTnella stessa transazione possono restituire dati diversi se un’altra transazione ha fatto commit nel frattempo. - MySQL (InnoDB) usa come default Repeatable Read. Lo snapshot viene preso una sola volta, alla prima lettura della transazione, e ogni
SELECTsuccessivo in quella transazione vede lo stesso snapshot, anche se un’altra transazione ha fatto commit di un aggiornamento nel frattempo.
In PostgreSQL, imposta il livello subito dopo BEGIN, prima che venga eseguita qualsiasi query:
| |
In MySQL, la forma SESSION cambia il livello per ogni transazione successiva su quella connessione:
| |
Come Repeatable Read blocca un phantom read in PostgreSQL
Apri due sessioni psql sulla stessa tabella ed esegui questo. Vederlo una volta resta in mente meglio che memorizzare la tabella.
Sessione A:
| |
Sessione B, mentre la transazione di A è ancora aperta:
| |
Sessione A, stessa transazione aperta, ri-eseguendo la query identica:
| |
La riga inserita e committata da B non appare. Il Repeatable Read di PostgreSQL prende il suo snapshot alla prima query della transazione di A e lo mantiene fino al COMMIT, quindi le righe nuove di altre transazioni non si vedono mai. Questo è più rigido di quanto richiesto dallo standard: la tabella sopra permette phantom a questo livello, ma l’implementazione di PostgreSQL non li produce. Esegui la stessa sequenza con A in Read Committed e la seconda SELECT restituisce la riga nuova, perché Read Committed prende uno snapshot fresco a ogni istruzione.
Serializable va oltre. Rileva quando due transazioni concorrenti hanno prodotto un risultato che non sarebbe potuto accadere in nessun ordine sequenziale, e ne annulla una con un errore di serializzazione (PostgreSQL restituisce lo SQLSTATE 40001) invece di lasciare che l’anomalia vada a commit. È il livello che chiude la classica race condition dove due transazioni leggono entrambe un valore, decidono in base a quello, e scrivono entrambe. Il codice che usa SERIALIZABLE deve essere pronto a ritentare:
| |
Cosa costano i livelli di isolamento più rigidi
Il prezzo di un isolamento più rigido è la concorrenza:
- Le scritture restano bloccate fino al commit, a ogni livello. Un
UPDATEprende un lock sulla riga nel momento in cui viene eseguito e lo mantiene fino aCOMMIToROLLBACK, indipendentemente dal livello di isolamento. Quello cheRepeatable ReadeSerializableaggiungono è mantenere lo stesso snapshot (o, perSerializable, predicate lock sulle righe esaminate dalle tue query) per tutta la transazione. Fai una chiamata API esterna lenta traBEGINeCOMMITe ogni riga che hai scritto resta bloccata, e ogni riga che hai letto sottoSerializableresta tracciata, per tutto il tempo che richiede quella chiamata. - I retry non sono opzionali con Serializable. Rifiuterà transazioni apparentemente valide sotto carico concorrente: è un comportamento voluto, non un difetto. Se la tua applicazione non intercetta gli errori di serializzazione e non ritenta, quelle transazioni falliscono a intermittenza, sotto carico, che è il momento peggiore per scoprirlo.
Repeatable Readpuò generare lo stesso errore40001su un semplice aggiornamento concorrente, quindi il codice di retry non serve solo perSerializable— ogni transazione sopra Read Committed ne ha bisogno. - Le transazioni lunghe bloccano le modifiche allo schema e gonfiano lo storage. L’MVCC di PostgreSQL mantiene le vecchie versioni delle righe per tutto il tempo in cui una transazione aperta potrebbe ancora averne bisogno. Una transazione lasciata aperta per ore (un bug comune: apri una transazione in una richiesta web e non fai mai commit né rollback) impedisce a
VACUUMdi liberare quello spazio, e unALTER TABLEsu una tabella che ha toccato aspetta che finisca. - Non tutte le scritture hanno bisogno di una transazione esplicita. Una singola istruzione
UPDATEè già atomica. Racchiuderne una inBEGIN/COMMITaggiunge un round trip senza nessuna garanzia in più. Usa una transazione esplicita quando la correttezza dipende dal successo o fallimento congiunto di due o più istruzioni: il bonifico bancario, una registrazione che scrive sia una riga inusersche una inaccounts, un ordine che decrementa l’inventario e inserisce il record dell’ordine.
Gli ORM (Entity Framework, SQLAlchemy, ActiveRecord) di solito aprono una transazione per unità di lavoro e fanno commit per te. Non andare contro questo comportamento. Il bug da tenere d’occhio è l’opposto: codice che chiama .save() o .commit() separatamente per due scritture collegate, buttando via l’atomicità che l’ORM ti avrebbe dato.
Quale livello di isolamento usare
| Se il tuo workload è… | Usa |
|---|---|
| CRUD tipico di una web app, letture e scritture per singola richiesta | Read Committed. È il default di PostgreSQL per un motivo: contesa minima, e i dirty read sono già esclusi. |
| Report o job batch che richiedono che ogni query nella transazione sia coerente con le altre | Repeatable Read. Ogni query legge lo stesso snapshot, quindi totali e righe di dettaglio non possono disallinearsi a metà report. |
| Bonifici finanziari, conteggi di magazzino, qualsiasi caso dove una race tra due transazioni è un bug di correttezza, non un fastidio di performance | Serializable, con logica di retry su 40001. Mantieni queste transazioni brevi e il costo di concorrenza resta basso. |
| Una singola istruzione senza scritture collegate | Nessuna transazione esplicita necessaria. L’istruzione è già atomica. |
Parti da Read Committed e sali solo per le transazioni specifiche dove sai nominare la race che stai chiudendo. È la stessa disciplina di quando aggiungere un indice solo dopo aver trovato la query lenta. Se non riesci a scrivere quella race in una frase, non hai ancora bisogno di un livello più rigido.