5.1 Il problema della simultaneità

Cominciamo con una piccola teoria distante.

Qualsiasi sistema informativo (o semplicemente un'applicazione) creato dai programmatori è costituito da diversi blocchi tipici, ognuno dei quali fornisce una parte delle funzionalità necessarie. Ad esempio, la cache viene utilizzata per ricordare il risultato di un'operazione ad alta intensità di risorse per garantire una lettura più rapida dei dati da parte del client, gli strumenti di elaborazione del flusso consentono di inviare messaggi ad altri componenti per l'elaborazione asincrona e gli strumenti di elaborazione batch vengono utilizzati per " rake" i volumi accumulati di dati con una certa periodicità. .

E in quasi tutte le applicazioni, i database (DB) sono coinvolti in un modo o nell'altro, che di solito svolgono due funzioni: archiviare i dati quando vengono ricevuti da te e successivamente fornirteli su richiesta. Raramente qualcuno pensa di creare il proprio database, perché ci sono già molte soluzioni già pronte. Ma come scegliere quello giusto per la tua applicazione?

Quindi, supponiamo che tu abbia scritto un'applicazione con un'interfaccia mobile che ti consente di caricare un elenco di attività precedentemente salvato in casa, ovvero leggere dal database e integrarlo con nuove attività, oltre a dare la priorità a ciascuna specifica compito - da 1 (il più alto) a 3 (il più basso). Supponiamo che la tua applicazione mobile sia utilizzata da una sola persona alla volta. Ma ora hai osato raccontare a tua madre della tua creazione, e ora è diventata la seconda utente abituale. Cosa succede se decidi contemporaneamente, nello stesso millisecondo, di impostare un compito - "lavare i vetri" - a un diverso grado di priorità?

In termini professionali, le query sul database di tua madre e di tua madre possono essere considerate come 2 processi che hanno effettuato una query sul database. Un processo è un'entità in un programma per computer che può essere eseguito su uno o più thread. In genere, un processo ha un'immagine del codice macchina, memoria, contesto e altre risorse. In altre parole, il processo può essere caratterizzato come l'esecuzione di istruzioni di programma sul processore. Quando la tua applicazione effettua una richiesta al database, stiamo parlando del fatto che il tuo database elabora la richiesta ricevuta in rete da un processo. Se due utenti siedono contemporaneamente nell'applicazione, possono esserci due processi in un determinato momento.

Quando un processo fa una richiesta al database, lo trova in un certo stato. Un sistema stateful è un sistema che ricorda gli eventi precedenti e memorizza alcune informazioni, chiamate "stato". Una variabile dichiarata come integerpuò avere uno stato di 0, 1, 2 o diciamo 42. Mutex (esclusione reciproca) ha due stati: bloccato o sbloccato , proprio come un semaforo binario ("richiesto" vs. "rilasciato") e generalmente binario tipi di dati e variabili (binari) che possono avere solo due stati: 1 o 0.

Sulla base del concetto di stato, si basano diverse strutture matematiche e ingegneristiche, come un automa finito - un modello che ha un input e un output e si trova in uno di un insieme finito di stati in ogni momento del tempo - e lo "stato ” modello di progettazione, in cui un oggetto cambia comportamento a seconda dello stato interno (ad esempio, a seconda del valore assegnato a una o un'altra variabile).

Quindi, la maggior parte degli oggetti nel mondo delle macchine ha uno stato che può cambiare nel tempo: la nostra pipeline, che elabora un pacchetto di dati di grandi dimensioni, genera un errore e diventa non riuscita, o la proprietà dell'oggetto Wallet, che memorizza la quantità di denaro rimasta nella memoria dell'utente account, modifiche dopo le ricevute del libro paga.

Una transizione ("transizione") da uno stato a un altro, ad esempio da in corso a fallito , è chiamata operazione. Probabilmente tutti conoscono le operazioni CRUD - create, read, o metodi HTTPupdate simili - , , , . Ma i programmatori spesso danno altri nomi alle operazioni nel loro codice, perché l'operazione può essere più complessa della semplice lettura di un certo valore dal database - può anche controllare i dati, e quindi la nostra operazione, che ha assunto la forma di una funzione, si chiamerà, ad esempio, E chi svolge queste operazioni-funzioni? processi già descritti.deletePOSTGETPUTDELETEvalidate()

Ancora un po 'e capirai perché descrivo i termini in modo così dettagliato!

Qualsiasi operazione - sia essa una funzione, o, nei sistemi distribuiti, l'invio di una richiesta a un altro server - ha 2 proprietà: il tempo di invocazione e il tempo di completamento (completion time) , che sarà strettamente maggiore del tempo di invocazione (ricercatori di Jepsen procedere dai presupposti teorici che a entrambi questi timestamp verranno assegnati orologi immaginari, completamente sincronizzati e disponibili a livello globale).

Immaginiamo la nostra applicazione per la lista delle cose da fare. Fai una richiesta al database tramite l'interfaccia mobile in 14:00:00.014e tua madre in 13:59:59.678(ovvero 336 millisecondi prima) ha aggiornato l'elenco delle cose da fare tramite la stessa interfaccia, aggiungendovi il lavaggio dei piatti. Tenendo conto del ritardo della rete e della possibile coda di attività per il tuo database, se, oltre a te e tua madre, anche tutti gli amici di tua madre utilizzano la tua applicazione, il database può eseguire la richiesta della madre dopo aver elaborato la tua. In altre parole, c'è la possibilità che due delle tue richieste, così come le richieste delle amiche di tua madre, vengano inviate agli stessi dati contemporaneamente (contemporaneamente).

Quindi siamo giunti al termine più importante nel campo dei database e delle applicazioni distribuite: concorrenza. Cosa può significare esattamente la simultaneità di due operazioni? Se sono fornite un'operazione T1 e un'operazione T2, allora:

  • T1 può essere avviato prima dell'ora di inizio dell'esecuzione T2 e terminato tra l'ora di inizio e di fine di T2
  • T2 può essere avviato prima dell'ora di inizio di T1 e terminato tra l'inizio e la fine di T1
  • T1 può essere avviato e terminato tra l'ora di inizio e di fine dell'esecuzione di T1
  • e qualsiasi altro scenario in cui T1 e T2 hanno un tempo di esecuzione comune

È chiaro che nell'ambito di questa lezione stiamo parlando principalmente di query che entrano nel database e di come il sistema di gestione del database percepisce queste query, ma il termine concorrenza è importante, ad esempio, nel contesto dei sistemi operativi. Non mi allontanerò troppo dall'argomento di questo articolo, ma penso sia importante menzionare che la concorrenza di cui stiamo parlando qui non è correlata al dilemma della concorrenza e della concorrenza e della loro differenza, che è discussa nel contesto di sistemi operativi e computer ad alte prestazioni. Il parallelismo è un modo per ottenere la concorrenza in un ambiente con più core, processori o computer. Parliamo di concorrenza nel senso di accesso simultaneo di processi diversi a dati comuni.

E cosa, infatti, può andare storto, puramente teoricamente?

Quando si lavora su dati condivisi, possono verificarsi numerosi problemi legati alla concorrenza, chiamati anche "race condition". Il primo problema si verifica quando un processo riceve dati che non avrebbe dovuto ricevere: dati incompleti, temporanei, cancellati o altrimenti "errati". Il secondo problema è quando il processo riceve dati obsoleti, ovvero dati che non corrispondono all'ultimo stato salvato del database. Diciamo che qualche applicazione ha prelevato denaro dal conto di un utente con saldo zero, perché il database ha restituito lo stato del conto all'applicazione, non tenendo conto dell'ultimo prelievo di denaro da esso, avvenuto solo un paio di millisecondi fa. La situazione è così così, vero?

5.2 Le transazioni sono arrivate per salvarci

Per risolvere tali problemi, è apparso il concetto di transazione: un certo gruppo di operazioni sequenziali (cambiamenti di stato) con un database, che è un'operazione logicamente singola. Faccio ancora un esempio con una banca - e non a caso, perché il concetto di transazione è apparso, a quanto pare, proprio nel contesto del lavoro con il denaro. L'esempio classico di una transazione è il trasferimento di denaro da un conto bancario a un altro: è necessario prima prelevare l'importo dal conto di origine e poi depositarlo sul conto di destinazione.

Per eseguire questa transazione, l'applicazione dovrà eseguire diverse azioni nel database: controllare il saldo del mittente, bloccare l'importo sul conto del mittente, aggiungere l'importo sul conto del destinatario e detrarre l'importo dal mittente. Ci saranno diversi requisiti per tale transazione. Ad esempio, l'applicazione non può ricevere informazioni obsolete o errate sul saldo - ad esempio, se allo stesso tempo una transazione parallela si è conclusa con un errore a metà e i fondi non sono stati addebitati sul conto - e la nostra applicazione ha già ricevuto informazioni che i fondi sono stati cancellati.

Per risolvere questo problema, è stata invocata una proprietà di una transazione come "isolamento": la nostra transazione viene eseguita come se non ci fossero altre transazioni in corso nello stesso momento. Il nostro database esegue operazioni simultanee come se le stesse eseguendo una dopo l'altra, in sequenza - infatti, il livello di isolamento più alto è chiamato Strict Serializable . Sì, il più alto, il che significa che ci sono diversi livelli.

"Smettila", dici. Tieni i cavalli, signore.

Ricordiamo come ho descritto che ogni operazione ha un tempo di chiamata e un tempo di esecuzione. Per comodità, puoi considerare la chiamata e l'esecuzione come 2 azioni. Quindi l'elenco ordinato di tutte le azioni di chiamata ed esecuzione può essere chiamato cronologia del database. Quindi il livello di isolamento della transazione è un insieme di cronologie. Usiamo i livelli di isolamento per determinare quali storie sono "buone". Quando diciamo che una storia "rompe la serializzabilità" o "non è serializzabile", intendiamo che la storia non è nell'insieme delle storie serializzabili.

Per chiarire di che tipo di storie stiamo parlando, fornirò degli esempi. Ad esempio, esiste un tale tipo di storia - lettura intermedia . Si verifica quando alla transazione A è consentito leggere i dati da una riga che è stata modificata da un'altra transazione B in esecuzione e non è ancora stata confermata ("not commit") - ovvero, in effetti, le modifiche non sono state ancora definitivamente confermate da transazione B, e può annullarli in qualsiasi momento. E, ad esempio, la lettura interrotta è solo il nostro esempio con una transazione di prelievo annullata

Ci sono diverse possibili anomalie. In altre parole, le anomalie sono una sorta di stato dei dati indesiderato che può verificarsi durante l'accesso competitivo al database. E per evitare determinati stati indesiderati, i database utilizzano diversi livelli di isolamento, ovvero diversi livelli di protezione dei dati da stati indesiderati. Questi livelli (4 pezzi) sono stati elencati nello standard ANSI SQL-92.

La descrizione di questi livelli sembra vaga ad alcuni ricercatori e offrono le proprie classificazioni più dettagliate. Ti consiglio di prestare attenzione al già citato Jepsen, così come al progetto Hermitage, che mira a chiarire esattamente quali livelli di isolamento sono offerti da specifici DBMS, come MySQL o PostgreSQL. Se apri i file da questo repository, puoi vedere quale sequenza di comandi SQL usano per testare il database per determinate anomalie e puoi fare qualcosa di simile per i database che ti interessano). Ecco un esempio dal repository per mantenerti interessato:

