Parte I
Le fondamenta
Il vocabolario minimo, che cosa sia esattamente una transazione, e dove finiscono i byte quando premi «salva». Tutto il resto è conseguenza di queste tre cose.
1 · Le basi: tabelle, operazioni, confine
Un capitolo di vocabolario. Se questi termini ti sono già familiari, scorri fino al capitolo 2: ma è qui che vengono definiti, e tutto il documento li usa senza rispiegarli.
A chi si rivolge il documento
A chi scrive applicazioni che parlano con un database relazionale e vuole capire che cosa succede sotto le istruzioni che manda. Gli esempi SQL sono scritti per PostgreSQL (con confronti espliciti con MySQL/InnoDB dove il comportamento diverge); gli esempi di codice usano Java/Kotlin con JPA (Jakarta Persistence API) e Hibernate, tipicamente attraverso Spring Data. Non serve conoscere nulla di questo in anticipo: ogni concetto viene definito la prima volta che compare, e il glossario finale li raccoglie tutti.
Tabella, riga, pagina
Una tabella è un insieme di righe (o record) che hanno tutte le stesse colonne: la tabella conti ha le colonne id e saldo, e ogni riga è un conto. Fin qui è il modello logico, quello che vedi nelle query.
Il database però non memorizza le righe una per una. Le raggruppa in pagine: blocchi di dimensione fissa (8 KB in PostgreSQL, 16 KB in InnoDB) che sono l'unità minima spostata tra disco e memoria. Quando leggi una riga, il database carica l'intera pagina che la contiene; quando ne modifichi una, è la pagina intera a diventare «sporca». Questa distinzione, riga logica contro pagina fisica, tornerà nel capitolo 3.
tabella conti logica
| id | saldo |
|---|---|
| 1 | 100 |
| 2 | 250 |
| 3 | 40 |
pagina 17 su disco fisica, 8 KB
Le quattro operazioni sui dati
Tutto ciò che un'applicazione fa su una tabella si riduce a quattro istruzioni SQL (Structured Query Language), che insieme formano il DML (Data Manipulation Language). In gergo applicativo si chiamano CRUD (Create, Read, Update, Delete).
| Istruzione | Che cosa fa | Esempio |
|---|---|---|
SELECT | Legge righe. Non modifica nulla, ma è comunque un'operazione che il database deve coordinare con le scritture altrui | SELECT saldo FROM conti WHERE id = 1; |
INSERT | Crea una riga nuova | INSERT INTO conti (id, saldo) VALUES (4, 0); |
UPDATE | Modifica righe esistenti, quelle selezionate dalla WHERE. Senza WHERE le modifica tutte | UPDATE conti SET saldo = 70 WHERE id = 1; |
DELETE | Cancella le righe selezionate dalla WHERE | DELETE FROM conti WHERE id = 4; |
Accanto al DML c'è il DDL (Data Definition Language): le istruzioni che non toccano i dati ma la forma delle tabelle, come CREATE TABLE, ALTER TABLE, CREATE INDEX. Li incontrerai nel capitolo 2, perché non ovunque godono delle stesse garanzie del DML.
Il confine: BEGIN, COMMIT, ROLLBACK
Una singola istruzione fa una cosa sola. Quando un'operazione ne richiede più d'una, servono tre parole che disegnano un confine intorno al gruppo.
| Parola | Significato |
|---|---|
BEGIN | Apre una transazione: «da qui in avanti, tutto ciò che faccio va considerato insieme» |
COMMIT | Chiude confermando: le modifiche diventano definitive e visibili agli altri, tutte nello stesso istante |
ROLLBACK | Chiude annullando: il database riporta tutto a com'era al BEGIN, come se nulla fosse accaduto |
BEGIN;
UPDATE conti SET saldo = saldo - 100 WHERE id = 'anna';
UPDATE conti SET saldo = saldo + 100 WHERE id = 'bruno';
COMMIT; -- oppure ROLLBACK, e nessuno dei due saldi cambia
Il ROLLBACK può essere richiesto dall'applicazione, ma può anche essere deciso dal database da solo: se una UPDATE viola un vincolo (in PostgreSQL; InnoDB annulla solo l'istruzione fallita e lascia aperta la transazione) o se il processo si interrompe a metà, la transazione viene abortita e tutto ciò che aveva fatto scompare. È questo il senso in cui il confine è una garanzia, non una convenzione.
Autocommit: non esiste SQL fuori da una transazione
Domanda che vale la pena porsi esplicitamente: se non scrivo mai BEGIN, che cosa succede? Il database ne apre una implicita attorno alla singola istruzione e la committa appena finisce. È l'autocommit, il comportamento di default di JDBC e di molti driver (non di tutti: psycopg in Python, per esempio, apre una transazione implicita e aspetta il commit). Quindi non esiste SQL fuori da una transazione: quello che varia è solo se l'hai dichiarata tu.
UPDATE conto SET saldo = saldo - 10 WHERE id = 1;
-- il database esegue in realtà:
BEGIN;
UPDATE conto SET saldo = saldo - 10 WHERE id = 1;
COMMIT;
Ogni istruzione DML ha il suo confine? Quasi sempre sì
In autocommit il confine è la singola istruzione, qualunque sia la sua ampiezza: una SELECT, un INSERT, una UPDATE che tocca una riga o diecimila. Ogni istruzione apre la propria transazione e la committa appena finisce; l'istruzione successiva ne apre un'altra, nuova e indipendente.
Il database non raggruppa mai da solo due istruzioni consecutive: non ha modo di sapere che «leggi il saldo» e «aggiorna il saldo» appartengono alla stessa operazione di business, e non lo indovina.
Se le vuoi insieme, il confine lo devi dichiarare tu (o farlo dichiarare al framework: in Spring è ciò che fa @Transactional, che apre la transazione all'ingresso del metodo e la committa all'uscita).
due transazioni indipendenti nel primo caso, una sola nel secondo: la differenza è tutta nel «chi lo dichiara»
Ci sono però tre situazioni in cui più istruzioni finiscono davvero nella stessa transazione senza che tu abbia scritto BEGIN, e conviene conoscerle perché sembrano eccezioni alla regola ma non lo sono:
- Il lavoro derivato dall'istruzione. Trigger, cascate
ON DELETE CASCADE, aggiornamento degli indici, verifica dei constraint: tutto ciò che il database esegue per causa della tua istruzione sta nel suo confine (capitolo 5). Un trigger che fa dieciINSERTin un'altra tabella non apre dieci transazioni: è dentro quella dell'istruzione che l'ha scatenato. Non sono più istruzioni raggruppate: è una sola istruzione più larga di come appare. - Funzioni e procedure. In PostgreSQL una funzione chiamata da un'istruzione (
SELECT preleva(1, 10)) esegue tutte le sue istruzioni interne dentro la transazione della chiamata, perché per il database è sempre una istruzione. LePROCEDURE(dalla versione 11) possono invece fareCOMMITal proprio interno, e spezzano il confine. - Il driver che disattiva l'autocommit. Alcuni client (psycopg in Python, o JDBC con
setAutoCommit(false)) aprono una transazione implicita alla prima istruzione e la tengono aperta finché non chiamicommit(): lì le istruzioni si accumulano nella stessa transazione, ma è il driver a decidere, non il database. È la causa più comune di «transazione dimenticata aperta» (capitolo 7).
In tutti gli altri casi vale la regola semplice: un'istruzione, una transazione. E con essa il malinteso del capitolo 2: SELECT + if + UPDATE in autocommit sono due transazioni con una finestra scoperta in mezzo.
Ogni transazione ha un numero
Quando una transazione inizia a scrivere, il database le assegna un identificativo progressivo (in PostgreSQL lo leggi con SELECT pg_current_xact_id(), txid_current() prima della versione 13). Nei diagrammi di questo documento le transazioni si chiamano T1, T2, T88, T90: il numero non è decorativo. È ciò che il database scrive dentro le righe per sapere chi ha creato o cancellato ogni versione, e ciò che confronta per decidere che cosa una transazione può vedere. Il meccanismo è nel capitolo 7.
Il lock
Finché una transazione lavora da sola, non ha bisogno di nulla. Il problema nasce quando due transazioni vogliono la stessa riga nello stesso momento, e può prendere due forme:
- Due scrivono. T1 e T2 modificano entrambe il saldo del conto 1. Se le scritture si intrecciano, una copre l'altra e il valore finale non è quello che nessuna delle due voleva.
- Uno scrive, l'altro legge. T1 sta modificando il saldo e non ha ancora committato; T2 lo legge. T2 ha in mano un numero provvisorio, che T1 potrebbe ancora annullare con un
ROLLBACK: un numero che forse non esisterà mai.
Notare che cosa non è un problema: due che leggono. Dieci transazioni che leggono lo stesso saldo non si disturbano in nessun modo, perché nessuna lo cambia.
Il lock è la soluzione più antica ai due problemi. L'idea: prima di toccare una riga, la transazione deve ottenere dal database un permesso su quella riga; il database tiene un registro (il lock manager) con scritto, per ogni riga in uso, chi ha il permesso e di che tipo; e se il permesso che chiedi è incompatibile con uno già concesso a qualcun altro, la tua istruzione si ferma e aspetta, senza errori, finché quell'altro non finisce e lo restituisce. Non sei tu a chiederlo: lo fa il database per conto tuo quando esegui l'istruzione.
nessuno ha un permesso incompatibile Concesso
COMMIT / ROLLBACKqualcuno ne ha uno incompatibile Attesa
Perché servono due tipi di permesso
Se esistesse un solo tipo di permesso, «la riga è mia», anche due lettori si farebbero aspettare a vicenda, e abbiamo appena visto che non ce n'è motivo. Per questo il permesso ha due forme, che corrispondono alle due cose che puoi voler fare:
| Tipo | Che cosa dichiara chi lo prende | Quanti possono tenerlo insieme sulla stessa riga | Chi lo prende |
|---|---|---|---|
| Lock condiviso (shared, S) | «Sto leggendo questa riga. Nessuno la modifichi finché non ho finito, ma chi vuole leggerla insieme a me faccia pure» | Molti: si chiama condiviso proprio perché più transazioni lo possono tenere contemporaneamente | Chi legge, nei database basati solo su lock |
| Lock esclusivo (exclusive, X) | «Sto modificando questa riga. Nessuno la legga né la modifichi finché non ho committato o annullato» | Uno solo: si chiama esclusivo perché esclude tutti gli altri, lettori compresi | Chi scrive: UPDATE e DELETE lo prendono automaticamente |
Da queste due definizioni discendono da sole le quattro situazioni possibili quando una seconda transazione arriva su una riga che la prima sta già usando. Vediamole una per una, sempre con T1 arrivata prima e T2 dopo.
Caso 1 T1 legge, T2 vuole leggere
SELECT saldo … WHERE id=1 → lock condiviso su riga 1 ✔SELECT saldo … WHERE id=1 → chiede un condiviso; T1 ne tiene uno condiviso: compatibili ✔Caso 2 T1 legge, T2 vuole scrivere
SELECT saldo … WHERE id=1 → lock condiviso su riga 1 ✔UPDATE conti … WHERE id=1 → chiede un esclusivo; T1 tiene un condiviso: incompatibili ✘Caso 3 T1 scrive, T2 vuole leggere
UPDATE conti … WHERE id=1 → lock esclusivo su riga 1 ✔SELECT saldo … WHERE id=1 → chiede un condiviso; T1 tiene un esclusivo: incompatibili ✘Caso 4 T1 scrive, T2 vuole scrivere
UPDATE conti … WHERE id=1 → lock esclusivo su riga 1 ✔UPDATE conti … WHERE id=1 → chiede un esclusivo; T1 tiene un esclusivo: incompatibili ✘Riassunto in una frase: l'unica combinazione in cui nessuno aspetta è lettura con lettura. Appena c'è uno scrittore in gioco, chi arriva dopo si mette in coda, qualunque cosa voglia fare. E questo risolve entrambi i problemi di partenza: le scritture non si intrecciano (caso 4) e nessuno legge un valore provvisorio (caso 3).
Tre cose vanno fissate subito, perché tornano nella Parte III.
- Granularità. Il permesso può riguardare una singola riga, un'intera tabella, oppure un range di valori (tutte le righe con
stato = 'ATTIVO', incluse quelle che non esistono ancora). Più è fine, più transazioni possono lavorare in parallelo su righe diverse; più è grossa, meno costa gestirlo. Nei database moderni il lock di scrittura è di riga. - Implicito o esplicito.
UPDATEeDELETEprendono da soli il lock esclusivo sulle righe che toccano. Puoi anche chiederlo tu, su righe che stai solo leggendo, conSELECT … FOR UPDATE: «bloccami queste righe come se stessi per scriverle», utile quando sai che le scriverai tra poco e non vuoi che nessuno le cambi nel frattempo. Il capitolo 9 è dedicato a quando conviene. - Durata. Un lock esclusivo si tiene fino al commit o al rollback, mai prima: rilasciarlo appena finita l'istruzione permetterebbe a un altro di leggere un valore che tu potresti ancora annullare (il caso 3 tornerebbe a essere un problema). È il motivo per cui una transazione lunga fa male a tutti gli altri: non per il lavoro che fa, ma per i lock che tiene mentre lo fa.
Il deadlock
Se T1 tiene il lock su A e aspetta B, mentre T2 tiene B e aspetta A, nessuna delle due potrà mai proseguire: è un deadlock, un'attesa circolare.
Il database non lo previene: lo rileva. Chi resta in attesa controlla se si è formato un ciclo nel grafo delle attese (PostgreSQL dopo deadlock_timeout, 1 secondo di default; InnoDB subito) e, se lo trova, il database abortisce una delle transazioni coinvolte (in PostgreSQL con l'errore 40P01 deadlock_detected), liberando l'altra. L'applicazione riceve un'eccezione e deve riprovare. La contromisura di progetto è una sola, e la vedremo nel capitolo 9: far acquisire a tutti i lock nello stesso ordine.
Lo snapshot: la risposta al difetto del lock
Torna al caso 3: T1 sta modificando il saldo, T2 vuole solo leggerlo, e deve aspettare. Ora immagina un sistema reale, dove per ogni transazione che scrive ce ne sono cento che leggono: una sola scrittura lenta mette in coda cento lettori, che a loro volta (caso 2) bloccano le scritture successive. I lettori sono la maggioranza e sono innocui, eppure sono loro a pagare di più. È il difetto strutturale dello schema a soli lock.
La soluzione dei database moderni cambia domanda. Invece di «come faccio aspettare il lettore finché il valore non è definitivo?», chiede: «e se al lettore dessi il valore precedente, quello già committato, senza farlo aspettare?». Per farlo, il database non sovrascrive la riga quando la modifica: conserva la versione vecchia accanto a quella nuova, finché serve a qualcuno.
riga 1 T1 scrive, non ha ancora committato
Lo snapshot è il nome di ciò che T2 vede: una fotografia dello stato del database fatta di sole versioni committate, scattata a un certo istante. Chi lavora su uno snapshot non vede le modifiche non ancora committate degli altri (quindi il caso 3 non ha più bisogno di attesa) e non viene disturbato da ciò che gli altri committano dopo lo scatto. Questa tecnica si chiama MVCC (multiversion concurrency control, «controllo della concorrenza a più versioni») ed è quella di PostgreSQL, Oracle e MySQL/InnoDB.
Attenzione a che cosa cambia e che cosa no: lo snapshot serve ai lettori. Chi scrive continua a prendere il lock esclusivo, perché due scritture sulla stessa riga devono comunque mettersi in fila (caso 4). Ciò che sparisce è il conflitto tra chi legge e chi scrive.
| Situazione | Solo lock | Lock + snapshot (MVCC) |
|---|---|---|
| Caso 1 · leggere mentre un altro legge | nessuno aspetta | nessuno aspetta |
| Caso 2 · scrivere mentre un altro legge | lo scrittore aspetta | nessuno aspetta: il lettore continua a vedere la sua fotografia |
| Caso 3 · leggere mentre un altro scrive | il lettore aspetta | nessuno aspetta: il lettore vede la versione committata |
| Caso 4 · scrivere mentre un altro scrive | il secondo scrittore aspetta | il secondo scrittore aspetta (lock esclusivo, come prima) |
Laboratorio: chi aspetta?
comportamento reale, semplificatoStessa riga, due transazioni: T1 arriva prima, T2 dopo. Cambia il meccanismo e guarda quali casi mettono qualcuno in coda.
E se la lettura sta nella stessa transazione della scrittura?
Domanda che sorge spontanea a questo punto: con MVCC i lettori non prendono lock, ma se la mia transazione fa SELECT e poi UPDATE sulla stessa riga, quella SELECT «eredita» il lock esclusivo? No. Il lock non è una proprietà della transazione che si spalma su tutto ciò che fa: viene preso dall'istruzione che scrive, nel momento in cui scrive, e da lì in poi tenuto fino al commit. Tutto ciò che la transazione ha letto prima resta una lettura da snapshot, senza lock, esattamente come se fosse stata fatta da una transazione di sola lettura.
tra la SELECT e l'UPDATE di T1 la riga è libera: T2 può scrivere, e la decisione di T1 è presa su un valore che non è più vero
Il diagramma mostra la conseguenza: la finestra tra la SELECT e l'UPDATE è scoperta anche dentro una transazione esplicita. Dichiarare BEGIN … COMMIT ti dà atomicità (o tutto o niente) e regola che cosa le tue letture vedono, ma non ti dà l'esclusività sulla riga letta.
L'UPDATE lavora sullo snapshot della SELECT? No: chi scrive non guarda lo snapshot
Vale la pena scioglierlo subito, perché è la fonte più comune di confusione su MVCC. Lo snapshot governa solo ciò che vedi quando leggi. Uno snapshot «coerente» è una fotografia in cui tutte le righe appartengono allo stesso istante: nessuna metà vecchia e metà nuova, nessun valore provvisorio di transazioni non committate.
Ma nel momento in cui un'istruzione scrive, il database mette da parte la fotografia e va a prendere la versione fisicamente corrente della riga, quella che sta in cima alla catena delle versioni: scrivere sopra una foto non avrebbe senso, perché la foto potrebbe essere già vecchia. L'UPDATE di T1, quindi, non lavora sul 100 che la SELECT aveva visto: prende il lock sulla riga, e se la trova bloccata da T2 aspetta; quando T2 committa, T1 vede che la versione corrente è 50, cioè diversa da quella del suo snapshot. Che cosa faccia a quel punto dipende dal livello di isolamento:
READ_COMMITTED default di PostgreSQL
REPEATABLE_READ uno snapshot per transazione
In nessuno dei due casi l'UPDATE «lavora sullo snapshot»: in READ_COMMITTED lo ignora e scrive sulla versione corrente; in REPEATABLE_READ lo usa come termine di confronto e, se la realtà è andata avanti, si rifiuta. È il meccanismo first-updater-wins del capitolo 7. E la finestra tra SELECT e UPDATE, di per sé, resta scoperta in entrambi: cambia solo se il danno passa in silenzio o diventa un errore.
Se vuoi che la lettura prenda il lock esclusivo, devi chiederlo: è esattamente ciò che fa SELECT … FOR UPDATE (capitolo 9). Con quello, T2 nel diagramma si sarebbe fermata in attesa fin dalla SELECT di T1. L'alternativa è comprimere lettura e scrittura in un'unica istruzione (UPDATE saldo = saldo - 30), che prende il lock nell'istante stesso in cui legge.
SELECT poi UPDATE stessa transazione
SELECT … FOR UPDATE stessa transazione
Allora che relazione c'è tra transazione e lock? E a che cosa serve una transazione se leggo soltanto?
Le due domande hanno la stessa risposta, e conviene fissarla perché è facile confondersi. Il lock nasce da un'istruzione, la transazione decide quanto vive. Nessuna transazione «ha» un lock per il fatto di esistere: lo ottiene nel momento in cui una sua istruzione scrive (UPDATE, DELETE, INSERT) o lo chiede (SELECT … FOR UPDATE). Da quel momento, però, il lock non appartiene più all'istruzione, che è finita, ma alla transazione, che lo tiene fino a COMMIT o ROLLBACK. La transazione è quindi il contenitore della durata: allunga la vita del lock dall'istante della scrittura fino al confine.
autocommit confine = istruzione
transazione esplicita confine = gruppo
stesso lock, stessa istruzione che lo prende: cambia solo chi decide quando finisce
Da qui la risposta alla seconda domanda. Una SELECT normale non prende lock, quindi la transazione non le dà nessuna protezione dagli scrittori: se leggi soltanto, una transazione serve a una cosa sola, la coerenza tra più letture.
- Una sola lettura. La transazione non aggiunge nulla: la singola istruzione ha già il suo snapshot coerente (mai un numero «sporco a metà») e in autocommit è già dentro una transazione implicita. Un
BEGIN/COMMITattorno a unaSELECTisolata è inerte. - Più letture che devono raccontare lo stesso istante (un report con tre
SUM, una testata d'ordine più le sue righe, un export). Qui la transazione è l'unico modo per ottenerlo, ma solo a livelloREPEATABLE_READ: lo snapshot viene scattato una volta all'inizio e tutte le query vedono la stessa fotografia. InREAD_COMMITTED, il default, ogni istruzione scatta la propria foto anche dentro la stessa transazione, quindi ilBEGINda solo non basta: serve anche il livello (capitolo 7). - Leggere per poi scrivere. La transazione dà atomicità alla scrittura e la lega alle letture nello stesso confine, ma non dà esclusività sulla riga letta: la finestra tra
SELECTeUPDATEresta aperta, come nel diagramma sopra. Per chiuderla devi far nascere il lock dalla lettura conFOR UPDATE(capitolo 9), affidarti al40001diREPEATABLE_READ, oppure comprimere in un'unicaUPDATEche legge e scrive nello stesso istante.
Per le letture la transazione è ciò che lo snapshot è per l'istruzione, coerenza nel tempo estesa da una singola istruzione a un gruppo. Il diritto di scrivere dopo non è mai incluso: va chiesto.
Ecco la frase da ricordare, che in PostgreSQL è letteralmente vera: i lettori non bloccano gli scrittori e gli scrittori non bloccano i lettori. Restano solo gli scrittori a bloccarsi tra loro. Il prezzo è che le versioni vecchie occupano spazio e vanno ripulite quando nessuno le vede più (in PostgreSQL lo fa il VACUUM), e che quando viene scattata la fotografia (a ogni istruzione? una volta per transazione?) diventa una scelta con conseguenze: è esattamente la differenza tra i livelli di isolamento del capitolo 7.
- Transazione e concorrenza
- lavora su Tabelle
- sono fatte di righe
- stanno fisicamente in pagine unità di spostamento
- si modificano con DML SELECT, INSERT, UPDATE, DELETE
- è delimitata da BEGIN, COMMIT, ROLLBACK
- senza BEGIN c'è autocommit
- regola un'istruzione, una transazione
- è identificata da un numero progressivo
- fa aspettare con Lock
- ha due tipi condiviso ed esclusivo
- si tiene fino a commit o rollback
- può causare deadlock lo risolve l'abort di una transazione
- fa vedere il passato con Snapshot (MVCC)
- conserva versione vecchia e nuova
- libera i lettori dall'attesa
- non basta per due scrittori capitolo 7
- lavora su Tabelle
In sintesi · capitolo 1
Il database lavora su tabelle di righe, memorizzate fisicamente in pagine. Tutto ciò che fai sui dati è una delle quattro operazioni DML; tutto ciò che fai vive comunque dentro una transazione, esplicita (BEGIN/COMMIT/ROLLBACK) o implicita (autocommit). Per far convivere transazioni concorrenti il database ha due strumenti: il lock, che fa aspettare, e lo snapshot, che fa vedere il passato.
Concetti chiave
- Pagina — blocco di dimensione fissa (8 KB in PostgreSQL), unità minima spostata tra disco e RAM; una riga vive dentro una pagina
- DML / DDL — le quattro operazioni sui dati (
SELECT,INSERT,UPDATE,DELETE) contro le istruzioni che cambiano la forma delle tabelle - BEGIN / COMMIT / ROLLBACK — aprono, confermano o annullano il confine; il rollback può essere deciso anche dal database
- Autocommit — la transazione implicita attorno a ogni istruzione senza
BEGIN: non esiste SQL fuori da una transazione. Un'istruzione, una transazione: il database non raggruppa mai due istruzioni da solo (trigger e funzioni sono la stessa istruzione, più larga) - Id di transazione — numero progressivo che il database scrive nelle righe per sapere chi ha creato o cancellato ogni versione
- Lock — permesso su una riga registrato dal database; se è incompatibile con uno già concesso, l'istruzione aspetta. Tenuto fino al commit
- Condiviso / esclusivo — «sto leggendo, leggete pure con me» (molti insieme) contro «sto scrivendo, nessuno tocchi» (uno solo). L'unica coppia che non aspetta è lettura con lettura
- Deadlock — attesa circolare tra due transazioni; il database la rileva e ne abortisce una (
40P01) - Snapshot / MVCC — il database conserva la versione committata accanto a quella in modifica; i lettori vedono la fotografia committata e non aspettano gli scrittori. Solo gli scrittori si bloccano tra loro. Il lock X lo prende l'istruzione che scrive, non la transazione: una
SELECTprima dell'UPDATEresta senza lock, salvoFOR UPDATE - Transazione e lock — il lock nasce dall'istruzione che scrive, la transazione ne decide la durata (fino al commit). Per chi legge soltanto, la transazione serve solo alla coerenza tra più letture, e solo in
REPEATABLE_READ
Autodiagnosi · dovresti saper rispondere senza rileggere
- Che differenza c'è tra una riga e una pagina, e perché al database interessa la seconda?
- Se non scrivi mai
BEGIN, le tue istruzioni sono dentro una transazione? Chi la chiude? - Nei quattro casi (leggo/leggo, leggo/scrivo, scrivo/leggo, scrivo/scrivo), chi aspetta con i soli lock? E con lo snapshot?
- Perché un lock di scrittura si tiene fino al commit e non viene rilasciato appena l'istruzione finisce?
- Descrivi un deadlock con due transazioni e due righe: chi lo risolve e come?
2 · Che cos'è una transazione
Il concetto arriva dalla contabilità, non dall'informatica — e il malinteso più costoso riguarda dove ne finisca il confine.
Il termine viene dalla contabilità, non dall'informatica: una transazione è uno scambio che o avviene per intero o non avviene affatto. Non esiste mezzo pagamento. I database prendono il concetto alla lettera e lo rendono una primitiva: un'unità di lavoro che porta il sistema da uno stato consistente a un altro, senza stati intermedi osservabili.
Jim Gray formalizza l'idea nel 1981 (The Transaction Concept: Virtues and Limitations). Due anni dopo Theo Härder e Andreas Reuter coniano l'acronimo che tutti conoscono, ACID. Prima di scioglierlo, però, conviene capire da quali problemi nasce: le quattro lettere non sono un elenco imparato a memoria, sono quattro risposte a quattro modi in cui un'operazione può andare male.
Da quali esigenze nasce ACID
Torniamo al bonifico del capitolo 1: togli 100 € ad Anna, aggiungi 100 € a Bruno. Sono due istruzioni, e tra la prima e la seconda passa del tempo, poco, ma non zero. Un'applicazione che facesse questo lavoro senza un database transazionale dovrebbe preoccuparsi da sola di quattro cose che possono succedere.
| Che cosa può andare male | La domanda che ti fai | La proprietà che risponde |
|---|---|---|
| Il processo muore tra le due istruzioni: i 100 € sono usciti da Anna e non sono arrivati a Bruno | Come faccio a non lasciare mai il lavoro a metà? | Atomicity — tutto o niente |
| Il codice sbaglia: toglie 100 e aggiunge 50, oppure manda il saldo di Anna sotto zero | Come faccio a impedire che il sistema finisca in uno stato che le regole del dominio vietano? | Consistency — da uno stato valido a uno stato valido |
| Qualcun altro lavora nello stesso istante: un secondo bonifico legge il saldo di Anna mentre il primo è a metà, e decide su un numero che non è né quello di prima né quello di dopo | Come faccio a scrivere codice come se fossi l'unico a usare il database? | Isolation — come se le transazioni fossero eseguite una alla volta |
| Il database ha risposto «fatto», l'utente ha chiuso la pagina, e un secondo dopo salta la corrente: al riavvio il bonifico non c'è | Quando mi dicono «fatto», posso fidarmi? | Durability — dopo il commit, resiste a qualunque guasto |
Sono proprietà della transazione, non del database in generale: il database è il meccanismo che le realizza, ma la cosa che viene qualificata come atomica, consistente, isolata e durevole è il gruppo di istruzioni racchiuso tra BEGIN e COMMIT. È anche ciò che distingue una transazione da una semplice sequenza di istruzioni: due UPDATE lanciate una dopo l'altra sono una sequenza; due UPDATE dentro un confine transazionale sono un'unità che gode delle quattro garanzie insieme.
Senza ACID ogni riga di codice di business dovrebbe farsi carico dei guasti: «e se muoio qui? e se qualcuno ha scritto nel frattempo? e se il disco non ha davvero salvato?». La transazione è l'astrazione che permette di non porsi queste domande: scrivi la logica come se il processo non potesse morire, come se fossi l'unico utente e come se il disco fosse infallibile, e il database si occupa di rendere vero ciò che hai dato per scontato. È il contratto che Gray descriveva nel 1981, ed è il motivo per cui, quando quel contratto si spezza (Parte IV), il codice applicativo torna a doversi occupare di tutto ciò che qui è invisibile: compensazioni, riprova, duplicati.
Il resto di questa Parte e tutta la Parte II sono lo studio di come il database mantenga queste quattro promesse, una per una. Qui basta averle nominate per rispondere alla prima domanda.
Una singola istruzione è già una transazione
Abbiamo visto nel capitolo 1 che, grazie all'autocommit, anche un'istruzione senza BEGIN vive dentro una transazione implicita. La domanda successiva è: quanto di ACID vale su quella singola istruzione? Quasi tutto.
| Proprietà | Su una singola istruzione |
|---|---|
| Atomicity | Sì, e più ampia di quanto si pensi: un UPDATE su 10.000 righe è tutto-o-niente, e il confine include trigger, ON DELETE CASCADE e aggiornamento degli indici (capitolo 5) |
| Isolation | Sì per le letture, che lavorano su uno snapshot coerente; le scritture però leggono l'ultima versione committata delle righe che toccano (capitolo 7). Che cosa questo comporti tra due istruzioni della stessa transazione è il tema del capitolo 7 |
| Durability | Sì, con l'fsync del commit implicito (capitoli 3 e 8) |
| Consistency | Solo quella dichiarata nei constraint. L'invariante che vive nel tuo service layer non la conosce nessuno (capitolo 6) |
Istruzione SQL ≠ operazione di business
Qui sta il malinteso che genera più bug, ed è il ponte verso tutto il resto del documento. Le due cose coincidono quasi mai.
Una istruzione
Una operazione di business tre pezzi
BEGIN … COMMITPrendiamo il prelievo di 10 € dalla colonna di destra e guardiamo dove finiscono le garanzie:
- L'atomicità c'è, ma sul pezzo sbagliato — e la transazione la sistema. La
SELECTè atomica. LaUPDATEè atomica. L'operazione «preleva 10 se il saldo lo consente» in autocommit non lo è: non esiste da nessuna parte nel database un confine che la racchiuda, quindi se il processo muore tra il controllo e la scrittura il DB non ha nulla da disfare, perché per lui erano due lavori distinti, entrambi conclusi correttamente. Questo problema si risolve racchiudendo i tre pezzi inBEGIN … COMMIT: da quel momento o l'intera operazione è avvenuta o nulla lo è. Ma è l'unico dei due problemi che la transazione risolve. - L'isolamento c'è, ma non copre la finestra — e la transazione NON la chiude. La
SELECTha visto uno snapshot coerente. LaUPDATEha visto un altro snapshot coerente. In mezzo, nel tempo in cui la tua applicazione valutava l'if, chiunque poteva scrivere: l'isolamento protegge ciascuna istruzione mentre gira, non il tempo che passa tra l'una e l'altra. Nessuna delle due ha «letto un dato sporco»: hanno letto due dati puliti in due istanti diversi. E attenzione al punto che inganna: mettereSELECT,ifeUPDATEnella stessa transazione non cambia nulla qui. LaSELECTlegge dallo snapshot senza prendere alcun lock (capitolo 1); il lock esclusivo lo prende solo l'UPDATE, nell'istante in cui scrive. Fino a quell'istante la riga è libera, dentro o fuori daBEGIN, e un'altra transazione può modificarla. La transazione ti dice che i tre pezzi falliranno o riusciranno insieme; non ti dice che nessuno si sia infilato in mezzo. Questo è il problema come causa; il punto seguente è il danno che ne deriva.
Che cosa succede, concretamente, se qualcuno scrive in quella finestra? Saldo 100, due prelievi in parallelo. Leggi il diagramma immaginando che T1 sia dentro un BEGIN … COMMIT: non cambia una riga, perché fino all'UPDATE T1 non tiene nessun lock.
Calcolo · i due prelievi in parallelo
La decisione di T1 («posso prelevare, e il nuovo saldo è 90») è stata presa su un valore che, al momento della scrittura, aveva smesso di essere attuale; e la scrittura, non sapendolo, ha coperto quella di T2. Questa anomalia ha un nome, lost update (aggiornamento perso), e il capitolo 10 la mette in laboratorio con le tre protezioni possibili.
Nota la relazione con il punto precedente: la finestra scoperta è la condizione che rende possibile l'anomalia; il lost update è ciò che accade quando qualcuno la attraversa davvero. Senza concorrenza, la finestra resta scoperta ma non fa danni.
E nota che l'autocommit qui non c'entra: l'unica istruzione che prende un lock è l'UPDATE, e lo prende nell'istante in cui scrive, quindi la finestra tra SELECT e UPDATE è identica con o senza BEGIN. L'autocommit fa differenza solo per l'altro problema, il crash a metà: senza confine dichiarato non c'è nulla da disfare.
| Il problema | SELECT + if + UPDATE in autocommit | …dentro BEGIN … COMMIT | Che cosa lo risolve |
|---|---|---|---|
| Crash tra il controllo e la scrittura | nulla da disfare: due lavori distinti | tutto o niente: la transazione basta | il confine transazionale |
| Qualcuno scrive nella finestra (lost update) | la riga è libera fino all'UPDATE | la riga è libera fino all'UPDATE: il BEGIN non prende lock | UPDATE atomico, FOR UPDATE, @Version, oppure REPEATABLE_READ che trasforma il danno in errore 40001 (capitoli 7, 9, 10) |
Comprimendo l'operazione di business in una sola istruzione (UPDATE saldo = saldo - 10, con l'invariante in un CHECK saldo >= 0) fai coincidere il confine transazionale con il confine dell'invariante. Non stai aggiungendo una protezione: stai eliminando lo spazio in cui il problema poteva nascere. Le protezioni vere e proprie, il lock ottimistico (@Version) e quello pessimistico, arrivano nel capitolo 9 per i casi in cui questa compressione non è possibile. Riformulata così, la regola dell'aggregato in DDD (Domain-Driven Design, l'insieme di dati che deve restare consistente insieme: la definizione completa è nel capitolo 6) diventa: un aggregato dovrebbe essere piccolo abbastanza che la sua invariante sia difendibile in una transazione — e, quando possibile, in una sola istruzione.
Dove nemmeno la singola operazione è ACID
| Contesto | Cosa manca |
|---|---|
| DDL in MySQL / Oracle | CREATE TABLE, ALTER (il DDL del capitolo 1) committano implicitamente, chiudendo la transazione in corso (da MySQL 8.0 la singola istruzione DDL è almeno atomica). In PostgreSQL invece sono transazionali, con poche eccezioni come CREATE INDEX CONCURRENTLY |
| MyISAM | Nessuna transazione, nessun rollback. È il motivo per cui InnoDB l'ha sostituito come motore di default in MySQL |
| MongoDB | Atomicità garantita sul singolo documento. Multi-documento richiede una transazione esplicita |
| Redis | Il singolo comando è atomico, ma MULTI/EXEC non fa rollback se un comando fallisce |
| Cassandra / DynamoDB | Cassandra: atomicità e isolamento per partizione (i batch su più partizioni sono atomici ma non isolati). DynamoDB: il singolo item, oppure transazioni esplicite fino a 100 item (TransactWriteItems) |
È utile saperlo perché in un'architettura a servizi questi datastore convivono, e la saga (Parte IV) deve tenere conto di cosa ciascun passo può davvero garantire da solo.
- Transazione
- viene dalla contabilità
- è formalizzata da Gray, 1981
- è battezzata ACID, due anni dopo
- promette ACID
- atomicity risponde a crash a metà
- consistency risponde a regole del dominio violate
- isolation risponde a concorrenza
- durability risponde a blackout dopo il «fatto»
- non coincide con l'istruzione SQL
- l'operazione di business lascia una finestra scoperta
- chi la attraversa causa lost update
- si evita con scrittura atomica capitoli 9–10 per le altre
- non vale intero in DDL MySQL/Oracle, MyISAM, MongoDB, Redis, Cassandra
- viene dalla contabilità
In sintesi · capitolo 2
Una transazione è un'unità di lavoro tutto-o-niente, presa in prestito dalla contabilità. Le quattro proprietà ACID nascono da quattro modi in cui un'operazione può andare male (crash a metà, regola violata, concorrenza, blackout dopo il «fatto») e qualificano il gruppo di istruzioni tra BEGIN e COMMIT, non il database in generale. La singola istruzione gode già di A, I e D; ma il malinteso più costoso è confondere l'istruzione con l'operazione di business: quando questa è fatta di più istruzioni, la finestra tra l'una e l'altra non è coperta da nulla, e chi la attraversa produce un lost update.
Concetti chiave
- ACID — Atomicity, Consistency, Isolation, Durability: quattro risposte a quattro guasti, proprietà della transazione
- Perché ACID è fondamentale — permette di scrivere logica di business come se il processo non morisse, fossi solo e il disco fosse infallibile
- Istruzione ≠ operazione di business — ogni istruzione è atomica e isolata, l'insieme di più istruzioni no: non esiste un confine che lo racchiuda
- La finestra scoperta — il tempo tra una
SELECTe laUPDATEsuccessiva, in cui l'isolamento non protegge nulla - Lost update — una scrittura calcolata su un valore letto che nel frattempo è cambiato copre la scrittura di qualcun altro
- Scrittura atomica — comprimere l'operazione in una sola istruzione (
saldo = saldo - 10) elimina la finestra invece di proteggerla - Dove ACID non vale — DDL in MySQL/Oracle, MyISAM, MongoDB multi-documento, Redis
MULTI, Cassandra oltre la partizione
Autodiagnosi · dovresti saper rispondere senza rileggere
- Per ciascuna lettera di ACID, quale guasto concreto del bonifico previene?
- Perché «la finestra è scoperta» e «lost update» non sono la stessa cosa? Qual è causa e quale effetto?
- Con saldo 100 e due prelievi concorrenti di 10 e 30, come si arriva a un saldo finale di 90?
- Perché una singola
UPDATEsu 10.000 righe è atomica maSELECT+if+UPDATEsu una riga non lo è?
3 · Come funziona un database
Un database è una macchina costruita attorno a un solo problema: la memoria veloce si perde, quella durevole è lenta. Ogni componente di questo capitolo nasce da un tentativo di tenere insieme le due cose.
Il problema di partenza: due memorie, due difetti
Un computer ha due posti dove mettere i dati, e nessuno dei due va bene da solo. La tabella li confronta su tre aspetti; il terzo, la differenza tra scrivere in posizioni sparse e scrivere in fila, sembra il meno importante ed è invece quello attorno a cui è costruito tutto il capitolo, per un motivo spiegato subito dopo.
| RAM | Disco (SSD o rotante) | |
|---|---|---|
| Velocità | nanosecondi | da decine di microsecondi a millisecondi: mille o centomila volte più lento |
| Se salta la corrente | si svuota: tutto perso | resta |
| Scrivere in posizioni sparse (ed è così che un database modifica i dati: vedi sotto) | costa come scrivere in fila | costa molto di più che scrivere in fila |
Perché al database importa la differenza tra sparso e in fila
La terza riga della tabella non è una curiosità sull'hardware: è il cuore del problema. Guarda che cosa fa un database in un secondo qualunque di lavoro. Arrivano tre transazioni: una aggiorna il conto 1, una il conto 8.204, una il conto 51.377. Le tre righe vivono in tre pagine diverse, che sul disco stanno in tre punti lontani tra loro, perché i file dati sono ordinati per come le righe sono state inserite nel tempo, non per come verranno modificate.
tre UPDATE in un secondo · dove finirebbero sul disco, se scrivessi «al loro posto»
UPDATE conti … WHERE id = 1→pagina 17UPDATE conti … WHERE id = 8204→pagina 4.913UPDATE conti … WHERE id = 51377→pagina 30.201Tre punti lontani del file dati: tre scritture sparse, per forza. Il database non sceglie dove cadono le modifiche: lo decidono le query degli utenti.
Ecco il punto: le modifiche di un database sono sparse per natura. Non c'è modo di ordinare le richieste degli utenti perché tocchino pagine vicine. Se il database scrivesse ogni modifica nel punto del disco in cui la riga vive, farebbe per forza una scrittura sparsa a ogni UPDATE, cioè l'operazione più costosa che il disco conosca: su un disco rotante, un centinaio di transazioni al secondo e non una di più.
La distinzione sparso/in fila conta perché è la via d'uscita. Se invece di scrivere le tre pagine al loro posto il database annota le tre modifiche una dopo l'altra in fondo a un unico file, le tre scritture sparse diventano tre righe consecutive: in fila. Le pagine, al loro posto, le scriverà più tardi, quando potrà accorparne molte. Questo file è il log di cui parla tutto il resto del capitolo, e il motivo per cui esiste è esattamente la terza riga della tabella. Per capire quanto sia grande il guadagno, però, bisogna vedere da vicino perché il disco fa pagare tanto le scritture sparse.
Senza log scrivo al posto giusto
Con log annoto in fondo, sistemo dopo
una dietro l'altra, in fila: un solo posizionamento. Le tre pagine verranno scritte più tardi, tutte insieme.
Le memorie da vicino
La tabella usa tre parole che vanno chiarite prima di proseguire: che cosa rende la RAM volatile, che cosa rende il disco lento, e che cosa significhi scrivere «in fila» oppure «in ordine sparso». Sono dettagli fisici, ma tutto il progetto di un database è una risposta a questi dettagli.
Non esistono memorie «sequenziali» e memorie «casuali». In fila (sequenziale) e in ordine sparso (casuale) descrivono l'ordine in cui tu chiedi le posizioni: in fila significa «la posizione 42, poi la 43, poi la 44»; sparso significa «la 42, poi la 9.317, poi la 1.204». Qualunque memoria, RAM, SSD o disco rotante, può essere usata in entrambi i modi. Ciò che cambia da un dispositivo all'altro è quanto costa in più il modo sparso rispetto a quello in fila: nella RAM quasi nulla, nell'SSD un po' (soprattutto in scrittura), nel disco rotante cento o mille volte. È per questo che il database si sforza di scrivere il log in fila: non perché il disco «sia sequenziale», ma perché è l'unico modo di usarlo senza pagare la penalità.
Stesso schema di accesso, tre dispositivi diversi
| Dispositivo | In fila 42, 43, 44, 45, 46 | Sparso 42, 9.317, 1.204, 77.560, 500 | Confronto | Penalità |
|---|---|---|---|---|
| RAM | ~ 0,5 µs | ~ 0,5 µs | ≈ nessuna | |
| SSD (scrittura) | ~ 50 µs | ~ 100–250 µs sotto carico | ≈ 2–5× | |
| Disco rotante | ~ 5 ms | ~ 25–75 ms | ≈ 5–15× su 5 pagine, e cresce con il numero di pagine |
RAM: nessuna penalità per l'accesso sparso, ma volatile
La RAM (Random Access Memory) è fatta di miliardi di celle che tengono un bit come carica elettrica in un minuscolo condensatore. Si legge e si scrive per indirizzo, direttamente: chiedere il byte 4.000.000 costa quanto chiedere il byte 7, decine di nanosecondi in entrambi i casi (è il senso di random access: qualunque posizione, stesso costo; in fila o sparso, per la RAM non fa differenza). Il rovescio è nel meccanismo: la carica si disperde in decine di millisecondi e va rinfrescata continuamente (ogni 64 ms); senza corrente, sparisce. Quindi tutto ciò che sta solo in RAM non esiste più dopo un blackout.
Disco rotante (HDD): la penalità massima, per via della testina
Un disco rigido (HDD, Hard Disk Drive) memorizza i bit come magnetizzazione di piccole aree su piatti metallici che ruotano a 5.400–15.000 giri al minuto. Una testina montata su un braccio si sposta radialmente per raggiungere la traccia giusta e aspetta che il settore giusto le passi sotto. La magnetizzazione resta senza corrente: è durevole. Ma ogni accesso paga due attese meccaniche:
Disco rotante: piatti concentrici e testina sul braccio.
- Seek: il braccio si sposta sulla traccia~ 3–10 ms
- Rotazione: aspetta che il settore arrivi~ 2–4 ms
- Trasferimento: legge/scrive i byte in fila~ 0,05 ms per 8 KB
Ecco l'origine della distinzione che il capitolo usa di continuo. Scrivere in fila (in sequenza, sequenziale) significa scrivere nel settore immediatamente successivo a quello appena scritto: la testina è già lì, il piatto sta già passando, si paga solo il trasferimento. Scrivere in ordine sparso (casuale, random) significa che ogni scrittura è in un punto diverso del disco: ogni volta si paga seek e rotazione. La differenza è di due o tre ordini di grandezza: un disco rotante fa circa 100–200 scritture sparse al secondo, ma trasferisce 100–200 MB al secondo se scrive in fila, cioè decine di migliaia di pagine da 8 KB.
In fila sequenziale
un solo seek, poi scorre
In ordine sparso casuale
seek + rotazione a ogni scrittura
SSD: penalità piccola in lettura, non trascurabile in scrittura
Un SSD (Solid State Drive) memorizza i bit come carica intrappolata in celle di memoria flash NAND, che la conservano senza corrente: è durevole come il disco rotante, e senza testine da spostare un accesso sparso costa decine di microsecondi invece di millisecondi, cento volte meglio. Sembrerebbe che la distinzione fila/sparso non conti più. Conta meno, ma non sparisce, per una peculiarità fisica della flash:
- Si legge e si scrive per pagine (4–16 KB), ma una pagina già scritta non si può sovrascrivere: prima va cancellata.
- Si cancella solo per blocchi, gruppi di centinaia di pagine (da qualche megabyte a qualche decina): è la cancellazione di blocco. Non esiste il modo di azzerare una pagina sola.
Quindi il controller non riscrive la pagina al suo posto: scrive la nuova versione in una pagina libera altrove e marca la vecchia come non valida; più tardi, per liberare un blocco (garbage collection), dovrà copiare altrove le pagine ancora valide. Nei casi peggiori, per modificare 8 KB in mezzo a un blocco già pieno, deve copiare altrove le pagine ancora valide del blocco, cancellare l'intero blocco e riscrivere. Una piccola scrittura sparsa può costare fisicamente la riscrittura di megabyte (write amplification), e ogni blocco sopporta un numero limitato di cancellazioni prima di usurarsi. I controller mascherano tutto questo scrivendo sempre in blocchi «freschi» e riordinando in background (garbage collection, wear leveling), ma il risultato è che anche su SSD molte piccole scritture sparse costano più di una scrittura lunga in fila, e degradano il dispositivo più in fretta.
blocco NAND es. 128 pagine da 8 KB = 1 MB
Voglio modificare solo p1, ma p1 non è sovrascrivibile.
caso tipico Scrivo altrove
Scrivo la nuova p1 in una pagina libera di un altro blocco e marco la vecchia come non valida.
caso peggiore · quando i blocchi liberi finiscono Garbage collection
In media si scrivono fisicamente da 1 a qualche byte per ogni byte logico (write amplification). Con scritture in fila, invece, riempio blocchi nuovi pagina dopo pagina e la pulizia costa pochissimo.
I numeri, fianco a fianco
| Operazione | RAM | SSD | Disco rotante |
|---|---|---|---|
| Un accesso in posizione arbitraria | ~ 100 ns | ~ 50–100 µs | ~ 5–15 ms |
| Rapporto rispetto alla RAM | 1 | ~ 1.000× | ~ 100.000× |
| Scritture sparse al secondo (ordine di grandezza) | milioni | da decine a centinaia di migliaia (con usura) | 100–200 |
| Scrittura in fila | — | 0,5–7 GB/s | 100–200 MB/s |
| Senza corrente | si svuota | conserva | conserva |
Due regole che valgono per entrambi i tipi di disco, anche se per motivi fisici diversi (testina da spostare; blocchi da cancellare): scrivere in fila è molto più economico che scrivere sparso, e meno scritture fisiche si fanno, meglio è. Tutto il resto del capitolo è costruito su queste due regole: il log si scrive in fila, le pagine dati si scrivono il più tardi e il meno spesso possibile.
La promessa di durabilità del capitolo 2 dice: quando rispondo «fatto», il dato deve sopravvivere a un blackout. Quindi il dato deve finire su disco prima che io risponda. Ma se ogni UPDATE aspettasse che la sua pagina venga scritta sul disco, nel punto sparso in cui quella pagina vive, il database farebbe poche centinaia di operazioni al secondo. Vediamo i due progetti ingenui, e perché falliscono entrambi.
Progetto ingenuo 1 · scrivi subito sul disco
A ogni UPDATE cerchi la pagina sul disco, la modifichi, la riscrivi al suo posto. Corretto ma lentissimo: ogni modifica è una scrittura sparsa. E c'è un problema peggiore: se la corrente salta a metà della riscrittura di una pagina da 8 KB, sul disco resta una pagina mezza vecchia e mezza nuova, che non corrisponde a nessuno stato reale.
Progetto ingenuo 2 · lavora in RAM, scrivi dopo
Tieni le pagine in RAM, le modifichi lì, le scrivi sul disco ogni tanto. Velocissimo ma bugiardo: se rispondi «fatto» e poi salta la corrente prima della scrittura, il dato non è mai esistito. Hai violato la D.
La soluzione: prendere nota prima di fare
Pensa a un cameriere. Non corre in cucina a ogni parola del cliente: scrive l'ordine sul blocchetto, riga dopo riga, e la cucina lo eseguirà con calma. Se il cameriere viene distratto, l'ordine è comunque salvo sul blocchetto: basta rileggerlo. Il blocchetto è veloce da scrivere (una riga in fondo, sempre nello stesso posto) e non si perde.
Il database fa esattamente questo. Tiene un file speciale, il log o WAL (write-ahead log, «log scritto in anticipo»), in cui annota ogni modifica prima di applicarla: «riga 1: saldo era 100, diventa 70». Il log ha due proprietà che risolvono i due difetti dei progetti ingenui:
- Si scrive solo in fondo (append-only). Non importa quale pagina stai modificando: la nota va sempre in coda al file. È una scrittura sequenziale, l'operazione più economica che un disco sappia fare. Ecco risolto il problema della lentezza.
- Ogni nota è piccola e autosufficiente. Se la corrente salta a metà di una nota, la nota incompleta si riconosce, grazie a un checksum (spiegato nel riquadro qui sotto), e si scarta; tutte quelle prima sono intere. Resta un secondo problema: la pagina dati scritta a metà durante la scrittura in background, che il redo non può riparare confrontando gli LSN. PostgreSQL lo risolve salvando nel WAL l'immagine completa della pagina alla prima modifica dopo ogni checkpoint (
full_page_writes), InnoDB con il doublewrite buffer.
Ogni nota ha un indirizzo: l'LSN. Prima di andare avanti serve un nome che tornerà in continuazione. Ogni nota scritta nel log riceve un LSN (Log Sequence Number, «numero di sequenza nel log»). Non è un contatore assegnato a mano: è la posizione della nota dentro il log, cioè a quanti byte dall'inizio del log la nota comincia. In PostgreSQL si legge nella forma 0/16B3D50 (due numeri esadecimali, un unico indirizzo a 64 bit che cresce dal primo giorno di vita del database); InnoDB usa un contatore di byte analogo. Da questa definizione discendono le due proprietà che lo rendono utile:
- Ordina. Il log si scrive solo in fondo, quindi una nota con LSN più alto è stata scritta dopo: confrontare due LSN significa sapere chi è venuto prima, senza orologi.
- Localizza. Dato un LSN, il database sa esattamente dove leggere nel log, come un numero di pagina in un libro. È per questo che in tanti posti si annota «un LSN» invece di «una nota»: sono la stessa cosa.
Lo ritroverai scritto in tre posti: nell'intestazione di ogni nota del log, nell'intestazione di ogni pagina dati (il campo «LSN» dell'header visto nel capitolo 1, il cui ruolo è spiegato più sotto) e in un piccolo file di controllo che indica da dove ripartire dopo un crash. Nel resto del documento gli LSN sono numeri piccoli e comodi (4180, 4195, 4200…) al posto degli indirizzi veri, ma la logica è identica: più alto vuol dire più recente.
Un checksum («somma di controllo») è un numero corto, tipicamente 32 bit, calcolato da tutti i byte di un blocco di dati con una formula fissa, e scritto accanto al blocco. Chi rilegge il blocco rifà lo stesso calcolo e confronta: se il numero coincide, i byte sono quelli originali; se differisce, anche per un solo bit, il blocco è danneggiato o incompleto. La formula usata nei log dei database è il CRC (Cyclic Redundancy Check, in PostgreSQL CRC-32C): non è una semplice somma, è costruita in modo che quasi qualunque alterazione, incluso un pezzo mancante in coda, cambi il risultato.
in scrittura la nota nasce in RAM, il CRC si calcola lì, poi va in fondo al log su DISCO
in lettura al riavvio, il log su DISCO viene riletto nota per nota
tutto questo riguarda solo il file di log, non le pagine dati. Se la corrente salta a metà del passo 3, nel log su disco resta un'intestazione con un CRC che non corrisponde al contenuto troncato: la nota si scarta, e il redo si ferma all'ultima nota intera
Tre dettagli che rendono il meccanismo affidabile. Primo, il CRC copre anche l'intestazione (lunghezza e LSN inclusi), così una nota troncata non può fingersi più corta di quanto fosse. Secondo, il log è append-only: le note sono scritte in ordine, quindi la prima nota non valida segna con certezza la fine di ciò che è arrivato su disco, e non c'è bisogno di cercare oltre. Terzo, il commit è l'fsync del log (passo 5 della traccia): se il database ha risposto «fatto», la nota di commit era intera e su disco prima della risposta, quindi una nota troncata appartiene sempre a una transazione a cui nessuno aveva ancora promesso nulla. Il checksum risolve così il problema della «pagina mezza scritta» dal lato del log; per le pagine dati vale il rimedio già citato nel punto precedente (full_page_writes, doublewrite buffer).
Con il log in mano, il progetto diventa: le pagine si modificano in RAM (come nel progetto 2, veloce), ma una pagina sporca non può mai arrivare sul file dati prima che le note che la riguardano siano già sul disco nel log (write-ahead, «prima il log, poi i dati»). Al COMMIT non si scrivono le pagine: si forza sul disco il log. Da quel momento, qualunque cosa succeda, le note ci sono, e da esse le pagine si possono ricostruire.
| Momento | RAM · veloce, volatile | Disco · lento, durevole |
|---|---|---|
UPDATE | 1. nota nel log 2. modifica la pagina in RAM sporca | log: «riga 1: 100 → 70» sequenziale |
COMMIT | 3. fsync del log, poi risponde «fatto» all'applicazione | la nota è davvero su disco durevole |
| più tardi in background | 4. la pagina sporca viene scritta al suo posto | file dati, pagina 17 sparsa, ma rimandabile |
Da questo schema discendono direttamente i componenti che si trovano in ogni database relazionale. Ora hanno un nome perché hanno già un motivo.
I componenti, ognuno con il suo perché
| Componente | Che cos'è | Quale problema risolve |
|---|---|---|
| Buffer pool | L'area di RAM in cui il database tiene le pagine su cui sta lavorando. Una pagina modificata in RAM e non ancora scritta su disco si dice sporca (dirty) | Leggere e modificare in RAM invece che su disco: velocità |
| WAL (write-ahead log) | Il file append-only in cui ogni modifica viene annotata prima di essere applicata: valore vecchio e valore nuovo | Rendere durevole l'intenzione con una scrittura sequenziale, invece del risultato con una scrittura sparsa |
fsync | La chiamata al sistema operativo che dice «non tornare finché questi byte non sono fisicamente sul disco». Il capitolo 8 la smonta pezzo per pezzo | Senza, anche il log starebbe in una cache del sistema operativo: veloce e bugiarda come la RAM |
| Checksum / CRC (Cyclic Redundancy Check) | Numero corto calcolato da tutti i byte di una nota del log e scritto accanto a essa; al riavvio viene ricalcolato: se non coincide la nota è troncata o corrotta e il redo si ferma lì | |
| Checkpoint | L'operazione periodica che scrive su disco le pagine sporche accumulate e annota nel log fin dove si è arrivati | Impedire al log di crescere all'infinito e accorciare il riavvio (sezione dedicata più sotto) |
| Lock manager | Il registro di chi ha prenotato cosa (i lock del capitolo 1) | La convivenza tra transazioni, non la durabilità: è l'unico componente di natura diversa, e lo riapriremo nel capitolo 7 |
fsync del log, non delle pagine datiÈ la conseguenza più importante di tutto lo schema, e spiega una cosa che altrimenti sembra magia: perché committare è economico anche su una transazione che ha modificato diecimila righe. Le diecimila pagine restano sporche in RAM; su disco va solo il log, in un'unica scrittura sequenziale. Hai reso durevole l'intenzione, non ancora il risultato. Il risultato arriverà, ma può aspettare.
La traccia completa di una UPDATE, passo per passo
Seguiamo l'esempio del capitolo 1: UPDATE conti SET saldo = 70 WHERE id = 1, e la riga 1 vive nella pagina 17. A ogni passo guardiamo lo stato dei tre posti in cui un dato può trovarsi: la RAM del buffer pool, il file di log su disco e il file dati su disco. Prima dell'UPDATE la situazione è questa:
Prima dell'UPDATE
RAM · buffer pool volatile
Disco · log (WAL) durevole
Disco · file dati durevole
Passi 1 e 2 · caricamento e lock. Prima la pagina, poi la riga: il database cerca la pagina 17 nel buffer pool; non c'è, quindi la legge dal file dati e ne mette una copia in RAM. Ora esistono due copie identiche della pagina. Solo a questo punto, dentro la copia in RAM, trova la riga 1, verifica che nessun'altra transazione la stia modificando e la blocca per noi (in PostgreSQL il lock di riga è scritto nell'intestazione della riga stessa, quindi deve stare in una pagina già in memoria; InnoDB lo tiene in una struttura separata in RAM).
Passi 1–2 · copia identica al disco
RAM · buffer pool volatile
Disco · log (WAL) durevole
Disco · file dati durevole
Passo 3 · la nota nel log. Prima di toccare la pagina, il database scrive la nota «riga 1: saldo era 100, diventa 70». La nota riceve il numero progressivo successivo, 4180. Per ora sta in un piccolo buffer di RAM riservato al log: sul disco non è ancora arrivata.
Passo 3 · la nota nel buffer del log
RAM · buffer pool volatile
Disco · log (WAL) durevole
Disco · file dati durevole
Passo 4 · la modifica, solo in RAM. La copia della pagina 17 in RAM viene cambiata: saldo 70. La copia sul file dati resta 100. Da questo momento le due copie sono diverse, ed è esattamente questo che si intende con pagina sporca: una pagina in RAM che non coincide più con la sua versione su disco. Il database la segna come tale per ricordarsi che prima o poi andrà riscritta.
Passo 4 · la modifica, solo in RAM
RAM · buffer pool volatile
Disco · log (WAL) durevole
Disco · file dati durevole
Il passo 3 e il passo 4 li esegue lo stesso processo che sta servendo la tua UPDATE, uno dopo l'altro, in modo sincrono, tenendo un lock sulla pagina in RAM per tutta la durata: prepara la modifica, scrive la nota nel buffer del WAL e ne ottiene l'LSN, applica la modifica ai byte della pagina, scrive quell'LSN nell'header, marca la pagina sporca, rilascia il lock. La nota nel WAL non è un comando che qualcun altro deve interpretare: è la registrazione di ciò che quel processo fa con le proprie mani un istante dopo. Viene scritta prima solo perché, se il processo morisse tra il passo 3 e il 4, l'informazione esista comunque. L'idea di un processo che legge il WAL e applica le note alle pagine esiste, ma in due situazioni diverse: il recovery dopo un crash (sezione più sotto) e la replica, dove uno standby riceve il WAL del primario e lo applica in continuazione alle proprie pagine. In funzionamento normale, sul primario, il WAL viene solo scritto, mai riletto.
Passo 5 · COMMIT = la nota va su disco. L'applicazione chiede il commit. Il database non scrive la pagina 17: chiama fsync sul file di log, e aspetta che il disco confermi di aver ricevuto fisicamente la nota 4180. Solo allora risponde «fatto». È il momento in cui la modifica diventa durevole: non perché il saldo 70 sia nel file dati (non lo è), ma perché sul disco esiste una nota che dice come ottenerlo.
Passo 5 · COMMIT
RAM · buffer pool volatile
Disco · log (WAL) durevole
Disco · file dati durevole
L'applicazione riceve «fatto».
Questo è lo stato che sorprende: l'utente ha ricevuto conferma, eppure nel file dati c'è ancora 100. Non è un errore, è il progetto: il file dati è indietro di proposito, perché aggiornarlo ora costerebbe una scrittura sparsa, e il log basta a garantire che il 70 non si perda.
Passo 6 · più tardi, la pagina raggiunge il disco. Secondi o minuti dopo, un processo separato del database (in PostgreSQL soprattutto il checkpointer, aiutato dal background writer, che lavorano mentre le transazioni continuano) prende le pagine sporche accumulate nel buffer pool e le scrive al loro posto nel file dati. Ora le due copie coincidono di nuovo: la pagina è pulita. Quel «più tardi» è la ragione di tutto ciò che segue.
Passo 6 · la pagina raggiunge il disco
RAM · buffer pool volatile
Disco · log (WAL) durevole
Disco · file dati durevole
Passo 7 · il checkpoint, di cui parliamo tra poco, è ciò che ogni tanto forza il passo 6 per tutte le pagine sporche e lo annota nel log. Ecco la traccia completa in forma compatta, la stessa che si trova in ogni manuale:
La traccia completa
- la pagina che contiene la riga 1 viene cercata nel buffer pool se non c'è, viene letta da disco e messa in RAM
- nella pagina in RAM il lock manager verifica che nessun altro tenga la riga 1, e la blocca
- nel log viene scritto: «riga 1: saldo era 100, diventa 70»
- la pagina in RAM viene modificata e marcata «sporca»
- al
COMMITil log viene forzato su disco (fsync) - la pagina sporca finirà su disco più tardi, in background
- periodicamente un checkpoint garantisce che tutto ciò che precede un certo punto del log sia davvero nei file dati
Due dettagli ricorrono in tutto il documento. Al punto 3 il database scrive cosa sta per fare prima di farlo. Al punto 5 rende durevole il log, non i dati. Sono le due scelte che rendono possibili le transazioni — e il punto 7 è ciò che impedisce loro di far esplodere il disco.
E se la transazione viene abortita prima del commit?
Tra il passo 4 e il passo 5 la transazione può finire male in molti modi: un ROLLBACK esplicito, un vincolo violato, un deadlock rilevato, un errore 40001, la connessione che cade, il processo che muore. Due cose vanno capite: che fine fanno le note già scritte nel WAL, e che fine fa la pagina sporca in RAM.
Le note nel WAL restano. Il log è append-only: nulla viene cancellato, nemmeno le note di una transazione abortita, che potrebbero essere già su disco se nel frattempo il commit di un'altra transazione ha forzato l'fsync. L'abort si registra scrivendo un'altra nota, «T88: ABORT», oppure semplicemente non scrivendo mai il COMMIT. Al riavvio il redo rilegge tutto ma applica solo le note delle transazioni di cui trova la nota di commit; le altre le tratta come non avvenute.
La pagina sporca: dipende dal motore, ed è una differenza reale che il resto del capitolo semplifica.
PostgreSQL MVCC: l'UPDATE non sovrascrive, crea una versione
pg_xact); la pagina non viene toccata, resta sporca e andrà su disco così com'è. Nessuno vedrà mai il 70, perché la visibilità chiede «T88 ha committato?». Il VACUUM ripulirà la versione morta più tardiInnoDB / Oracle modifica sul posto + undo log
in entrambi i casi la pagina resta sporca; cambia se contiene una versione morta da ripulire (PostgreSQL) o il valore vecchio ripristinato (InnoDB)
Un corollario che spiega una cosa vista nel capitolo 7: in PostgreSQL «disfare» non costa quasi nulla, perché non c'è niente da riscrivere, ma lascia spazzatura nelle pagine (le versioni morte, il bloat). In InnoDB l'abort costa lavoro proporzionale a quanto la transazione aveva scritto, ma le pagine restano pulite.
Che cosa succede se salta la corrente
Un crash cancella tutta la colonna RAM e lascia intatte le due colonne DISCO. Il problema del riavvio è quindi sempre lo stesso: guardando solo log e file dati, rimettere il file dati nello stato che le transazioni committate avevano promesso. Vediamo i tre momenti in cui il crash può arrivare nella traccia sopra.
| Crash arriva… | Sul disco trovo | Che cosa fa il riavvio |
|---|---|---|
| tra il passo 4 e il 5 (modificata in RAM, non committata) | log: la nota 4180 non c'è (era solo in RAM) file dati: saldo=100 | Nulla. La transazione non aveva mai promesso niente e il file dati ha il valore giusto. L'utente non ha ricevuto «fatto», quindi riproverà |
| tra il passo 5 e il 6 (committata, pagina ancora sporca) | log: nota 4180 con COMMIT file dati: saldo=100 | Redo: legge la nota, vede che la transazione era committata, riapplica il valore nuovo alla pagina 17 → saldo=70. La promessa fatta all'utente viene mantenuta |
| caso raro ma previsto: la pagina sporca è stata scritta sul file dati prima del commit (il buffer pool era pieno e ha dovuto fare spazio), poi la transazione non ha committato | log: nota 4180 senza COMMIT file dati: saldo=70 | Undo: legge la nota, vede che la transazione non era committata, ripristina il valore vecchio che la nota conserva → saldo=100. Uno stato mai promesso viene cancellato |
Nel modello classico (ARIES) la stessa nota di log serve a due cose opposte: contiene il valore nuovo (per il redo) e il valore vecchio (per l'undo). È per questo che al passo 3 il database ha scritto entrambi. Il riavvio analizza il log, poi rifà tutte le modifiche registrate, anche quelle non committate («ripete la storia»), e infine annulla quelle delle transazioni rimaste incomplete: questo procedimento si chiama ARIES (Algorithms for Recovery and Isolation Exploiting Semantics, IBM 1992), ed è ancora lo standard di fatto di molti database, InnoDB compreso (che tiene i valori vecchi in un undo log separato, protetto a sua volta dal redo log).
PostgreSQL fa eccezione: il suo WAL è di solo redo. Al riavvio non annulla nulla, perché le versioni scritte da una transazione mai committata restano nella tabella ma risultano invisibili (la transazione è registrata come abortita) e il VACUUM le rimuove più tardi. Il capitolo 5 mostra perché questa doppia mossa è esattamente ciò che realizza l'atomicità.
Laboratorio: stacca la corrente a ogni passo
recovery reale, semplificatoSegui l'UPDATE conti SET saldo = 70 WHERE id = 1 nei tre posti in cui il dato può trovarsi. A ogni passo puoi simulare un crash: la RAM sparisce, i due file su disco restano, e il riavvio deve rimettere le cose a posto.
Un problema del redo: «l'ho già applicata?»
Il redo sembra semplice: rileggi le note delle transazioni committate e riapplica il valore nuovo alla pagina. Ma c'è un dettaglio che lo complica, e nasce ancora dal «più tardi» del passo 6: al momento del crash, ogni pagina sul disco può essere aggiornata fino a una nota diversa, a seconda di quando il processo di scrittura in background è passato a scriverla. Il redo deve quindi decidere, nota per nota, se quella modifica è già dentro la pagina su disco oppure no. Se la applica due volte, o non la applica affatto, il file dati finisce sbagliato.
Costruiamo il caso. Sul conto 1 avvengono tre transazioni committate in sequenza, ognuna con la sua nota nel log:
Log · tutte committate, tutte su disco grazie all'fsync del commit
| LSN | Modifica sulla riga 1 | Causa |
|---|---|---|
| 4180 | saldo 100 → 70 | prelievo di 30 |
| 4195 | saldo 70 → 40 | prelievo di 30 |
| 4200 | saldo 40 → 100 | accredito di 60 |
La pagina 17 è stata scritta sul disco «più tardi, in background», ma quando? Non lo sappiamo: dipende da quando il processo di scrittura è passato. Sul disco possiamo trovare uno qualunque di questi quattro stati:
| Pagina 17 su disco contiene… | Significa che il background writer l'ha scritta… |
|---|---|
| saldo = 100 | (a) prima di tutte e tre le note, oppure (b) dopo tutte e tre (40 → 100 l'ha riportata a 100!) |
| saldo = 70 | dopo la 4180, prima della 4195 |
| saldo = 40 | dopo la 4195, prima della 4200 |
Guarda la prima riga: il valore 100 è compatibile con due storie opposte. Nel caso (a) la pagina è indietro di tre note e il redo deve applicarle tutte; nel caso (b) la pagina è già aggiornata e il redo non deve toccare nulla. Se il redo ragionasse sui valori («la nota 4180 dice 100 → 70, la pagina contiene 100, quindi la 4180 non è ancora applicata: applico»), nel caso (b) porterebbe il saldo a 70, poi a 40, poi di nuovo a 100: per fortuna qui torna giusto, ma solo per coincidenza. Basta che la sequenza sia diversa (per esempio una nota «saldo = saldo + 30», che PostgreSQL non scrive ma altri sistemi sì, o una nota su un'altra colonna della stessa riga) e riapplicare a una pagina già aggiornata produce un valore che non è mai esistito.
Il punto generale è questo: il contenuto di una pagina non dice fino a quale nota è aggiornata. Valori uguali possono nascondere storie diverse, e il redo non può permettersi di indovinare. Serve che la pagina porti con sé, esplicitamente, la risposta alla domanda «a che nota sei arrivata?».
La soluzione: l'LSN scritto nella pagina
Ogni nota del log ha il suo numero progressivo, l'LSN (Log Sequence Number), che cresce sempre e non si riusa mai. La regola in più è una sola: ogni volta che il database applica una nota a una pagina, scrive l'LSN di quella nota nell'intestazione della pagina (l'header visto nel capitolo 1), sovrascrivendo il precedente. La pagina porta così con sé, ovunque vada, in RAM come sul disco, il numero dell'ultima nota che contiene.
Riprendiamo i quattro stati possibili della pagina 17. Sono quattro scenari alternativi, non quattro passi: al momento del crash il disco contiene uno solo di questi stati, a seconda di quando il background writer è passato l'ultima volta. In ogni scenario il redo parte dal valore e dall'LSN che trova nell'header, scorre le tre note del log in ordine e per ciascuna applica la stessa regola: applica se il numero della nota è maggiore di quello nell'header, altrimenti salta. Ogni nota applicata aggiorna anche l'header.
le tre note nel log
Scenario A la pagina non era mai stata scritta dopo le tre note
saldo=100, header.LSN=4150Scenario B la pagina era stata scritta dopo la prima nota
saldo=70, header.LSN=4180Scenario C la pagina era stata scritta dopo la seconda nota
saldo=40, header.LSN=4195Scenario D la pagina era stata scritta dopo tutte e tre le note
saldo=100, header.LSN=4200Confronta lo scenario A con lo scenario D: entrambi partono da saldo=100, ma l'header dice 4150 in un caso e 4200 nell'altro, e il redo si comporta in modo opposto, applicando tutto oppure niente. Senza l'LSN i due scenari sarebbero stati indistinguibili. Con l'LSN, in tutti e quattro il file dati finisce nello stato corretto, quello che le transazioni committate avevano promesso.
Il redo non interpreta più i valori: confronta due numeri. Se l'LSN della nota è maggiore di quello nell'header, la pagina non contiene ancora quella modifica e va applicata; altrimenti la contiene già e si salta. Ed è per questo che, nella traccia del capitolo 1, l'intestazione della pagina conteneva un campo «LSN»: adesso sai a cosa serve.
Idempotente significa che eseguirlo due volte produce lo stesso risultato di eseguirlo una volta (la parola tornerà nella Parte IV). Con il confronto sull'LSN è automatico: se il database crasha di nuovo a metà del riavvio, al terzo avvio ripete il redo dall'inizio, e le pagine che aveva già sistemato hanno ormai un header.LSN alto e vengono saltate. Senza questa proprietà un crash durante il recovery sarebbe irrecuperabile, perché il secondo riavvio non saprebbe che cosa il primo aveva già fatto.
Resta una domanda che a questo punto è legittima: al riavvio, come fa il database a sapere qual è l'ultima nota arrivata su disco, e da quale nota cominciare a confrontare? Non certo rileggendo tutte le pagine. La risposta ha bisogno del checkpoint, quindi arriva subito dopo di esso.
Due problemi del «più tardi»: il checkpoint
Torniamo al passo 6. Le pagine sporche raggiungono il file dati «più tardi», e il log intanto continua a crescere a ogni modifica. Lasciato così, lo schema ha due difetti che si aggravano col tempo:
- Il log non si può mai cancellare. Finché una nota descrive una modifica che potrebbe non essere ancora nel file dati, quella nota serve al redo. Se non sappiamo quali pagine sono state scritte, dobbiamo tenere tutto: il log occuperebbe alla fine l'intero disco.
- Il riavvio dovrebbe rileggere tutto il log dal primo giorno, perché non sa da dove le pagine potrebbero essere indietro. Un database attivo da un anno impiegherebbe ore a ripartire.
La soluzione a entrambi è un'operazione periodica, il checkpoint. Vediamola su un caso concreto, nello stesso modo degli scenari precedenti.
Lo scenario: tre pagine, sei note, nessun checkpoint ancora
In un minuto di lavoro sono arrivate sei transazioni committate su tre conti diversi, che vivono nelle pagine 17, 4.913 e 30.201. Il log le ha registrate in ordine; il background writer non è ancora passato su nessuna pagina. Ecco lo stato dei tre posti:
Sei note, nessun checkpoint ancora
Disco · log (WAL)
RAM · buffer pool sporche
Disco · file dati
Se il crash arrivasse ora, il redo dovrebbe rileggere dalla 4180 (o da prima) e riapplicare tutto; e nessuna di queste sei note può essere cancellata dal log.
Il checkpoint, passo per passo
Poniamo che il checkpoint scatti subito dopo la nota 4210 (prima che la 4215 sia scritta). Esegue tre operazioni in sequenza:
Passo 1 · annota la posizione corrente del log. L'ultima nota è la 4210: il checkpoint si segna «sto per garantire tutto fino alla 4210». Le note che arrivano dopo questo istante (la 4215) non sono coperte da questo checkpoint, e va bene così: le prenderà il prossimo.
Passo 2 · forza sul file dati tutte le pagine sporche. È il passo 6 della traccia, eseguito per tutte le pagine insieme: le tre pagine in RAM vengono scritte al loro posto nel file dati, ognuna con l'header aggiornato. Costa tre scritture sparse, ma sono tre, non cinque: la pagina 17 aveva assorbito due modifiche e la 30.201 altre due, e ciascuna viene scritta una volta sola (è il guadagno spiegato nel callout più sotto).
| Pagina | In RAM (durante il passo 2) | File dati, dopo il passo 2 | |
|---|---|---|---|
| 17 | saldo=40 · hdr=4195 | → | saldo=40 · hdr=4195 ✔ |
| 4.913 | saldo=450 · hdr=4185 | → | saldo=450 · hdr=4185 ✔ |
| 30.201 | saldo=20 · hdr=4210 | → | saldo=20 · hdr=4210 ✔ |
Ora ogni pagina su disco è aggiornata almeno fino alla 4210: tutto ciò che il log dice prima della 4210 è dentro i file dati.
Passo 3 · scrive nel log il record di checkpoint. Una nota speciale che dice: «tutto ciò che precede l'LSN 4210 è nei file dati». Da questo momento il database sa due cose che prima non sapeva.
Disco · log (WAL), dopo il checkpoint
Riciclabili: il loro contenuto è già nei file dati, nessun redo avrà mai bisogno di rileggerle.
Prima cosa: il log fino alla 4210 può essere riciclato (salvo che serva a repliche o all'archiviazione per i backup), perché non serve più a nessun recovery. Nella realtà le transazioni continuano mentre il checkpoint lavora, quindi il recovery riparte dalla posizione annotata all'inizio del checkpoint (il redo point), non dal record di fine. Il WAL smette di crescere all'infinito: contiene solo ciò che è successo dall'ultimo checkpoint in qua. Seconda cosa: il prossimo recovery partirà dalla 4211, non dall'inizio dei tempi.
Ora il crash: che cosa fa il recovery
Dopo il checkpoint arriva la nota 4215 (pagina 17: 40 → 100), committata e quindi su disco nel log. La pagina 17 in RAM diventa di nuovo sporca (saldo=100, header=4215), ma il background writer non ha ancora avuto tempo di scriverla. Salta la corrente.
Al riavvio, sul disco
Log al riavvio
File dati al riavvio
Il recovery
- cerca l'ultimo record di checkpoint → 4211, che dice «tutto prima di 4210 è a posto»
- ignora tutto ciò che precede: non lo legge nemmeno
- riparte dalla 4215: 4215 > 4195 (header della pagina 17) → applica → saldo=100, hdr=4215
- fine. Una nota letta invece di sei.
Senza il checkpoint il recovery avrebbe dovuto rileggere dalla 4180 e confrontare sei note con gli header (saltandone cinque grazie all'LSN, ma leggendole tutte); e il log avrebbe dovuto conservarle tutte per sempre. Con il checkpoint entrambi i problemi si chiudono nello stesso istante. Nella metafora del cameriere: ogni tanto controlla che la cucina abbia eseguito tutto fino a una certa riga del blocchetto, e strappa le pagine precedenti.
Il checkpoint dice da dove ripartire (dalla 4211); l'LSN nell'header dice, nota per nota, se applicare o saltare. Servono tutti e due, perché il checkpoint garantisce che tutto prima della sua posizione sia su disco, ma non dice nulla sulle pagine modificate dopo: alcune di quelle potrebbero essere state scritte dal background writer tra il checkpoint e il crash, altre no, e a distinguerle è ancora il confronto sull'LSN. Il checkpoint restringe il lavoro; l'LSN lo rende corretto.
Se il passo 6 è così importante, perché non farlo subito, al commit, invece di aspettare il checkpoint? Perché le scritture sul log sono sequenziali: la testina non si sposta, ed è l'operazione più economica che un disco sappia fare. Le scritture sui file dati sono sparse: una pagina qui, una là. Rimandarle significa poterle accorpare: una pagina modificata cinquanta volte tra due checkpoint viene scritta su disco una volta sola, con l'ultimo valore. Il database non sta rimandando per pigrizia — sta convertendo cinquanta scritture casuali in una.
| Log (WAL) | File dati | |
|---|---|---|
| Tipo di scrittura | Sequenziale, append-only | Sparsa, in posizioni arbitrarie |
| Quando avviene | A ogni modifica, forzata al COMMIT | Differita, in blocco al checkpoint |
| Se lo perdi | Perdi transazioni già confermate all'utente | Ricostruibile solo da un backup più il WAL archiviato da quel momento (il log più vecchio dell'ultimo checkpoint viene riciclato) |
Checkpoint frequenti: riavvio rapido dopo un crash (poco log da rigiocare), ma picchi di I/O che rallentano tutto il resto mentre il passo 2 scrive tutte le pagine sporche in una volta. Checkpoint radi: meno I/O in regime, ma un riavvio che può durare minuti. In PostgreSQL si governa con checkpoint_timeout e max_wal_size, mentre checkpoint_completion_target distribuisce la scrittura nel tempo invece di concentrarla in una raffica. Da qui due fenomeni riconoscibili: un database appena riavviato che è lento (sta rigiocando il log) e picchi di latenza periodici senza alcun cambiamento nel traffico (è il checkpointer).
Dopo il crash: come si trova l'ultima nota scritta?
Mettiamo insieme LSN e checkpoint per rispondere alla domanda lasciata aperta. Il riavvio ha bisogno di due estremi: da dove cominciare a rileggere il log e fino a dove il log è arrivato prima del crash. Il sospetto naturale è che debba scorrere tutte le pagine del file dati e cercare l'header con l'LSN più alto. Non lo fa, e per due motivi. Il primo è il costo: un database da un terabyte ha oltre cento milioni di pagine, e leggerle tutte in ordine sparso richiederebbe ore. Il secondo è che non servirebbe: l'LSN più alto che si trova nelle pagine dice fin dove è arrivato il background writer, non fin dove è arrivato il log, e le note che il redo cerca sono proprio quelle che nelle pagine non ci sono ancora. Le pagine dati, al riavvio, non si scansionano mai. L'unica cosa che si legge in sequenza è il log, ed è fatto apposta.
Da dove: il file di controllo. Accanto al log il database tiene un file minuscolo (in PostgreSQL pg_control, poche centinaia di byte, aggiornato a ogni checkpoint; InnoDB scrive la stessa informazione nell'intestazione del redo log). Contiene l'LSN dell'ultimo record di checkpoint completato e, dentro quel record, il redo point: la posizione da cui il recovery deve ripartire perché tutto ciò che precede è garantito nei file dati. Nel nostro esempio il file di controllo dice «ultimo checkpoint: 4211, redo point 4210». Il riavvio legge quel file, salta all'LSN indicato (è un indirizzo: la proprietà «localizza») e comincia da lì.
Fino a dove: la prima nota che non regge. Da quel punto il database legge il log in avanti, nota per nota, con il controllo già visto: lunghezza dall'intestazione, CRC ricalcolato sul contenuto. Finché i controlli passano, la nota è valida e viene messa in coda per il redo. Alla prima nota che non regge (CRC diverso, byte insufficienti, oppure spazio vuoto) il log finisce lì: l'ultima nota valida è l'ultimo LSN scritto. Non c'è bisogno di cercare oltre, perché il log è append-only: dopo un buco non può esserci nulla di buono.
Il riavvio, sul disco · l'unica lettura sequenziale è quella del log, dal redo point in avanti
File di controllo pg_control · poche centinaia di byte
Log (WAL) letto in avanti da 4210
risultato: l'ultimo LSN scritto è 4222; le note da rigiocare sono due; il tutto senza aver aperto una sola pagina dati
Solo adesso entrano in gioco le pagine. Ma come sa il redo quale pagina aprire? Non lo cerca: lo legge nella nota. Il WAL è un log fisico (PostgreSQL lo chiama «fisiologico»: fisico nell'indirizzo, logico nel contenuto della modifica), e ogni nota porta scritto dentro di sé dove avviene la modifica. In PostgreSQL è una terna: il file della tabella (relfilenode), il numero del blocco, cioè della pagina, e l'offset della riga dentro la pagina; InnoDB usa tablespace, numero di pagina e offset. La nota 4215 completa suona quindi così: «file 16384 · blocco 17 · riga 3 · saldo 40 → 100». Chi l'ha scritta poteva farlo senza sforzo: la transazione stava già lavorando su quella pagina in RAM, quindi l'indirizzo lo conosceva. Al riavvio il redo legge l'indirizzo e carica esattamente quella pagina dal file dati, senza indici né ricerche.
le due informazioni viaggiano in due posti diversi
nella nota del log «dove» e «cosa»
nell'header della pagina 17 «fino a dove»
la nota risponde a «quale pagina e che modifica»; l'header della pagina risponde a «questa modifica c'è già?». L'LSN nella pagina serve solo al confronto, mai a trovare la pagina
A questo punto il ciclo del redo è completo: legge la nota → l'indirizzo nella nota dice quale pagina → carica quella pagina → confronta il suo header.LSN con l'LSN della nota → applica o salta, come negli scenari A–D. Nell'esempio tocca due pagine, la 17 e la 4.913, su milioni. Il costo del riavvio è quindi proporzionale a quanto log si è accumulato dall'ultimo checkpoint, non alla dimensione del database: è la ragione per cui la frequenza dei checkpoint (il callout qui sopra) governa il tempo di ripartenza.
Due dettagli che rendono la ricerca della fine affidabile in pratica.
Primo, PostgreSQL non allunga il log all'infinito: lo tiene in file da 16 MB che, una volta riciclabili, vengono riutilizzati rinominandoli, quindi dopo l'ultima nota vera può esserci il contenuto vecchio di un file precedente, con CRC perfettamente validi. Per non scambiarlo per log buono, ogni blocco di 8 KB del WAL porta nella propria intestazione l'LSN che dovrebbe avere in quella posizione, e ogni nota porta l'LSN della nota precedente: se uno dei due non coincide, quello che segue è rimasuglio, e il log finisce lì.
Secondo, la posizione «corrente» del log, quella a cui il database appende mentre lavora, vive in RAM e il crash la cancella: viene ricostruita proprio da questa scansione, e le nuove note dopo il riavvio riprendono dall'LSN successivo all'ultima nota valida (qui dopo la 4222). Il pezzo di 4230 mai completato viene sovrascritto: apparteneva a una transazione a cui nessuno aveva ancora risposto «fatto».
Il riavvio legge un file di poche centinaia di byte per sapere da dove partire, poi scorre il log in avanti fino alla prima nota che non supera i controlli: quella prima è l'ultimo LSN scritto. Le pagine dati vengono aperte solo se una nota le nomina. Nessuna scansione del disco, mai.
Il componente che non abbiamo ancora aperto
Torniamo alla traccia. Il punto 2 diceva «il lock manager verifica che nessun altro tenga la riga 1», e l'abbiamo lasciato cadere lì. È l'unico blocco della mappa che non riguarda la durabilità ma la convivenza: cosa succede quando due transazioni vogliono la stessa riga nello stesso istante.
Non è una nota a margine dell'architettura. La strategia che un database sceglie qui determina la forma fisica dei dati sul disco: nei database moderni una riga non è una riga, è una catena di versioni. Le famiglie sono due, lock (2PL) e versioni (MVCC), e le apriremo nel capitolo 7, dedicato all'isolamento, perché è lì che la scelta produce le sue conseguenze visibili. Prima però conviene mettere in fila le quattro promesse a cui tutta questa architettura serve.
- Database e durabilità
- nasce da due memorie con un difetto
- RAM veloce ma volatile
- disco durevole ma lento
- sul disco costa di più scrivere sparso che in fila
- si risolve con Log (WAL)
- si scrive solo in fondo, prima delle pagine
- ogni nota ha un LSN ordina e localizza
- una nota incompleta si riconosce con il checksum
- il commit è fsync del solo log
- le pagine restano sporche nel buffer pool
- arrivano su disco più tardi, accorpate
- tiene il log corto con Checkpoint
- permette di riciclare il log vecchio
- accorcia il riavvio
- frequente o raro picchi di I/O contro riavvio lento
- dopo un crash c'è Recovery
- parte dal file di controllo
- rifà e annulla redo e undo capitolo 5
- decide con LSN della nota contro LSN della pagina
- nasce da due memorie con un difetto
In sintesi · capitolo 3
Sei partito da due memorie con un difetto ciascuna (RAM veloce ma volatile, disco durevole ma lento, pessimo nelle scritture sparse) e hai aggiunto un solo trucco: prendere nota nel log, in fondo e in sequenza, prima di modificare la pagina in RAM. Da lì viene tutto il resto: il buffer pool perché le pagine possono restare in RAM, l'fsync del solo log come definizione stessa di commit, redo e undo perché dal log si ricostruisce qualunque cosa, l'LSN perché il redo deve sapere a che punto è ogni pagina, il checkpoint perché il log deve poter essere strappato. Ogni comportamento sorprendente dei capitoli successivi, dal commit veloce alla transazione lunga che gonfia il database, discende da questo schema.
Concetti chiave
- WAL (write-ahead log) — file append-only in cui ogni modifica è annotata, con valore vecchio e nuovo, prima di essere applicata
- Buffer pool e pagina sporca — le pagine si modificano in RAM; una pagina è sporca quando la copia in RAM è diversa da quella su disco
- Commit = fsync del log — al commit va su disco la nota, non la pagina: durevole è l'intenzione, il risultato arriva dopo
- Redo / undo — al riavvio: riapplica il valore nuovo di ciò che era committato, ripristina il valore vecchio di ciò che non lo era (ARIES)
- LSN (Log Sequence Number) — l'indirizzo della nota nel log (posizione in byte), quindi ordina e localizza; scritto anche nell'header della pagina: confrontandoli il redo sa se una modifica è già dentro, e diventa idempotente
- Log fisico — ogni nota porta l'indirizzo della modifica (file, pagina, offset della riga): al riavvio il redo sa quale pagina caricare leggendo la nota, non cercando; l'LSN nella pagina serve solo a decidere se applicare
- Checkpoint — scrive tutte le pagine sporche e annota nel log fin dove si è arrivati: da lì il log si ricicla e il riavvio riparte
- Fine del log dopo un crash — il file di controllo dice da dove partire (redo point), il log letto in avanti fino alla prima nota con CRC non valido dice dove finisce; le pagine dati non si scansionano mai
- Perché rimandare conviene — cinquanta modifiche alla stessa pagina tra due checkpoint diventano una sola scrittura sparsa
Autodiagnosi · dovresti saper rispondere senza rileggere
- Subito dopo il
COMMITdisaldo = 70, che valore c'è nel file dati su disco? Perché non è un problema? - Salta la corrente dopo il commit ma prima che la pagina sia scritta: che cosa trova il riavvio su disco e che cosa fa?
- Perché il redo non può fidarsi dei valori e ha bisogno dell'LSN?
- Quali due problemi risolve il checkpoint, e che cosa succederebbe con checkpoint troppo frequenti o troppo radi?
- Dopo un crash, come fa il database a sapere qual è l'ultima nota arrivata su disco, senza leggere le pagine dati?
Parte II
ACID, una lettera per volta
Ogni lettera con il suo scenario concreto, il meccanismo che la realizza e il modo tipico in cui viene fraintesa.
4 · Le quattro promesse
Il capitolo 2 ha mostrato da quali guasti nascono le quattro promesse. Qui, prima di prenderle una per una, il meccanismo che realizza ciascuna.
| Lettera | Promessa | Meccanismo che la realizza |
|---|---|---|
| Atomicity | Tutto o niente | Undo log (InnoDB, Oracle) oppure versioni multiple più stato di commit (PostgreSQL): il DB sa sempre come ignorare o disfare le scritture non committate |
| Consistency | Da uno stato valido a un altro stato valido | Constraint del DB più logica applicativa. È l'unica lettera che non dipende solo dal database |
| Isolation | Come se le transazioni fossero eseguite in serie | Lock (2PL, two-phase locking) oppure MVCC (multiversion concurrency control): i due meccanismi sono spiegati nel capitolo 7 |
| Durability | Dopo il commit, resiste al crash | Write-ahead log + fsync |
La lettera aggiunta da Härder e Reuter è la I: Gray aveva nominato atomicità, consistenza e durabilità, ma non l'isolamento come proprietà separata. La C c'era fin dall'inizio — resta però la lettera diversa dalle altre tre, e vale la pena capire perché.
I quattro capitoli che seguono affrontano una lettera ciascuno.
Vale la pena anticipare la conclusione, perché cambia il modo di leggerli: A, I e D sono proprietà del meccanismo — il database le garantisce senza sapere nulla di cosa contengano le tue tabelle — mentre la C è una proprietà del contenuto, e senza il tuo contributo non esiste.
- ACID
- proprietà del meccanismo A, I, D
- A si realizza con undo log o versioni
- I si realizza con lock o MVCC capitolo 7
- D si realizza con WAL più fsync
- proprietà del contenuto C
- dipende da constraint e logica applicativa
- è l'unica che non dipende solo dal database
- storia Gray 1981, Härder e Reuter 1983
- l'ultima aggiunta è la I
- proprietà del meccanismo A, I, D
In sintesi · capitolo 4
Ogni lettera ha un meccanismo che la realizza: undo log per la A, constraint più logica applicativa per la C, lock o MVCC per la I, WAL più fsync per la D. Tre lettere sono proprietà del meccanismo e il database le garantisce senza sapere nulla del contenuto; la C è una proprietà del contenuto e senza il tuo contributo non esiste.
Concetti chiave
- A, I, D: proprietà del meccanismo — valgono su una tabella di gattini come su un libro mastro
- C: proprietà del contenuto — il database non può conoscere le tue invarianti se non gliele dichiari
- La I fu aggiunta per ultima — Gray (1981) nominava A, C e D; Härder e Reuter (1983) aggiungono l'isolamento e coniano l'acronimo
Autodiagnosi · dovresti saper rispondere senza rileggere
- Qual è il meccanismo che realizza ciascuna lettera?
- Perché la C è di natura diversa dalle altre tre?
5 · A · Atomicity
Tutto o niente. La lettera più semplice da enunciare e la più fraintesa nel meccanismo.
Un bonifico di 100 € da Anna a Bruno, scritto come lo scriveresti davvero:
BEGIN;
UPDATE conti SET saldo = saldo - 100 WHERE id = 'anna';
UPDATE conti SET saldo = saldo + 100 WHERE id = 'bruno';
INSERT INTO movimenti (da, a, importo) VALUES ('anna','bruno',100);
COMMIT;
Se il processo muore dopo la prima UPDATE, i 100 € sono usciti da un conto e non sono entrati in nessun altro: sono scomparsi. L'atomicità è la promessa che questo stato non possa sopravvivere.
| Il guasto arriva… | Senza atomicità | Con atomicità |
|---|---|---|
dopo la 1ª UPDATE | Anna −100, Bruno +0: cento euro svaniti | nulla è avvenuto |
dopo la 2ª UPDATE | saldi corretti, nessuna traccia in movimenti | nulla è avvenuto |
dopo il COMMIT | tutto è avvenuto, e resta — quella è la D | |
Fin qui l'enunciato. Le due parti che seguono sono quelle che in genere sorprendono: quanto è largo il confine, e il fatto che lo stato invalido arrivi su disco e venga cancellato dopo.
Che cosa c'è dentro il confine atomico
Una UPDATE non fa solo quello che c'è scritto: il database esegue una serie di lavori derivati, e la garanzia utile è che anche quelli stanno nel tutto-o-niente. I tre casi che vale la pena conoscere.
Aggiornamento degli indici
Un indice è una struttura dati separata dalla tabella — tipicamente un B-tree — che tiene i valori di una colonna ordinati, così una ricerca non deve scorrere l'intera tabella. Se modifichi una colonna indicizzata, il DB deve aggiornare due strutture.
UPDATE clienti SET email = 'nuova@x.it' WHERE id = 7;
email riscrittaindice su email →rimuovi 'vecchia@x.it', inserisci 'nuova@x.it'Se un crash cadesse tra le due, l'indice punterebbe a un valore che nella tabella non esiste più: una ricerca per email darebbe risultati sbagliati o nessun risultato. Non succede, perché l'aggiornamento dell'indice è nella stessa transazione.
ON DELETE CASCADE
È un'opzione della foreign key (chiave esterna, il vincolo che dice «questa colonna deve corrispondere alla chiave primaria di un'altra tabella»): quando cancelli la riga padre, il database cancella automaticamente le righe figlie che la referenziano.
CREATE TABLE ordini (
id INT PRIMARY KEY,
cliente_id INT REFERENCES clienti(id) ON DELETE CASCADE
);
DELETE FROM clienti WHERE id = 7;
-- una istruzione, ma cancella anche i 40 ordini del cliente 7
Quaranta righe cancellate da un'istruzione che ne nominava una — e la cascata può propagarsi su più livelli (cliente → ordini → righe d'ordine). Tutto dentro lo stesso confine: se qualcosa fallisce a metà, non resta un ordine orfano che punta a un cliente inesistente.
Trigger
Un trigger è codice che il database esegue automaticamente al verificarsi di un evento su una tabella. Lo registri una volta, e da quel momento scatta da solo.
CREATE TRIGGER log_variazioni
AFTER UPDATE ON conti
FOR EACH ROW
EXECUTE FUNCTION scrivi_audit();
Da lì in poi, ogni UPDATE su conti fa girare anche scrivi_audit(), che tipicamente scrive su un'altra tabella. Chi lancia l'UPDATE non sa che sta succedendo. Su 10.000 righe il trigger scatta 10.000 volte. E anche queste scritture sono nel confine: un rollback annulla sia le modifiche ai conti sia le righe di audit — non ti ritrovi un log che documenta un evento mai avvenuto.
dentro il confine
Il confine transazionale non è «l'istruzione che ho scritto», è tutto ciò che il database ha fatto per causa mia. È una garanzia forte e comoda — ed è anche il motivo per cui una DELETE su una singola riga può impiegare venti secondi e bloccare mezzo database: non stava cancellando una riga, stava cancellando in cascata quarantamila righe figlie, ognuna coi suoi trigger e i suoi indici, tenendo i lock su tutte fino al commit.
La garanzia si ferma al confine del database: un trigger che chiama un servizio esterno o manda una mail non viene annullato dal rollback. È esattamente il problema che risolve il transactional outbox: invece di emettere l'effetto esterno durante la transazione, lo si registra come riga in una tabella dentro la transazione e lo si emette dopo, a commit avvenuto. Il pattern è descritto nel capitolo 14.
Redo e undo · cosa succede davvero al riavvio
C'è un punto controintuitivo che vale la pena rendere esplicito: le pagine sporche possono finire su disco prima del commit. Il buffer pool ha spazio limitato, e se serve fare posto il DB scrive su disco anche pagine appartenenti a transazioni ancora aperte. Quindi uno stato invalido può materializzarsi sul disco. Non è un bug: è previsto.
| Nel log | Contiene | Serve a |
|---|---|---|
| redo | il valore nuovo | Rifare il lavoro di transazioni committate che erano ancora solo in memoria |
| undo | il valore vecchio | Disfare il lavoro di transazioni non committate finito su disco |
al riavvio
È «sapere sempre come tornare indietro». La scrittura può avvenire in qualunque momento; ciò che conta è che l'informazione per disfarla sia già nel log prima che la pagina tocchi il disco. Questa è letteralmente la regola del write-ahead logging del capitolo 3, ed è il motivo per cui lo stato invalido non sopravvive a un crash: non perché non ci sia mai arrivato, ma perché al riavvio viene disfatto (undo, in InnoDB) o reso invisibile (in PostgreSQL la transazione risulta abortita e le sue versioni le rimuove poi il VACUUM).
- Atomicità
- promette tutto o niente
- impedisce lo stato a metà
- non è «non scrivere finché non sei sicuro»
- ha un confine largo tutto ciò che il database fa per causa tua
- è fatto di indici, cascate, trigger
- si ferma ai effetti esterni outbox, capitolo 14
- si realizza con redo e undo nel log
- redo rifà il committato
- undo disfa il non committato
- serve perché le pagine sporche arrivano su disco prima del commit
- promette tutto o niente
In sintesi · capitolo 5
L'atomicità promette che uno stato a metà non sopravviva. Il confine è più largo dell'istruzione che hai scritto: include indici, cascate, trigger e verifica dei constraint, tutto ciò che il database ha fatto per causa tua. E il meccanismo non è «non scrivere finché non sei sicuro»: le pagine sporche possono finire su disco anche prima del commit, e lo stato invalido viene cancellato al riavvio dall'undo. L'atomicità è «sapere sempre come tornare indietro».
Concetti chiave
- Confine atomico — tutto ciò che il database fa per causa tua: righe, indici, cascate
ON DELETE CASCADE, trigger,CHECKe foreign key - Indice — struttura separata (B-tree) con i valori ordinati di una colonna; va aggiornata insieme alla tabella, nella stessa transazione
- Trigger — codice eseguito dal database su un evento; le sue scritture sono nel confine, i suoi effetti esterni no
- Redo e undo — il log contiene valore nuovo e vecchio: rifare il committato, disfare il non committato
- Stato invalido su disco — previsto: il buffer pool può scrivere pagine di transazioni aperte; l'undo lo cancella al riavvio
- Limite del confine — un trigger che manda una mail non viene annullato dal rollback: da qui il transactional outbox
Autodiagnosi · dovresti saper rispondere senza rileggere
- Perché una
DELETEsu una riga può bloccare mezzo database per venti secondi? - Uno stato invalido può arrivare fisicamente sul disco? Se sì, perché non sopravvive?
- Che cosa deve essere già nel log prima che una pagina sporca tocchi il disco, e perché?
6 · C · Consistency
L'unica lettera che non dipende soltanto dal database.
Riprendiamo il bonifico del capitolo precedente. L'atomicità garantisce che le due UPDATE avvengano entrambe o nessuna. Ma non ha alcuna opinione su questo:
BEGIN;
UPDATE conti SET saldo = saldo - 100 WHERE id = 'anna';
UPDATE conti SET saldo = saldo + 50 WHERE id = 'bruno'; -- ← 50, non 100
COMMIT;
Atomica. Isolata. Durevole. E sbagliata: cinquanta euro sono stati distrutti. Nessun meccanismo del database si è accorto di nulla, perché «in un bonifico la somma dei saldi non cambia» è una regola che vive nel tuo dominio, non nel motore.
Calcolo · la somma dei saldi
Questo capitolo riguarda quindi una domanda diversa dalle altre tre: non «come fa il database a garantirla», ma dove conviene scrivere l'invariante perché sia il database a difenderla.
La C è di natura diversa dalle altre
A, I e D sono proprietà del meccanismo. Il database le garantisce senza sapere nulla di cosa contiene: può darti atomicità su una tabella di gattini o su un libro mastro, gli è indifferente.
La C è una proprietà del contenuto. Nessun database può sapere che «la somma dei dare deve uguagliare la somma degli avere» se non gliel'hai detto tu. Non è qualcosa che il DB ti dà: è qualcosa che tu definisci e che il DB ti aiuta a difendere.
È un'implicazione condizionata: se il DB parte da uno stato valido e la transazione è scritta correttamente, allora finisce in uno stato valido. Nessuna delle due premesse è garantita dal database.
Durante la transazione l'invariante è violata. Un bonifico ha un istante in cui i 100 € sono usciti dal conto A e non sono ancora entrati in B: la somma totale è sbagliata, e quello stato esiste. Da qui la cosa più utile da capire:
Oltre alla verifica dei constraint dichiarati, è il risultato di A e I (e di D, perché duri). L'atomicità garantisce che se crashi in quell'istante lo stato invalido non sopravviva; l'isolamento garantisce che mentre ci sei dentro nessun altro lo veda. Togli una delle due e la C cade da sola. Nel database esiste un lock manager e un log manager: non esiste un «consistency manager».
Le tre «consistency» che si confondono
La stessa parola indica tre cose che non hanno nulla in comune, ed è la fonte di equivoci più frequente di tutto l'argomento.
| Quale | Significa | Chi la garantisce |
|---|---|---|
| C di ACID | Le invarianti di dominio valgono a fine transazione | Tu, con constraint e logica applicativa |
| C di CAP | Linearizzabilità: tutti i nodi vedono lo stesso valore nello stesso istante, come se esistesse una sola copia del dato e ogni operazione avvenisse in un punto preciso del tempo | Il protocollo di replica |
| Eventual consistency | Le repliche convergono, se smetti di scrivere | Il sistema, col tempo |
La C di CAP riguarda dove sta il dato — più copie che devono accordarsi. La C di ACID riguarda cosa dice il dato. Puoi avere un sistema perfettamente linearizzabile e pieno di saldi negativi. CAP e la consistenza eventuale tornano nel capitolo 12, quando il dato smette di vivere in un solo posto.
Dove si mette concretamente l'invariante
Tre livelli, in ordine di robustezza decrescente.
- Constraint del database —
CHECK saldo >= 0,UNIQUE,FOREIGN KEY. È l'unico posto dove l'invariante è vera sempre, anche se domani qualcuno scrive uno script che bypassa la tua applicazione. Limite: esprime solo vincoli su una riga o su una relazione semplice. - L'aggregato — nel design tattico DDD (Domain-Driven Design) un aggregato è esattamente l'insieme di dati che deve restare consistente dentro una singola transazione. Non è una definizione estetica, è operativa: il confine dell'aggregato è il confine della C.
- Nessuno dei due — se l'invariante attraversa più aggregati o più servizi non è difendibile transazionalmente. Diventa consistenza eventuale, e la compensazione (Parte IV) è ciò che la ripristina.
Alcune invarianti riguardano un insieme di righe, non una riga sola: «almeno un medico deve restare reperibile». Non esiste un CHECK che la esprima, e due transazioni concorrenti, ciascuna corretta da sola, possono violarla insieme senza mai toccare la stessa riga. È l'anomalia chiamata write skew: il capitolo 7 la sviluppa passo per passo con proprio questo esempio e mostra perché il lock ottimistico non basta. Qui interessa la lezione di progetto: la via migliore è spesso ridisegnare l'aggregato perché il «turno» sia un'entità unica con la sua riga, così l'invariante d'insieme torna a essere un'invariante di riga.
Nelle saghe (Parte IV) la C non vale durante l'esecuzione: credito riservato, ordine non confermato, e stavolta gli altri lo vedono. Quello che una saga garantisce è che la C torni valida alla fine, per completamento o per compensazione. Non «da stato valido a stato valido», ma «da stato valido a stato valido, passando per un territorio esposto» — e progettarla significa in buona parte decidere quanto grande sia quel territorio e chi possa guardarci dentro.
Caso pratico · l'indice parziale
Un indice normale (capitolo 5) copre tutte le righe della tabella. Un indice parziale ha una WHERE, e il database ci mette dentro solo le righe che la soddisfano.
CREATE INDEX idx_entry_athlete_occurred
ON medical_record_entry (athlete_id, occurred_on DESC, created_at DESC)
WHERE deleted_at IS NULL; -- ← questa riga lo rende parziale
tabella 100.000 righe
indice parziale 70.000 voci
le cancellate non entrano
Il guadagno è doppio e vale la pena distinguerlo: l'indice è più piccolo (meno pagine da leggere, più cache utile) e le scritture sulle righe escluse non lo toccano affatto. Se l'unica lettura che esiste chiede le voci vive di un atleta, le voci cancellate nell'indice sarebbero peso che nessuno legge mai. (C'è una trappola nell'uso degli indici parziali che non riguarda la consistenza ma il planner: la trovi nel capitolo 11, insieme all'altro caso in cui un predicato «messo dove serve» rompe qualcosa.)
L'indice parziale unico: un invariante dichiarato
Qui la cosa diventa interessante. Quando l'indice parziale è UNIQUE, il vincolo di unicità vale solo dentro quel sottoinsieme. È il modo di scrivere nel database un invariante che una UNIQUE normale non sa esprimere: al più uno attivo. Attenzione: MySQL non supporta indici parziali; lì si ottiene lo stesso effetto con una colonna generata che vale NULL per le righe revocate e un indice UNIQUE su di essa.
CREATE UNIQUE INDEX uq_user_consent_active
ON user_consents (user_id, consent_type)
WHERE revoked_at IS NULL;
| user_id | consent_type | granted_at | revoked_at | nell'indice? |
|---|---|---|---|---|
| Anna | HEALTH_DATA | 2024-01-10 | 2024-03-02 | no · è storia |
| Anna | HEALTH_DATA | 2024-03-05 | 2024-06-01 | no · è storia |
| Anna | HEALTH_DATA | 2024-06-10 | NULL | sì · l'unica attiva |
Una UNIQUE (user_id, consent_type) normale vieterebbe le prime due righe — cioè renderebbe lo storico impossibile. Quella parziale dice esattamente la regola di dominio, «al più un consenso attivo», e lascia libera la storia. Torna il tema di apertura di questo capitolo: la C di ACID è un'invariante che tu dichiari, e il posto più robusto per dichiararla è il database, dove vale anche per lo script che domani bypassa la tua applicazione.
SERIALIZABLEDue transazioni concorrenti concedono lo stesso consenso: entrambe leggono «nessun consenso attivo», entrambe inseriscono. Righe diverse, quindi né READ_COMMITTED né REPEATABLE_READ (due dei livelli di isolamento del capitolo 7) vedono un conflitto — è la forma esatta del write skew appena citato. Ma l'indice unico lo blocca lo stesso, perché il controllo di unicità non è snapshot-based: il secondo inseritore si accorge della chiave in arrivo, attende l'esito della prima transazione e poi riceve un duplicate key value violates unique constraint. È molto più economico di SERIALIZABLE, il livello massimo che vedremo: l'errore va comunque gestito (in PostgreSQL annulla la transazione, salvo ON CONFLICT o SAVEPOINT), ma è deterministico e mirato.
Il limite è preciso, e conviene conoscerlo: l'indice unico parziale esprime «al più uno per chiave». Non esprime «almeno uno» (i medici reperibili) né «al più N». Per quelle invarianti resti su SERIALIZABLE o ridisegni l'aggregato.
- Consistenza (C)
- è proprietà del contenuto
- è il risultato di A e I
- durante la transazione l'invariante è violata
- la parola indica tre cose C di ACID, C di CAP, eventuale
- ACID cosa dice il dato
- CAP dove sta il dato capitolo 12
- si difende con l'invariante nel posto giusto
- 1° livello constraint del database
- 2° livello aggregato
- altrimenti consistenza eventuale e compensazione
- un caso pratico è l'indice unico parziale
- esprime al più uno attivo
- blocca il write skew sull'unicità
- non esprime «almeno uno»
- è proprietà del contenuto
In sintesi · capitolo 6
La C è l'unica lettera che dipende da te: il database può garantire che un bonifico sia atomico, isolato e durevole, non che tolga 100 e aggiunga 100. Non ha un meccanismo proprio, è il risultato di A e I. La parola «consistency» indica tre cose diverse (ACID, CAP, eventuale), e l'invariante va messa nel posto più robusto possibile: un constraint, poi l'aggregato, e quando attraversa più aggregati diventa consistenza eventuale. L'indice unico parziale è l'esempio di un'invariante di dominio scritta nel database.
Concetti chiave
- C = risultato di A e I — l'atomicità impedisce che lo stato intermedio sopravviva, l'isolamento che qualcuno lo veda: non esiste un «consistency manager»
- Tre consistency — ACID: le invarianti valgono a fine transazione; CAP: tutti i nodi vedono lo stesso valore (linearizzabilità); eventuale: le repliche convergono col tempo
- Dove mettere l'invariante — constraint del database (vale anche per lo script che bypassa l'app) → aggregato DDD (il confine dell'aggregato è il confine della C) → nessuno dei due (consistenza eventuale + compensazione)
- Aggregato — l'insieme di dati che deve restare consistente dentro una singola transazione
- Indice parziale — indice con una
WHERE: più piccolo, e le righe escluse non lo toccano - Indice unico parziale — esprime «al più uno attivo per chiave» lasciando libero lo storico; blocca il write skew perché il controllo di unicità non è snapshot-based
- Invarianti d'insieme — «almeno uno», «al più N»: nessun
CHECKle esprime, servonoSERIALIZABLEo un ridisegno dell'aggregato
Autodiagnosi · dovresti saper rispondere senza rileggere
- Perché un bonifico che toglie 100 e aggiunge 50 è ACID a tre lettere su quattro?
- In che senso la C non ha un meccanismo proprio?
- Perché una
UNIQUEnormale su (utente, tipo consenso) renderebbe impossibile lo storico, e come lo risolve quella parziale? - Quale invariante l'indice unico parziale non può esprimere?
7 · I · Isolation
La lettera aggiunta per ultima, e quella che costa di più. È anche la più lunga da trattare, perché non è una promessa sola: è una scala.
Due casse dello stesso negozio, un solo pezzo in magazzino:
Entrambe le transazioni sono atomiche, entrambe rispettano i propri constraint, entrambe sono durevoli. Il problema non sta dentro nessuna delle due: sta nel fatto che sono girate insieme. È il territorio dell'isolamento — e il resto del capitolo è, in buona parte, un catalogo di quanto isolamento sei disposto a pagare.
Il capitolo procede in quattro tempi: prima che cosa promette l'isolamento e che nome hanno le sue violazioni; poi come un database lo realizza fisicamente (è qui che riapriamo il lock manager lasciato chiuso nel capitolo 3); poi i quattro livelli con cui lo compri a rate; infine come scegliere.
Perché si dice «come se fossero eseguite in serie»
Vale la pena fermarsi su questa formula, perché nasconde il punto centrale: l'isolamento non è una proprietà di una transazione, è una proprietà di un insieme di transazioni concorrenti.
Se nel database c'è una transazione sola, l'isolamento è gratis e non c'è nulla da discutere: nessuno può disturbarti perché non c'è nessuno. Tutte le anomalie — dirty read, non-repeatable read, phantom, write skew — richiedono almeno due transazioni. Non a caso ogni diagramma di questo capitolo ha due righe.
La domanda a cui l'isolamento risponde è: quando N transazioni girano insieme e le loro operazioni si mescolano, il risultato è accettabile? E «accettabile» ha bisogno di un metro di paragone. Il metro scelto è l'esecuzione seriale.
esecuzione reale concorrente, operazioni mescolate
- Non «in serie» davvero. Nessuno esegue le transazioni una alla volta: sarebbe corretto e inutilizzabile. Il DB le fa girare in parallelo e garantisce solo l'equivalenza a un risultato seriale. È un criterio di correttezza, non un piano di esecuzione.
- Qualsiasi ordine va bene. La definizione non promette che T1 venga prima di T2, ma che il risultato corrisponda a uno dei due ordini possibili. Se ti serve un ordine specifico, quello è un requisito di business e devi imporlo tu.
- È il livello massimo, non il default. «Come se in serie» descrive
SERIALIZABLE. Gli altri tre livelli sono deviazioni deliberate da questa promessa, comprate in cambio di prestazioni: ogni ✗ nella matrice che vedremo è una situazione in cui il risultato non corrisponde a nessun ordine seriale, e tu hai accettato che sia così.
Le due casse di apertura sono già un esempio: se fossero andate davvero una dopo l'altra, la seconda avrebbe letto giacenza 0 e non avrebbe venduto. Lo stato «due clienti, un pezzo» non è raggiungibile in nessun ordine seriale. Il caso dei medici reperibili anticipato nel capitolo 6 ha la stessa struttura, e lo vedremo passo per passo più avanti.
Le anomalie, e perché si chiamano così
Anche i nomi delle anomalie sono descrittivi, e ognuno risponde a una domanda diversa.
| Anomalia | Alla lettera | La domanda a cui risponde |
|---|---|---|
| Dirty read | lettura «sporca» | Il dato che leggo esiste? (o è ancora provvisorio) |
| Non-repeatable read | lettura non ripetibile | Se rileggo la stessa riga, trovo lo stesso valore? |
| Phantom read | lettura «fantasma» | Se rieseguo la stessa query, trovo le stesse righe? (i fantasmi sono quelle apparse dal nulla) |
| Lost update | aggiornamento perso | La mia scrittura sopravvive, o qualcuno l'ha coperta? |
| Write skew | «sbilanciamento» in scrittura | Due scritture legittime prese da sole possono violare insieme un'invariante? |
La progressione dirty → non-repeatable → phantom è una scala di granularità crescente: prima il singolo valore, poi la singola riga nel tempo, poi l'insieme delle righe. Ed è nello stesso ordine in cui i livelli le eliminano. Le prime tre sono anomalie di lettura e sono quelle che lo standard usa per definire i livelli; le ultime due sono anomalie di scrittura (o meglio: di decisione), non compaiono nella matrice ufficiale, ed è proprio per questo che sono le più insidiose. Il lost update ha un capitolo tutto suo (il 10); il write skew lo incontreremo alla fine di questo.
I nomi dei livelli dicono la promessa
Tre nomi su quattro sono costruiti allo stesso modo: [cosa faccio io] + [che tipo di dato]. Traducendoli, la matrice si ricostruisce da sola.
Il nome è una confessione: ti sto dicendo in faccia che leggerai roba che nessuno ha ancora confermato. Se quella transazione fa rollback, hai letto un dato che non è mai esistito.
→ lascia passare tutto, dirty read inclusaPromette una cosa sola, e la mantiene: quello che leggi esiste davvero, qualcuno l'ha confermato. Non promette nulla su quando: ogni istruzione guarda l'orologio da capo.
→ niente dirty read · tutto il resto passaIl soggetto cambia: non più cosa leggo, ma che rileggendo trovo lo stesso valore. Fotografo il database all'inizio e guardo quella foto fino alla fine.
→ niente non-repeatable read · restano i phantom (ANSI)Non parla più di letture: parla dell'intera esecuzione. Il risultato deve essere lo stesso che si otterrebbe eseguendo le transazioni una dopo l'altra, in fila.
→ niente anomalie · è il massimoREAD_* parla di cosa leggi · REPEATABLE_* parla di quante volte puoi rileggerlo · SERIALIZABLE parla di tutti insieme. E l'ordine è cumulativo: ogni livello mantiene le promesse del precedente e ne aggiunge una. Se ricordi la scala, ricordi la matrice.
Come il database realizza l'isolamento: i due modi
Riprendiamo il lock manager della mappa del capitolo 3. La strategia che un database sceglie per far convivere le transazioni determina la forma fisica dei dati sul disco, e con essa tutto ciò che leggerai nel resto del capitolo. Le famiglie sono due.
2PL · two-phase locking
Usa i lock del capitolo 1, con una regola in più: una fase in cui acquisisci lock e non ne rilasci nessuno, poi una fase in cui li rilasci e non ne acquisisci più. Nella variante usata in pratica (strict 2PL) il rilascio avviene tutto al commit. Semplice e corretto, ma i lettori bloccano gli scrittori e viceversa, perché anche una SELECT deve prendere un lock condiviso.
MVCC · multiversion concurrency control
Ogni scrittura crea una nuova versione della riga; ogni lettura vede uno snapshot coerente (preso all'inizio dell'istruzione o della transazione, secondo il livello). I lettori non bloccano nessuno e nessuno blocca loro. È come funzionano PostgreSQL, Oracle e InnoDB. Costo: versioni vecchie da raccogliere (in Postgres, il VACUUM).
Questa distinzione spiega la matrice che vedremo tra poco: con MVCC, «rileggere lo stesso valore» è gratis, mentre impedire che l'insieme dei risultati cambi è caro. Ma prima va resa esplicita la conseguenza fisica di MVCC, quella che chiude il cerchio con il buffer pool e il log: se ogni scrittura crea una nuova versione, allora sul disco una riga non è una riga.
Una riga non è una riga: le versioni
Questa è la parte che raramente viene spiegata, ed è quella che rende tutto il resto ovvio. In MVCC ogni riga non è una riga: è una catena di versioni (in PostgreSQL si chiamano tuple), ognuna con due marcatori nascosti che contengono gli identificativi di transazione del capitolo 1.
| Campo | Contiene |
|---|---|
xmin | L'id della transazione che ha creato questa versione |
xmax | L'id della transazione che l'ha sostituita o cancellata (vuoto se è la versione corrente) |
Lo snapshot non è una copia dei dati: è la lista delle transazioni committate al momento dello scatto. Leggere significa scorrere la catena e prendere la versione il cui xmin è nello snapshot e il cui xmax non lo è.
riga id=1 · catena di versioni
La mia transazione T88 ha scattato lo snapshot prima che T90 committasse: nel mio snapshot vedo ancora saldo = 100.
Quando si scatta lo snapshot: per istruzione o per transazione
Resta una domanda che decide la differenza fra i due livelli più usati: lo snapshot si prende quando? In READ_COMMITTED ogni istruzione prende il proprio snapshot nell'istante in cui parte — e la garanzia è più stretta di quanto sembri: non è «T1 ignora T2 per sempre», ma mentre l'istruzione scorre la tabella, il mondo non cambia sotto i suoi piedi.
Ecco cosa succederebbe senza quella garanzia:
Un numero così non corrisponderebbe a nessuno stato reale del database: né a quello prima dell'INSERT, né a quello dopo. Sarebbe un miscuglio di due momenti diversi. Questo intendiamo con «mai un numero sporco a metà», ed è ciò che il capitolo 2 chiamava «isolamento a livello di istruzione».
Rifare però la stessa query un istante dopo è un'altra storia, perché lo snapshot è nuovo:
È la phantom read: stessa query, stessa transazione, insieme di risultati diverso. Ed è la differenza fra i due livelli più usati.
| Livello | Lo snapshot si prende | Conseguenza |
|---|---|---|
READ_COMMITTED | a ogni istruzione | Due COUNT nella stessa transazione possono differire |
REPEATABLE_READ | una volta, all'inizio della transazione | I due COUNT sono identici fino al commit |
Ecco perché READ_COMMITTED previene le dirty read ma non le non-repeatable read né i phantom: la coerenza è garantita dentro l'istruzione, non tra due istruzioni della stessa transazione.
I livelli ANSI e la loro critica
Lo standard SQL-92 (Structured Query Language), pubblicato da ANSI (American National Standards Institute), definisce i quattro livelli di isolamento non per come funzionano, ma per quali anomalie vietano. Nel 1995 Berenson, Bernstein, Gray e altri pubblicano A Critique of ANSI SQL Isolation Levels: la definizione è ambigua e incompleta: manca il dirty write, mancano lost update e write skew, e non c'è posto per lo snapshot isolation, che il paper definisce formalmente e che negli anni successivi sarebbe diventato il comportamento reale della maggior parte dei DB.
Quando chiedi REPEATABLE_READ a PostgreSQL non ottieni il livello ANSI: ottieni snapshot isolation, che previene anche i phantom ma lascia passare il write skew, l'anomalia di decisione che chiude questo capitolo. Tienilo a mente leggendo la matrice qui sotto: descrive lo standard, non il tuo database.
La matrice (secondo ANSI)
Clicca una cella per vedere l'anomalia che quel livello lascia passare, con lo scenario concreto. La matrice riporta le tre anomalie di lettura dello standard: lost update e write skew non vi compaiono perché ANSI non le contempla, ed è uno dei motivi della critica del 1995.
| Livello | Dirty read | Non-repeatable read | Phantom read |
|---|
Seleziona una cella
La matrice non è teoria: ogni ✗ è un bug che puoi avere in produzione oggi.
I quattro livelli, uno per uno
Per ciascuno: che cosa promette in concreto, dove ha senso usarlo e dove no.
READ_UNCOMMITTED · «leggo anche il non-committato»
In pratica quasi non esiste. PostgreSQL lo accetta nella sintassi ma lo tratta come READ_COMMITTED: con MVCC leggere dati non committati richiederebbe lavoro in più, non in meno. Resta reale su MySQL e SQL Server.
Quando ha senso: il caso citato in letteratura è la stima approssimativa su tabelle enormi — «quante righe circa ci sono» per una progress bar o un cruscotto interno, dove sbagliare non ha conseguenze e la scansione senza vincoli è più veloce.
Quando no: ovunque il numero venga mostrato a un utente, salvato, o usato per decidere qualcosa. Un report finanziario prodotto in READ_UNCOMMITTED può contenere cifre che non sono mai state vere, e non lo scopri mai perché non lascia traccia.
READ_COMMITTED · «leggo solo il committato»
Il default di PostgreSQL, Oracle e SQL Server, e il livello su cui gira la stragrande maggioranza delle applicazioni. Ogni istruzione vede lo stato confermato al proprio istante di partenza.
-- caso tipico: una richiesta HTTP che legge e mostra
BEGIN;
SELECT * FROM ordini WHERE id = 42; -- dati reali, confermati
SELECT * FROM righe_ordine WHERE ordine_id = 42;
COMMIT;
Quando ha senso: praticamente sempre nel CRUD e nelle API di lettura. Ha il costo di concorrenza più basso, i lettori non bloccano nessuno, e per il 95% delle operazioni la garanzia «il dato esiste davvero» è tutto ciò che serve.
Attenzione al caso sopra: le due SELECT prendono snapshot diversi. Se qualcuno aggiunge una riga d'ordine tra la prima e la seconda, ottieni una testata e un dettaglio che non corrispondono. Sui report multi-query è un problema reale, ed è il motivo principale per cui si sale di livello.
Quando no: quando più letture devono raccontare lo stesso momento — report, esportazioni, riconciliazioni contabili.
REPEATABLE_READ · «la lettura è ripetibile»
Snapshot preso una volta, alla prima istruzione della transazione (non al BEGIN), e valido fino al commit. Il default di MySQL/InnoDB.
-- report finanziario: tutte le query devono vedere lo stesso istante
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT SUM(importo) FROM movimenti WHERE tipo='ENTRATA';
SELECT SUM(importo) FROM movimenti WHERE tipo='USCITA';
SELECT COUNT(*) FROM movimenti;
COMMIT;
-- entrate, uscite e conteggio sono coerenti tra loro
Quando ha senso: è il livello dei report e delle esportazioni. Ogni volta che produci un documento composto da più query e le cifre devono quadrare tra loro, questo livello è la risposta — non un lock. Vale anche per i backup logici e le migrazioni che leggono molto.
Il costo nascosto: lo snapshot va mantenuto. In PostgreSQL una transazione aperta a lungo impedisce al VACUUM di rimuovere le versioni vecchie delle righe — il fenomeno del bloat, tabelle che crescono senza motivo apparente. Una transazione REPEATABLE_READ dimenticata aperta è una delle cause più comuni di degrado in produzione.
Quando no: nelle transazioni di scrittura brevi che non fanno read-modify-write, dove non aggiunge nulla e (in PostgreSQL) ti espone a errori 40001 da gestire.
Fotografia contro fila indiana
I due livelli alti si confondono di continuo. La differenza in una riga:
REPEATABLE_READprotegge quello che vedi.SERIALIZABLEprotegge quello che decidi in base a ciò che vedi.
REPEATABLE_READ ti consegna una fotografia del database scattata all'inizio della transazione. Da lì in avanti guardi la foto, qualunque cosa accada fuori.
Il vantaggio è la coerenza interna: tutte le tue query raccontano lo stesso istante. Il limite è già nella metafora — la foto invecchia mentre la guardi, e tu non te ne accorgi. Finché leggi soltanto non è un problema: stai producendo un report di com'era il mondo alle 14:32, ed è quello che volevi. Il problema nasce quando scrivi basandoti sulla foto, perché la scrittura avviene nel presente mentre la decisione l'hai presa guardando il passato.
Il meccanismo: come il DB si accorge che stai scrivendo su un dato vecchio
Le versioni di riga e lo snapshot li abbiamo visti poco sopra. Quando provi a scrivere una riga, il database esegue questa sequenza precisa:
- Prende un lock esclusivo sulla riga. Scrivere non è mai snapshot-based: per modificare devi passare dalla versione fisicamente corrente, non dalla tua foto.
- Guarda l'
xmaxdella versione che vede. Se è vuoto, o appartiene a una transazione abortita o a un semplice lock di riga, procede; se appartiene a una transazione già committata dopo il suo snapshot, il conflitto è immediato. - Se l'
xmaxè valorizzato da una transazione ancora aperta, si mette in attesa che quella finisca. - Quando quella committa, confronta. La versione corrente è stata creata da una transazione che ha committato dopo il mio snapshot: quindi io non l'ho mai vista.
Ed è qui che i due livelli divergono radicalmente:
| Livello | Cosa fa al punto 4 |
|---|---|
READ_COMMITTED | Riparte: rilegge la versione nuova, rivaluta la clausola WHERE su di essa (meccanismo che PostgreSQL chiama EvalPlanQual) e prosegue. La sua promessa è solo «dati committati», e la versione nuova lo è |
REPEATABLE_READ | Non può. Proseguire significherebbe scrivere su una versione che il suo snapshot non contiene, rompendo la promessa di lavorare su una foto sola. Aborta con l'errore 40001 (serialization_failure, «non riesco a serializzare l'accesso»): la transazione viene annullata e l'applicazione deve riprovarla da capo |
Un caso limite della prima riga che sorprende: in PostgreSQL con READ_COMMITTED, se un UPDATE incontra una riga bloccata da un'altra transazione, aspetta il commit di quella e poi rivaluta la clausola WHERE sulla versione nuova. Una singola istruzione può quindi vedere due snapshot diversi, e il risultato complessivo può non corrispondere a nessuna esecuzione seriale. Atomica sì, serializzabile no. Il capitolo 11 mostra un caso reale in cui questa rivalutazione fa sparire un lock che credevi di aver preso.
Il controllo che REPEATABLE_READ esegue — «la versione che sto per sovrascrivere è ancora quella che avevo letto?» — è identico a quello che fa il lock ottimistico applicativo: in JPA si ottiene annotando un campo numerico con @Version, e Hibernate lo incrementa a ogni scrittura aggiungendo alla UPDATE la condizione AND version = 7 (il capitolo 9 lo spiega per intero). Le differenze: il «numero» sta nei marcatori fisici della tupla (xmin/xmax) invece che in una colonna che gestisci tu, e il controllo vale solo dentro la stessa transazione, mentre @Version funziona anche tra richieste diverse. Da qui il nome della regola: first-updater-wins, vince chi arriva primo, il secondo viene abortito.
Conseguenza pratica importante: il lost update classico sulla stessa riga in PostgreSQL non passa già in REPEATABLE_READ. Due prelievi concorrenti sullo stesso conto producono un 40001 sulla seconda transazione. Non serve salire a SERIALIZABLE.
In InnoDB una SELECT semplice è una lettura da snapshot, ma UPDATE, DELETE e SELECT … FOR UPDATE sono letture correnti con lock (next-key lock): vedono l'ultima versione committata, aspettando chi la tiene, e non abortiscono. Con SELECT … FOR UPDATE il lost update sparisce; con una SELECT semplice seguita da UPDATE con un valore calcolato no (MariaDB dalla 11.6, invece, segnala l'errore di default). Quindi il pattern «SELECT poi UPDATE con valore calcolato» in MySQL perde davvero l'aggiornamento, mentre in PostgreSQL avresti ricevuto un errore. Stesso livello di isolamento nominale, esito opposto: è la ragione più concreta per non fidarsi dell'etichetta e verificare il comportamento del proprio motore.
Dove REPEATABLE_READ non arriva: il write skew
Il buco si apre quando due transazioni leggono le stesse righe ma ne scrivono di diverse. Nessuna tocca la riga dell'altra, quindi il meccanismo appena descritto non ha nulla da rilevare: non c'è nessun xmax contestato. È l'esempio dei medici reperibili anticipato nel capitolo 6, e questa è la sua sede.
invariante: almeno uno deve restare reperibile
Ognuna delle due, presa da sola, è ineccepibile. Insieme violano l'invariante. E non è servito a nulla che la foto fosse coerente: era vera al momento dello scatto e falsa al momento della scrittura, e nessuno l'ha riverificata. Da notare: non si è verificata nessuna anomalia di lettura — niente dirty read, niente non-repeatable read, niente phantom. È un'anomalia di decisione, non di lettura. È il write skew.
Torna anche la formula «come se in serie»: se i due medici fossero andati davvero uno dopo l'altro, il secondo avrebbe visto di essere rimasto solo e si sarebbe fermato. Lo stato «zero reperibili» non corrisponde a nessun ordine seriale, ed è per questo che serve il livello che promette l'equivalenza seriale, oppure bloccare esplicitamente le righe lette con SELECT … FOR UPDATE, quando esistono. Nemmeno @Version aiuta: nessuna delle due ha toccato la riga dell'altra.
SERIALIZABLE · «riconducibile a una serie»
Aggiunge una cosa sola, ma decisiva: tiene traccia anche di su quali dati ciascuna transazione ha basato le proprie scritture. Non solo cosa hai scritto — cosa hai letto per decidere di scriverlo.
-- invariante d'insieme: almeno un medico deve restare reperibile
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT COUNT(*) FROM turni WHERE reperibile = true; -- 2
-- l'applicazione verifica: 2 > 1, posso togliermi
UPDATE turni SET reperibile = false WHERE medico_id = 7;
COMMIT; -- se le due transazioni hanno letto ciò che l'altra ha scritto: 40001
Al commit la domanda che si pone è: questo risultato sarebbe stato possibile eseguendo le due transazioni una dopo l'altra?
Se fossero andate davvero in fila
- T1 completa→ resta 1 reperibile
- T2 parte, legge→ COUNT = 1
- «1 non è > 1»→ T2 si ferma ✋
Lo stato «zero reperibili» non è raggiungibile in NESSUN ordine seriale: una delle due viene abortita.
Ecco perché serializable: non promette di eseguirle in fila, promette che il risultato sia indistinguibile da un'esecuzione in fila. In PostgreSQL il meccanismo si chiama SSI (Serializable Snapshot Isolation) ed è ottimistico: non blocca nessuno, lascia correre tutti e, quando rileva una struttura di dipendenze pericolosa (anche con qualche falso positivo), aborta una delle transazioni, al commit o già a un'istruzione precedente — tracciando le dipendenze di lettura tramite predicate lock: lock «su un predicato», cioè su una condizione come reperibile = true, che in SSI non bloccano nessuno ma registrano chi ha letto cosa.
Quando ha senso: invarianti che riguardano un insieme di righe e non una riga sola, dove nessun CHECK e nessun @Version possono aiutarti. Turni di reperibilità, capienza di una sala, limiti di posizione in un sistema di trading, «massimo N iscritti al corso».
Il prezzo: la retry è obbligatoria. Se non intercetti il 40001 e non riprovi, hai solo convertito un dato corrotto in un errore in faccia all'utente. In MySQL invece SERIALIZABLE si traduce in lock di range (lock sull'intervallo di valori letto, che blocca anche gli inserimenti futuri in quell'intervallo): paghi in contesa e possibili deadlock.
Quando no: come impostazione globale del datasource. Applicalo alla singola transazione che ne ha bisogno.
La discrepanza tra lo standard e i database veri
| Cosa dice ANSI (1992) | Cosa fa PostgreSQL | |
|---|---|---|
REPEATABLE_READ | Permette i phantom read | Non permette i phantom read |
| Write skew | Non è nemmeno contemplato | Passa in REPEATABLE_READ, bloccato in SERIALIZABLE |
Lo standard descriveva database basati su lock (il 2PL visto sopra), dove REPEATABLE_READ bloccava le righe lette ma non i range — da lì i phantom. I database MVCC ti danno uno snapshot completo, che elimina i phantom nelle letture semplici; in cambio lasciano passare il write skew che il 2PL bloccava. E InnoDB, mescolando letture da snapshot e letture correnti, può ancora mostrarli (lì li bloccano i next-key lock).
Su PostgreSQL il vero confine tra REPEATABLE_READ e SERIALIZABLE non sono i phantom: è il write skew. La matrice ANSI, presa alla lettera, descrive male i database che usi davvero.
Laboratorio: due transazioni, passo per passo
comportamento reale dei due motoriTre scenari del capitolo, eseguiti con motori e livelli diversi. Avanza un passo alla volta e guarda che cosa vede ciascuna transazione e come finisce.
Come deciderlo in tre passi
La domanda è una sola: sto scrivendo qualcosa in base a una condizione che ho verificato leggendo altre righe?
| Situazione | Livello |
|---|---|
| Leggo soltanto (report, export) | REPEATABLE_READ |
| Leggo e scrivo la stessa riga | REPEATABLE_READ basta in PostgreSQL (conflitto rilevato sull'xmax); in MySQL/InnoDB serve SELECT … FOR UPDATE o @Version |
| Verifico su un insieme e scrivo un elemento | SERIALIZABLE con retry |
Il terzo caso si riconosce da una forma ricorrente: «conto o verifico qualcosa su un insieme, poi modifico un elemento di quell'insieme». Massimo N iscritti, almeno uno reperibile, capienza della sala, saldo aggregato su più conti.
Rendi l'invariante una riga sola. Se il «turno» diventa un'entità con un contatore e un @Version, le due transazioni tornano a litigare sulla stessa riga — e a quel punto REPEATABLE_READ con optimistic lock basta. Hai trasformato un write skew in un normale conflitto di scrittura, che è un problema molto più economico. È di nuovo la regola della granularità dell'aggregato del capitolo 6.
Riepilogo: scenario per scenario
| Scenario | Livello | Perché |
|---|---|---|
| API di lettura, CRUD, dettaglio di un'entità | READ_COMMITTED | Il default. Costo di concorrenza minimo, garanzia sufficiente |
| Report, export, riconciliazione: più query, cifre che devono quadrare | REPEATABLE_READ | Tutte le query vedono lo stesso istante |
| Read-modify-write su una singola entità | READ_COMMITTED + @Version | Il livello non c'entra: serve l'optimistic lock (capitolo 9), mirato sull'aggregato e portabile tra motori |
| Contatore, saldo, stock | READ_COMMITTED + UPDATE atomico | Elimini la finestra invece di proteggerla (capitolo 2) |
| Invariante su un insieme di righe (write skew) | SERIALIZABLE | È l'unico che la difende — con retry su 40001 |
| Stima approssimativa su tabelle enormi | READ_UNCOMMITTED | Solo se il numero non viene mostrato né usato |
1) Resta sul default. 2) Se il problema è una singola entità aggiornata in concorrenza, usa @Version — non alzare il livello: è mirato al singolo aggregato invece di penalizzare tutte le query. 3) Se il problema è una scrittura derivata da una lettura, prova prima a comprimerla in un UPDATE atomico. 4) Solo se l'invariante riguarda un insieme di righe, sali a SERIALIZABLE, e solo su quella transazione — ricordando che in PostgreSQL non blocca ma aborta con 40001, quindi ti serve comunque una retry (è isolamento ottimistico mascherato), mentre in MySQL si traduce in lock di range e paghi in contesa. Alzare il livello globale è quasi sempre il modo di pagare un costo su tutte le query per un problema che ne riguardava una.
DEFAULT (il valore dell'attributo isolation di @Transactional in Spring) significa «usa quello del datasource». PostgreSQL e Oracle stanno su READ_COMMITTED, MySQL/InnoDB su REPEATABLE_READ: lo stesso identico codice cambia comportamento cambiando database.
- Isolamento
- promette equivalenza a un ordine seriale
- è proprietà di un insieme di transazioni
- è il massimo in SERIALIZABLE
- si viola con le anomalie
- di lettura dirty read, non-repeatable read, phantom
- di decisione lost update e write skew
- si realizza con 2PL o MVCC
- 2PL i lettori bloccano
- MVCC versioni con xmin e xmax, e snapshot
- si compra a rate in quattro livelli
- READ_COMMITTED snapshot a ogni istruzione
- REPEATABLE_READ snapshot per transazione, errore 40001
- SERIALIZABLE traccia anche ciò che hai letto
- si sceglie così dal default verso l'alto
- entità singola @Version capitolo 9
- scrittura derivata UPDATE atomico
- invariante d'insieme SERIALIZABLE con retry
- promette equivalenza a un ordine seriale
In sintesi · capitolo 7
L'isolamento è una proprietà di un insieme di transazioni concorrenti, e il suo metro è l'equivalenza a un'esecuzione seriale. Il database lo realizza con lock (2PL) o con versioni (MVCC), dove ogni riga è una catena di versioni marcate con xmin/xmax e ogni transazione vede uno snapshot. I quattro livelli comprano l'isolamento a rate: READ_COMMITTED scatta lo snapshot a ogni istruzione, REPEATABLE_READ una volta per transazione, SERIALIZABLE traccia anche ciò che hai letto per decidere. In PostgreSQL il vero confine tra gli ultimi due non sono i phantom (che MVCC elimina gratis) ma il write skew.
Concetti chiave
- Equivalenza seriale — il risultato deve coincidere con uno degli ordini seriali possibili: criterio di correttezza, non piano di esecuzione
- Anomalie di lettura — dirty read (dato provvisorio), non-repeatable read (stessa riga, valore diverso), phantom (stessa query, righe diverse): granularità crescente
- Anomalie di decisione — lost update e write skew: non sono nella matrice ANSI, ed è per questo che sono le più insidiose
- 2PL vs MVCC — lock in due fasi (i lettori bloccano) contro versioni e snapshot (i lettori non bloccano nessuno)
- xmin / xmax — id della transazione che ha creato la versione e di quella che l'ha sostituita; con lo snapshot decidono la visibilità
- Snapshot per istruzione o per transazione — la differenza tra
READ_COMMITTEDeREPEATABLE_READ: dueCOUNTpossono differire nel primo, non nel secondo - First-updater-wins e 40001 — in
REPEATABLE_READscrivere su una versione non nel tuo snapshot aborta la transazione: è optimistic locking fatto dal database - EvalPlanQual — in
READ_COMMITTEDl'UPDATEche trova una riga bloccata aspetta e rivaluta laWHEREsulla versione nuova - Write skew — due transazioni leggono lo stesso insieme e scrivono righe diverse, violando insieme un'invariante che nessuna violava da sola; solo
SERIALIZABLElo previene - SSI — l'implementazione ottimistica di
SERIALIZABLEin PostgreSQL: non blocca, traccia le letture con predicate lock e aborta al commit - Regola di scelta — resta sul default;
@Versionper la singola entità;UPDATEatomico per la scrittura derivata;SERIALIZABLEsolo per invarianti d'insieme, con retry
Autodiagnosi · dovresti saper rispondere senza rileggere
- Perché con una sola transazione nel database l'isolamento è gratis?
- In
READ_COMMITTEDdueCOUNT(*)nella stessa transazione possono differire: perché, e a quale livello non possono? - Perché il lost update sulla stessa riga in PostgreSQL non passa già in
REPEATABLE_READ, e perché in MySQL/InnoDB invece passa? - Nell'esempio dei due medici, quale anomalia di lettura si verifica? Perché
@Versionnon aiuta? - Che cosa aggiunge
SERIALIZABLErispetto aREPEATABLE_READ, in una frase?
8 · D · Durability
Dopo il commit, resiste. Sembra la lettera più ovvia: è quella che dipende dal maggior numero di strati fuori dal database.
L'applicazione mostra «ordine confermato». Un secondo dopo il server perde corrente. Alla riaccensione, l'ordine c'è?
Deve esserci. Il commit è una promessa fatta a qualcuno che ora agisce di conseguenza — l'utente che chiude il browser, il servizio a valle che ha ricevuto 200 OK. La parte non ovvia è che il database, da solo, non può mantenerla: deve convincere il sistema operativo, che a sua volta deve convincere il disco. Ognuno dei due, se lasciato fare, mentirebbe per andare più veloce.
Che cos'è fsync, e perché serve
fsync, già incontrato al punto 5 della traccia del capitolo 3, è una system call POSIX — non un comando di shell. POSIX (Portable Operating System Interface) è la famiglia di standard IEEE che definisce l'interfaccia comune dei sistemi Unix-like: quali chiamate esistono, come si chiamano, cosa devono garantire. È il motivo per cui lo stesso codice C che apre un file e lo sincronizza compila su Linux, macOS, BSD e Solaris, anche se la garanzia effettiva varia (su macOS fsync non svuota la cache del disco: serve F_FULLFSYNC). La firma è int fsync(int fd), dove fd è il file descriptor del file da rendere durevole.
Serve perché tra la tua scrittura e il supporto fisico ci sono almeno tre livelli di cache.
Giallo: volatile, si perde se manca la corrente. Verde: durevole.
La page cache del kernel
Il kernel non fa passare le scritture direttamente al disco: tiene in RAM una copia delle pagine di file su cui si sta lavorando. Quella copia è la page cache, e ha due compiti.
- In lettura funziona da cache vera: rileggere un file appena letto non tocca il disco. È il motivo per cui su Linux la RAM «libera» tende a zero — la memoria non usata viene impiegata come cache, e liberata appena serve altrove.
- In scrittura funziona da buffer:
write()copia i byte nella page cache, marca la pagina come dirty e ritorna subito. La scrittura vera avviene dopo, in modo asincrono, quando lo decide il kernel (writeback).
Da qui l'espressione «veloce e bugiarda»: quando write() ti risponde con successo, i dati esistono solo in memoria volatile. Se stacchi la corrente in quell'istante, non sono mai esistiti. Per un editor di testo è un compromesso ragionevole; per un database che ha appena promesso committed a un'applicazione, è inaccettabile.
fsync(fd) risolve proprio questo: blocca il chiamante finché tutte le pagine sporche di quel file — e i relativi metadati — non sono arrivate su supporto non volatile.
| Chiamata | Cosa fa | Costo tipico |
|---|---|---|
write() | Copia nella page cache e ritorna. Nessuna garanzia di durabilità | microsecondi |
fsync() | Forza dati e metadati del file su disco | decine di µs su SSD enterprise con protezione da blackout, fino a millisecondi su SSD consumer e dischi rotanti |
fdatasync() | Come sopra, ma salta i metadati non essenziali (per esempio il timestamp di modifica): leggermente più veloce | < fsync |
sync (comando shell) | Chiede al kernel di svuotare tutte le cache, senza garanzie sul singolo file | — |
fsync per rigaUn fsync è di ordini di grandezza più lento di un write: su disco rotante è una rotazione fisica del piatto. Con le impostazioni di default un database fa un fsync del log (non dei file dati) per commit, non uno per modifica; synchronous_commit=off in PostgreSQL o innodb_flush_log_at_trx_commit=0/2 in InnoDB rinunciano a questa garanzia e possono perdere l'ultimo secondo circa di commit — e i sistemi più maturi fanno group commit: raggruppano i commit arrivati nello stesso istante e li rendono durevoli con una sola sincronizzazione. È il motivo per cui il throughput in transazioni al secondo dipende molto più dalla latenza di fsync del tuo storage che dalla CPU.
Nota storica utile: per anni alcuni dischi hanno mentito sul completamento di fsync, e nel 2018 si scoprì che Linux, dopo un fsync fallito, poteva marcare le pagine come pulite: un secondo fsync restituiva successo anche se i dati erano persi. Da allora PostgreSQL va in PANIC al primo errore di fsync e riparte dal WAL. La vicenda è nota come fsyncgate e costrinse PostgreSQL a rivedere la propria gestione degli errori di sincronizzazione. La durabilità è una catena: vale quanto il suo anello più bugiardo.
- Durabilità
- promette che il commit sopravviva a un blackout
- il database da solo non può mantenerla
- attraversa tre livelli di cache
- kernel page cache, veloce e bugiarda
- disco cache propria
- solo l'ultimo è supporto non volatile
- si ottiene con fsync
- è una system call POSIX
- costa ordini di grandezza più di write
- si ammortizza con group commit
- decide le transazioni al secondo
- dipende dalla latenza di fsync, non dalla CPU
- può essere tradita da dischi e kernel bugiardi fsyncgate
- promette che il commit sopravviva a un blackout
In sintesi · capitolo 8
La durabilità è la promessa che il commit sopravviva a un blackout, e il database da solo non può mantenerla: deve convincere il sistema operativo, la cui page cache è veloce e bugiarda, e il disco, che ha una cache propria. fsync è la system call che attraversa entrambi. È lenta, quindi il database ne fa una per commit (o una per gruppo di commit), ed è la latenza di fsync, non la CPU, a decidere quante transazioni al secondo puoi fare.
Concetti chiave
- Page cache — copia in RAM delle pagine di file gestita dal kernel;
write()ci scrive dentro e ritorna subito - fsync — system call POSIX che blocca finché dati e metadati del file non sono su supporto non volatile;
fdatasyncsalta i metadati non essenziali - Tre livelli di cache — page cache del kernel, cache del disco, supporto fisico: solo l'ultimo è durabilità vera
- Group commit — più commit vicini nel tempo resi durevoli con un solo
fsync - fsyncgate — alcuni dischi dichiarano scritto ciò che è ancora in cache, e fino al 2018 Linux poteva far sparire l'errore di un
fsyncfallito: la durabilità vale quanto il suo anello più bugiardo
Autodiagnosi · dovresti saper rispondere senza rileggere
- Che cosa garantisce
write()quando ritorna con successo? - Perché il throughput in transazioni al secondo dipende più dallo storage che dalla CPU?
- Perché un database non fa un
fsyncper riga modificata?
Parte III
Concorrenza, nella pratica
Le decisioni che prendi scrivendo codice: quale strategia di lock, dove metterlo, e come non perdere un aggiornamento.
9 · Lock: ottimistico vs pessimistico
Due scommesse opposte sulla stessa domanda: quanto è probabile che qualcuno tocchi questo dato mentre lo sto usando?
Prima le due primitive
Il capitolo 1 ha definito il lock come una prenotazione tenuta fino al commit, e il capitolo 7 ha mostrato che il database ne prende uno esclusivo ogni volta che scrive. Qui interessano i due strumenti con cui l'applicazione governa la concorrenza su una riga, perché tutto il capitolo è un confronto tra loro.
SELECT … FOR UPDATE
Una SELECT che, oltre a leggere, prende sulle righe restituite lo stesso lock esclusivo che prenderebbe un UPDATE, e lo tiene fino al commit. Chi vuole scrivere (o fare a sua volta FOR UPDATE) sulle stesse righe si mette in attesa; chi vuole solo leggere, in MVCC, non è disturbato. In JPA si chiede con LockModeType.PESSIMISTIC_WRITE, e Hibernate lo traduce in FOR UPDATE (nelle versioni recenti, su PostgreSQL, nel più leggero FOR NO KEY UPDATE).
@Version
Un campo numerico dell'entità annotato con @Version. Hibernate lo legge insieme al resto e, quando salva, genera UPDATE … SET …, version = version + 1 WHERE id = ? AND version = ? con il valore letto. Se nel frattempo qualcuno ha scritto, la versione sul database è cambiata, la WHERE non trova nulla, zero righe aggiornate: Hibernate lo interpreta come conflitto e lancia OptimisticLockException (via JPA; in Spring arriva come ObjectOptimisticLockingFailureException). Nessun lock è stato tenuto: il controllo avviene solo al momento di scrivere.
Le due scommesse
Pessimistico · «succederà»
Prevengo: prendo il lock prima di leggere e lo rilascio al commit. Paga sempre, che ci sia contesa o no.
SELECT ... FOR UPDATE
LockModeType.PESSIMISTIC_WRITE
Ottimistico · «non succederà»
Procedo, e al momento di scrivere verifico che nessuno mi abbia preceduto. Paga solo quando il conflitto avviene davvero, ma paga di più.
UPDATE conto SET saldo=?, version=version+1
WHERE id=? AND version=7
È letteralmente la stessa idea a livelli diversi dello stack:
| Livello | Meccanismo ottimistico |
|---|---|
| JPA (Jakarta Persistence API) / Hibernate | @Version → OptimisticLockException |
Database, livello REPEATABLE_READ | controllo sull'xmax della versione letta (aggiornata da una transazione non visibile nello snapshot) → errore 40001 (capitolo 7) |
| HTTP (HyperText Transfer Protocol) | ETag + If-Match → 412 Precondition Failed |
| DynamoDB / Firestore | conditional writes |
| Event store (EventStoreDB, Marten) | expectedVersion sullo stream |
| CPU / strutture lock-free | compare-and-swap |
I costi reali
Pessimistico
- Deadlock (l'attesa circolare del capitolo 1) se due transazioni prendono le risorse in ordine diverso: T1 blocca
accounte chiedeorder, T2 il contrario. Contromisura: ordine di acquisizione globale (sempre primaaccount, poiorder, in ogni punto del codice) più timeout, così che nel peggiore dei casi una delle due fallisca in fretta e riprovi. - Throughput a picco sulle righe calde: la contesa serializza tutto.
- Nel distribuito il lock è una bugia: vedi il box qui sotto.
Ottimistico
- La retry non è gratis. Va riesposta con senso: automatica se l'operazione è ricalcolabile, altrimenti
409 Conflictall'utente. - Livelock sotto contesa alta: tutti riprovano, tutti falliscono, il throughput va a zero. Serve backoff con jitter (attese crescenti e casuali tra un tentativo e l'altro) e un tetto ai tentativi.
Quando la risorsa da proteggere non sta in un database ma è condivisa tra più processi o nodi (un job che deve girare su una sola istanza, una risorsa esterna da usare in esclusiva), si ricorre a un lock distribuito: una chiave in Redis o ZooKeeper che vale «la risorsa è mia», con un TTL (time to live) dopo il quale scade da sola, perché chi l'ha presa potrebbe essere morto. Il difetto strutturale: TTL scaduto ≠ lavoro finito. Il nodo A prende il lock, si blocca per una pausa di garbage collection più lunga del TTL, il lock scade, il nodo B lo prende e inizia a scrivere; poi A si risveglia e, convinto di avere ancora il lock, scrive anche lui. Senza fencing token — un numero crescente rilasciato insieme al lock, che la risorsa protetta verifica rifiutando qualunque scrittura con un token più vecchio dell'ultimo visto — due nodi si credono entrambi proprietari. Il lock di un database non ha questo problema perché è il database stesso a farlo rispettare; quello distribuito sì, perché la risorsa protetta e il lock vivono in due posti diversi.
Il criterio di scelta
| Scegli ottimistico | Scegli pessimistico |
|---|---|
| Letture ≫ scritture | Contesa alta su poche righe calde |
| Conflitti rari, entità «possedute» da un utente | Il retry è costoso o impossibile (effetti esterni già emessi) |
| API stateless, client disconnessi | Operazione breve, dentro un solo DB |
| Serve scalare orizzontalmente | Serve garantire l'ordine di ingresso (FIFO, first in first out, su una risorsa) |
Tenere un lock di database durante il think time dell'utente. Un form aperto dieci minuti con FOR UPDATE sotto è un incidente. Lì l'unica scelta sana è l'Optimistic Offline Lock (dal catalogo di Martin Fowler, Patterns of Enterprise Application Architecture, pattern scritto con David Rice): la versione viaggia al client, in un campo nascosto del form o in un ETag, e torna indietro con la richiesta di salvataggio, dove viene confrontata come farebbe @Version. Se il business richiede davvero l'esclusività, Pessimistic Offline Lock — una tabella di prenotazioni applicativa con scadenza, mai un lock di database. Il capitolo 15 ne mostra un caso.
- Governare la concorrenza su una riga
- scommessa «succederà» Pessimistico
- si fa con SELECT … FOR UPDATE
- paga sempre e rischia deadlock
- si cura con ordine globale e timeout
- scommessa «non succederà» Ottimistico
- si fa con @Version
- paga solo al conflitto e rischia livelock
- si cura con backoff con jitter
- la stessa idea ottimistica vale in ogni livello dello stack
- per esempio ETag, conditional writes, compare-and-swap
- fuori dal database serve lock distribuito
- scade con TTL
- si difende con fencing token
- si sceglie con probabilità di conflitto per costo del retry
- mai durante il think time dell'utente
- scommessa «succederà» Pessimistico
In sintesi · capitolo 9
Le due primitive con cui l'applicazione governa la concorrenza su una riga sono SELECT … FOR UPDATE (prendo il lock prima, pago sempre) e @Version (verifico al momento di scrivere, pago solo in caso di conflitto, ma pago di più). La stessa idea ottimistica torna a ogni livello dello stack, da ETag a compare-and-swap. La scelta è un calcolo: pessimistico quando probabilità di conflitto per costo del retry supera il costo dell'attesa. Mai un lock di database durante il think time dell'utente.
Concetti chiave
- SELECT … FOR UPDATE — lettura che prende il lock esclusivo di una scrittura sulle righe restituite, fino al commit (
PESSIMISTIC_WRITEin JPA) - @Version — campo incrementato a ogni scrittura e verificato nella
WHERE: zero righe aggiornate =OptimisticLockException - Costi del pessimistico — deadlock (contromisura: ordine globale di acquisizione + timeout), throughput a picco sulle righe calde
- Costi dell'ottimistico — la retry va progettata (automatica o
409), livelock sotto contesa alta (backoff con jitter, tetto ai tentativi) - Lock distribuito, TTL, fencing token — TTL scaduto ≠ lavoro finito; senza un token crescente verificato dalla risorsa, due nodi si credono proprietari
- Optimistic / Pessimistic Offline Lock — versione che viaggia al client, oppure tabella di prenotazioni con scadenza: mai un lock di database tra due richieste HTTP
Autodiagnosi · dovresti saper rispondere senza rileggere
- Che cosa genera Hibernate nella
UPDATEquando un'entità ha@Version, e come si accorge del conflitto? - Scrivi il criterio di scelta come disuguaglianza e spiega ogni termine.
- Perché un lock distribuito con TTL è «una bugia», e che cosa lo rende sicuro?
- Un form resta aperto dieci minuti: quale strategia e perché non
FOR UPDATE?
10 · Lost update, in laboratorio
Due thread, saldo iniziale 100. T1 preleva 10, T2 preleva 30. Il risultato corretto è 60. Scegli una strategia e avanza passo per passo.
È l'anomalia annunciata nel capitolo 2: la sequenza leggi → decidi → scrivi fatta in tre pezzi, dove la scrittura usa un valore che nel frattempo ha smesso di essere vero. Le quattro modalità del simulatore corrispondono a nessuna protezione e alle tre soluzioni elencate sotto.
Calcolo · il risultato corretto
Le tre soluzioni, in ordine di preferenza
- Scrittura atomica. Non leggere per poi scrivere: fai calcolare al DB.
UPDATE conto SET saldo = saldo - 10 WHERE id = ?. Nessuna finestra tra read e write, quindi nessun conflitto. L'invariante si sposta in un constraint (CHECK saldo >= 0). Se l'operazione è esprimibile così, finisce qui. - Optimistic lock. Quando la scrittura dipende da logica applicativa che non sta in una
UPDATE.@Versioncome default su ogni aggregato. - Pessimistic lock. Quando la contesa è alta o il retry è insostenibile.
Nota sul livello di isolamento: come visto nel capitolo 7, in PostgreSQL REPEATABLE_READ rileva da solo il lost update sulla stessa riga (errore 40001), mentre in MySQL/InnoDB no. Il simulatore ragiona in READ_COMMITTED, il default di PostgreSQL (in InnoDB il default è REPEATABLE_READ, che comunque non lo rileva), dove l'anomalia passa in entrambi: è il caso in cui le tre soluzioni sopra servono davvero.
Due errori strutturali da escludere prima
- Lettura e scrittura in transazioni diverse. Se leggi in una richiesta HTTP e scrivi nella successiva, nessun lock di DB ti copre: è morto col primo commit. Serve la versione che viaggia al client (l'Optimistic Offline Lock del capitolo 9).
- Persistence context stale. Il persistence context è la cache di primo livello di JPA: le entità caricate in una transazione restano in memoria, e se l'entità è già nella sessione,
find()non va nemmeno al DB e ti restituisce la copia in memoria. In transazioni lunghe è spesso il vero colpevole: soloem.refresh(entity)(anche nella formaem.refresh(entity, LockModeType.PESSIMISTIC_WRITE), che rilegge e blocca insieme) forza la rilettura; un lock da solo non aggiorna la copia già in memoria.
Non c'è nulla da risolvere: è fisiologico. Nel momento in cui restituisci un dato, quel dato può già essere vecchio. READ_COMMITTED garantisce che non sia sporco, non che sia l'ultimo. L'unica cosa sensata è rendere esplicita la staleness — timestamp o versione nella risposta — e rileggere quando serve.
- Lost update
- nasce da leggi, decidi, scrivi in tre pezzi
- la scrittura usa un valore non più vero
- si risolve, in ordine, con tre soluzioni
- 1ª scrittura atomica
- 2ª optimistic lock @Version, capitolo 9
- 3ª pessimistic lock
- prima si escludono due errori strutturali
- lettura e scrittura in transazioni diverse
- copia stale nel persistence context si risolve con refresh
- se il lettore legge e basta la staleness è fisiologica
- nasce da leggi, decidi, scrivi in tre pezzi
In sintesi · capitolo 10
Il lost update nasce dal ciclo leggi → decidi → scrivi in tre pezzi. Le soluzioni in ordine di preferenza: eliminare la finestra con una scrittura atomica, altrimenti @Version, altrimenti il lock pessimistico. Prima di tutto vanno esclusi due errori strutturali: lettura e scrittura in transazioni diverse (nessun lock di database può coprirle) e il persistence context che restituisce una copia stale.
Concetti chiave
- Lost update — la scrittura di A, calcolata su un valore letto prima della scrittura di B, la copre: il prelievo di B sparisce
- Scrittura atomica —
saldo = saldo - 10conCHECK saldo >= 0: nessuna finestra, nessun conflitto - Ordine di preferenza — atomica → ottimistica → pessimistica
- Persistence context stale —
find()restituisce la copia in memoria se l'entità è già in sessione; solorefreshforza la rilettura (un lock da solo non aggiorna la copia in memoria) - Staleness in lettura — fisiologica:
READ_COMMITTEDgarantisce che il dato non sia sporco, non che sia l'ultimo
Autodiagnosi · dovresti saper rispondere senza rileggere
- Nel simulatore, perché con
@Versionil thread A riceve zero righe aggiornate e che cosa fa dopo? - Perché leggere in una richiesta HTTP e scrivere nella successiva rende inutile qualunque lock di database?
- Che cosa cambia nel simulatore se il livello fosse
REPEATABLE_READsu PostgreSQL?
11 · Il predicato al posto giusto
Due casi reali in cui la mossa apparentemente più pulita — mettere il filtro dentro la query, rendere l'indice più piccolo — rompe silenziosamente qualcosa. E per lo stesso motivo.
Caso 1 · Il filtro che disattiva il lock
Due finder che cercano la stessa entità per id, deliberatamente diversi. Il codice è Kotlin con Spring Data JPA: @Lock chiede il lock pessimistico del capitolo 9, @Query fissa la query JPQL, takeIf restituisce l'oggetto solo se la condizione è vera (altrimenti null), e .left() incapsula un errore di dominio.
// A · lettura semplice, nessun lock — il filtro sta DENTRO
fun findByIdAndDeletedAtIsNull(id: UUID): MedicalRecordEntry?
// B · lettura per aggiornamento, con lock — il filtro sta FUORI
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM MedicalRecordEntry e WHERE e.id = :id")
fun findByIdForUpdate(@Param("id") id: UUID): MedicalRecordEntry?
// il chiamante di B filtra dopo, a mano:
val entry = entries.findByIdForUpdate(entryId)?.takeIf { it.deletedAt == null }
?: return MedicalRecordErrors.entryNotFound().left()
La ragione sta in una frase che vale la pena imparare a memoria:
FOR UPDATE blocca il risultato, non «la riga con quell'id»Tutto ciò che restringe la WHERE restringe anche cosa viene bloccato. Un predicato su una colonna che un'altra transazione sta modificando è un predicato che può evaporare esattamente nell'istante della contesa — e portarsi via il lock con sé.
Il meccanismo, passo per passo
È la rivalutazione della WHERE (EvalPlanQual) del capitolo 7, vista sul campo. Anna ha due schede aperte: nella prima cancella la voce, nella seconda la chiude.
| Filtro dentro la query | Filtro fuori | |
|---|---|---|
| Cosa riceve il chiamante | Può ricevere niente, senza aver preso alcun lock | La riga, sempre, con il lock in mano |
| Su cosa decide | Su un'assenza | Sullo stato committato della riga bloccata |
| Cosa protegge il seguito | Niente: nessun lock è mai stato preso | Il lock, per tutta la transazione |
L'esito visibile all'utente può coincidere — in questo caso entrambe le strade arrivano a un 404 — ma la posizione del presidio no. Nella prima colonna il lock non è mai stato acquisito, quindi ogni ragionamento successivo della transazione avviene su una riga non protetta. Se dopo il controllo ci fossero altri passi (scrivere una riga collegata, decrementare un contatore, emettere un evento), quelli girerebbero senza alcuna esclusività.
Lo scenario descritto è READ_COMMITTED, il default di PostgreSQL. In REPEATABLE_READ la stessa query non restituisce zero righe: aborta con 40001, perché lì non esiste la rivalutazione — è il meccanismo first-updater-wins del capitolo 7. Stesso codice, due comportamenti diversi: silenzio in un caso, eccezione nell'altro. Un motivo in più per non far dipendere la correttezza dal livello.
La regola generale, e quella di progettazione
- Blocca su colonne immutabili, filtra su quelle mutabili dopo il lock. La chiave primaria è il predicato ideale per un
FOR UPDATEproprio perché nessuno la cambia: la rivalutazione non può mai farti perdere la riga. Ogni colonna che qualcun altro può scrivere è un candidato pessimo. - Una primitiva di lock non è una query di dominio. Se infili una regola di business dentro
findByIdForUpdate, ogni chiamante futuro se la porta dietro in silenzio — e chi avesse bisogno di bloccare proprio una riga cancellata, per ripristinarla, non potrebbe. La primitiva resta minima, la politica sta al call site. - Il finder senza lock è un altro discorso. Lì il filtro dentro va benissimo: non c'è nessun lock da restringere, il predicato non ha niente da portarsi via, e scriverlo nel nome del metodo è più corto e più chiaro.
Il lock ti restituisce la versione fresca a livello di database, ma se l'entità era già stata caricata nella sessione JPA, l'oggetto che hai in mano potrebbe non riflettere quella versione. È lo stesso problema segnalato nel capitolo 10: in caso di dubbio, refresh.
Caso 2 · La trappola dell'indice parziale: «parziale» non è automaticamente meglio
L'indice parziale del capitolo 6 ha una controindicazione che non riguarda la consistenza ma il planner, il componente che decide come eseguire una query. PostgreSQL può usare un indice parziale solo se dimostra, al momento di pianificare la query, che il predicato della query implica quello dell'indice. È una dimostrazione simbolica sul testo, non una verifica sui dati.
indice: … WHERE status = 'PENDING'
| Query | Il planner deve dimostrare | Esito |
|---|---|---|
| A · WHERE status = 'PENDING' | 'PENDING' implica 'PENDING' | ✔ usa l'indice |
| B · WHERE status = $1 | $1 implica 'PENDING'? boh | ✘ indice inerte |
Nel caso B il valore arriva come parametro bind. Finché PostgreSQL usa un piano custom (le prime esecuzioni, o quando il piano generico non conviene) il valore è noto e l'indice può servire; ma quando passa al piano generico il valore non c'è al momento del piano, e l'indice parziale resta inerte. Con un piano generico, riutilizzato per qualunque valore, l'implicazione non è dimostrabile e l'indice resta inutilizzato proprio nel caso per cui l'avevi costruito. Il costo non sparisce: l'indice va comunque mantenuto a ogni scrittura.
Parziale quando il predicato è una costante nel testo del vincolo (IS NULL, = 'ONLINE', = 'PENDING' scritto lì). Non parziale quando il predicato arriva come parametro. WHERE deleted_at IS NULL è del primo tipo: la costante è nel testo, l'implicazione è banale, l'indice viene usato.
Il filo comune
In entrambi i casi la mossa apparentemente più naturale — «metti il filtro dove serve», «rendi l'indice più piccolo» — è quella che rompe qualcosa, e per lo stesso motivo di fondo: un predicato non è solo un filtro, è una promessa che il database deve poter valutare in un preciso momento. Nell'indice parziale il momento è la pianificazione, e con un parametro bind la promessa non è dimostrabile. Nel FOR UPDATE il momento è la contesa, e su una colonna mutabile la promessa può decadere proprio allora.
- Un predicato è una promessa
- caso 1 FOR UPDATE con filtro dentro
- blocca il risultato, non la riga con quell'id
- fallisce per la rivalutazione EvalPlanQual capitolo 7
- esito zero righe e nessun lock
- si risolve con blocca su immutabili, filtra dopo
- la primitiva resta minima
- la politica sta al call site
- caso 2 indice parziale e parametro bind
- il planner deve dimostrare l'implicazione
- con un piano generico l'indice resta inerte
- regola parziale solo con una costante
- caso 1 FOR UPDATE con filtro dentro
In sintesi · capitolo 11
Un predicato non è solo un filtro: è una promessa che il database deve poter valutare in un preciso momento. Nel FOR UPDATE il momento è la contesa, e un filtro su una colonna mutabile può evaporare proprio allora, portandosi via il lock: blocca sulla chiave primaria, filtra dopo. Nell'indice parziale il momento è la pianificazione, e con un parametro bind l'implicazione non è dimostrabile: parziale solo quando il predicato è una costante nel testo.
Concetti chiave
- FOR UPDATE blocca il risultato — tutto ciò che restringe la
WHERErestringe cosa viene bloccato - Rivalutazione (EvalPlanQual) — dopo l'attesa, PostgreSQL rivaluta il predicato sulla versione nuova; se non vale più, zero righe e nessun lock
- Blocca su immutabili, filtra su mutabili — la chiave primaria è il predicato ideale per un lock; la regola di business sta al call site, non nella primitiva
- Dipendenza dal livello — stesso codice: zero righe in
READ_COMMITTED,40001inREPEATABLE_READ - Parametro bind e indice parziale —
WHERE status = $1non dimostra l'implicazione conWHERE status = 'PENDING': indice inerte ma da mantenere
Autodiagnosi · dovresti saper rispondere senza rileggere
- Perché
findByIdForUpdatenon deve contenereAND deleted_at IS NULL, mentre il finder senza lock può? - Nella gara tra T1 che cancella e T2 che chiude, che cosa riceve T2 con il filtro dentro? E con il filtro fuori?
- Che cosa hanno in comune i due casi del capitolo?
Parte IV
Oltre il singolo database
Quando l'operazione attraversa due datasource, il confine ACID si rompe e l'atomicità diventa un problema di protocollo.
12 · Quando il confine si rompe
In un monolite l'atomicità te la regala il database. Appena spezzi il dominio in servizi con datasource separati, diventa un problema di protocollo.
La premessa: un database per servizio
Tutto ciò che precede vale dentro un database: il BEGIN e il COMMIT del capitolo 1 sono comandi che un motore esegue sui propri dati. Nelle architetture a servizi la regola di progetto è che ogni servizio possiede il proprio datastore e nessun altro vi accede direttamente (database per service): è ciò che permette a Ordini e Pagamenti di essere sviluppati, rilasciati e scalati in modo indipendente, e di scegliere ciascuno il datastore più adatto (il capitolo 2 ha mostrato che non tutti garantiscono le stesse cose). Il prezzo è che «crea l'ordine e addebita il cliente» ora scrive su due database diversi, e nessuno dei due può aprire una transazione che includa l'altro.
Il confine ACID coincide con il confine del datasource. Quando l'operazione ne attraversa due, hai solo due strategie: bloccare tutti finché non sono d'accordo, oppure procedere a passi e prevedere come disfare.
| Tipologia | Atomicità | Isolamento | Costo | Quando |
|---|---|---|---|---|
| Locale (ACID) | reale | reale | nessuno | Sempre, se puoi ridisegnare i confini per farci stare l'operazione |
| 2PC / XA | reale | reale | bloccante, lock aperti per tutto il round-trip | Solo con risorse XA già in casa e latenza tollerabile |
| Saga | semantica (compensazione) | assente | complessità applicativa | Default nelle architetture a servizi |
2PC (two-phase commit) e XA (eXtended Architecture) · che cosa sono
Il two-phase commit è descritto da Jim Gray nel 1978 (Notes on Data Base Operating Systems). Un coordinatore chiede a tutti i partecipanti se possono committare (fase di prepare, in cui ciascuno vota sì o no); se tutti votano sì, ordina il commit (fase di commit).
Two-phase commit: il coordinatore chiede prepare? ad A e B, ciascuno vota sì o no; se tutti votano sì, il coordinatore scrive la decisione e ordina commit! ad A e B.
Il punto sottile è cosa promette un partecipante quando risponde «sì»: promette di poter committare comunque, anche dopo un crash. Per mantenerla deve rendere durevole il proprio lavoro (scrivere il proprio WAL, capitolo 3) e tenere i lock aperti fino all'ordine finale.
XA è lo standard che nel 1991 il consorzio X/Open pubblica dentro il modello DTP (Distributed Transaction Processing): definisce l'interfaccia tra un transaction manager (il coordinatore) e i resource manager (DB, code di messaggi). In Java lo vedi come JTA (Java Transaction API), UserTransaction e XAResource. Funziona bene tra un DB e una coda JMS (Java Message Service) nello stesso datacenter; non esiste per una chiamata REST (Representational State Transfer).
Perché 2PC/XA tra servizi quasi non si usa più
- È costoso in latenza. I lock restano aperti sui partecipanti per l'intera durata del protocollo, cioè per tutto il round-trip di rete (il «bloccante» in senso tecnico è il punto seguente).
- Stato in dubbio. Se il coordinatore muore tra prepare e commit, i partecipanti restano in-doubt: hanno votato sì, quindi non possono abortire da soli, ma non hanno ricevuto l'ordine, quindi non possono committare. E non possono rilasciare i lock. È dimostrato che il problema non si aggira: non esiste un protocollo di commit atomico non bloccante in presenza di partizioni di rete. Il 3PC (three-phase commit, che aggiunge una fase intermedia di «pre-commit» per permettere ai partecipanti di decidere senza il coordinatore) risolve i crash, non le partizioni.
- Supporto assente. REST, Kafka e la maggior parte dei NoSQL non parlano XA.
- Disponibilità accoppiata. Un partecipante giù significa transazione giù per tutti.
Laboratorio: il coordinatore cade
protocollo reale, semplificatoUn coordinatore e due partecipanti, A e B. Scegli in quale momento del protocollo il coordinatore si spegne, e guarda in che stato restano i partecipanti.
CAP, BASE, e il cambio di paradigma
Nel 2000 Eric Brewer enuncia la congettura CAP (Consistency, Availability, Partition tolerance), dimostrata formalmente da Gilbert e Lynch nel 2002 (con C intesa come linearizzabilità): in presenza di una partizione di rete (due parti del sistema che non riescono più a parlarsi) devi scegliere tra consistenza (la C «di CAP» del capitolo 6: tutti vedono lo stesso valore) e disponibilità (tutti ricevono una risposta). Dato che la partizione non è un'opzione ma un fatto, la scelta reale è sempre tra le altre due.
Abadi lo estende con PACELC (2010): if Partition then A or C, else L or C. Anche quando tutto funziona resta un compromesso tra latenza e consistenza — ed è quello che paghi ogni volta che usi 2PC.
BASE (Basically Available, Soft state, Eventually consistent) nasce come contrapposizione esplicita ad ACID: accettare stati intermedi visibili e convergenza nel tempo (la consistenza eventuale del capitolo 6), in cambio di disponibilità e scala. La saga è il modo di stare in BASE senza rinunciare alla correttezza del business.
- Operazione su due database
- nasce da un database per servizio
- il confine ACID coincide con il datasource
- strategia 1: bloccare tutti con 2PC / XA
- prepare voto del partecipante
- commit ordine del coordinatore
- se il coordinatore muore partecipanti in-doubt
- perché non si usa più costi tra servizi
- latenza e lock aperti
- manca il supporto in REST e Kafka
- strategia 2: procedere a passi con saga capitolo 13
- sullo sfondo CAP, PACELC, BASE
- CAP con una partizione, C oppure A
- BASE accetta stati intermedi visibili
- nasce da un database per servizio
In sintesi · capitolo 12
Nelle architetture a servizi ogni servizio possiede il proprio database, quindi un'operazione che ne tocca due non può stare in una transazione. Restano due strade: bloccare tutti finché non sono d'accordo (2PC/XA: atomicità reale, ma bloccante, con partecipanti in dubbio se il coordinatore muore, e senza supporto in REST e Kafka) oppure procedere a passi e prevedere come disfare (saga). CAP dice che in presenza di partizioni scegli tra consistenza e disponibilità; BASE è il nome del paradigma che accetta stati intermedi visibili.
Concetti chiave
- Database per service — ogni servizio possiede il proprio datastore: indipendenza in cambio del confine ACID
- 2PC (two-phase commit) — prepare (voto) + commit (ordine) guidati da un coordinatore; il «sì» impegna a committare comunque, quindi a tenere i lock
- In-doubt — il partecipante che ha votato sì e non riceve l'esito: non può decidere né rilasciare; il 3PC risolve i crash, non le partizioni
- XA / JTA — lo standard X/Open DTP e la sua incarnazione Java; funziona tra DB e coda JMS, non esiste per REST
- CAP e PACELC — con una partizione scegli tra C e A; anche senza, tra latenza e consistenza
- BASE — Basically Available, Soft state, Eventually consistent: la contrapposizione ad ACID in cui vive la saga
Autodiagnosi · dovresti saper rispondere senza rileggere
- Perché il confine ACID coincide con il confine del datasource?
- Che cosa promette un partecipante quando vota «sì» in 2PC, e che cosa gli costa?
- Che differenza c'è tra la C di CAP e la C di ACID (capitolo 6)?
13 · La saga
Nata nel 1987 per un problema completamente diverso. Prima il concetto, poi la macchina per farla fallire.
Origine · Garcia-Molina & Salem, Princeton, 1987
Il paper si intitola semplicemente Sagas e non parla di servizi né di reti: tratta il caso centralizzato, limitandosi a osservare che le saghe si possono implementare anche in un database distribuito. Il problema che affronta è un altro: le long-lived transaction (LLT). Un batch contabile che gira per sei ore dentro un solo database, tenendo lock su mezzo schema e bloccando tutti gli altri.
L'idea: spezzare quella transazione lunga in una sequenza di transazioni brevi che committano subito — liberando i lock — e associare a ciascuna una compensating transaction che ne annulla l'effetto. Una saga esegue T1 … Tn, oppure, se si ferma a metà, T1 … Tj seguita da Cj … C1 in ordine inverso.
Il nome fu suggerito da Bruce Lindsay, come dicono i ringraziamenti del paper, che non ne spiegano il motivo. Nella narrativa norrena una saga è una storia lunga fatta di episodi autonomi, ciascuno con un senso compiuto, che insieme compongono un'unica vicenda. È esattamente la struttura di una long-lived transaction — e il motivo per cui il nome ha retto quarant'anni.
Nel 2015 circa il pattern viene riscoperto per i microservizi (Caitie McCaffrey, poi Chris Richardson). Il problema era cambiato — non più lock troppo lunghi, ma database separati che non possono condividere una transazione — mentre la soluzione è rimasta identica. Ed è per questo che la saga ha caratteristiche che sorprendono chi la incontra oggi: non è stata progettata per sopravvivere alle partizioni di rete, quindi outbox e idempotenza (capitolo 14) sono aggiunte nostre, non sue.
Compensazione, non rollback
Non torni indietro nel tempo: esegui un'operazione di business che neutralizza la precedente. Non annulli un addebito cancellando la riga — emetti un rimborso. La traccia resta, ed è esattamente quello che vuole l'audit. Questa è la differenza concreta tra rollback e compensazione semantica.
Anche questa non è un'invenzione informatica: viene dalla partita doppia. In contabilità una scrittura errata non si cancella: si emette una scrittura di storno, uguale e contraria. Il libro mastro è append-only per obbligo di legge, ed è per questo che la compensazione è naturale nei domini finanziari e sembra strana solo a chi arriva dal CRUD.
Le tre categorie di passo
Classificarli in fase di design è la parte che conta davvero.
| Tipo | Posizione | Significato | Esempio |
|---|---|---|---|
| Compensabile | prima del pivot | ha una compensazione definita | riserva credito → rilascia credito |
| Pivot | il punto di non ritorno | se passa, la saga arriva in fondo | addebito reale sulla carta |
| Ritentabile | dopo il pivot | non può fallire logicamente, solo retry | invio email, richiesta spedizione |
È la decisione architetturale più importante della saga. Dopo di lui non torni indietro: ci metti l'addebito reale, l'invio della mail al cliente, l'ordine al magazzino fisico. Tutto ciò che il mondo esterno vede e non puoi disfare va lì o dopo.
Provala: la saga di creazione ordine
Quattro transazioni locali, ognuna sul database del proprio servizio, con il pivot sull'addebito. «Esegui» la porta in fondo; «Fai fallire T3» simula lo stock esaurito e mostra le compensazioni che tornano indietro in ordine inverso.
Saga · creazione ordine
Coreografia vs orchestrazione
Resta da decidere chi conosce la sequenza dei passi e chi lancia le compensazioni. Le due risposte possibili:
Coreografia: Ordini manda un evento a Pagamenti, Pagamenti a Magazzino, Magazzino di nuovo a Ordini; nessuno conosce la sequenza intera. Orchestrazione: un Orchestratore chiama Ordini, Pagamenti e Magazzino e conosce la sequenza.
ognuno reagisce agli eventi Coreografia
uno conosce la sequenza Orchestrazione
Coreografia
Ogni servizio reagisce a eventi, nessun componente centrale. Il flusso però non esiste in nessun file: per capirlo devi leggere sei repository. Sostenibile fino a 2–3 passi.
Orchestrazione
Una state machine esplicita comanda i passi e invoca le compensazioni. Leggibile e testabile. Rischio: diventa un dominio anemico se ci metti anche le regole di business. Sopra i 3–4 passi, quasi sempre la scelta giusta.
Regola con DDD (Domain-Driven Design): l'orchestratore conosce l'ordine dei passi, non le regole. Le regole restano negli aggregati.
- Saga
- nasce nel 1987 per long-lived transaction
- le spezza in transazioni brevi che committano subito
- riscoperta per i microservizi, 2015
- disfa con compensazione
- non è un rollback
- è come lo storno in partita doppia
- classifica i passi in tre categorie
- prima del pivot compensabili
- punto di non ritorno pivot
- dopo il pivot ritentabili
- si coordina con coreografia o orchestrazione
- coreografia eventi, nessun centro
- orchestrazione state machine esplicita
- il prezzo è nel capitolo 14
- nasce nel 1987 per long-lived transaction
In sintesi · capitolo 13
La saga nasce nel 1987 per spezzare transazioni lunghe in un solo database, e viene riscoperta nel 2015 per un problema diverso con la stessa soluzione: una sequenza di transazioni locali che committano subito, ciascuna con una compensazione. La compensazione non è un rollback ma un'operazione di business uguale e contraria, come lo storno in partita doppia. I passi si classificano in compensabili, pivot e ritentabili; individuare il pivot è la decisione più importante. Il flusso può essere coreografato (eventi, nessun centro) o orchestrato (state machine esplicita).
Concetti chiave
- Long-lived transaction — il problema originale: un batch che tiene lock per ore su un solo database
- Compensazione — operazione di business che neutralizza un passo già committato; la traccia resta (storno, non cancellazione)
- Compensabile / pivot / ritentabile — prima del pivot si può disfare; il pivot è il punto di non ritorno; dopo si può solo riprovare
- Coreografia vs orchestrazione — eventi senza centro (fino a 2–3 passi) contro un orchestratore che conosce l'ordine dei passi ma non le regole
- Ciò che la saga del 1987 non aveva — outbox e idempotenza sono aggiunte per le reti, non del paper
Autodiagnosi · dovresti saper rispondere senza rileggere
- Perché un rimborso è una compensazione e la cancellazione della riga di addebito non lo è?
- Dove metti l'invio della mail al cliente rispetto al pivot, e perché?
- Nella macchina interattiva, che cosa succede a T1 e T2 quando T3 fallisce?
14 · Il prezzo della saga: manca la I
Saga garantisce A (semantica, via compensazione), C (alla fine, non durante) e D. Non I — e stavolta gli stati intermedi li vedono tutti.
Mentre la saga è a metà, il credito è riservato ma l'ordine non è confermato. Chiunque legga in quel momento vede uno stato che potrebbe non esistere mai. Da qui nascono anomalie identiche a quelle dei database (capitolo 7), ma a livello di business. Le contromisure, catalogate da Lars Frank e Torben Zahle (1998) e riprese da Chris Richardson (che include anche il version file, un registro delle operazioni per riordinarle), hanno ciascuna un corrispettivo tra i meccanismi già visti.
| Contromisura | Cosa fa | Corrisponde a |
|---|---|---|
| Semantic lock | Stato PENDING / IN_APPROVAL sull'aggregato: segnala «non toccarmi». Chi legge un ordine PENDING sa che non è ancora definitivo e si comporta di conseguenza (aspetta, rifiuta, o mostra «in elaborazione») | lock pessimistico applicativo (capitolo 9) |
| Commutative updates | reserve/release invece di set: l'ordine non conta. Due riserve da 1 su uno stock di 10 danno 8 in qualunque sequenza; due setQuantity calcolati in parallelo si sovrascrivono | operazione atomica (capitolo 10) |
| Pessimistic view | Riordina i passi per ridurre la finestra di esposizione: fai per ultimo il passo il cui stato intermedio è più pericoloso da mostrare | design del flusso |
| Re-read value | Rileggi e verifica le precondizioni prima di scrivere: prima di confermare l'ordine, ricontrolla che sia ancora PENDING e non sia stato annullato nel frattempo | lock ottimistico (capitolo 9) |
| By value | Scegli il meccanismo in base al valore della richiesta: saga per le operazioni a basso rischio, transazione distribuita (2PC) per quelle rischiose o di importo alto | routing per criticità |
Calcolo · due riserve commutative
reserve da 1 su uno stock di 10: stesso risultato in qualunque sequenza
Se la saga muore a metà, l'aggregato resta PENDING per sempre: è il TTL scaduto del capitolo 9, visto dal lato del business. Serve sempre un timeout con processo di riconciliazione, altrimenti hai costruito un deadlock permanente e silenzioso.
I due pattern senza cui la saga si rompe
La saga del 1987 viveva in un solo database e non aveva reti da attraversare. Quella del 2015 comunica tra servizi tramite messaggi su un broker (Kafka, RabbitMQ), e questo introduce due problemi che il paper non poteva prevedere.
Transactional outbox
Il problema: scrivere sul DB e pubblicare su Kafka non è atomico, perché sono due risorse diverse e nessuna transazione le abbraccia entrambe (è il caso del trigger che manda una mail, capitolo 5). Se il commit riesce e la pubblicazione fallisce, il passo è avvenuto ma nessuno lo sa; viceversa, l'evento gira ma il dato non c'è. La soluzione: scrivi l'evento in una tabella outbox nella stessa transazione locale del dato, così o esistono entrambi o nessuno dei due; un processo separato (relay) legge la tabella e pubblica, oppure lo fa il CDC (change data capture, per esempio Debezium), che legge il log delle transazioni del database (il WAL via logical decoding in PostgreSQL, il binlog in MySQL) e trasforma ogni riga inserita in outbox in un messaggio. Senza questo la saga si spezza in silenzio.
Idempotenza
Il problema: la consegna dei messaggi è at-least-once: il broker garantisce che il messaggio arrivi, non che arrivi una volta sola (per essere sicuro che sia arrivato, in caso di dubbio lo rimanda). Quindi ogni handler e ogni compensazione devono aspettarsi di ricevere lo stesso messaggio più di una volta. La soluzione: renderli idempotenti, cioè tali che eseguirli due volte produca lo stesso risultato di eseguirli una (la stessa proprietà del redo nel capitolo 3). Con una chiave di deduplicazione (l'id del messaggio salvato in una tabella processed_messages, nella stessa transazione dell'effetto) o con operazioni naturalmente idempotenti (set status = 'APPROVED' lo è; saldo = saldo - 10 no). Non è opzionale.
- Saga senza isolamento
- garantisce A semantica, C alla fine, D
- non garantisce I
- chi legge vede stati intermedi
- si difende con le contromisure
- semantic lock stato PENDING, con timeout
- commutative updates reserve e release
- re-read value lock ottimistico di business
- scrivere e pubblicare non è atomico, si risolve con transactional outbox
- evento scritto nella stessa transazione del dato
- pubblicato da relay o CDC
- la consegna è at-least-once, si risolve con idempotenza
- si ottiene con chiave di deduplicazione
- garantisce A semantica, C alla fine, D
In sintesi · capitolo 14
La saga garantisce A (semantica), C (alla fine) e D, ma non I: mentre è a metà, tutti vedono lo stato intermedio. Le contromisure di Richardson ricalcano i meccanismi dei database: semantic lock, commutative updates, pessimistic view, re-read value, by value. E poiché i passi comunicano via messaggi, servono due pattern che il paper del 1987 non aveva: il transactional outbox, perché scrivere sul DB e pubblicare sul broker non è atomico, e l'idempotenza, perché la consegna è at-least-once.
Concetti chiave
- Semantic lock — stato
PENDINGsull'aggregato: «non toccarmi»; senza timeout e riconciliazione è un deadlock permanente - Commutative updates —
reserve/releaseinvece diset: l'ordine non conta - Re-read value — rileggere e verificare le precondizioni prima di scrivere: il lock ottimistico a livello di business
- Transactional outbox — l'evento scritto in una tabella nella stessa transazione del dato, pubblicato dopo da un relay o dal CDC (Debezium legge il WAL)
- At-least-once — il broker garantisce che il messaggio arrivi, non che arrivi una volta sola
- Idempotenza — eseguire due volte = eseguire una volta: chiave di deduplicazione o operazioni naturalmente idempotenti
Autodiagnosi · dovresti saper rispondere senza rileggere
- Perché «scrivi sul DB e pubblica su Kafka» può spezzare la saga in silenzio, e come l'outbox lo impedisce?
- Perché anche le compensazioni devono essere idempotenti?
- Quale contromisura corrisponde al lock pessimistico e quale a quello ottimistico?
15 · Casi d'uso reali
Come si combinano i pezzi in quattro domini che conosci.
E-commerce · checkout
Saga orchestrata a 4 passi. Pivot sull'addebito. Lo stock usa reserve/release (commutativo) invece di setQuantity, così due checkout paralleli non si sovrascrivono. Ordine in PENDING come semantic lock, con job di riconciliazione che scade le riserve dopo 15 minuti.
Prenotazione posti · volo, cinema, palestra
Il posto è una risorsa esclusiva e il think time dell'utente è lungo: Pessimistic Offline Lock applicativo (capitolo 9). Una tabella reservation con TTL di 10 minuti, non un FOR UPDATE. La scadenza è ciò che ti salva quando il browser viene chiuso.
Banking · bonifico
Il saldo è una riga calda: scrittura atomica più CHECK saldo >= 0. Se le due parti stanno in servizi diversi, saga con passo pivot sull'addebito e passo ritentabile sull'accredito — mai il contrario, altrimenti crei denaro in caso di guasto. Idempotenza obbligatoria sulla chiave del bonifico.
Editing collaborativo di un documento o profilo
Conflitti rari, client disconnessi, think time lungo: ottimistico puro. ETag + If-Match, e 412 con diff mostrato all'utente (l'editing in tempo reale a più mani è un altro problema: OT o CRDT). Il pessimistico qui sarebbe un disastro di UX (user experience).
Spesso la domanda giusta non è «quale lock», ma «perché queste due scritture confliggono?». Se tutti litigano sulla stessa riga, di solito l'aggregato è troppo grosso: una riga per movimento invece di un totale elimina la contesa alla radice. Oppure partiziona per chiave — un consumer per partizione, e la concorrenza semplicemente non esiste.
- Casi d'uso reali
- checkout saga orchestrata
- pivot sull' addebito
- stock reserve e release
- prenotazione posti Pessimistic Offline Lock
- con TTL, mai FOR UPDATE
- bonifico scrittura atomica più CHECK
- tra servizi addebito pivot, accredito ritentabile
- editing collaborativo ottimistico puro
- con ETag e If-Match
- terza via ridurre la contesa alla radice
- se l'aggregato è troppo grosso una riga per movimento
- checkout saga orchestrata
In sintesi · capitolo 15
Quattro domini, quattro combinazioni: saga orchestrata con pivot sull'addebito e stock commutativo per il checkout; Pessimistic Offline Lock con TTL per i posti; scrittura atomica più CHECK per il saldo, con saga «addebito pivot, accredito ritentabile» se i conti stanno in servizi diversi; ottimistico puro con ETag per l'editing collaborativo. E la terza via che risolve più problemi di tutte: se tutti litigano sulla stessa riga, l'aggregato è troppo grosso.
Concetti chiave
- Checkout — saga orchestrata, pivot sull'addebito,
reserve/release,PENDINGcon riconciliazione a 15 minuti - Prenotazione posti — tabella
reservationcon TTL, maiFOR UPDATEdurante il think time - Bonifico — atomico +
CHECK; tra servizi: addebito pivot, accredito ritentabile, mai il contrario - Editing collaborativo —
ETag+If-Match→412con diff - Ridurre la contesa alla radice — una riga per movimento invece di un totale; partizionare per chiave
Autodiagnosi · dovresti saper rispondere senza rileggere
- Perché nel bonifico tra servizi l'accredito non può essere il pivot?
- In quale dei quattro casi il pessimistico sarebbe un disastro di UX, e perché?
- Che cosa significa «l'aggregato è troppo grosso» in termini di conflitti?
Parte V
Oltre il modello relazionale
Tutto il documento finora parlava di database relazionali. Esistono altre famiglie, nate da esigenze che il modello relazionale serve male, e ognuna ridisegna il confine transazionale a modo suo.
16 · Le altre famiglie di database
Una mappa: che cosa sono, da quale esigenza nascono, che cosa garantiscono e quando sceglierle. Tre famiglie hanno un approfondimento dedicato in documenti separati.
Da dove nasce il bisogno
Il modello relazionale ha una forza precisa: dati normalizzati in tabelle, ricomposti al momento della query con le JOIN, protetti da ACID su un unico server. Per decenni è bastato. Le altre famiglie nascono quando una di queste tre premesse smette di reggere.
| Premessa relazionale | Quando non regge | Che cosa nasce |
|---|---|---|
| Un solo server (scalabilità verticale: comprane uno più grosso) | Dati o traffico che nessuna singola macchina sostiene; disponibilità richiesta anche se un nodo muore | Database distribuiti per costruzione, che partizionano i dati su molti nodi e accettano il compromesso di CAP (capitolo 12): Cassandra, DynamoDB |
Schema fisso e normalizzato, ricomposto con le JOIN | Oggetti di dominio annidati e variabili (un ordine con le sue righe, un profilo con campi diversi per utente) letti sempre interi | Database orientati agli aggregati, che memorizzano l'oggetto intero così com'è: MongoDB e i documentali |
| Il disco come casa dei dati | Latenza sotto il millisecondo, milioni di operazioni al secondo su dati piccoli e volatili (sessioni, contatori, cache) | Database in memoria: Redis |
La query per uguaglianza o intervallo (WHERE id = 7, WHERE data > …) | Cercare «ciò che è simile a questo», dove il criterio non è un valore ma una distanza in uno spazio a centinaia di dimensioni | Database vettoriali: pgvector, Qdrant, Milvus, Pinecone |
Relazioni come JOIN | Percorsi lunghi e imprevedibili tra entità («amici degli amici che hanno comprato…»), dove ogni salto è un'altra JOIN | Database a grafo: Neo4j |
Il termine ombrello NoSQL («not only SQL») raccoglie quasi tutte queste famiglie, ma dice solo che cosa non sono. Per capire ciò che sono conviene guardare due assi: il modello dei dati (come sono fatti gli oggetti che memorizzi) e il confine transazionale (fin dove arriva l'atomicità, che è il filo di tutto questo documento).
La mappa
| Famiglia | Modello dei dati | Esempi | Fin dove arriva l'atomicità | Casi d'uso tipici |
|---|---|---|---|---|
| Key-value | Una chiave, un valore opaco o strutturato | Redis, DynamoDB (nel suo uso base), Memcached | Il singolo comando (in Redis anche su più chiavi, ed estendibile con MULTI/EXEC o script Lua) | Cache, sessioni, contatori, code leggere, lock distribuiti |
| Documentale | Documenti JSON annidati, schema flessibile, raccolti in collezioni | MongoDB, CouchDB, Firestore | Il singolo documento (multi-documento solo con transazione esplicita) | Cataloghi, profili, contenuti, aggregati DDD letti interi |
| Wide-column | Tabelle partizionate per chiave, righe con colonne variabili, ordinate dentro la partizione | Cassandra, ScyllaDB, HBase, DynamoDB | La singola partizione (Cassandra), la singola riga (HBase), il singolo item (DynamoDB; più item con TransactWriteItems) | Serie di eventi, log, IoT (Internet of Things), scritture massive distribuite geograficamente |
| Grafo | Nodi e archi con proprietà; le relazioni sono dati di prima classe | Neo4j, Amazon Neptune | Transazioni ACID sull'intero grafo (Neo4j) | Reti sociali, raccomandazioni, antifrode, dipendenze |
| Vettoriale | Vettori numerici (embedding) con metadati, indicizzati per similarità | pgvector, Qdrant, Milvus, Weaviate, Pinecone | Il singolo vettore; nei motori dedicati l'indice può essere aggiornato in modo eventuale (pgvector è transazionale come PostgreSQL) | Ricerca semantica, RAG (Retrieval-Augmented Generation), raccomandazioni, deduplicazione |
| Ricerca full-text | Documenti con indice invertito per parola | Elasticsearch, OpenSearch | Il singolo documento, indice aggiornato in modo eventuale (near real-time) | Ricerca testuale, log analytics, osservabilità |
| Serie temporali | Misure con timestamp, compresse e aggregate per finestra | TimescaleDB, InfluxDB | Come il relazionale sottostante (Timescale) o per punto | Metriche, sensori, monitoraggio |
Le prime due colonne dicono come pensi i dati; la quarta dice quanto puoi fidarti di una singola scrittura. È la stessa tabella del capitolo 2 («dove nemmeno la singola operazione è ACID»), vista dall'altra parte: non un elenco di mancanze, ma una scelta di progetto. Ogni famiglia restringe il confine atomico alla sua unità naturale (chiave, documento, partizione) proprio perché è quella che può garantire senza coordinare più nodi.
Focus 1 · Redis: il database in memoria
Redis (REmote DIctionary Server) tiene tutti i dati in RAM e li serve con latenze di poche decine di microsecondi. Non è una cache e basta: è un server di strutture dati. Una chiave può contenere una stringa, ma anche una lista, un insieme, un insieme ordinato per punteggio, una hash, uno stream di eventi, e su ognuna esistono comandi specifici (INCR, LPUSH, ZADD, HSET…). È il motivo per cui con Redis si costruiscono contatori, classifiche, code, rate limiter e lock distribuiti con una riga di codice ciascuno.
| Chiave | Tipo | Valore | TTL |
|---|---|---|---|
| session:8f3a | hash | { user: 42, role: "admin", … } | 30 min |
| rate:api:42:2026-09-07T10 | string | "187" | 60 s |
| leaderboard:weekly | sorted set | { anna: 1200, bruno: 980, … } | — |
| orders:events | stream | [ id-1 {created…}, id-2 {paid…} ] | — |
Il legame con il filo del documento è duplice. Atomicità: Redis esegue i comandi uno alla volta su un singolo thread, quindi ogni comando è atomico per costruzione (INCR non ha lost update, mai) e MULTI/EXEC raggruppa più comandi in un blocco che nessun altro può interrompere, ma senza rollback, come anticipato nel capitolo 2. Durabilità: è la lettera che Redis ti chiede di negoziare. Con la persistenza a snapshot (RDB) puoi perdere gli ultimi minuti; con il log append-only (AOF) puoi scegliere quanto spesso fare fsync, e la scelta ti riporta esattamente al capitolo 8: più sicurezza, meno velocità. Il lock distribuito con TTL del capitolo 9 vive proprio qui, e il fencing token è la sua correzione.
Sì per dati piccoli, caldi, con scadenza naturale, letti molto più che scritti o aggiornati con operazioni atomiche semplici: cache di query costose, sessioni, contatori, rate limiting, code di lavoro, pub/sub. No come unica fonte di verità per dati che non puoi permetterti di perdere, o che non stanno in RAM. Il caso classico di errore è usarlo come cache e come sorgente senza decidere quale dei due sia autorevole: quando cache e database divergono, vince sempre chi hai dichiarato tale.
Approfondimento: Redis e i database non relazionali (documento separato, prima parte), con strutture dati, persistenza, transazioni, pattern di cache e lock.
Focus 2 · I database non relazionali orientati agli aggregati
Documentali e wide-column condividono un'idea che Martin Fowler ha chiamato aggregate-oriented: invece di spezzare un ordine in tre tabelle (ordini, righe_ordine, indirizzi) da ricomporre con le JOIN, memorizzi l'ordine intero, con le sue righe annidate dentro, e lo leggi e scrivi in un colpo solo. È letteralmente l'aggregato del DDD (Domain-Driven Design) del capitolo 6, reso unità fisica di memorizzazione.
Relazionale normalizzato
| 42 | … |
| 42 | pen | 2 |
| 42 | ink | 1 |
| 42 | … |
Documentale aggregato
{
"id": 42,
"righe": [ {"sku":"pen","q":2},
{"sku":"ink","q":1} ],
"indirizzo": { "via": "…" }
}La conseguenza sul confine transazionale è immediata e spiega la tabella sopra: l'atomicità arriva fin dove arriva l'aggregato. Un documento in MongoDB, una partizione in Cassandra. Dentro quel confine hai tutto-o-niente gratis; fuori, devi coordinare, e ricadi nei problemi della Parte IV. Per questo questi database spingono a denormalizzare: duplicare il nome del cliente dentro l'ordine, invece di puntare alla riga cliente, così che leggere l'ordine non richieda una seconda lettura. Il prezzo è che aggiornare il nome del cliente significa aggiornare mille ordini, e quel «mille ordini» non è atomico.
La seconda peculiarità è la consistenza regolabile. In un sistema distribuito con repliche, Cassandra lascia scegliere per ogni operazione quante repliche devono confermare (ONE, QUORUM, ALL); DynamoDB offre una scelta più grossolana, lettura eventuale o fortemente consistente (ConsistentRead): è la manopola tra consistenza e latenza/disponibilità (CAP e PACELC, capitolo 12) messa in mano allo sviluppatore, e il valore di default è quasi sempre eventuale. MongoDB ha la stessa manopola con writeConcern e readConcern.
Laboratorio: la manopola dei quorum
modello semplificato di CassandraTre repliche (N = 3) dello stesso dato. W è quante repliche devono confermare una scrittura, R quante ne interroga una lettura. Scrivi un valore nuovo e poi leggi, nel caso peggiore: la lettura interroga proprio le repliche che la scrittura non ha ancora raggiunto.
Sì quando il dominio ha aggregati chiari, letti e scritti interi, con forma variabile o in evoluzione, e quando serve scalare in orizzontale. No quando le query attraversano continuamente gli aggregati in direzioni diverse (reporting, analisi ad hoc: lì le JOIN sono la soluzione, non il problema) o quando un'invariante coinvolge più aggregati e non puoi accettare la consistenza eventuale. La domanda decisiva è la stessa del capitolo 6: qual è il confine dentro cui devo essere consistente subito? Se coincide con un documento, il documentale è naturale; se lo attraversa, stai per costruire una saga.
Approfondimento: Redis e i database non relazionali (documento separato, seconda parte), con documentali, wide-column e grafo, modellazione per aggregati, consistenza regolabile e casi d'uso.
Focus 3 · I database vettoriali
Un modello di machine learning può trasformare un testo, un'immagine o un prodotto in un embedding: un vettore di alcune centinaia o migliaia di numeri, costruito in modo che oggetti simili nel significato abbiano vettori vicini nello spazio. «Scarpe da corsa leggere» e «running shoes ultralight» finiscono a pochi gradi di distanza anche se non condividono una parola. La query non è più «trova dove id = 7» ma «trova i dieci vettori più vicini a questo».
Embedding · 768 dimensioni, qui 3 per disegnarlo
| Testo | Embedding | |
|---|---|---|
| «scarpe da corsa leggere» | [ 0.81, -0.12, 0.44 ] | vicini |
| «running shoes ultralight» | [ 0.79, -0.10, 0.47 ] | vicini |
| «contratto di locazione» | [-0.35, 0.62, 0.08 ] | lontano |
Query: k-NN (k nearest neighbours) = i k vettori a distanza minima (coseno, euclidea, prodotto scalare).
Il problema che rende necessaria una famiglia a sé è che nessun indice tradizionale funziona: un B-tree ordina valori su una dimensione, qui le dimensioni sono centinaia e la domanda è una distanza. Confrontare la query con ogni vettore (scansione esatta) costa linearmente e su milioni di righe non regge. I database vettoriali usano indici approssimati (ANN, Approximate Nearest Neighbour: HNSW, IVF) che trovano quasi sempre i vicini giusti in tempo sublineare (circa logaritmico per HNSW), accettando di sbagliarne qualcuno. È un compromesso nuovo rispetto a tutto il documento: correttezza approssimata in cambio di velocità, misurata come recall.
Il legame con il filo transazionale è meno ovvio ma c'è. Gli indici ANN sono costosi da aggiornare: molti motori li ricostruiscono o li aggiornano in modo asincrono, quindi un vettore appena inserito può non essere ancora trovabile per qualche secondo. È consistenza eventuale a livello di indice, e va saputo quando si progetta un flusso «scrivi il documento, poi cercalo». E quando i vettori vivono in un database separato da quello dei dati (Qdrant accanto a PostgreSQL), aggiornare entrambi è di nuovo un problema a due datastore, con outbox e idempotenza (capitolo 14); è il motivo per cui pgvector, che mette i vettori in una colonna di PostgreSQL, è così attraente: il vettore e la riga stanno nella stessa transazione.
Sì per ricerca semantica, RAG (Retrieval-Augmented Generation: recuperare i passaggi pertinenti da dare in contesto a un modello linguistico), raccomandazioni per somiglianza, deduplicazione di contenuti quasi identici. Un database dedicato quando i vettori sono decine di milioni o la ricerca è il carico principale; pgvector quando sono meno e conta più la coerenza con il resto dei dati. No come sostituto della ricerca per parola chiave esatta (un codice prodotto, un nome proprio): lì l'indice invertito del full-text vince, e la pratica migliore è la ricerca ibrida che combina i due.
Approfondimento: I database vettoriali (documento separato), con embedding, metriche di distanza, indici HNSW e IVF, filtri, ricerca ibrida, pgvector contro motori dedicati e il flusso RAG.
Il filo comune: la persistenza poliglotta
Un sistema reale usa quasi sempre più di una famiglia: PostgreSQL per gli ordini, Redis per le sessioni, Elasticsearch per la ricerca, un vettoriale per le raccomandazioni. Si chiama persistenza poliglotta, ed è la regola, non l'eccezione. Ha però una conseguenza che tutto il documento ti ha preparato a riconoscere: ogni coppia di datastore è un confine transazionale spezzato. Scrivere l'ordine su PostgreSQL e invalidare la cache su Redis, oppure indicizzarlo su Elasticsearch, è lo stesso problema del capitolo 14 (scrivere sul DB e pubblicare su Kafka): due risorse, nessuna transazione che le abbracci. Le soluzioni sono le stesse: una sola fonte di verità dichiarata, outbox o CDC (Change Data Capture) per propagare, idempotenza a valle, e l'accettazione esplicita che i datastore secondari siano eventualmente allineati.
PostgreSQL
fonte di verità, ACID
- Altre famiglie di database
- nascono quando una premessa relazionale non regge
- un solo server Cassandra, DynamoDB
- schema normalizzato documentali
- disco come casa Redis
- ridefiniscono il confine atomico
- alla loro unità chiave, documento, partizione
- aggregate-oriented l'aggregato è l'unità fisica
- negoziano una lettera di ACID
- Redis la durabilità
- distribuiti consistenza regolabile
- i vettoriali cercano per similarità
- usano embedding e indici ANN
- cambiano esattezza in velocità
- convivono in persistenza poliglotta
- ogni coppia è un confine spezzato
- si gestisce con outbox, CDC, idempotenza capitolo 14
- nascono quando una premessa relazionale non regge
In sintesi · capitolo 16
Le altre famiglie di database nascono quando una premessa del modello relazionale smette di reggere: un solo server, schema normalizzato con JOIN, disco come casa dei dati, query per uguaglianza. Ognuna ridefinisce il confine atomico alla propria unità naturale (chiave, documento, partizione, vettore) e chiede di negoziare qualche lettera di ACID: Redis la D, i distribuiti la C (consistenza regolabile), i vettoriali persino l'esattezza del risultato. In un sistema reale convivono, e ogni coppia di datastore è un confine transazionale spezzato da gestire con gli strumenti della Parte IV.
Concetti chiave
- NoSQL — termine ombrello per le famiglie non relazionali; dice che cosa non sono, non che cosa sono
- Key-value / in memoria (Redis) — strutture dati in RAM con comandi atomici per costruzione; la durabilità è negoziata (RDB, AOF, frequenza di
fsync) - Aggregate-oriented (documentali, wide-column) — l'aggregato DDD diventa unità fisica; l'atomicità arriva fin dove arriva l'aggregato; denormalizzazione come conseguenza
- Consistenza regolabile — quante repliche devono confermare (
ONE/QUORUM/ALL,writeConcern): la manopola di CAP in mano allo sviluppatore - Embedding e k-NN — oggetti simili diventano vettori vicini; la query è una distanza, non un'uguaglianza
- ANN (HNSW, IVF) — indici approssimati: velocità in cambio di un recall inferiore al 100%; aggiornamento spesso asincrono
- Persistenza poliglotta — più datastore convivono; ogni coppia è un confine spezzato: fonte di verità unica, outbox/CDC, idempotenza
Autodiagnosi · dovresti saper rispondere senza rileggere
- Per ciascuna famiglia, fin dove arriva l'atomicità e perché proprio lì?
- Perché
INCRin Redis non può avere un lost update, mentreSELECT+UPDATEsu PostgreSQL sì? - Che cosa si guadagna e che cosa si perde memorizzando l'ordine come documento invece che su tre tabelle?
- Perché un B-tree non serve per la ricerca per similarità, e quale compromesso nuovo introducono gli indici ANN?
- Scrivi l'ordine su PostgreSQL e invalidi la cache su Redis: quale problema del documento stai rivivendo, e con quali strumenti?
Parte VI
Riferimenti e verifica
La cronologia delle idee, il glossario completo e il test di autovalutazione.
17 · Riferimenti
Quarant'anni in dieci righe
Nessuno dei pattern di questo documento è nato per i microservizi. Sapere da quale problema vengono spiega perché hanno la forma che hanno.
Glossario
| Termine | Significato |
|---|---|
| WAL (write-ahead log) | Write-ahead log: il log sequenziale scritto prima delle pagine dati. È ciò che rende possibile la durabilità e il recovery |
| MVCC (multiversion concurrency control) | Ogni scrittura crea una nuova versione della riga; i lettori vedono uno snapshot coerente e non bloccano nessuno |
| Snapshot isolation | La transazione vede il database «congelato» al proprio istante di inizio. Previene i phantom, non il write skew |
| Write skew | Due transazioni leggono lo stesso insieme e scrivono righe diverse, violando insieme un'invariante che nessuna violava da sola |
| 2PL (two-phase locking) | Two-phase locking: prima acquisisci soltanto, poi rilasci soltanto. La strategia di isolamento alternativa a MVCC |
| 2PC (two-phase commit) | Two-phase commit: prepare (voto) + commit (esecuzione), guidati da un coordinatore |
| In-doubt | Lo stato del partecipante che ha votato sì e non ha ancora ricevuto l'esito. Non può decidere da solo, e tiene i lock |
| XA (eXtended Architecture) / JTA (Java Transaction API) | Lo standard X/Open per il commit distribuito e la sua incarnazione Java |
| LLT (long-lived transaction) | Long-lived transaction: il problema originale che la saga risolveva nel 1987 |
| Compensazione | Operazione di business che annulla semanticamente un passo già committato. Non è un rollback |
| Idempotenza | Eseguire l'operazione due volte produce lo stesso risultato di eseguirla una volta |
| At-least-once | Il broker garantisce che il messaggio arrivi, non che arrivi una volta sola. Da qui l'obbligo di idempotenza |
| Outbox | Tabella in cui l'evento viene scritto nella stessa transazione del dato, per essere pubblicato dopo |
| CDC (change data capture) | Change data capture: leggere il WAL del database per produrre eventi (Debezium). Il modo più solido di svuotare l'outbox |
| Fencing token | Numero crescente rilasciato con un lock distribuito: la risorsa rifiuta i token vecchi, impedendo a un proprietario scaduto di scrivere |
| xmin / xmax | Marcatori nascosti di ogni versione di riga: la transazione che l'ha creata e quella che l'ha sostituita. Con lo snapshot determinano la visibilità |
| First-updater-wins | Regola di REPEATABLE_READ: chi scrive per primo vince, il secondo viene abortito perché la versione che voleva sovrascrivere non è più quella che aveva letto |
| SSI (Serializable Snapshot Isolation) | Implementazione ottimistica di SERIALIZABLE in PostgreSQL: traccia le dipendenze lettura-scrittura e aborta una delle transazioni che formano una struttura potenzialmente non seriale (con possibili falsi positivi) |
| Lettura corrente | In InnoDB, una lettura che ignora lo snapshot e prende l'ultima versione committata: è ciò che fanno UPDATE, DELETE e SELECT … FOR UPDATE |
| Checkpoint | Momento in cui il DB forza su disco le pagine sporche fino a un certo punto del log e lo annota: da lì il log si ricicla e il recovery riparte |
| LSN (Log Sequence Number) | Posizione progressiva di un record nel log. Confrontata con l'LSN scritto nella pagina, dice se una modifica è già stata applicata |
| Bloat | Crescita delle tabelle dovuta a versioni vecchie non rimosse, tipicamente causata da transazioni tenute aperte troppo a lungo |
| Errore 40001 | serialization_failure: il DB aborta una transazione in conflitto invece di bloccarla. Va gestito con una retry |
| Indice parziale | Indice con una WHERE: contiene solo le righe che la soddisfano. Se UNIQUE, esprime l'invariante «al più uno attivo per chiave» |
| Parametro bind | Segnaposto ($1, ?) quando il piano è generico, il valore non è noto al momento della pianificazione: il planner non può dimostrare l'implicazione richiesta da un indice parziale |
| EvalPlanQual | Meccanismo PostgreSQL che, dopo l'attesa su una riga bloccata, rivaluta il predicato sulla versione nuova. Se non è più soddisfatto, la riga esce dal risultato |
| Indice | Struttura dati separata (di solito un B-tree) che tiene ordinati i valori di una colonna, per evitare la scansione dell'intera tabella |
| Foreign key | Vincolo che impone a una colonna di corrispondere a una chiave (primaria o UNIQUE) di un'altra tabella, o della stessa. Con ON DELETE CASCADE la cancellazione del padre propaga ai figli |
| Trigger | Codice eseguito automaticamente dal DB al verificarsi di un evento su una tabella. Sta dentro il confine transazionale, ma i suoi effetti esterni no |
| Autocommit | Transazione implicita che il DB apre e chiude attorno a ogni istruzione quando non ne dichiari una tu |
| DDL (Data Definition Language) | Le istruzioni che modificano lo schema (CREATE, ALTER, DROP). Non ovunque sono transazionali |
| POSIX (Portable Operating System Interface) | Famiglia di standard IEEE che definisce l'interfaccia comune dei sistemi Unix-like: quali system call esistono e cosa devono garantire |
| Page cache | Copia in RAM delle pagine di file gestita dal kernel. write() ci scrive dentro e ritorna: i dati non sono ancora durevoli |
| fsync | System call che blocca il chiamante finché le pagine sporche di un file non sono su supporto non volatile. Il commit di un DB è questo |
| Redo / undo | Le due facce del log: il valore nuovo per rifare ciò che era committato, il valore vecchio per disfare ciò che non lo era |
| Group commit | Raggruppare i commit arrivati nello stesso istante e renderli durevoli con un solo fsync |
| Lock (condiviso / esclusivo) | Prenotazione di una risorsa tenuta fino al commit. Condiviso per leggere (compatibile con altri lettori), esclusivo per scrivere (incompatibile con tutti) |
| Deadlock | Attesa circolare: T1 tiene A e aspetta B, T2 tiene B e aspetta A. Il DB lo rileva e abortisce una delle due (40P01) |
SELECT … FOR UPDATE | Lettura che prende sulle righe restituite il lock esclusivo di una scrittura, fino al commit. In JPA: PESSIMISTIC_WRITE |
@Version | Campo JPA incrementato a ogni scrittura e verificato nella WHERE dell'UPDATE: se non combacia, OptimisticLockException. È il lock ottimistico applicativo |
| Lock distribuito | Chiave con TTL (time to live) in un sistema condiviso (Redis, ZooKeeper) che vale «la risorsa è mia». Non è fatto rispettare dalla risorsa protetta: da qui la necessità del fencing token |
| Database per service | Regola delle architetture a servizi: ogni servizio possiede il proprio datastore. È il motivo per cui il confine ACID si spezza tra servizi |
| Persistence context | Cache di primo livello di JPA: le entità già caricate nella transazione vengono restituite dalla memoria, non rilette dal DB |
| NoSQL | Termine ombrello per le famiglie non relazionali (key-value, documentali, wide-column, grafo, vettoriali): ciascuna restringe il confine atomico alla propria unità naturale |
| Aggregate-oriented | Database che memorizzano l'aggregato intero (documento, partizione) come unità di lettura, scrittura e atomicità |
| Consistenza regolabile | Scelta per operazione di quante repliche devono confermare (ONE/QUORUM/ALL, writeConcern): la manopola tra consistenza e latenza/disponibilità |
| Embedding | Vettore numerico prodotto da un modello, tale che oggetti simili nel significato abbiano vettori vicini |
| ANN (Approximate Nearest Neighbour) / HNSW | Indici per la ricerca dei vettori più vicini che accettano un recall inferiore al 100% in cambio di tempo sublineare |
| Persistenza poliglotta | Uso di più famiglie di database nello stesso sistema; ogni coppia di datastore è un confine transazionale spezzato |
| CAP (Consistency, Availability, Partition tolerance) / PACELC | I due enunciati che descrivono cosa devi sacrificare in presenza di partizioni, e cosa continui a sacrificare anche senza |
18 · Test di autovalutazione
Quarantaquattro domande su tutto quello che c'è sopra. Ogni risposta mostra la spiegazione, giusta o sbagliata che sia.