Guida interattiva · dal singolo commit alle architetture a servizi

Quando il COMMIT non basta più

Dalle operazioni di base al funzionamento interno di un database, cosa promette davvero ACID (Atomicity, Consistency, Isolation, Durability) lettera per lettera, come si gestisce la concorrenza e cosa resta quando il confine transazionale si spezza in più servizi. Gli esempi usano PostgreSQL, con i confronti con MySQL/InnoDB dove il comportamento cambia; ogni laboratorio dice se riproduce un comportamento reale o un modello semplificato.

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
idsaldo
1100
2250
340
→sta in
pagina 17 su disco fisica, 8 KB
header (LSN, un numero spiegato nel capitolo 3; spazio libero…)
riga id=1 · saldo=100
riga id=2 · saldo=250
riga id=3 · saldo=40
…

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).

IstruzioneChe cosa faEsempio
SELECTLegge righe. Non modifica nulla, ma è comunque un'operazione che il database deve coordinare con le scritture altruiSELECT saldo FROM conti WHERE id = 1;
INSERTCrea una riga nuovaINSERT INTO conti (id, saldo) VALUES (4, 0);
UPDATEModifica righe esistenti, quelle selezionate dalla WHERE. Senza WHERE le modifica tutteUPDATE conti SET saldo = 70 WHERE id = 1;
DELETECancella le righe selezionate dalla WHEREDELETE 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.

ParolaSignificato
BEGINApre una transazione: «da qui in avanti, tutto ciò che faccio va considerato insieme»
COMMITChiude confermando: le modifiche diventano definitive e visibili agli altri, tutte nello stesso istante
ROLLBACKChiude annullando: il database riporta tutto a com'era al BEGIN, come se nulla fosse accaduto
SQL · bonifico
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.

SQL · autocommit
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).

autocommit
[BEGIN] SELECT saldo [COMMIT]—[BEGIN] UPDATE saldo [COMMIT]
esplicito
BEGIN—SELECT saldo—UPDATE saldo—COMMIT

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:

Regola da ricordare

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:

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.

la tua istruzione tocca la riga 1
↓
il database chiede il permesso al lock manager
↓
nessuno ha un permesso incompatibile Concesso
prosegue · l'istruzione prosegue; il permesso resta tuo fino a COMMIT / ROLLBACK
qualcuno ne ha uno incompatibile Attesa
ferma · l'istruzione resta ferma finché quel qualcuno non committa o annulla, poi il permesso passa a te e l'istruzione riparte

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:

TipoChe cosa dichiara chi lo prendeQuanti possono tenerlo insieme sulla stessa rigaChi 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 contemporaneamenteChi 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 compresiChi 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
T1 SELECT saldo … WHERE id=1 → lock condiviso su riga 1 ✔
T2 SELECT saldo … WHERE id=1 → chiede un condiviso; T1 ne tiene uno condiviso: compatibili ✔
procede · T2 legge subito. Nessuno aspetta.
Caso 2 T1 legge, T2 vuole scrivere
T1 SELECT saldo … WHERE id=1 → lock condiviso su riga 1 ✔
T2 UPDATE conti … WHERE id=1 → chiede un esclusivo; T1 tiene un condiviso: incompatibili ✘
aspetta · T2 aspetta che T1 committi. Non si può cambiare una riga mentre qualcuno la sta leggendo: leggerebbe metà vecchia e metà nuova.
Caso 3 T1 scrive, T2 vuole leggere
T1 UPDATE conti … WHERE id=1 → lock esclusivo su riga 1 ✔
T2 SELECT saldo … WHERE id=1 → chiede un condiviso; T1 tiene un esclusivo: incompatibili ✘
aspetta · Altrimenti leggerebbe un valore provvisorio, che T1 potrebbe ancora annullare.
Caso 4 T1 scrive, T2 vuole scrivere
T1 UPDATE conti … WHERE id=1 → lock esclusivo su riga 1 ✔
T2 UPDATE conti … WHERE id=1 → chiede un esclusivo; T1 tiene un esclusivo: incompatibili ✘
aspetta · Poi riparte sul valore che T1 ha committato. Le due scritture non si intrecciano mai.
Da ricordare

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.

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.

T1
T2
1
UPDATE conti WHERE id='anna' lock su anna ✔
UPDATE conti WHERE id='bruno' lock su bruno ✔
2
UPDATE conti WHERE id='bruno' → aspetta T2 …
UPDATE conti WHERE id='anna' → aspetta T1 …
Deadlock Nessuna delle due finirà mai da sola.

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
saldo = 100 versione committata · ciò che T2 e tutti i lettori vedono tutti
saldo = 70 versione di T1 · visibile solo a T1, finché non committa solo T1
T2 SELECT saldo … WHERE id=1 → 100 subito, senza aspettare T1 e senza chiedere lock
T1 COMMIT da ora la versione 70 è quella committata; la 100 resta finché qualche transazione ancora aperta ha bisogno di vederla

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.

SituazioneSolo lockLock + snapshot (MVCC)
Caso 1 · leggere mentre un altro leggenessuno aspettanessuno aspetta
Caso 2 · scrivere mentre un altro leggelo scrittore aspettanessuno aspetta: il lettore continua a vedere la sua fotografia
Caso 3 · leggere mentre un altro scriveil lettore aspettanessuno aspetta: il lettore vede la versione committata
Caso 4 · scrivere mentre un altro scriveil secondo scrittore aspettail secondo scrittore aspetta (lock esclusivo, come prima)

Laboratorio: chi aspetta?

comportamento reale, semplificato

Stessa 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.

T1
BEGIN—SELECT saldo → 100 snapshot, nessun lock—if (100 ≥ 30) ok—UPDATE saldo = 70 ORA prende il lock X—COMMIT
T2
UPDATE saldo = 50 libero: T1 non ha ancora nessun lock—COMMIT

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
SELECT · snapshot dell'istruzione: vede 100
UPDATE saldo = 70 · nuovo snapshot, versione corrente 50: aspetta T2, poi scrive 70 sopra 50
il prelievo di T2 è sparito: lost update. Ogni istruzione ha il proprio snapshot, l'UPDATE non «ricorda» la SELECT
vs
REPEATABLE_READ uno snapshot per transazione
SELECT · snapshot della transazione: vede 100
UPDATE saldo = 70 · versione corrente 50, creata dopo il mio snapshot: non posso scriverci sopra senza tradire la foto
40001 · T1 abortita, va riprovata da capo. Il lost update non passa, ma non perché l'UPDATE abbia usato lo snapshot: perché lo ha confrontato con la realtà e ha visto che divergevano
Idea sbagliata

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 · snapshot, nessun lock: la riga resta libera
UPDATE · lock X da qui al commit
vs
SELECT … FOR UPDATE stessa transazione
SELECT FOR UPDATE · lock X da qui al commit
UPDATE · il lock era già mio

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
UPDATE · lock X preso
[COMMIT implicito] · lock X rilasciato subito
l'istruzione dopo trova la riga libera: chiunque può scriverci
vs
transazione esplicita confine = gruppo
UPDATE · lock X preso
SELECT, UPDATE, … · il lock resta mio per tutte
COMMIT · rilasciato solo qui

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.