-- Database: MySQL

-- Setup before test
create table test (id int primary key, value int) engine=innodb;
insert into test (id, value) values (1, 10), (2, 20);

-- Test the "read uncommited" isolation level on the "Intermediate Reads" (G1b) anomaly
set session transaction isolation level read uncommitted; begin; -- T1
set session transaction isolation level read uncommitted; begin; -- T2
update test set value = 101 where id = 1; -- T1
select * from test; -- T2. Shows 1 => 101
update test set value = 11 where id = 1; -- T1
commit; -- T1
select * from test; -- T2. Now shows 1 => 11
commit; -- T2

-- Result: doesn't prevent G1b

È importante capire che per lo stesso database, di norma, è possibile scegliere uno dei diversi tipi di isolamento. Perché non scegliere l'isolamento più resistente? Perché, come ogni cosa in informatica, il livello di isolamento scelto dovrebbe corrispondere a un compromesso che siamo pronti a fare - in questo caso, un compromesso nella velocità di esecuzione: più forte è il livello di isolamento, più lente saranno le richieste elaborato. Per capire di quale livello di isolamento hai bisogno, devi comprendere i requisiti per la tua applicazione e per capire se il database che hai scelto offre questo livello, dovrai esaminare la documentazione: per la maggior parte delle applicazioni questo sarà sufficiente, ma se hai dei requisiti particolarmente stringenti, è meglio organizzare un test come quello che fanno i ragazzi del progetto Hermitage.

