5.1 Problema simultaneității

Să începem cu o mică teorie îndepărtată.

Orice sistem informatic (sau pur și simplu, o aplicație) creat de programatori este format din mai multe blocuri tipice, fiecare dintre acestea oferind o parte din funcționalitatea necesară. De exemplu, memoria cache este folosită pentru a reține rezultatul unei operațiuni care consumă mult resurse pentru a asigura o citire mai rapidă a datelor de către client, instrumentele de procesare a fluxului vă permit să trimiteți mesaje către alte componente pentru procesare asincronă, iar instrumentele de procesare în lot sunt folosite pentru " rake” volumele acumulate de date cu o oarecare periodicitate. .

Și în aproape fiecare aplicație, bazele de date (DB) sunt implicate într-un fel sau altul, care îndeplinesc de obicei două funcții: stochează date atunci când sunt primite de la tine și ți le oferă ulterior la cerere. Rareori cineva se gândește să-și creeze propria bază de date, deoarece există deja multe soluții gata făcute. Dar cum o alegi pe cea potrivită pentru aplicația ta?

Așadar, să ne imaginăm că ați scris o aplicație cu o interfață mobilă care vă permite să încărcați o listă de sarcini salvate anterior prin casă - adică să citiți din baza de date și să o completați cu noi sarcini, precum și să prioritizați fiecare specific sarcină - de la 1 (cel mai mare) la 3 (cel mai mic). Să presupunem că aplicația dvs. mobilă este utilizată de o singură persoană odată. Dar acum ai îndrăznit să-i spui mamei tale despre creația ta, iar acum a devenit al doilea utilizator obișnuit. Ce se întâmplă dacă decideți în același timp, chiar în aceeași milisecundă, să setați o sarcină - „spălați geamurile” - la un alt grad de prioritate?

În termeni profesionali, interogările dumneavoastră și ale mamei în baza de date pot fi considerate ca 2 procese care au făcut o interogare la baza de date. Un proces este o entitate dintr-un program de calculator care poate rula pe unul sau mai multe fire. De obicei, un proces are o imagine de cod de mașină, memorie, context și alte resurse. Cu alte cuvinte, procesul poate fi caracterizat ca execuția instrucțiunilor programului pe procesor. Atunci când aplicația dvs. face o cerere către baza de date, vorbim despre faptul că baza dvs. de date procesează cererea primită în rețea de la un proces. Dacă există doi utilizatori care stau în aplicație în același timp, atunci pot exista două procese la un anumit moment în timp.

Când un proces face o cerere către baza de date, o găsește într-o anumită stare. Un sistem cu stare este un sistem care își amintește evenimentele anterioare și stochează unele informații, care se numește „stare”. O variabilă declarată ca integerpoate avea o stare de 0, 1, 2 sau să spunem 42. Mutex (excluderea reciprocă) are două stări: blocat sau deblocat , la fel ca un semafor binar ("necesar" vs. "eliberat") și, în general, binar tipuri de date (binare) și variabile care pot avea doar două stări - 1 sau 0.

Pe baza conceptului de stare, se bazează mai multe structuri matematice și de inginerie, cum ar fi un automat finit - un model care are o intrare și o ieșire și se află într-una dintr-un set finit de stări în fiecare moment de timp - și „starea”. ” model de design, în care un obiect își schimbă comportamentul în funcție de starea internă (de exemplu, în funcție de ce valoare este atribuită uneia sau alteia variabile).

Deci, majoritatea obiectelor din lumea mașinilor au o stare care se poate schimba în timp: conducta noastră, care procesează un pachet mare de date, aruncă o eroare și devine eșuată, sau proprietatea obiectului Wallet, care stochează suma de bani rămasă în contul utilizatorului. cont, modificări după încasările de salarii.

O tranziție („tranziție”) de la o stare la alta – de exemplu, de la în curs la eșuat – se numește operație. Probabil, toată lumea cunoaște operațiunile CRUD - create, read, update, deletesau metode HTTP similare - POST, GET, PUT, DELETE. Dar programatorii dau adesea alte nume operațiunilor în codul lor, deoarece operația poate fi mai complexă decât simpla citire a unei anumite valori din baza de date - poate verifica și datele, iar apoi operația noastră, care a luat forma unei funcții, va fi numit, de exemplu, Și validate()cine efectuează aceste operații-funcții? procese deja descrise.

