Transazioni e Concorrenza
Schedule, serializzabilità e locking
Un database è un modello della realtà, e lo schema definisce i vincoli di integrità che ogni stato deve rispettare. Quando la realtà cambia, il modello deve cambiare di conseguenza: lo fa una transazione, cioè un programma che porta la base di dati da uno stato consistente a un altro stato consistente.
Finché le transazioni si eseguono una per volta non ci sono problemi. Ma eseguirle in sequenza sprecherebbe le risorse del sistema: i DBMS le eseguono in parallelo, e allora servono meccanismi che diano a ciascun utente l'illusione di essere solo. Questo modulo studia quei meccanismi.
Transazioni
Le proprietà ACID
| Proprietà | Che cosa garantisce | Chi se ne occupa |
|---|---|---|
| Atomicità | tutto o niente: o tutte le operazioni hanno effetto, o nessuna | gestore dell'affidabilità |
| Consistenza | partendo da uno stato che rispetta i vincoli si arriva a uno stato che li rispetta | transazione + DBMS |
| Isolamento | l'effetto finale è lo stesso che si otterrebbe eseguendo la transazione da sola | gestore della concorrenza |
| Durabilità | gli effetti di una transazione terminata con successo sopravvivono ai guasti | gestore dell'affidabilità |
Operativamente, una transazione termina con un commit — il DBMS accetta gli effetti e li rende permanenti — oppure con un rollback, che annulla tutto quello che aveva fatto: dopo un rollback la base di dati deve trovarsi nello stato in cui sarebbe stata se la transazione non fosse mai partita.
Operazioni e notazione
Per studiare la concorrenza si astrae quasi tutto. La base di dati è un insieme di oggetti indivisibili x, y, z… e su ciascuno si possono fare solo due operazioni elementari, entrambe atomiche:
- ri(x) — la transazione Ti legge l'oggetto x;
- wi(x) — la transazione Ti scrive l'oggetto x.
A queste si aggiungono le operazioni di transazione: bi (begin), ci (commit), ai (abort). Begin e commit di solito non si scrivono: si assumono presenti prima della prima e dopo l'ultima operazione.
Tre ipotesi semplificative valide per tutto il modulo: gli oggetti sono un insieme fisso (niente inserimenti e cancellazioni); ogni transazione legge o scrive un dato oggetto al più una volta; tutte le transazioni terminano con successo (ipotesi di commit-proiezione). I fallimenti sono affare del gestore dell'affidabilità.
Le anomalie della concorrenza
Senza controllo, l'intreccio delle operazioni di transazioni diverse produce quattro anomalie classiche. Vale la pena riconoscerle a colpo d'occhio, perché le definizioni formali che seguono servono proprio a escluderle.
Perdita di aggiornamento
Due transazioni incrementano lo stesso oggetto: T₁ = r₁(x)w₁(x) e T₂ = r₂(x)w₂(x), con x che vale 2. Eseguite in sequenza il risultato sarebbe 4. Ma se si intrecciano così:
T₂ legge x prima che T₁ scriva: entrambe leggono 2, entrambe scrivono 3. Un incremento è sparito. La base di dati resta in uno stato che nessuna esecuzione seriale avrebbe prodotto.
Lettura sporca
T₁ scrive x e poi fallisce; nel frattempo T₂ ha letto il valore intermedio:
Il rollback di T₁ cancella la scrittura, ma T₂ ha già portato fuori un dato che non è mai esistito ufficialmente. È l'anomalia che l'ipotesi di commit-proiezione mette da parte, ma che nei sistemi reali va gestita.
Letture inconsistenti
T₁ legge due volte lo stesso oggetto e nel mezzo T₂ lo modifica e conferma:
T₁ vede due valori diversi per lo stesso oggetto durante la propria esecuzione: la sua "fotografia" del database è incoerente.
Aggiornamenti fantasma
Il caso più insidioso, perché nessuna singola lettura è sbagliata. Siano y e z due oggetti legati dal vincolo y + z = 1000. T₂ sposta 100 da y a z (rispettando il vincolo), T₁ legge y prima e z dopo:
T₁ somma il vecchio y con il nuovo z e ottiene 1100: un totale che viola il vincolo pur essendo fatto di due valori entrambi "veri" in momenti diversi.
Schedule
Definizione
Uno schedule su un insieme di transazioni è una sequenza che contiene tutte le loro operazioni e che rispetta l'ordine interno di ciascuna transazione. È il modo formale di dire «una possibile esecuzione concorrente».
Date T₁ = r₁(x)w₁(x)w₁(y), T₂ = r₂(x)w₂(y), T₃ = r₃(x)w₃(x), uno schedule possibile è:
Schedule seriali e serializzabili
Uno schedule è seriale se le transazioni non si intrecciano affatto: tutte le operazioni di una prima di qualsiasi operazione dell'altra. Con n transazioni gli schedule seriali sono n! (con tre transazioni, sei).
Gli schedule seriali sono corretti per costruzione: se ogni transazione presa da sola mantiene la consistenza, eseguirle una dopo l'altra la mantiene. Ma sono lenti. Da qui la definizione che conta:
Il problema è che questa definizione non dice come verificarlo. Servono nozioni di equivalenza precise, ed è quello che fanno le due sezioni seguenti.
View-equivalenza
Legge-da e scritture finali
Due schedule equivalenti devono (1) lasciare la base di dati nello stesso stato e (2) far leggere alle transazioni gli stessi valori. Si formalizzano con due insiemi.
Scritture finali. Finali(S) contiene, per ogni oggetto, l'ultima scrittura su di esso: sono quelle che determinano lo stato finale.
Relazione legge-da. LeggeDa(S) contiene le coppie (wi(x), rj(x)) con i ≠ j tali che la scrittura precede la lettura e fra le due non c'è nessun'altra scrittura su x: dice quale valore ha effettivamente letto ciascuna lettura.
Esempio: in S = r₁(x) r₂(y) w₁(y) r₃(z) w₃(z) r₂(x) w₂(z) w₁(x) si ha Finali(S) = {w₁(x), w₁(y), w₂(z)}.
Schedule view-serializzabili (VSR)
Due schedule sono view-equivalenti se hanno le stesse operazioni, la stessa relazione legge-da e lo stesso insieme di scritture finali. Uno schedule è view-serializzabile se è view-equivalente a qualche schedule seriale.
Verificare se due schedule dati sono view-equivalenti costa poco (tempo polinomiale). Decidere se uno schedule è view-serializzabile è invece un problema NP-completo: bisognerebbe confrontarlo con tutti gli n! schedule seriali. Per questo la VSR è inutilizzabile in uno scheduler reale.
Conflict-equivalenza
Operazioni in conflitto
Esistono quindi conflitti lettura-scrittura e scrittura-scrittura; due letture non sono mai in conflitto. Due schedule sono conflict-equivalenti se hanno le stesse operazioni e ogni coppia di operazioni in conflitto compare nello stesso ordine in entrambi. L'ordine conta: r₁(x)w₂(x) è diverso da w₂(x)r₁(x).
Grafo dei conflitti
Il grafo dei conflitti ha un nodo per ogni transazione e un arco Ti → Tj quando in S c'è un'operazione di Ti che precede ed è in conflitto con una di Tj. Vale il teorema che rende tutto praticabile:
Verificare l'aciclicità costa tempo lineare nel numero di nodi e archi, e un ordinamento topologico del grafo fornisce direttamente lo schedule seriale equivalente.
Verificatore di serializzabilità
Scrivi uno schedule e guarda i conflitti che genera: il verificatore costruisce gli archi, cerca i cicli e, se non ne trova, mostra l'ordine seriale equivalente. Notazione: r1(x), w2(y), c1, separate da spazi.
Prova questi schedule:
• r1(x) r2(x) w1(x) w2(x) — perdita di aggiornamento: ciclo T1↔T2;
• r1(x) w2(x) w1(x) w3(x) — view-serializzabile ma non conflict-serializzabile;
• r1(x) w1(y) w2(x) w2(y) — seriale, quindi banalmente in CSR.
CSR e VSR a confronto
Vale CSR ⊂ VSR: ogni schedule conflict-serializzabile è anche view-serializzabile, ma non viceversa. Il controesempio classico è
che è view-equivalente al seriale r₁(x) w₁(x) w₂(x) w₃(x) — le letture leggono gli stessi valori e l'ultima scrittura è la stessa — ma ha i conflitti r₁(x)w₂(x) e w₂(x)w₁(x), che creano un ciclo fra T₁ e T₂. In pratica si preferisce la classe più piccola, perché è l'unica verificabile in fretta.
Locking e 2PL
Anche la conflict-serializzabilità non basta per costruire uno scheduler reale: il grafo dei conflitti si conosce solo a posteriori, mentre lo scheduler deve decidere operazione per operazione, in tempo reale. La soluzione adottata da tutti i DBMS commerciali è più pragmatica: i lock.
Lock condivisi ed esclusivi
| Azione | Notazione | Prima di |
|---|---|---|
| acquisizione lock condiviso (read lock) | rli(x) | una lettura |
| rilascio lock condiviso | rui(x) | — |
| acquisizione lock esclusivo (write lock) | wli(x) | una scrittura |
| rilascio lock esclusivo | wui(x) | — |
Due lock sullo stesso oggetto, di transazioni diverse, sono in conflitto se almeno uno è esclusivo. Due letture convivono senza problemi; una scrittura esclude tutti.
La regola delle due fasi
Lo scheduler 2PL (two-phase locking) applica tre regole:
- quando arriva un'operazione, se il lock necessario è in conflitto con uno già concesso, l'operazione viene bloccata e la transazione messa in attesa; altrimenti il lock viene assegnato e l'operazione eseguita;
- un lock non può essere rilasciato prima che l'operazione corrispondente sia terminata;
- una volta che una transazione ha rilasciato un lock, non può più acquisirne altri, per nessun oggetto.
La terza è la regola delle due fasi e dà il nome all'algoritmo: ogni transazione ha una fase crescente in cui accumula lock e una fase calante in cui li rilascia, senza tornare indietro.
Perché funziona. Siano T₁ = r₁(x)w₁(y) e T₂ = w₂(x)w₂(y) eseguite così: T₁ legge x e rilascia subito il lock, T₂ scrive x e y, poi T₁ scrive y. Ignorando i lock l'ordine è r₁(x) w₂(x) w₂(y) w₁(y), che ha un ciclo T₁→T₂→T₁: non è in CSR. Il colpevole è il rilascio anticipato di T₁, vietato dalla regola delle due fasi. Uno scheduler 2PL avrebbe messo T₂ in attesa, producendo l'esecuzione seriale T₁ T₂.
2PL stretto
Nella pratica si usa il 2PL stretto: tutti i lock di una transazione sono rilasciati insieme, al termine della transazione (commit o abort). Il motivo è pragmatico: per rilasciare un lock in anticipo lo scheduler dovrebbe sapere che la transazione non toccherà più quell'oggetto e che ha già preso tutti i lock che le serviranno — informazioni che ha con certezza solo alla fine. In più, così si evitano le letture sporche.
Deadlock
Il 2PL elimina le anomalie ma ne introduce una nuova: lo stallo. Due transazioni aspettano ciascuna un lock posseduto dall'altra:
| Passo | Operazione | Decisione dello scheduler |
|---|---|---|
| 1 | r₁(x) | assegna rl₁(x), esegue |
| 2 | w₂(y) | assegna wl₂(y), esegue |
| 3 | w₁(y) | conflitto con wl₂(y) → T₁ in attesa su y |
| 4 | w₂(x) | conflitto con rl₁(x) → T₂ in attesa su x |
Nessuna delle due può proseguire. Le due strategie standard:
Timeout
Se una transazione aspetta più di un tempo prefissato, lo scheduler assume che sia in stallo e la abortisce. Semplice, ma può sbagliarsi: l'attesa poteva essere dovuta a una transazione molto lunga. Timeout alti riducono i falsi positivi e aumentano il tempo perso.
Grafo delle attese
Lo scheduler mantiene un grafo in cui Ti → Tj significa «Ti aspetta un lock posseduto da Tj». Un ciclo è un deadlock: si sceglie una vittima e la si abortisce. Preciso, ma costa mantenerlo aggiornato.
Non confondere i due grafi: il grafo dei conflitti ha un arco per ogni precedenza imposta dalle operazioni e un ciclo significa «non serializzabile»; il grafo delle attese ha un arco per ogni attesa su un lock e un ciclo significa «deadlock». Il primo si usa in analisi, il secondo a runtime.
Esercizi svolti
Verificare uno schedule
1. Dato S = r₁(x) r₂(x) r₁(z) w₁(x) w₂(y) r₃(z) w₃(y) w₃(z), stabilisci se è conflict-serializzabile e, in caso affermativo, indica lo schedule seriale equivalente.
Soluzione
Si analizzano le operazioni oggetto per oggetto, tenendo solo le coppie in conflitto:
- x: r₁(x) r₂(x) w₁(x) → conflitto r₂(x) w₁(x), quindi T₂ → T₁
- y: w₂(y) w₃(y) → conflitto, quindi T₂ → T₃
- z: r₁(z) r₃(z) w₃(z) → conflitto r₁(z) w₃(z), quindi T₁ → T₃
Il grafo T₂→T₁, T₂→T₃, T₁→T₃ è aciclico: lo schedule è conflict-serializzabile. L'unico ordinamento topologico è T₂ T₁ T₃, cioè il seriale r₂(x) w₂(y) r₁(x) r₁(z) w₁(x) r₃(z) w₃(y) w₃(z).
Nota che r₁(x) r₂(x) non è un conflitto (due letture) e che r₁(x) w₁(x) non lo è (stessa transazione).
2. Lo schedule S = r₁(x) w₂(x) w₁(x) w₃(x) è in CSR? È in VSR?
Soluzione
CSR: no. I conflitti su x sono r₁(x)w₂(x) (T₁→T₂), w₂(x)w₁(x) (T₂→T₁), e poi entrambe verso T₃. L'arco T₁→T₂ e l'arco T₂→T₁ formano un ciclo: non conflict-serializzabile.
VSR: sì. Confrontiamolo con il seriale r₁(x) w₁(x) w₂(x) w₃(x): in entrambi l'unica lettura legge il valore iniziale (LeggeDa vuota rispetto alle transazioni fra loro) e l'ultima scrittura è w₃(x) in tutti e due. Gli insiemi coincidono, quindi gli schedule sono view-equivalenti.
È il controesempio che dimostra CSR ⊂ VSR con inclusione stretta.
Ragionare sui lock
3. Perché lo schedule rl₁(x) r₁(x) ru₁(x) wl₂(x) w₂(x) wl₂(y) w₂(y) wu₂(x) wu₂(y) wl₁(y) w₁(y) wu₁(y) non è producibile da uno scheduler 2PL?
Soluzione
La transazione T₁ rilascia il lock su x (ru₁(x)) e più tardi acquisisce un nuovo lock su y (wl₁(y)): viola la regola delle due fasi, perché dopo il primo rilascio non può più acquisire nulla.
Le conseguenze sono concrete: nel frattempo T₂ ha scritto sia x sia y, e l'ordine effettivo delle operazioni diventa r₁(x) w₂(x) w₂(y) w₁(y), con il ciclo T₁→T₂→T₁ nel grafo dei conflitti. Uno scheduler 2PL avrebbe tenuto il lock su x fino alla fine di T₁, mettendo T₂ in attesa e producendo l'esecuzione seriale T₁ T₂.
4. Una transazione lunga di reportistica legge migliaia di righe mentre gli operatori inseriscono nuove visite. Con 2PL stretto cosa succede, e cosa si può fare?
Soluzione
Con 2PL stretto la transazione di report accumula lock condivisi su tutto ciò che legge e li rilascia solo al commit: gli inserimenti che toccano quegli oggetti restano in attesa, e il sistema sembra bloccato. È il classico conflitto fra carico analitico e carico transazionale.
Le contromisure tipiche: eseguire i report su una copia dei dati (una materialized view aggiornata di notte, vedi il modulo sui database attivi); usare livelli di isolamento più deboli quando l'esattezza assoluta non serve; oppure spostare l'analisi in orari di basso carico. È la stessa logica del trade-off fra push e refresh: si sposta il costo dove dà meno fastidio.
Il quadro completo. Hai visto il percorso intero: dai requisiti allo schema concettuale, dallo schema concettuale alle tabelle, dalle tabelle alle query, e infine dalle query all'esecuzione concorrente che le rende sicure. Ogni modulo risponde a una domanda che nasce dal precedente.