5.3 "I" e altre lettere in ACID

L'isolamento è fondamentalmente ciò che le persone intendono quando parlano di ACID in generale. Ed è per questo motivo che ho iniziato l'analisi di questo acronimo con l'isolamento, e non sono andato con ordine, come di solito fa chi cerca di spiegare questo concetto. Ora diamo un'occhiata alle restanti tre lettere.

Ricordiamo ancora il nostro esempio con un bonifico bancario. Un'operazione di trasferimento di fondi da un conto all'altro prevede un'operazione di prelievo dal primo conto e un'operazione di ricarica sul secondo. Se l'operazione di rifornimento del secondo conto fallisce, probabilmente non vuoi che si verifichi l'operazione di prelievo dal primo conto. In altre parole, o la transazione riesce completamente, oppure non avviene affatto, ma non può essere effettuata solo in parte. Questa proprietà si chiama "atomicità" ed è una "A" in ACID.

Quando la nostra transazione viene eseguita, quindi, come qualsiasi operazione, trasferisce il database da uno stato valido a un altro. Alcuni database offrono i cosiddetti vincoli , ovvero regole che si applicano ai dati archiviati, ad esempio in merito a chiavi primarie o secondarie, indici, valori predefiniti, tipi di colonne, ecc. Quindi, quando effettuiamo una transazione, dobbiamo essere sicuri che tutti questi vincoli saranno rispettati.