Încă puțin și veți înțelege de ce descriu termenii atât de detaliat!

Orice operațiune - fie că este o funcție, fie, în sistemele distribuite, trimiterea unei cereri către un alt server - are 2 proprietăți: timpul de invocare și timpul de finalizare (timpul de finalizare) , care va fi strict mai mare decât timpul de invocare (cercetătorii de la Jepsen). pornește de la ipotezele teoretice conform cărora ambelor marcaje de timp vor primi ceasuri imaginare, complet sincronizate, disponibile la nivel global).

Să ne imaginăm aplicația noastră pentru lista de activități. Faceți o solicitare la baza de date prin interfața mobilă în 14:00:00.014, iar mama dvs. în 13:59:59.678(adică cu 336 de milisecunde înainte) a actualizat lista de activități prin aceeași interfață, adăugând la ea vase de spălat. Ținând cont de întârzierea rețelei și de posibila coadă de sarcini pentru baza ta de date, dacă, pe lângă tine și mama ta, toți prietenii mamei tale folosesc și aplicația ta, baza de date poate executa cererea mamei după ce o prelucrează pe a ta. Cu alte cuvinte, există șansa ca două dintre solicitările tale, precum și cererile de la prietenele mamei tale, să fie trimise la aceleași date în același timp (concurente).

Așa că am ajuns la cel mai important termen din domeniul bazelor de date și aplicațiilor distribuite - concurența. Ce poate însemna mai exact simultaneitatea a două operații? Dacă sunt date o operație T1 și o operațiune T2, atunci:

  • T1 poate fi început înainte de ora de începere a execuției T2 și se poate termina între ora de început și de sfârșit a lui T2
  • T2 poate fi pornit înainte de ora de începere a lui T1 și se poate termina între începutul și sfârșitul lui T1
  • T1 poate fi pornit și terminat între ora de început și de sfârșit a execuției T1
  • și orice alt scenariu în care T1 și T2 au un timp de execuție comun

Este clar că în cadrul acestei prelegeri, vorbim în primul rând despre interogările care intră în baza de date și despre modul în care sistemul de management al bazei de date percepe aceste interogări, dar termenul de concurență este important, de exemplu, în contextul sistemelor de operare. Nu mă voi abate prea mult de la subiectul acestui articol, dar cred că este important să menționăm că concurența despre care vorbim aici nu are legătură cu dilema concurenței și concurenței și diferența lor, care este discutată în context. a sistemelor de operare si performante.calculatoare. Paralelismul este o modalitate de a obține concurență într-un mediu cu mai multe nuclee, procesoare sau computere. Vorbim de concurență în sensul accesului simultan al diferitelor procese la date comune.

Și ce, de fapt, poate merge prost, pur teoretic?

Când se lucrează la date partajate, pot apărea numeroase probleme legate de concurență, numite și „condiții de cursă”. Prima problemă apare atunci când un proces primește date pe care nu ar fi trebuit să le primească: date incomplete, temporare, anulate sau „incorecte” în alt mod. A doua problemă este atunci când procesul primește date învechite, adică date care nu corespund ultimei stări salvate a bazei de date. Să presupunem că o aplicație a retras bani din contul unui utilizator cu sold zero, deoarece baza de date a returnat aplicației starea contului, fără a ține cont de ultima retragere de bani din acesta, care s-a întâmplat cu doar câteva milisecunde în urmă. Situația este așa-așa, nu-i așa?

5.2 Tranzacțiile au venit pentru a ne salva

Pentru a rezolva astfel de probleme, a apărut conceptul de tranzacție - un anumit grup de operații secvențiale (modificări de stare) cu o bază de date, care este o singură operație logic. Voi da din nou un exemplu cu o bancă - și nu întâmplător, pentru că conceptul de tranzacție a apărut, aparent, tocmai în contextul lucrului cu banii. Exemplul clasic de tranzacție este transferul de bani dintr-un cont bancar în altul: mai întâi trebuie să retragi suma din contul sursă și apoi să o depui în contul țintă.