In una riga

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.

La frase da ricordare

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.

Mappa concettuale · le basi
  • 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

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 SELECT prima dell'UPDATE resta senza lock, salvo FOR 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

  1. Che differenza c'è tra una riga e una pagina, e perché al database interessa la seconda?
  2. Se non scrivi mai BEGIN, le tue istruzioni sono dentro una transazione? Chi la chiude?
  3. Nei quattro casi (leggo/leggo, leggo/scrivo, scrivo/leggo, scrivo/scrivo), chi aspetta con i soli lock? E con lo snapshot?
  4. Perché un lock di scrittura si tiene fino al commit e non viene rilasciato appena l'istruzione finisce?
  5. 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 maleLa domanda che ti faiLa proprietà che risponde
Il processo muore tra le due istruzioni: i 100 € sono usciti da Anna e non sono arrivati a BrunoCome 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 zeroCome 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 dopoCome 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.

Perché è fondamentale averle

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
AtomicitySì, 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)
IsolationSì 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
DurabilitySì, con l'fsync del commit implicito (capitoli 3 e 8)
ConsistencySolo quella dichiarata nei constraint. L'invariante che vive nel tuo service layer non la conosce nessuno (capitolo 6)

Istruzione SQL ≠ operazione di business

Il malinteso che genera più bug

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
UPDATE saldo = saldo - 10
↓
atomica · da sola, sempre
≠
Una operazione di business tre pezzi
SELECT saldo
if (saldo >= 10) { … }
UPDATE saldo = 90
attenzione · ogni pezzo è atomico e isolato, l'insieme non lo è, a meno di racchiuderlo in BEGIN … COMMIT

Prendiamo il prelievo di 10 € dalla colonna di destra e guardiamo dove finiscono le garanzie:

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.

T1 · preleva 10
T2 · preleva 30
1
SELECT saldo → 100
2
if (100 >= 10) ok
3
SELECT saldo → 100
4
if (100 >= 30) ok
5
UPDATE saldo = 70 ; COMMIT ✔
6
UPDATE saldo = 90 ; COMMIT ✔ ← calcolato su 100, che non è più vero
Lost update Saldo finale 90. Corretto: 60. Il prelievo di 30 è sparito.

Calcolo · i due prelievi in parallelo

T2=100 − 30 = 70
T2 legge 100, sottrae 30 e scrive 70
T1=100 − 10 = 90
T1 aveva letto 100: scrive 90 sopra il 70 di T2
saldo corretto=100 − 10 − 30 = 60

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 problemaSELECT + if + UPDATE in autocommit…dentro BEGIN … COMMITChe cosa lo risolve
Crash tra il controllo e la scritturanulla da disfare: due lavori distintitutto o niente: la transazione bastail confine transazionale
Qualcuno scrive nella finestra (lost update)la riga è libera fino all'UPDATEla riga è libera fino all'UPDATE: il BEGIN non prende lockUPDATE atomico, FOR UPDATE, @Version, oppure REPEATABLE_READ che trasforma il danno in errore 40001 (capitoli 7, 9, 10)
Perché la scrittura atomica è la prima soluzione, prima di qualunque lock

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

ContestoCosa manca
DDL in MySQL / OracleCREATE 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
MyISAMNessuna transazione, nessun rollback. È il motivo per cui InnoDB l'ha sostituito come motore di default in MySQL
MongoDBAtomicità garantita sul singolo documento. Multi-documento richiede una transazione esplicita
RedisIl singolo comando è atomico, ma MULTI/EXEC non fa rollback se un comando fallisce
Cassandra / DynamoDBCassandra: 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.

Mappa concettuale · che cos'è una transazione
  • 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

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 SELECT e la UPDATE successiva, 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

  1. Per ciascuna lettera di ACID, quale guasto concreto del bonifico previene?
  2. Perché «la finestra è scoperta» e «lost update» non sono la stessa cosa? Qual è causa e quale effetto?
  3. Con saldo 100 e due prelievi concorrenti di 10 e 30, come si arriva a un saldo finale di 90?
  4. Perché una singola UPDATE su 10.000 righe è atomica ma SELECT + if + UPDATE su 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.

RAMDisco (SSD o rotante)
Velocitànanosecondida decine di microsecondi a millisecondi: mille o centomila volte più lento
Se salta la correntesi svuota: tutto persoresta
Scrivere in posizioni sparse
(ed è così che un database modifica i dati: vedi sotto)
costa come scrivere in filacosta 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.201

Tre 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
pagina 17 seek
pagina 4.913 seek
pagina 30.201 seek
costo · 3 scritture sparse ≈ 3 × (5–15 ms)
vs
Con log annoto in fondo, sistemo dopo
log: …nota: id 1 → 70nota: id 8204 → …nota: id 51377 → …

una dietro l'altra, in fila: un solo posizionamento. Le tre pagine verranno scritte più tardi, tutte insieme.

costo · 3 scritture in fila ≈ 5 ms + 3 × 0,05 ms

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.

Prima un chiarimento: «in fila» e «sparso» descrivono l'uso, non il dispositivo

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

DispositivoIn fila
42, 43, 44, 45, 46
Sparso
42, 9.317, 1.204, 77.560, 500
ConfrontoPenalità
RAM~ 0,5 µs~ 0,5 µs
filasparso
≈ nessuna
SSD (scrittura)~ 50 µs~ 100–250 µs
sotto carico
filasparso
≈ 2–5×
Disco rotante~ 5 ms~ 25–75 ms
filasparso
≈ 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.

  1. Seek: il braccio si sposta sulla traccia~ 3–10 ms
  2. Rotazione: aspetta che il settore arrivi~ 2–4 ms
  3. Trasferimento: legge/scrive i byte in fila~ 0,05 ms per 8 KB
sparso · un accesso «sparso» costa ~ 5–15 ms, quasi tutto in 1 e 2
in fila · un accesso «in fila» (il settore successivo) costa solo il 3

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
4243444546

un solo seek, poi scorre

5 pagine · ≈ 5 ms + 5 × 0,05 ms ≈ 5 ms
vs
In ordine sparso casuale
42…9.317…1.204…77.560

seek + rotazione a ogni scrittura

5 pagine · ≈ 5 × (5–15 ms) ≈ 25–75 ms

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:

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
p0p1p2p3p4p5…p127

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.

costo · 8 KB
caso peggiore · quando i blocchi liberi finiscono Garbage collection
1 copio le pagine ancora valide in un blocco libero
2 cancello tutto il blocco (unica operazione di cancellazione possibile)

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

OperazioneRAMSSDDisco rotante
Un accesso in posizione arbitraria~ 100 ns~ 50–100 µs~ 5–15 ms
Rapporto rispetto alla RAM1~ 1.000×~ 100.000×
Scritture sparse al secondo (ordine di grandezza)milionida decine a centinaia di migliaia (con usura)100–200
Scrittura in fila—0,5–7 GB/s100–200 MB/s
Senza correntesi svuotaconservaconserva
Che cosa ne segue per il database

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

