Laboratorio di Sistemi Informativi
Concorrenza per le applicazioni web
Il problema della concorrenza
Supponiamo di voler realizzare una procedura per l'immissione di una
nuova prenotazione. Il sistema deve accettare dall'utente il
numero del volo e il nome della prenotazione, e deve automaticamente
assegnare al nome indicato il primo posto libero nell'aereo. Per
stabilire quali siano i posti liberi su un volo, si può usare la
seguente query:
select posti.idposto from voli natural join posti left join prenotazioni
on posti.idposto=prenotazioni.idposto and prenotazioni.idvolo=voli.id
where voli.id='<numvolo>' and nome is null
Notare l'uso di natural join
, che non avevamo mai usato fin'ora, per legare la tabella voli e la tabella posti in base ai loro attributi comuni, ovvero idaereo
. Senza l'uso del natural join, la query diventerebbe:
select posti.idposto from voli, posti left join prenotazioni
on posti.idposto=prenotazioni.idposto and prenotazioni.idvolo=voli.id
where voli.id='<numvolo>' and nome is null and voli.idaereo=posti.idaereo
Possiamo quindi prendere il primo valore del risultato e fornirlo in
input ad una query di inserimento, del tipo
insert into prenotazioni values(<numvolo>, <numposto>, <nome>)
Lo script prenotazioni.php svolge
proprio
questo compito. Notare che, per semplicità espositiva, non
si
utilizza
la struttura a tre fasi di cui abbiamo parlato diffusamente le lezioni
scorse, ma la fase di inserimento e quella di comunicazione del
risultato sono inglobate in un sola.
Il punto che ci interessa evidenziare, in questo caso, è che
l'inserimento di una prenotazione richiede due
query distinte, una di lettura per determinare il numero del
posto, e una di scrittura per aggiornare la tabella prenotazioni. Ma cosa succede
se due persone stanno tentando
contemporaneamente di inserire una nuova prenotazione? In generale, non
è possibile stabilire in che ordine vengano eseguite le varie
query. Ovviamente la select dell'utente 1 è sempre eseguita
prima
dell'insert dell'utente 1, e analogamente avviene per l'utente 2. Ma
questo lascia comunque aperte varie possibilità. Ad esempio:
Caso
1
|
Caso
2
|
Caso
3
|
select1
|
select1
|
select1
|
insert1
|
select2
|
select2
|
select2
|
insert1
|
insert2
|
insert2
|
insert2
|
insert1
|
Mentre per il caso 1 non c'è nessun problema, in quanto le due
prenotazioni vengono eseguite sequenzialmente uno dopo l'altro, i casi
2
e 3 presentano dei problemi. Le select vengono eseguite prima delle
insert, e produrranno esattamente lo steso risultato. Successivamente
le
due insert tenteranno di inserire la stessa riga due volte nella
tabella
prenotazioni, causando una violazione di vincolo della chiave primaria
(che per la tabella prenotazioni è formata dalla coppia idvolo e
idposto).
È possibile osservare questo fenomeno con lo script di
prima, aggiungendo artificialmente
una pausa tra la select e
la insert. Si usa la
funzione
- void sleep (int seconds): attende il
numero di secondi specificato prima di proseguire con lo script.
In questo modo si facilita il
verificarsi delle situazioni dannose (la situazione dannosa, prima o
poi, si
verificherebbe anche senza pausa.. ma non abbiamo ore di tempo per
tentare ripetutamente). Basta a questo punto richiedere due
prenotazioni in sequenza, in modo che la seconda prenotazione sia
richiesta prima che il sistema porti a termini la prima, e la frittata
è fatta!
Inoltre, mentre in questo caso il secondo inserimento non avviene per
nulla, ci sono delle situazioni in cui entrambe le operazioni hanno
apparentemente successo, magari compromettendo l'integrità
della base di dati.
ATTENZIONE. Nel compiere questi esperimenti sulla concorrenza è necessario, spesso, richiedere in contemporanea l'esecuzione di più di una istanza della stesso script PHP. Anche se è possibile farlo semplicemente aprendo due finestre dallo stesso browser, per sicurezza è preferibile utilizzare un browser diverso per ogni istanza della pagina. Questo perchè alcuni browser, Firefox compreso, in varie situazioni si rifiutano di richiedere due copie della stessa pagina, e aspettano che una copia sia arrivata interamente prima di richiederne un'altra. È ovvio che un comportamento simile, che è ragionevole quando si tratta di un utilizzo normale del browser, rende difficile effettuare gli esperimenti che ci interessano. Utilizzando browser diversi, invece, il problema non sussiste.
Mandatory Locking
Come fare per risolvere questo problema? Abbiamo in realtà varie
possibilità: oggi esamineremo l'uso dei blocchi di tabella. Come
avviene per i sistemi operativi, il MySQL ha delle primitive che
consentono di bloccare le risorse (in questo caso le tabelle) ed
assegnarne l'uso esclusivo ad un thread (processo interno di MySQL). Il
comando SQL che fa al caso
nostro è LOCK TABLES.
La sintassi del comando è la seguente:
LOCK TABLES <nome_tabella>
[READ | WRITE] [, <nome_tabella> [READ | WRITE]]....
Ad esempio,
LOCK TABLES prenotazioni WRITE,
voli READ
blocca la tabella prenotazioni per la scrittura e la tabella voli per
la lettura. Ma cosa vuol dire che queste tabelle sono bloccate?
Una tabella bloccata con la clausola
READ può essere letta da
altri thread ma non può essere modificata. Una tabella bloccata
con la clausola WRITE non può essere nè letta nè
scritta da altri thread. Qualora un thread tenti di effettuare
una
operazione non consentita su una tabella bloccata, il thread si
sospende, in attesa che il blocco venga rilasciato e che quindi la
query
in sospeso possa essere ripresa.
In questo modo, finchè il LOCK è attivo, possiamo essere
sicuri che nulla di strano può accadere alle nostre tabelle. Per
rilasciare il lock quando abbiamo finito con le manipolazioni, si
può usare il comando SQL:
UNLOCK TABLES
che rilascia tutti i lock acquisiti.
NOTA. Le tabelle vengono
comunque sbloccate automaticamente quando una connessione al database
termina. Questo implica che, al termine dello script PHP, visto che le
connessioni vengono automaticamente chiuse, vi è un UNLOCK
automatico (in realtà ci sarebbe il problema delle connessioni
persistenti, che però non abbiamo mai introdotto e che quindi
preferisco ignorare).
Notare alcune caratteristiche dell'uso della LOCK:
- se si fa il LOCK di una tabella, bisogna fare il LOCK di tutte le tabelle che si usano. Un
comando del tipo
LOCK TABLES prenotazioni READ;
SELECT * FROM voli;
UNLOCK TABLES;
fallisce in quanto la tabella voli non è bloccata.
- se una tabella è bloccata in modalità READ, non
è possibile scrivere su quella tabella. Un comando del tipo
LOCK TABLES prenotazioni READ;
INSERT INTO prenotazioni
VALUES (12,34,'prova');
UNLOCK TABLES;
fallisce in quanto la tabella prenotazioni non è
bloccata per la scrittura;
- tutti i blocchi vanno acquisiti contemporaneamente nella stessa
istruzione LOCK. Quando si effettua una LOCK, tutti i blocchi posseduti
vengono automaticamente rilasciati. Così, le istruzioni:
LOCK TABLES prenotazioni
WRITE;
LOCK TABLES voli READ;
equivalgono a
LOCK TABLES prenotazioni WRITE;
UNLOCK TABLES;
LOCK TABLES voli READ;
Questo allo scopo di evitare il rischio di DEADLOCK!
- quando si esegue una LOCK, bisogna ricordarsi che l'accesso alle
tabelle bloccate da parte degli altri processi è fortemente
limitato. Per questo è bene rilasciare le tabelle il prima
possibile.
A questo punto, è possibile risolvere il problema
dell'inserimento di nuove prenotazioni bloccando le tabelle incriminate
prima di aggiornarle. In particolare, prima di effettuare la SELECT che
restituisce il posto da occupare, si bloccano tutte le tabelle con
lock tables posti read, voli
read, prenotazioni write
e alla fine delle operazioni si rilasciano i blocchi con
unlock tables;
Lo script corrispondente è prenotazioni-lock.php.
SUGGERIMENTO.
Per acquisire una maggiore familiarità con i problemi legati alla concorrenza (non solo nel mondo dei database), vi consiglio di rivedere il corso di "Sistemi di Elaborazione"
Esercizio 1
Sia data la tabella contatore
, all'interno del database test
, creata e inzializzata con i seguenti comandi:
create table contatore (
val int
);
insert into contatore values (0);
e lo script conteggia.php che aggiunge un numero specificato dall'utente al valore corrente del contatore. Modificare lo script in modo che non si verifichino problemi nel caso di più esecuzioni concorrenti.
Advisory Locking
I tipi di blocchi introdotti con i comandi LOCK/UNLOCK vengono di
solito chiamati col nome di mandatory
lockings (blocchi obbligatori). Questo perché, una volta che un
blocco è stato acquisito, tutti gli altri thread del sistema
devono obbligatoriamente rispettarlo. Se la tabella prenotazioni ha un
blocco in lettura, nessun altro processo sarà in grado di
scrivere su quella tabella, indipendentemente dal fatto che tenti
a sua
volta di acquisire un blocco oppure no.
Quando l'acceso al database avviene tramite una unica applicazione,
tuttavia, si può utilizzare un metodo di lock diverso, che ha il
nome di advisory locking. Con
un blocco di questo tipo, i thread devono volontariamente chiedere di
sottostare alle sue restrizioni. Su MySQL gli advisory lock si
realizzano con le funzioni predefinite GET_LOCK e RELEASE_LOCK.
Per acquisire un lock, si esegue una query del tipo SELECT GET_LOCK('nome_lock',timeout)
dove nome_lock è il
nome
del blocco da acquisire e timeout
è il numero di secondi che si è disposti ad attendere. Il
risultato sarà 1 se il blocco è stato acquisito o 0 se
è trascorso il tempo di timeout senza essere riusciti ad
acquisire il blocco (anche NULL è un possibile risultato qualora
si verifichi un errore di qualche tipo). Una volta che un
blocco è acquisito, qualunque altro GET_LOCK con lo stesso nome
di blocco deve attendere il rilascio del blocco (o la scadenza del
timeout) prima di poter continuare.
Un blocco viene rilasciato con SELECT
RELEASE_LOCK('nome_lock') dove il nome_lock deve essere uguale a
quello usato durante l'acquisizione.
Notare che se un processo non esegue un GET_LOCK, non ha nessun tipo di
restrizione su nessuna tabella. GET_LOCK
e RELEASE_LOCK vanno
quindi
utilizzati per proteggere le regioni critiche di una applicazione di
database. Nel caso dell'inserimento di prenotazioni, può essere
usato come nello script prenotazioni-lock-adv.php.
Prima di iniziare le interrogazioni, si acquisisce il lock di nome
'prenotazione' come segue:
$result=mysql_query("select
GET_LOCK('prenotazione',5)") or mysql_showerror();
$row=mysql_fetch_array($result);
if ($row[0]==1) {
.... prenotazioni...
} else
echo
"errore: sistema congestionato<p>";
La chiamata select
GET_LOCK('prenotazione',5) restituisce una unica riga con un
unico campo. Se il valore di questo campo (ottenuto con $row[0])
è 1, allora il lock è stato acquisito e si può
proseguire con la prenotazione. Altrimenti, vuol dire che sono
trascorsi
i 5 secondi di timeout senza essere riusciti ad ottenere il lock.
Questo è probabilmente dovuto al fatto che il sistema è
sottoposto a un numero notevole di interrogazioni al momento, e quindi
non riesce a rispondere abbastanza velocemente alle richieste. Per
questo visualizziamo un messaggio di errore indicando che il sistema
è congestionato.
Una volta eseguita la prenotazione, sia nel caso di successo (posti
disponibili) che di insuccesso (posti tutti occupati), si deve
rilasciare il lock con
mysql_query("select
RELEASE_LOCK('prenotazione')");
Esercizio 2
Risolvere l'esercizio 1 usando gli advisory lock invece dei mandatory lock.
I file utili per questa lezione: airdb3.sql, config.php, libreria.php
e .htaccess