Livelli di isolamento delle transazioni in PostgreSQL nella pratica
PostgreSQL implementa tre livelli di isolamento distinti — Read Committed, Repeatable Read e Serializable. (READ UNCOMMITTED esiste per compatibilità con lo standard SQL ma si comporta esattamente come Read Committed: PostgreSQL non ti mostra mai dati non committati.) Le differenze contano solo sotto concorrenza, che è esattamente quando sono più difficili da debuggare — quindi conviene conoscerle prima dell'incidente.
Read Committed: il default, e il suo punto cieco
Ogni statement vede uno snapshot del database al momento in cui quello statement è iniziato. Due conseguenze:
- Due
SELECTidentiche dentro una transazione possono restituire risultati diversi se qualcuno fa commit nel mezzo (non-repeatable read). - Il classico lost update: due transazioni leggono entrambe un saldo di 100, calcolano entrambe 100 − 10, scrivono entrambe 90. Un prelievo svanisce. Read Committed non previene questo schema quando la lettura e la scrittura sono statement separati.
I rimedi idiomatici a questo livello:
-- make the read-modify-write atomic in one statement:
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
-- or lock the row at read time:
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
Un dettaglio utile: quando un UPDATE a Read Committed trova una riga già modificata da una transazione concorrente già committata, rivaluta la sua clausola WHERE sulla nuova versione e prosegue. Non ottieni un errore — il che è comodo, e occasionalmente la fonte di bug molto subdoli.
Repeatable Read: uno snapshot per l'intera transazione
Lo snapshot viene preso alla prima query e mantenuto fino al commit: ogni statement vede gli stessi dati. In PostgreSQL questo previene anche i phantom read (più forte di quanto richieda lo standard SQL). Il prezzo si presenta sulle scritture:
ERROR: could not serialize access due to concurrent update
Se provi a modificare una riga che una transazione concorrente ha cambiato e committato dopo che il tuo snapshot è stato preso, PostgreSQL non può fingere che entrambi gli ordini siano avvenuti — ti aborta. La tua applicazione deve essere pronta a ritentare l'intera transazione. Repeatable Read è il livello naturale per letture consistenti su più query: report, export, backup (pg_dump gira a questo livello).
Serializable: come se le transazioni girassero una alla volta
Serializable in PostgreSQL è Repeatable Read più SSI (serializable snapshot isolation): il motore traccia le dipendenze di lettura/scrittura tra transazioni concorrenti e aborta una ogni volta che un ciclo renderebbe il risultato impossibile sotto qualsiasi ordine seriale. Cattura anomalie che Repeatable Read non può — il caso da manuale è il write skew:
-- Two doctors, rule: at least one must stay on call.
-- T1: SELECT count(*) FROM oncall; -- sees 2, so it's safe to leave
-- T2: SELECT count(*) FROM oncall; -- sees 2, so it's safe to leave
-- T1: DELETE FROM oncall WHERE doctor = 'alice'; COMMIT;
-- T2: DELETE FROM oncall WHERE doctor = 'bob'; COMMIT;
-- Repeatable Read: both commit, zero doctors on call.
-- Serializable: one of them gets error 40001 and must retry.
Costi: un po' di CPU e memoria per il tracciamento delle dipendenze e — cosa importante — falsi positivi: le transazioni possono essere abortite "per sicurezza". Tutte le transazioni del carico di lavoro devono girare a Serializable perché le sue garanzie reggano, e ognuna di esse ha bisogno di una logica di retry.
Ritentare correttamente
- Ritenta su SQLSTATE
40001(serialization_failure) e40P01(deadlock_detected). - Ritenta l'intera transazione, incluse le sue letture — i valori che hai letto la prima volta sono esattamente ciò che potrebbe non essere più vero. Rieseguire solo lo statement fallito è sbagliato.
- Mantieni le transazioni ritentate corte e limitate (tentativi limitati, piccolo backoff). Una transazione che legge metà del database continuerà a collidere all'infinito.
- Non racchiudere effetti collaterali (email, chiamate HTTP) dentro una transazione che intendi ritentare.
Scegliere nella pratica
| Situazione | Scelta ragionevole |
|---|---|
| OLTP tipico con aggiornamenti mirati di righe | Read Committed + aggiornamenti atomici / SELECT FOR UPDATE dove conta |
| Letture su più statement che devono essere mutuamente consistenti | Repeatable Read |
| Invarianti che coprono più righe ("almeno uno", "la somma non deve superare", doppia prenotazione) | Serializable con retry — oppure locking esplicito se i punti caldi sono pochi e noti |
| Code di lavori (job queue) | Read Committed + FOR UPDATE SKIP LOCKED |
Imposta il livello per transazione (BEGIN ISOLATION LEVEL SERIALIZABLE) o per sessione; mescolare i livelli va bene, tranne che, come detto, le garanzie di Serializable richiedono che tutte le transazioni correlate lo usino.