Pentru ca această tranzacție să fie efectuată, aplicația va trebui să efectueze mai multe acțiuni în baza de date: verificarea soldului expeditorului, blocarea sumei din contul expeditorului, adăugarea sumei în contul destinatarului și deducerea sumei de la expeditor. Vor fi mai multe cerințe pentru o astfel de tranzacție. De exemplu, aplicația nu poate primi informații învechite sau incorecte despre sold - de exemplu, dacă, în același timp, o tranzacție paralelă s-a încheiat cu o eroare la jumătate și fondurile nu au fost debitate din cont - iar aplicația noastră a primit deja informații că fondurile au fost anulate.

Pentru a rezolva această problemă, a fost apelată la o astfel de proprietate a unei tranzacții precum „izolare”: tranzacția noastră este executată ca și cum nu ar fi fost efectuate alte tranzacții în același moment. Baza noastră de date efectuează operații concurente ca și cum le-ar fi executat una după alta, secvențial - de fapt, cel mai înalt nivel de izolare se numește Strict Serializable . Da, cel mai înalt, ceea ce înseamnă că există mai multe niveluri.

„Oprește-te”, spui. Țineți-vă caii, domnule.

Să ne amintim cum am descris că fiecare operație are un timp de apel și un timp de execuție. Pentru comoditate, puteți lua în considerare apelarea și executarea ca 2 acțiuni. Apoi lista sortată a tuturor acțiunilor de apel și execuție poate fi numită istoricul bazei de date. Apoi nivelul de izolare a tranzacției este un set de istorii. Folosim niveluri de izolare pentru a determina ce povești sunt „bune”. Când spunem că o poveste „rupe serializabilitatea” sau „nu este serializabilă”, ne referim la faptul că povestea nu se află în setul poveștilor serializabile.

Ca să fie clar despre ce fel de povești vorbim, voi da exemple. De exemplu, există un astfel de fel de istorie - citire intermediară . Apare atunci când tranzacției A i se permite să citească date dintr-un rând care a fost modificat de o altă tranzacție B în curs de desfășurare și care nu a fost încă comisă („not committed”) - adică, de fapt, modificările nu au fost încă comise în cele din urmă de către tranzacția B și le poate anula oricând. Și, de exemplu, citirea întreruptă este doar exemplul nostru cu o tranzacție de retragere anulată

Există mai multe anomalii posibile. Adică, anomaliile sunt un fel de stare nedorită a datelor care pot apărea în timpul accesului competitiv la baza de date. Și pentru a evita anumite stări nedorite, bazele de date folosesc diferite niveluri de izolare - adică diferite niveluri de protecție a datelor față de stările nedorite. Aceste niveluri (4 bucăți) au fost listate în standardul ANSI SQL-92.

Descrierea acestor niveluri pare vagă pentru unii cercetători și oferă propriile lor clasificări, mai detaliate. Vă sfătuiesc să fiți atenți la deja menționatul Jepsen, precum și la proiectul Hermitage, care își propune să clarifice exact ce niveluri de izolare sunt oferite de SGBD-uri specifice, precum MySQL sau PostgreSQL. Dacă deschideți fișierele din acest depozit, puteți vedea ce secvență de comenzi SQL folosesc pentru a testa baza de date pentru anumite anomalii și puteți face ceva similar pentru bazele de date care vă interesează). Iată un exemplu din depozit pentru a vă menține interesat:

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

Este important să înțelegeți că pentru aceeași bază de date, de regulă, puteți alege unul dintre mai multe tipuri de izolare. De ce să nu alegeți cea mai puternică izolație? Pentru că, ca tot în informatică, nivelul de izolare ales ar trebui să corespundă unui compromis pe care suntem gata să-l facem - în acest caz, un compromis în viteza de execuție: cu cât nivelul de izolare este mai puternic, cu atât cererile vor fi mai lente. prelucrate. Pentru a înțelege ce nivel de izolare aveți nevoie, trebuie să înțelegeți cerințele aplicației dvs. și pentru a înțelege dacă baza de date pe care ați ales-o oferă acest nivel, va trebui să vă uitați la documentație - pentru majoritatea aplicațiilor acest lucru va fi suficient, dar dacă aveți niște cerințe deosebit de strânse, este mai bine să aranjați un test ca ceea ce fac băieții din proiectul Hermitage.