Analogia · il cameriere

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:

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:

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.

Come fa il database a capire che una nota è incompleta: il checksum

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
1 · costruisci la nota: lunghezza · LSN · dove (file, pagina 17, offset della riga) · «riga 1: 100 → 70»
2 · calcola CRC su tutti quei byte → 0x9F3A…
3 · scrivi in fondo al log: [intestazione con lunghezza e CRC][contenuto]
→crash?
in lettura al riavvio, il log su DISCO viene riletto nota per nota
1 · leggi l'intestazione: lunghezza, CRC atteso
2 · leggi lunghezza byte di contenuto e ricalcola il CRC
coincide → nota valida, continua con la successiva ✔
non coincide (o i byte non bastano) → qui il log finisce: tutto ciò che segue è ignorato stop

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.

MomentoRAM · veloce, volatileDisco · lento, durevole
UPDATE1. nota nel log
2. modifica la pagina in RAM sporca
log: «riga 1: 100 → 70» sequenziale
COMMIT3. fsync del log, poi risponde «fatto» all'applicazionela nota è davvero su disco durevole
più tardi
in background
4. la pagina sporca viene scritta al suo postofile 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é

ComponenteChe cos'èQuale problema risolve
Buffer poolL'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 nuovoRendere durevole l'intenzione con una scrittura sequenziale, invece del risultato con una scrittura sparsa
fsyncLa chiamata al sistema operativo che dice «non tornare finché questi byte non sono fisicamente sul disco». Il capitolo 8 la smonta pezzo per pezzoSenza, 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ì
CheckpointL'operazione periodica che scrive su disco le pagine sporche accumulate e annota nel log fin dove si è arrivatiImpedire al log di crescere all'infinito e accorciare il riavvio (sezione dedicata più sotto)
Lock managerIl 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
applicazione
BEGIN / UPDATE / COMMIT
LOCK MANAGER
chi può toccare cosa
BUFFER POOL
pagine in RAM (sporche)
WAL
log append-only → fsync su disco
file dati su disco
in background, con calma
Il commit è un 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
(la pagina 17 non c'è)
Disco · log (WAL) durevole
… ultima nota: LSN 4179
Disco · file dati durevole
pag. 17: id=1 saldo=100

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
pag. 17: saldo=100 = disco
Disco · log (WAL) durevole
… LSN 4179
Disco · file dati durevole
pag. 17: saldo=100

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
pag. 17: saldo=100
nota 4180 «100 → 70» in attesa di andare su disco buffer log
Disco · log (WAL) durevole
… LSN 4179
la 4180 non c'è ancora
Disco · file dati durevole
pag. 17: saldo=100

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
pag. 17: saldo=70 sporca
nota 4180 «100 → 70»
Disco · log (WAL) durevole
… LSN 4179
Disco · file dati durevole
pag. 17: saldo=100 vecchio
Chi applica la modifica alla pagina? Nessun «processo in ascolto» sul WAL

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
pag. 17: saldo=70 sporca
Disco · log (WAL) durevole
… LSN 4179
LSN 4180 · «riga 1: 100 → 70, COMMIT» fsync ✔
Disco · file dati durevole
pag. 17: saldo=100 ancora vecchio!

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
pag. 17: saldo=70 pulita
Disco · log (WAL) durevole
… LSN 4179, LSN 4180
Disco · file dati durevole
pag. 17: saldo=70 aggiornata

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

  1. la pagina che contiene la riga 1 viene cercata nel buffer pool se non c'è, viene letta da disco e messa in RAM
  2. nella pagina in RAM il lock manager verifica che nessun altro tenga la riga 1, e la blocca
  3. nel log viene scritto: «riga 1: saldo era 100, diventa 70»
  4. la pagina in RAM viene modificata e marcata «sporca»
  5. al COMMIT il log viene forzato su disco (fsync)
  6. la pagina sporca finirà su disco più tardi, in background
  7. 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
saldo=100 · xmin=50 versione vecchia: mai toccata resta
saldo=70 · xmin=T88 versione nuova: resta fisicamente nella pagina morta
all'abort il database scrive «T88 abortita» nel registro delle transazioni (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ù tardi
vs
InnoDB / Oracle modifica sul posto + undo log
saldo=70 la riga è stata sovrascritta nella pagina
undo: «era 100» salvato a parte prima di scrivere
all'abort il database esegue l'undo a caldo: rilegge il valore vecchio e lo riscrive nella pagina in RAM, che ora contiene di nuovo 100, resta sporca e andrà su disco più tardi. È il redo/undo del capitolo 5, applicato subito invece che al riavvio

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 trovoChe 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 committatolog: 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, semplificato

Segui 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

LSNModifica sulla riga 1Causa
4180saldo 100 → 70prelievo di 30
4195saldo 70 → 40prelievo di 30
4200saldo 40 → 100accredito di 60
poi · crash

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 = 70dopo la 4180, prima della 4195
saldo = 40dopo 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

4180 · 100→704195 · 70→404200 · 40→100
Scenario A la pagina non era mai stata scritta dopo le tre note
▸ trovo su disco: saldo=100, header.LSN=4150
4180 4180 > 4150 applica→ saldo=70, header=4180
4195 4195 > 4180 applica→ saldo=40, header=4195
4200 4200 > 4195 applica→ saldo=100, header=4200
risultato · saldo=100 ✔
Scenario B la pagina era stata scritta dopo la prima nota
▸ trovo su disco: saldo=70, header.LSN=4180
4180 4180 > 4180 ? no salta→ già dentro
4195 4195 > 4180 applica→ saldo=40, header=4195
4200 4200 > 4195 applica→ saldo=100, header=4200
risultato · saldo=100 ✔
Scenario C la pagina era stata scritta dopo la seconda nota
▸ trovo su disco: saldo=40, header.LSN=4195
4180 4180 > 4195 ? no salta
4195 4195 > 4195 ? no salta
4200 4200 > 4195 applica→ saldo=100, header=4200
risultato · saldo=100 ✔
Scenario D la pagina era stata scritta dopo tutte e tre le note
▸ trovo su disco: saldo=100, header.LSN=4200
4180 4180 > 4200 ? no salta
4195 4195 > 4200 ? no salta
4200 4200 > 4200 ? no salta
risultato · saldo=100 ✔ (non ha toccato nulla, e aveva ragione)

Confronta 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.

Effetto collaterale prezioso: il redo diventa idempotente

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:

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)
4180 · pag. 17: 100 → 70
4185 · pag. 4.913: 500 → 450
4195 · pag. 17: 70 → 40
4203 · pag. 30.201: 90 → 60
4210 · pag. 30.201: 60 → 20
4215 · pag. 17: 40 → 100 la RAM qui accanto è fotografata PRIMA che la 4215 venga applicata attenzione
RAM · buffer pool sporche
pag. 17 · saldo=40 hdr=4195 sporca
pag. 4.913 · saldo=450 hdr=4185 sporca
pag. 30.201 · saldo=20 hdr=4210 sporca
Disco · file dati
pag. 17 · saldo=100 hdr=4150 vecchia
pag. 4.913 · saldo=500 hdr=4100 vecchia
pag. 30.201 · saldo=90 hdr=3980 vecchia

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).