Questa garanzia si chiama "coerenza" e una lettera Cin ACID (da non confondere con la coerenza del mondo delle applicazioni distribuite, di cui parleremo più avanti). Darò un chiaro esempio di coerenza nel senso di ACID: un'applicazione per un negozio online vuole aggiungere ordersuna riga alla tabella e l'ID della tabella product_idverrà indicato nella colonna - tipico .productsforeign key

Se il prodotto, ad esempio, è stato rimosso dall'assortimento e, di conseguenza, dal database, l'operazione di inserimento della riga non dovrebbe avvenire e otterremo un errore. Questa garanzia, rispetto ad altre, è un po' inverosimile, a mio avviso, se non altro perché l'uso attivo dei vincoli del database significa spostare la responsabilità dei dati (così come il parziale spostamento della logica aziendale, se parliamo di un vincolo come CHECK ) dall'applicazione al database, che, come si dice ora, è proprio così.

E infine, rimane D- "resistenza" (durevolezza). Un errore di sistema o qualsiasi altro errore non dovrebbe portare alla perdita dei risultati delle transazioni o del contenuto del database. Cioè, se il database ha risposto che la transazione è andata a buon fine, significa che i dati sono stati registrati nella memoria non volatile, ad esempio su un disco rigido. Questo, a proposito, non significa che vedrai immediatamente i dati alla prossima richiesta di lettura.

Proprio l'altro giorno, stavo lavorando con DynamoDB di AWS (Amazon Web Services) e ho inviato alcuni dati per il salvataggio, e dopo aver ricevuto una risposta HTTP 200(OK), o qualcosa del genere, ho deciso di controllarlo e non ho visto questo dati nel database per i successivi 10 secondi. Cioè, DynamoDB ha eseguito il commit dei miei dati, ma non tutti i nodi si sono sincronizzati istantaneamente per ottenere l'ultima copia dei dati (anche se potrebbe essere stata nella cache). Qui siamo nuovamente saliti nel territorio della coerenza nell'ambito dei sistemi distribuiti, ma non è ancora arrivato il momento di parlarne.

Quindi ora sappiamo cosa sono le garanzie ACID. E sappiamo anche perché sono utili. Ma ne abbiamo davvero bisogno in ogni applicazione? E se no, quando esattamente? Tutti i DB offrono queste garanzie e, in caso contrario, cosa offrono invece?