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:

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

Scegliere nella pratica

SituazioneScelta ragionevole
OLTP tipico con aggiornamenti mirati di righeRead Committed + aggiornamenti atomici / SELECT FOR UPDATE dove conta
Letture su più statement che devono essere mutuamente consistentiRepeatable 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.

🔍 Letture correlate: VACUUM & bloat — le transazioni lunghe a qualsiasi livello di isolamento trattengono la pulizia per l'intero database.
🧯 Errori correlati: could not serialize access, deadlock detected, lock timeout, e current transaction is aborted — quello che vedi quando manca la logica di retry.