PaginaIn RAM (durante il passo 2)File dati, dopo il passo 2
17saldo=40 · hdr=4195→saldo=40 · hdr=4195 ✔
4.913saldo=450 · hdr=4185→saldo=450 · hdr=4185 ✔
30.201saldo=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
4180 · pag. 17: 100 → 70 riciclabile
4185 · pag. 4.913: 500 → 450 riciclabile
4195 · pag. 17: 70 → 40 riciclabile
4203 · pag. 30.201: 90 → 60 riciclabile
4210 · pag. 30.201: 60 → 20 riciclabile
4211 · ★ CHECKPOINT tutto prima di 4210 è nei file dati checkpoint
4215 · pag. 17: 40 → 100 arrivata dopo: NON coperta va tenuta

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
… 4211 ★ CHECKPOINT (fino a 4210) checkpoint
4215 · pag. 17: 40 → 100 dopo
File dati al riavvio
pag. 17 · saldo=40 hdr=4195
pag. 4.913 · saldo=450 hdr=4185
pag. 30.201 · saldo=20 hdr=4210

Il recovery

  1. cerca l'ultimo record di checkpoint → 4211, che dice «tutto prima di 4210 è a posto»
  2. ignora tutto ciò che precede: non lo legge nemmeno
  3. riparte dalla 4215: 4215 > 4195 (header della pagina 17) → applica → saldo=100, hdr=4215
  4. 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 legame con l'LSN, e perché servono entrambi

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.

Perché conviene rimandare invece di scrivere subito

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 scritturaSequenziale, append-onlySparsa, in posizioni arbitrarie
Quando avvieneA ogni modifica, forzata al COMMITDifferita, in blocco al checkpoint
Se lo perdiPerdi transazioni già confermate all'utenteRicostruibile solo da un backup più il WAL archiviato da quel momento (il log più vecchio dell'ultimo checkpoint viene riciclato)
Il compromesso, e due cose che vedrai in produzione

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
ultimo checkpoint: 4211 · redo point 4210 da qui
Log (WAL) letto in avanti da 4210
… 4180 · 4185 · 4195 · 4203 prima del redo point: non vengono nemmeno letti
4211 ★ CHECKPOINT (fino a 4210) CRC ✔
4215 · pag. 17: 40 → 100 CRC ✔
4222 · pag. 4.913: 450 → 300 CRC ✔
4230 · intestazione presente, contenuto troncato CRC ✘ · fine del log

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»
LSN 4215
indirizzo: file 16384 · blocco 17 · riga 3 quale pagina
modifica: saldo 40 → 100
→carica
nell'header della pagina 17 «fino a dove»
header.LSN = 4195 se applicare
4195 < 4215 → la modifica non c'è ancora: applica, header = 4215

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».

In una frase

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.

Mappa concettuale · come funziona un database
  • 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

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

  1. Subito dopo il COMMIT di saldo = 70, che valore c'è nel file dati su disco? Perché non è un problema?
  2. Salta la corrente dopo il commit ma prima che la pagina sia scritta: che cosa trova il riavvio su disco e che cosa fa?
  3. Perché il redo non può fidarsi dei valori e ha bisogno dell'LSN?
  4. Quali due problemi risolve il checkpoint, e che cosa succederebbe con checkpoint troppo frequenti o troppo radi?
  5. 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.

LetteraPromessaMeccanismo che la realizza
AtomicityTutto o nienteUndo log (InnoDB, Oracle) oppure versioni multiple più stato di commit (PostgreSQL): il DB sa sempre come ignorare o disfare le scritture non committate
ConsistencyDa uno stato valido a un altro stato validoConstraint del DB più logica applicativa. È l'unica lettera che non dipende solo dal database
IsolationCome se le transazioni fossero eseguite in serieLock (2PL, two-phase locking) oppure MVCC (multiversion concurrency control): i due meccanismi sono spiegati nel capitolo 7
DurabilityDopo il commit, resiste al crashWrite-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.

La conclusione, in anticipo

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.

Mappa concettuale · le quattro promesse
  • 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

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

  1. Qual è il meccanismo che realizza ciascuna lettera?
  2. 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:

SQL · bonifico con movimento
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ª UPDATEAnna −100, Bruno +0: cento euro svanitinulla è avvenuto
dopo la 2ª UPDATEsaldi corretti, nessuna traccia in movimentinulla è avvenuto
dopo il COMMITtutto è 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.

SQL
UPDATE clienti SET email = 'nuova@x.it' WHERE id = 7;
tabella →riga 7, colonna 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.

SQL · ON DELETE CASCADE
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.

SQL · trigger
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.

UPDATE conti SET saldo = saldo * 1.02;
↓ tu scrivi questo, il DB fa questo:
dentro il confine
10.000 righe riscritte
+ indice su saldo aggiornato
+ 10.000 trigger di audit eseguiti
+ cascate sulle tabelle collegate
+ verifica di tutti i CHECK e le FK
↓
tutto o niente · o tutte queste scritture, o nessuna
La conseguenza che si vede in produzione

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.

Il limite del confine

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 logContieneServe a
redoil valore nuovoRifare il lavoro di transazioni committate che erano ancora solo in memoria
undoil valore vecchioDisfare il lavoro di transazioni non committate finito su disco
tempo→
T1
BEGIN—UPDATE A—UPDATE B—COMMIT ✔
T2
BEGIN—UPDATE C—💥 crash

al riavvio

redo · riapplica A e B (T1 aveva committato: promessa da mantenere)
undo · ripristina C (T2 non aveva committato: non deve esistere)
L'atomicità non è «non scrivere finché non sei sicuro»

È «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).

Mappa concettuale · Atomicity
  • 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

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, CHECK e 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

  1. Perché una DELETE su una riga può bloccare mezzo database per venti secondi?
  2. Uno stato invalido può arrivare fisicamente sul disco? Se sì, perché non sopravvive?
  3. 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:

SQL · bonifico sbagliato
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

somma prima=Anna + Bruno
somma dopo=(Anna − 100) + (Bruno + 50) = Anna + Bruno − 50
il codice toglie 100 e aggiunge 50
differenza=−50 €
cinquanta euro distrutti, e nessun meccanismo se ne accorge

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.

stato valido S1
→T
stato intermedio NON valido: esiste davvero, ma nessuno lo vede
→
stato valido S2

È 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:

La C non ha un meccanismo proprio

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.

QualeSignificaChi la garantisce
C di ACIDLe invarianti di dominio valgono a fine transazioneTu, con constraint e logica applicativa
C di CAPLinearizzabilità: 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 tempoIl protocollo di replica
Eventual consistencyLe repliche convergono, se smetti di scrivereIl 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.

  1. 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.
  2. 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.
  3. 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.
