Basi di Dati - Concorrenza
Cerca per parola chiave 0/0

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 garantisceChi se ne occupa
Atomicitàtutto o niente: o tutte le operazioni hanno effetto, o nessunagestore dell'affidabilità
Consistenzapartendo da uno stato che rispetta i vincoli si arriva a uno stato che li rispettatransazione + DBMS
Isolamentol'effetto finale è lo stesso che si otterrebbe eseguendo la transazione da solagestore della concorrenza
Durabilitàgli effetti di una transazione terminata con successo sopravvivono ai guastigestore 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.

i

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

r₁(x)   r₂(x)   w₁(x)   w₂(x)

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:

r₁(x)   w₁(x)   r₂(x)   a₁   c₂

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:

r₁(x)   r₂(x)   w₂(x)   c₂   r₁(x)

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:

r₁(y)   r₂(y)   r₂(z)   w₂(y)   w₂(z)   c₂   r₁(z)

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

S = r₁(x) r₂(x) r₃(x) w₁(x) w₂(y) w₁(y) w₃(x)

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:

Uno schedule è serializzabile se la sua esecuzione produce lo stesso risultato di qualche schedule seriale sulle stesse transazioni.

Il problema è che questa definizione non dice come verificarlo. Servono nozioni di equivalenza precise, ed è quello che fanno le due sezioni seguenti.

seriali CSR VSR tutti gli schedule 2PL sta dentro CSR, ma non coincide con esso
Ogni schedule seriale è conflict-serializzabile; ogni conflict-serializzabile è view-serializzabile; non vale il viceversa.

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

Due operazioni di transazioni diverse sono in conflitto se agiscono sullo stesso oggetto e almeno una delle due è una scrittura.

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:

Uno schedule è conflict-serializzabile (CSR) se e solo se il suo grafo dei conflitti è aciclico.

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 è

S = r₁(x) w₂(x) w₁(x) w₃(x)

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

AzioneNotazionePrima di
acquisizione lock condiviso (read lock)rli(x)una lettura
rilascio lock condivisorui(x)—
acquisizione lock esclusivo (write lock)wli(x)una scrittura
rilascio lock esclusivowui(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:

  1. 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;
  2. un lock non può essere rilasciato prima che l'operazione corrispondente sia terminata;
  3. 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.

lock posseduti tempo fase crescente fase calante punto di lock massimo
Se dopo aver rilasciato un lock la transazione ne chiedesse un altro, la curva risalirebbe: è proprio quello che la regola 3 vieta.

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 ⊂ CSR: ogni schedule prodotto da uno scheduler 2PL è conflict-serializzabile, ma esistono schedule in CSR che il 2PL non produrrebbe mai.

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:

PassoOperazioneDecisione dello scheduler
1r₁(x)assegna rl₁(x), esegue
2w₂(y)assegna wl₂(y), esegue
3w₁(y)conflitto con wl₂(y) → T₁ in attesa su y
4w₂(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.