5.3 „I” și alte litere în ACID

Izolarea este în principiu ceea ce se referă oamenii când vorbesc despre ACID în general. Și tocmai din acest motiv am început analiza acestui acronim cu izolare și nu am mers în ordine, așa cum fac de obicei cei care încearcă să explice acest concept. Acum să ne uităm la cele trei litere rămase.

Amintiți-vă din nou exemplul nostru cu un transfer bancar. O tranzacție de transfer de fonduri dintr-un cont în altul include o operațiune de retragere din primul cont și o operațiune de reaprovizionare pe al doilea. Dacă operațiunea de realimentare a celui de-al doilea cont a eșuat, probabil că nu doriți ca operațiunea de retragere din primul cont să aibă loc. Cu alte cuvinte, fie tranzacția reușește complet, fie nu are loc deloc, dar nu se poate face doar pentru o anumită parte. Această proprietate se numește „atomicitate” și este un „A” în ACID.

Când tranzacția noastră este executată, atunci, ca orice operațiune, aceasta transferă baza de date dintr-o stare validă în alta. Unele baze de date oferă așa-numitele constrângeri - adică reguli care se aplică datelor stocate, de exemplu, privind cheile primare sau secundare, indici, valori implicite, tipuri de coloane etc. Deci, atunci când facem o tranzacție, trebuie să fim siguri că toate aceste constrângeri vor fi îndeplinite.

Această garanție se numește „consistență” și o literă Cîn ACID (a nu se confunda cu consistența din lumea aplicațiilor distribuite, despre care vom vorbi mai târziu). Voi da un exemplu clar pentru coerență în sensul ACID: o aplicație pentru un magazin online dorește să adauge ordersun rând la tabel, iar ID- ul din tabel product_idva fi indicat în coloană - tipic .productsforeign key

Dacă produsul, să zicem, a fost eliminat din sortiment și, în consecință, din baza de date, atunci operația de inserare a rândului nu ar trebui să aibă loc și vom primi o eroare. Această garanție, în comparație cu altele, este puțin exagerată, după părerea mea - fie și doar pentru că utilizarea activă a constrângerilor din baza de date înseamnă schimbarea responsabilității pentru date (precum și schimbarea parțială a logicii de afaceri, dacă vorbim despre o astfel de constrângere precum CHECK ) de la aplicație la baza de date, ceea ce, așa cum se spune acum, este chiar așa.

Și, în sfârșit, rămâne D- „rezistență” (durabilitate). O defecțiune a sistemului sau orice altă defecțiune nu ar trebui să ducă la pierderea rezultatelor tranzacției sau a conținutului bazei de date. Adică, dacă baza de date a răspuns că tranzacția a avut succes, atunci aceasta înseamnă că datele au fost înregistrate în memorie nevolatilă - de exemplu, pe un hard disk. Acest lucru, apropo, nu înseamnă că veți vedea imediat datele la următoarea solicitare de citire.

Chiar zilele trecute, lucram cu DynamoDB de la AWS (Amazon Web Services) și am trimis niște date pentru salvare, iar după ce am primit un răspuns HTTP 200(OK), sau ceva de genul ăsta, am decis să-l verific - și nu am văzut asta date din baza de date pentru următoarele 10 secunde. Adică, DynamoDB a comis datele mele, dar nu toate nodurile s-au sincronizat instantaneu pentru a obține cea mai recentă copie a datelor (deși este posibil să fi fost în cache). Aici am urcat din nou pe teritoriul consecvenței în contextul sistemelor distribuite, dar momentul să vorbim despre asta încă nu a venit.

Deci acum știm ce sunt garanțiile ACID. Și știm chiar de ce sunt utile. Dar chiar avem nevoie de ele în fiecare aplicație? Și dacă nu, când mai exact? Toate DB-urile oferă aceste garanții și, dacă nu, ce oferă în schimb?