L'invariante che rompe l'intuizione

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.

SQL · indice parziale
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
70.000 vive30.000 cancellate
→
indice parziale 70.000 voci
70.000 vive

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.

SQL · al più un consenso attivo
CREATE UNIQUE INDEX uq_user_consent_active
    ON user_consents (user_id, consent_type)
    WHERE revoked_at IS NULL;
user_idconsent_typegranted_atrevoked_atnell'indice?
AnnaHEALTH_DATA2024-01-102024-03-02no · è storia
AnnaHEALTH_DATA2024-03-052024-06-01no · è storia
AnnaHEALTH_DATA2024-06-10NULLsì · 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.

Un indice unico parziale è un antidoto al write skew — senza SERIALIZABLE

Due 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 dell'indice parziale

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.

Mappa concettuale · Consistency
  • 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»

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 CHECK le esprime, servono SERIALIZABLE o un ridisegno dell'aggregato

Autodiagnosi · dovresti saper rispondere senza rileggere

  1. Perché un bonifico che toglie 100 e aggiunge 50 è ACID a tre lettere su quattro?
  2. In che senso la C non ha un meccanismo proprio?
  3. Perché una UNIQUE normale su (utente, tipo consenso) renderebbe impossibile lo storico, e come lo risolve quella parziale?
  4. 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:

Cassa A
Cassa B
1
SELECT giacenza → 1
SELECT giacenza → 1
2
«c'è, posso vendere»
«c'è, posso vendere»
3
UPDATE giacenza = 0
UPDATE giacenza = 0
4
COMMIT ✔
COMMIT ✔
Venduto due volte Due clienti hanno comprato lo stesso pezzo.

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
T1 readwriteT2 readwrite
↓ deve produrre lo stesso risultato di UNA di queste:
seriale 1
T1 completa—poi T2 completa
seriale 2
T2 completa—poi T1 completa

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.

AnomaliaAlla letteraLa domanda a cui risponde
Dirty readlettura «sporca»Il dato che leggo esiste? (o è ancora provvisorio)
Non-repeatable readlettura non ripetibileSe rileggo la stessa riga, trovo lo stesso valore?
Phantom readlettura «fantasma»Se rieseguo la stessa query, trovo le stesse righe? (i fantasmi sono quelle apparse dal nulla)
Lost updateaggiornamento persoLa mia scrittura sopravvive, o qualcuno l'ha coperta?
Write skew«sbilanciamento» in scritturaDue 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.

READ_UNCOMMITTED
«leggo anche il non-committato»

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 inclusa
READ_COMMITTED
«leggo solo il committato»

Promette 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 passa
REPEATABLE_READ
«la lettura è ripetibile»

Il 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)
SERIALIZABLE
«riconducibile a una serie»

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 massimo
Tre agganci per non confonderli mai più

READ_* 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.

CampoContiene
xminL'id della transazione che ha creato questa versione
xmaxL'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

saldo = 100
xmin 50xmax 90
sostituita da T90
→
saldo = 70
xmin 90xmax —
versione corrente

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:

COUNT(*) sta scorrendo
riga 1, 2, 3 … 50—contate: 50
T2 nel frattempo INSERT alla posizione 12 ; COMMIT
COUNT(*) prosegue
51, 52 … 100
risultato · 100? 101? Dipende da dove si trovava il cursore.

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:

T1
T2
1
BEGIN
2
SELECT COUNT(*) FROM ordini; → 100 snapshot A
3
INSERT … ; COMMIT; crea la riga 101
4
SELECT COUNT(*) FROM ordini; → 101 snapshot B, diverso!

È la phantom read: stessa query, stessa transazione, insieme di risultati diverso. Ed è la differenza fra i due livelli più usati.

LivelloLo snapshot si prendeConseguenza
READ_COMMITTEDa ogni istruzioneDue COUNT nella stessa transazione possono differire
REPEATABLE_READuna volta, all'inizio della transazioneI 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.

Conseguenza pratica

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.

LivelloDirty readNon-repeatable readPhantom 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.

T1
T2
1
UPDATE conti SET saldo = 5000 WHERE id = 1; non committata
2
SELECT SUM(saldo) FROM conti; → include i 5000
3
ROLLBACK;
Dirty read Il totale di T1 non è mai esistito.

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.

SQL · READ_COMMITTED
-- 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.

SQL · REPEATABLE_READ
-- 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

La differenza in una riga

I due livelli alti si confondono di continuo. La differenza in una riga:

  • REPEATABLE_READ protegge quello che vedi.
  • SERIALIZABLE protegge 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.

stato reale del DB→
T1
📷 scatta—guarda la foto—COMMIT
T2
✏️ modifica ; COMMIT—T1 non lo vede

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:

  1. Prende un lock esclusivo sulla riga. Scrivere non è mai snapshot-based: per modificare devi passare dalla versione fisicamente corrente, non dalla tua foto.
  2. Guarda l'xmax della 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.
  3. Se l'xmax è valorizzato da una transazione ancora aperta, si mette in attesa che quella finisca.
  4. 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:

LivelloCosa fa al punto 4
READ_COMMITTEDRiparte: 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_READNon 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 che sorprende

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.

È optimistic locking fatto dal database

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

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.

MySQL/InnoDB si comporta diversamente — e va saputo

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.

stato iniziale: medico A reperibile ✔ · medico B reperibile ✔
invariante: almeno uno deve restare reperibile
T1 · medico A
T2 · medico B
1
📷 scatta
📷 scatta
2
COUNT reperibili → 2
COUNT reperibili → 2
3
«2 > 1, posso togliermi»
«2 > 1, posso togliermi»
4
UPDATE riga A → false
UPDATE riga B → false
5
COMMIT ✔
COMMIT ✔
righe DIVERSE: nessun conflitto
Write skew Risultato: ZERO medici reperibili.

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.

SQL · SERIALIZABLE
-- 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

  1. T1 completa→ resta 1 reperibile
  2. T2 parte, legge→ COUNT = 1
  3. «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_READPermette i phantom readNon permette i phantom read
Write skewNon è nemmeno contemplatoPassa 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).

Se ricordi una sola cosa

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 motori

Tre 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 da farsi

La domanda è una sola: sto scrivendo qualcosa in base a una condizione che ho verificato leggendo altre righe?

SituazioneLivello
Leggo soltanto (report, export)REPEATABLE_READ
Leggo e scrivo la stessa rigaREPEATABLE_READ basta in PostgreSQL (conflitto rilevato sull'xmax); in MySQL/InnoDB serve SELECT … FOR UPDATE o @Version
Verifico su un insieme e scrivo un elementoSERIALIZABLE 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.

L'alternativa che spesso è migliore

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

ScenarioLivelloPerché
API di lettura, CRUD, dettaglio di un'entitàREAD_COMMITTEDIl default. Costo di concorrenza minimo, garanzia sufficiente
Report, export, riconciliazione: più query, cifre che devono quadrareREPEATABLE_READTutte le query vedono lo stesso istante
Read-modify-write su una singola entitàREAD_COMMITTED + @VersionIl livello non c'entra: serve l'optimistic lock (capitolo 9), mirato sull'aggregato e portabile tra motori
Contatore, saldo, stockREAD_COMMITTED + UPDATE atomicoElimini 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 enormiREAD_UNCOMMITTEDSolo se il numero non viene mostrato né usato
Alzare il livello non è mai la prima risposta: l'ordine in cui provarci

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.

Mappa concettuale · Isolation
  • 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

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_COMMITTED e REPEATABLE_READ: due COUNT possono differire nel primo, non nel secondo
  • First-updater-wins e 40001 — in REPEATABLE_READ scrivere su una versione non nel tuo snapshot aborta la transazione: è optimistic locking fatto dal database
  • EvalPlanQual — in READ_COMMITTED l'UPDATE che trova una riga bloccata aspetta e rivaluta la WHERE sulla versione nuova
  • Write skew — due transazioni leggono lo stesso insieme e scrivono righe diverse, violando insieme un'invariante che nessuna violava da sola; solo SERIALIZABLE lo previene
  • SSI — l'implementazione ottimistica di SERIALIZABLE in PostgreSQL: non blocca, traccia le letture con predicate lock e aborta al commit
  • Regola di scelta — resta sul default; @Version per la singola entità; UPDATE atomico per la scrittura derivata; SERIALIZABLE solo per invarianti d'insieme, con retry

Autodiagnosi · dovresti saper rispondere senza rileggere

  1. Perché con una sola transazione nel database l'isolamento è gratis?
  2. In READ_COMMITTED due COUNT(*) nella stessa transazione possono differire: perché, e a quale livello non possono?
  3. Perché il lost update sulla stessa riga in PostgreSQL non passa già in REPEATABLE_READ, e perché in MySQL/InnoDB invece passa?
  4. Nell'esempio dei due medici, quale anomalia di lettura si verifica? Perché @Version non aiuta?
  5. Che cosa aggiunge SERIALIZABLE rispetto a REPEATABLE_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.

applicazione · write()
la chiamata scende verso il disco
PAGE CACHE del kernel
write() ritorna QUI. Veloce, e bugiarda.
cache del disco / SSD
fsync() forza il flush anche di questa
NAND / piatti magnetici
durabilità vera

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.

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.

ChiamataCosa faCosto tipico
write()Copia nella page cache e ritorna. Nessuna garanzia di durabilitàmicrosecondi
fsync()Forza dati e metadati del file su discodecine 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—
Perché un DB non fa un fsync per riga

Un 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 · fsyncgate

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.

Mappa concettuale · Durability
  • 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

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; fdatasync salta 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 fsync fallito: la durabilità vale quanto il suo anello più bugiardo

Autodiagnosi · dovresti saper rispondere senza rileggere

  1. Che cosa garantisce write() quando ritorna con successo?
  2. Perché il throughput in transazioni al secondo dipende più dallo storage che dalla CPU?
  3. Perché un database non fa un fsync per 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.

SQL · JPA
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ù.

SQL · versione
UPDATE conto SET saldo=?, version=version+1
WHERE id=? AND version=7

È letteralmente la stessa idea a livelli diversi dello stack:

LivelloMeccanismo ottimistico
JPA (Jakarta Persistence API) / Hibernate@Version → OptimisticLockException
Database, livello REPEATABLE_READcontrollo 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 / Firestoreconditional writes
Event store (EventStoreDB, Marten)expectedVersion sullo stream
CPU / strutture lock-freecompare-and-swap

I costi reali

Pessimistico

  • Deadlock (l'attesa circolare del capitolo 1) se due transazioni prendono le risorse in ordine diverso: T1 blocca account e chiede order, T2 il contrario. Contromisura: ordine di acquisizione globale (sempre prima account, poi order, 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 Conflict all'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.
Il lock distribuito, il TTL e il fencing token

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

conviene il pessimistico quando P(conflitto) × costo_retry>costo_attesa_lock
Scegli ottimisticoScegli pessimistico
Letture ≫ scrittureContesa alta su poche righe calde
Conflitti rari, entità «possedute» da un utenteIl retry è costoso o impossibile (effetti esterni già emessi)
API stateless, client disconnessiOperazione breve, dentro un solo DB
Serve scalare orizzontalmenteServe garantire l'ordine di ingresso (FIFO, first in first out, su una risorsa)
Il caso da evitare in assoluto

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.

Mappa concettuale · lock ottimistico e pessimistico
  • 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

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_WRITE in 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

  1. Che cosa genera Hibernate nella UPDATE quando un'entità ha @Version, e come si accorge del conflitto?
  2. Scrivi il criterio di scelta come disuguaglianza e spiega ogni termine.
  3. Perché un lock distribuito con TTL è «una bugia», e che cosa lo rende sicuro?
  4. 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

saldo finale=100 − 10 − 30 = 60
saldo iniziale 100, prelievo di T1 pari a 10, prelievo di T2 pari a 30
Thread A · preleva 10
    Thread B · preleva 30

      Le tre soluzioni, in ordine di preferenza

      1. 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.
      2. Optimistic lock. Quando la scrittura dipende da logica applicativa che non sta in una UPDATE. @Version come default su ogni aggregato.
      3. Pessimistic lock. Quando la contesa è alta o il retry è insostenibile.
      Nota sul livello di isolamento

      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

      Se il lettore legge e basta

      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.

      Mappa concettuale · lost update
      • 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

      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 - 10 con CHECK 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; solo refresh forza la rilettura (un lock da solo non aggiorna la copia in memoria)
      • Staleness in lettura — fisiologica: READ_COMMITTED garantisce che il dato non sia sporco, non che sia l'ultimo

      Autodiagnosi · dovresti saper rispondere senza rileggere

      1. Nel simulatore, perché con @Version il thread A riceve zero righe aggiornate e che cosa fa dopo?
      2. Perché leggere in una richiesta HTTP e scrivere nella successiva rende inutile qualunque lock di database?
      3. Che cosa cambia nel simulatore se il livello fosse REPEATABLE_READ su 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.

      Kotlin · Spring Data JPA
      // 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: SELECT … WHERE id = :id AND deleted_at IS NULL FOR UPDATE
      T1 · cancella
      T2 · chiude
      1
      UPDATE … deleted_at = now() lock sulla riga
      SELECT … FOR UPDATE riga visibile nello snapshot ✔
      2
      prova a bloccarla → IN ATTESA
      3
      COMMIT ✔
      4
      PostgreSQL rivaluta il predicato sulla versione NUOVA deleted_at non è più NULL → la riga esce dal risultato
      5
      ZERO righe, NESSUN lock
      filtro FUORI: SELECT … WHERE id = :id FOR UPDATE
      il predicato è sulla CHIAVE PRIMARIA, che nessuno modifica mai
      ↓
      la rivalutazione riesce sempre
      ↓
      LOCK PRESO, sulla versione fresca
      ↓
      poi il codice chiede: è cancellata?
      Filtro dentro la queryFiltro fuori
      Cosa riceve il chiamantePuò ricevere niente, senza aver preso alcun lockLa riga, sempre, con il lock in mano
      Su cosa decideSu un'assenzaSullo stato committato della riga bloccata
      Cosa protegge il seguitoNiente: nessun lock è mai stato presoIl 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à.

      E cambia anche con il livello di isolamento

      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

      Attenzione al persistence context

      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'

      QueryIl planner deve dimostrareEsito
      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.

      Regola pratica

      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.

      Mappa concettuale · il predicato al posto giusto
      • 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

      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 WHERE restringe 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, 40001 in REPEATABLE_READ
      • Parametro bind e indice parziale — WHERE status = $1 non dimostra l'implicazione con WHERE status = 'PENDING': indice inerte ma da mantenere

      Autodiagnosi · dovresti saper rispondere senza rileggere

      1. Perché findByIdForUpdate non deve contenere AND deleted_at IS NULL, mentre il finder senza lock può?
      2. Nella gara tra T1 che cancella e T2 che chiude, che cosa riceve T2 con il filtro dentro? E con il filtro fuori?
      3. 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.

      TipologiaAtomicitàIsolamentoCostoQuando
      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.

      CoordinatoreABFASE 1prepare?prepare?vota sì / novota sì / notutti sì: COMMIT← decisione presaFASE 2commit!commit!

      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ù

      Laboratorio: il coordinatore cade

      protocollo reale, semplificato

      Un 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.

        Mappa concettuale · quando il confine si rompe
        • 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

        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

        1. Perché il confine ACID coincide con il confine del datasource?
        2. Che cosa promette un partecipante quando vota «sì» in 2PC, e che cosa gli costa?
        3. 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.

        Perché si chiama «saga»

        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.

        TipoPosizioneSignificatoEsempio
        Compensabileprima del pivotha una compensazione definitariserva credito → rilascia credito
        Pivotil punto di non ritornose passa, la saga arriva in fondoaddebito reale sulla carta
        Ritentabiledopo il pivotnon può fallire logicamente, solo retryinvio email, richiesta spedizione
        Individuare il pivot

        È 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

        Premi «Esegui» per vedere la saga completarsi, oppure falla fallire per vedere le compensazioni.

        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
        OrdiniPagamentiMagazzinoevtevtevt
        uno conosce la sequenza Orchestrazione
        OrchestratoreOrdiniPagamentiMagazzino

        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.

        Mappa concettuale · la saga
        • 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

        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

        1. Perché un rimborso è una compensazione e la cancellazione della riga di addebito non lo è?
        2. Dove metti l'invio della mail al cliente rispetto al pivot, e perché?
        3. 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.

        ContromisuraCosa faCorrisponde a
        Semantic lockStato 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 updatesreserve/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 sovrascrivonooperazione atomica (capitolo 10)
        Pessimistic viewRiordina i passi per ridurre la finestra di esposizione: fai per ultimo il passo il cui stato intermedio è più pericoloso da mostraredesign del flusso
        Re-read valueRileggi e verifica le precondizioni prima di scrivere: prima di confermare l'ordine, ricontrolla che sia ancora PENDING e non sia stato annullato nel frattempolock ottimistico (capitolo 9)
        By valueScegli il meccanismo in base al valore della richiesta: saga per le operazioni a basso rischio, transazione distribuita (2PC) per quelle rischiose o di importo altorouting per criticità

        Calcolo · due riserve commutative

        stock finale=10 − 1 − 1 = 8
        due reserve da 1 su uno stock di 10: stesso risultato in qualunque sequenza
        Il semantic lock ha lo stesso difetto dei lock distribuiti

        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.

        Mappa concettuale · il prezzo della saga
        • 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

        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 PENDING sull'aggregato: «non toccarmi»; senza timeout e riconciliazione è un deadlock permanente
        • Commutative updates — reserve/release invece di set: 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

        1. Perché «scrivi sul DB e pubblica su Kafka» può spezzare la saga in silenzio, e come l'outbox lo impedisce?
        2. Perché anche le compensazioni devono essere idempotenti?
        3. 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).

        La terza via che risolve più problemi di tutte

        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.

        Mappa concettuale · casi d'uso reali
        • 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

        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, PENDING con riconciliazione a 15 minuti
        • Prenotazione posti — tabella reservation con TTL, mai FOR UPDATE durante il think time
        • Bonifico — atomico + CHECK; tra servizi: addebito pivot, accredito ritentabile, mai il contrario
        • Editing collaborativo — ETag + If-Match → 412 con diff
        • Ridurre la contesa alla radice — una riga per movimento invece di un totale; partizionare per chiave

        Autodiagnosi · dovresti saper rispondere senza rileggere

        1. Perché nel bonifico tra servizi l'accredito non può essere il pivot?
        2. In quale dei quattro casi il pessimistico sarebbe un disastro di UX, e perché?
        3. 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 relazionaleQuando non reggeChe 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 muoreDatabase 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 JOINOggetti di dominio annidati e variabili (un ordine con le sue righe, un profilo con campi diversi per utente) letti sempre interiDatabase orientati agli aggregati, che memorizzano l'oggetto intero così com'è: MongoDB e i documentali
        Il disco come casa dei datiLatenza 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 dimensioniDatabase vettoriali: pgvector, Qdrant, Milvus, Pinecone
        Relazioni come JOINPercorsi lunghi e imprevedibili tra entità («amici degli amici che hanno comprato…»), dove ogni salto è un'altra JOINDatabase 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

        FamigliaModello dei datiEsempiFin dove arriva l'atomicitàCasi d'uso tipici
        Key-valueUna chiave, un valore opaco o strutturatoRedis, DynamoDB (nel suo uso base), MemcachedIl singolo comando (in Redis anche su più chiavi, ed estendibile con MULTI/EXEC o script Lua)Cache, sessioni, contatori, code leggere, lock distribuiti
        DocumentaleDocumenti JSON annidati, schema flessibile, raccolti in collezioniMongoDB, CouchDB, FirestoreIl singolo documento (multi-documento solo con transazione esplicita)Cataloghi, profili, contenuti, aggregati DDD letti interi
        Wide-columnTabelle partizionate per chiave, righe con colonne variabili, ordinate dentro la partizioneCassandra, ScyllaDB, HBase, DynamoDBLa 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
        GrafoNodi e archi con proprietà; le relazioni sono dati di prima classeNeo4j, Amazon NeptuneTransazioni ACID sull'intero grafo (Neo4j)Reti sociali, raccomandazioni, antifrode, dipendenze
        VettorialeVettori numerici (embedding) con metadati, indicizzati per similaritàpgvector, Qdrant, Milvus, Weaviate, PineconeIl 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-textDocumenti con indice invertito per parolaElasticsearch, OpenSearchIl singolo documento, indice aggiornato in modo eventuale (near real-time)Ricerca testuale, log analytics, osservabilità
        Serie temporaliMisure con timestamp, compresse e aggregate per finestraTimescaleDB, InfluxDBCome il relazionale sottostante (Timescale) o per puntoMetriche, 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.

        ChiaveTipoValoreTTL
        session:8f3ahash{ user: 42, role: "admin", … }30 min
        rate:api:42:2026-09-07T10string"187"60 s
        leaderboard:weeklysorted set{ anna: 1200, bruno: 980, … }—
        orders:eventsstream[ 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.

        Quando Redis, e quando no

        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
        ordini
        42…
        righe_ordine
        42pen2
        42ink1
        indirizzi
        42…
        costo · 3 letture + 2 JOIN
        vs
        Documentale aggregato
        {
          "id": 42,
          "righe": [ {"sku":"pen","q":2},
                     {"sku":"ink","q":1} ],
          "indirizzo": { "via": "…" }
        }
        costo · 1 lettura, 1 scrittura atomica

        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 Cassandra

        Tre 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.

        Quando un documentale, e quando no

        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

        TestoEmbedding
        «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.

        Quando un vettoriale, e quando no

        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.

        scrittura →
        PostgreSQL

        fonte di verità, ACID

        →outbox / CDC
        Elasticsearch indice eventuale
        Redis cache eventuale
        vettoriale embedding eventuale
        Mappa concettuale · le altre famiglie di database
        • 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

        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

        1. Per ciascuna famiglia, fin dove arriva l'atomicità e perché proprio lì?
        2. Perché INCR in Redis non può avere un lost update, mentre SELECT + UPDATE su PostgreSQL sì?
        3. Che cosa si guadagna e che cosa si perde memorizzando l'ordine come documento invece che su tre tabelle?
        4. Perché un B-tree non serve per la ricerca per similarità, e quale compromesso nuovo introducono gli indici ANN?
        5. 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.

        1978Two-phase commit (2PC) · Jim Gray descrive il protocollo di commit atomico distribuito
        1981The Transaction Concept · Gray formalizza cosa sia una transazione e quali siano i suoi limiti
        1983ACID · Härder e Reuter coniano l'acronimo (Atomicity, Consistency, Isolation, Durability) aggiungendo la I al lavoro di Gray
        1987Sagas · Garcia-Molina e Salem, Princeton. Per le transazioni lunghe, non per i servizi
        1991X/Open DTP (Distributed Transaction Processing) e XA (eXtended Architecture) · lo standard che diventerà JTA (Java Transaction API) in Java
        1992ARIES · (Algorithms for Recovery and Isolation Exploiting Semantics) l'algoritmo di recovery basato su WAL (write-ahead log), tuttora lo standard di fatto
        1995Critique of ANSI SQL Isolation Levels · i quattro livelli standard non descrivono i database reali
        2000Teorema CAP (Consistency, Availability, Partition tolerance) · Brewer; dimostrato da Gilbert e Lynch nel 2002
        2010PACELC · Abadi: if Partition then Availability or Consistency, Else Latency or Consistency. Il compromesso resta anche senza partizioni
        2015La saga per i microservizi · problema nuovo, soluzione del 1987

        Glossario

        TermineSignificato
        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 isolationLa transazione vede il database «congelato» al proprio istante di inizio. Previene i phantom, non il write skew
        Write skewDue 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-doubtLo 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
        CompensazioneOperazione di business che annulla semanticamente un passo già committato. Non è un rollback
        IdempotenzaEseguire l'operazione due volte produce lo stesso risultato di eseguirla una volta
        At-least-onceIl broker garantisce che il messaggio arrivi, non che arrivi una volta sola. Da qui l'obbligo di idempotenza
        OutboxTabella 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 tokenNumero crescente rilasciato con un lock distribuito: la risorsa rifiuta i token vecchi, impedendo a un proprietario scaduto di scrivere
        xmin / xmaxMarcatori 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-winsRegola 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 correnteIn InnoDB, una lettura che ignora lo snapshot e prende l'ultima versione committata: è ciò che fanno UPDATE, DELETE e SELECT … FOR UPDATE
        CheckpointMomento 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
        BloatCrescita delle tabelle dovuta a versioni vecchie non rimosse, tipicamente causata da transazioni tenute aperte troppo a lungo
        Errore 40001serialization_failure: il DB aborta una transazione in conflitto invece di bloccarla. Va gestito con una retry
        Indice parzialeIndice con una WHERE: contiene solo le righe che la soddisfano. Se UNIQUE, esprime l'invariante «al più uno attivo per chiave»
        Parametro bindSegnaposto ($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
        EvalPlanQualMeccanismo PostgreSQL che, dopo l'attesa su una riga bloccata, rivaluta il predicato sulla versione nuova. Se non è più soddisfatto, la riga esce dal risultato
        IndiceStruttura dati separata (di solito un B-tree) che tiene ordinati i valori di una colonna, per evitare la scansione dell'intera tabella
        Foreign keyVincolo 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
        TriggerCodice eseguito automaticamente dal DB al verificarsi di un evento su una tabella. Sta dentro il confine transazionale, ma i suoi effetti esterni no
        AutocommitTransazione 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 cacheCopia in RAM delle pagine di file gestita dal kernel. write() ci scrive dentro e ritorna: i dati non sono ancora durevoli
        fsyncSystem 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 / undoLe due facce del log: il valore nuovo per rifare ciò che era committato, il valore vecchio per disfare ciò che non lo era
        Group commitRaggruppare 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)
        DeadlockAttesa 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 UPDATELettura che prende sulle righe restituite il lock esclusivo di una scrittura, fino al commit. In JPA: PESSIMISTIC_WRITE
        @VersionCampo JPA incrementato a ogni scrittura e verificato nella WHERE dell'UPDATE: se non combacia, OptimisticLockException. È il lock ottimistico applicativo
        Lock distribuitoChiave 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 serviceRegola delle architetture a servizi: ogni servizio possiede il proprio datastore. È il motivo per cui il confine ACID si spezza tra servizi
        Persistence contextCache di primo livello di JPA: le entità già caricate nella transazione vengono restituite dalla memoria, non rilette dal DB
        NoSQLTermine ombrello per le famiglie non relazionali (key-value, documentali, wide-column, grafo, vettoriali): ciascuna restringe il confine atomico alla propria unità naturale
        Aggregate-orientedDatabase che memorizzano l'aggregato intero (documento, partizione) come unità di lettura, scrittura e atomicità
        Consistenza regolabileScelta per operazione di quante repliche devono confermare (ONE/QUORUM/ALL, writeConcern): la manopola tra consistenza e latenza/disponibilità
        EmbeddingVettore numerico prodotto da un modello, tale che oggetti simili nel significato abbiano vettori vicini
        ANN (Approximate Nearest Neighbour) / HNSWIndici per la ricerca dei vettori più vicini che accettano un recall inferiore al 100% in cambio di tempo sublineare
        Persistenza poliglottaUso di più famiglie di database nello stesso sistema; ogni coppia di datastore è un confine transazionale spezzato
        CAP (Consistency, Availability, Partition tolerance) / PACELCI 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.

        Domanda 1 di 44 Corrette: 0