5.1 De kwestie van gelijktijdigheid

Laten we beginnen met een beetje verre theorie.

Elk informatiesysteem (of gewoon een applicatie) dat programmeurs maken, bestaat uit verschillende typische blokken, die elk een deel van de noodzakelijke functionaliteit bieden. De cache wordt bijvoorbeeld gebruikt om het resultaat van een resource-intensieve bewerking te onthouden om ervoor te zorgen dat gegevens sneller door de client worden gelezen, met tools voor streamverwerking kunt u berichten naar andere componenten verzenden voor asynchrone verwerking, en met tools voor batchverwerking kunt u " rake" de geaccumuleerde hoeveelheden gegevens met enige periodiciteit. .

En in bijna elke toepassing zijn databases (DB's) op de een of andere manier betrokken, die meestal twee functies vervullen: gegevens opslaan wanneer ze van u worden ontvangen en ze later op verzoek aan u verstrekken. Zelden denkt iemand eraan om een ​​eigen database aan te maken, omdat er al veel kant-en-klare oplossingen zijn. Maar hoe kiest u de juiste voor uw toepassing?

Stel je voor dat je een applicatie hebt geschreven met een mobiele interface waarmee je een eerder opgeslagen lijst met taken in huis kunt laden - dat wil zeggen uit de database lezen en deze aanvullen met nieuwe taken, en prioriteit kunt geven aan elk specifiek taak - van 1 (hoogste) tot 3 (laagste). Stel dat uw mobiele applicatie door slechts één persoon tegelijk wordt gebruikt. Maar nu durfde je je moeder over je creatie te vertellen, en nu is ze de tweede vaste gebruiker geworden. Wat gebeurt er als je tegelijkertijd, precies in dezelfde milliseconde, besluit om een ​​bepaalde taak - "de ramen wassen" - een andere prioriteit te geven?

In professionele termen kunnen de databasequery's van u en uw moeder worden beschouwd als 2 processen die een query naar de database hebben uitgevoerd. Een proces is een entiteit in een computerprogramma dat op een of meer threads kan draaien. Een proces heeft doorgaans een machinecode-image, geheugen, context en andere bronnen. Met andere woorden, het proces kan worden gekarakteriseerd als het uitvoeren van programma-instructies op de processor. Wanneer uw applicatie een verzoek doet aan de database, hebben we het over het feit dat uw database het verzoek verwerkt dat via het netwerk vanuit één proces wordt ontvangen. Als er twee gebruikers tegelijkertijd in de applicatie zitten, kunnen er op elk moment twee processen zijn.

Wanneer een proces een verzoek doet aan de database, vindt het deze in een bepaalde staat. Een stateful systeem is een systeem dat eerdere gebeurtenissen onthoudt en wat informatie opslaat, die "status" wordt genoemd. Een variabele die is gedeclareerd als integerkan een status hebben van 0, 1, 2 of zeg 42. Mutex (wederzijdse uitsluiting) heeft twee statussen: vergrendeld of ontgrendeld , net als een binaire semafoor ("vereist" versus "vrijgegeven") en over het algemeen binair (binaire) gegevenstypen en variabelen die slechts twee toestanden kunnen hebben - 1 of 0.

Gebaseerd op het concept van de staat, zijn verschillende wiskundige en technische structuren gebaseerd, zoals een eindige automaat - een model dat één invoer en één uitvoer heeft en zich op elk moment in een van een eindige reeks toestanden bevindt - en de "staatstoestand". ” ontwerppatroon, waarin een object van gedrag verandert afhankelijk van de interne status (bijvoorbeeld afhankelijk van welke waarde aan een of andere variabele is toegewezen).

De meeste objecten in de machinewereld hebben dus een toestand die in de loop van de tijd kan veranderen: onze pijplijn, die een groot datapakket verwerkt, genereert een fout en wordt mislukt , of de eigenschap Wallet-object, die de hoeveelheid geld opslaat die overblijft in het geheugen van de gebruiker. rekening, mutaties na loonontvangsten.

Een overgang ("overgang") van de ene toestand naar de andere, bijvoorbeeld van bezig naar mislukt , wordt een operatie genoemd. Waarschijnlijk kent iedereen de CRUD- bewerkingen - create, read, update, deleteof vergelijkbare HTTP- methoden - POST, GET, PUT, DELETE. Maar programmeurs geven vaak andere namen aan operaties in hun code, omdat de operatie complexer kan zijn dan alleen het lezen van een bepaalde waarde uit de database - het kan ook de gegevens controleren, en dan onze operatie, die de vorm van een functie heeft aangenomen, wordt bijvoorbeeld genoemd En validate()wie voert deze bewerkingsfuncties uit? reeds beschreven processen.

Een beetje meer, en je zult begrijpen waarom ik de termen zo gedetailleerd beschrijf!

Elke bewerking - of het nu een functie is of, in gedistribueerde systemen, een verzoek naar een andere server stuurt - heeft 2 eigenschappen: de aanroeptijd en de voltooiingstijd (voltooiingstijd) , die strikt groter zal zijn dan de aanroeptijd (onderzoekers van Jepsen ga uit van de theoretische veronderstelling dat beide tijdstempels denkbeeldige, volledig gesynchroniseerde, wereldwijd beschikbare klokken zullen krijgen).

Laten we ons onze takenlijst-applicatie voorstellen. Je doet een verzoek aan de database via de mobiele interface in 14:00:00.014, en je moeder heeft binnen 13:59:59.678(dat wil zeggen 336 milliseconden ervoor) de takenlijst bijgewerkt via dezelfde interface en er de afwas aan toegevoegd. Rekening houdend met de netwerkvertraging en de mogelijke wachtrij van taken voor uw database, als naast u en uw moeder ook alle vrienden van uw moeder uw applicatie gebruiken, kan de database het verzoek van moeder uitvoeren nadat het uw verzoek heeft verwerkt. Met andere woorden, de kans bestaat dat twee van uw verzoeken, evenals verzoeken van de vriendinnen van uw moeder, tegelijkertijd (gelijktijdig) naar dezelfde gegevens worden verzonden.

Zo zijn we aangekomen bij de belangrijkste term op het gebied van databases en gedistribueerde applicaties: concurrency. Wat kan de gelijktijdigheid van twee operaties precies betekenen? Als een bewerking T1 en een bewerking T2 worden gegeven, dan:

  • T1 kan worden gestart vóór de starttijd van uitvoering T2 en worden beëindigd tussen de start- en eindtijd van T2
  • T2 kan worden gestart vóór de starttijd van T1 en worden beëindigd tussen de start en het einde van T1
  • T1 kan worden gestart en beëindigd tussen de start- en eindtijd van T1-uitvoering
  • en elk ander scenario waarin T1 en T2 een gemeenschappelijke uitvoeringstijd hebben

Het is duidelijk dat we het in het kader van deze lezing vooral hebben over queries die de database binnenkomen en hoe het databasemanagementsysteem deze queries waarneemt, maar de term concurrency is bijvoorbeeld belangrijk in de context van besturingssystemen. Ik zal niet te ver afwijken van het onderwerp van dit artikel, maar ik denk dat het belangrijk is om te vermelden dat de concurrency waar we het hier over hebben niet gerelateerd is aan het dilemma van concurrency en concurrency en hun verschil, dat wordt besproken in de context van besturingssystemen en krachtige computers. Parallellisme is een manier om gelijktijdigheid te bereiken in een omgeving met meerdere cores, processors of computers. We hebben het over gelijktijdigheid in de zin van gelijktijdige toegang van verschillende processen tot gemeenschappelijke gegevens.

En wat kan er eigenlijk fout gaan, puur theoretisch?

Bij het werken aan gedeelde gegevens kunnen tal van problemen met gelijktijdigheid optreden, ook wel "race-condities" genoemd. Het eerste probleem doet zich voor wanneer een proces gegevens ontvangt die het niet had mogen ontvangen: onvolledige, tijdelijke, geannuleerde of anderszins "onjuiste" gegevens. Het tweede probleem doet zich voor wanneer het proces verouderde gegevens ontvangt, dat wil zeggen gegevens die niet overeenkomen met de laatst opgeslagen status van de database. Laten we zeggen dat een applicatie geld heeft afgeschreven van het account van een gebruiker met een saldo van nul, omdat de database de accountstatus terugstuurde naar de applicatie, zonder rekening te houden met de laatste opname van geld, wat slechts een paar milliseconden geleden gebeurde. De situatie is zo-zo, nietwaar?

5.2 Transacties kwamen om ons te redden

Om dergelijke problemen op te lossen, verscheen het concept van een transactie - een bepaalde groep opeenvolgende bewerkingen (toestandsveranderingen) met een database, wat een logisch enkele bewerking is. Ik zal opnieuw een voorbeeld geven met een bank - en niet toevallig, want het concept van een transactie verscheen blijkbaar juist in de context van het werken met geld. Het klassieke voorbeeld van een transactie is het overmaken van geld van de ene bankrekening naar de andere: je moet eerst het bedrag van de bronrekening afhalen en vervolgens op de doelrekening storten.

Om deze transactie uit te voeren, moet de applicatie verschillende acties in de database uitvoeren: het saldo van de afzender controleren, het bedrag op de rekening van de afzender blokkeren, het bedrag op de rekening van de ontvanger toevoegen en het bedrag afschrijven van de afzender. Er zullen verschillende vereisten zijn voor een dergelijke transactie. De applicatie kan bijvoorbeeld geen verouderde of onjuiste informatie over het saldo ontvangen - bijvoorbeeld als tegelijkertijd een parallelle transactie halverwege in een fout is geëindigd en het geld niet van de rekening is afgeschreven - en onze applicatie heeft al informatie ontvangen dat het geld is afgeschreven.

Om dit probleem op te lossen werd een beroep gedaan op een eigenschap van een transactie als "isolatie": onze transactie wordt uitgevoerd alsof er op hetzelfde moment geen andere transacties worden uitgevoerd. Onze database voert gelijktijdige bewerkingen uit alsof ze ze een voor een uitvoeren, opeenvolgend - in feite wordt het hoogste isolatieniveau Strict Serializable genoemd . Ja, de hoogste, wat betekent dat er verschillende niveaus zijn.

"Stop", zeg je. Houd uw paarden vast, meneer.

Laten we onthouden hoe ik heb beschreven dat elke bewerking een oproeptijd en een uitvoeringstijd heeft. Voor het gemak kun je callen en uitvoeren beschouwen als 2 acties. Dan kan de gesorteerde lijst van alle oproep- en uitvoeringsacties de geschiedenis van de database worden genoemd. Dan is het transactie-isolatieniveau een reeks geschiedenissen. We gebruiken isolatieniveaus om te bepalen welke verhalen "goed" zijn. Als we zeggen dat een verhaal "serialiseerbaarheid doorbreekt" of "niet serialiseerbaar is", bedoelen we dat het verhaal niet in de reeks van serialiseerbare verhalen zit.

Om duidelijk te maken over wat voor soort verhalen we het hebben, zal ik voorbeelden geven. Er is bijvoorbeeld zo'n soort geschiedenis - tussentijds lezen . Het treedt op wanneer transactie A gegevens mag lezen uit een rij die is gewijzigd door een andere lopende transactie B en nog niet is vastgelegd ("niet vastgelegd") - dat wil zeggen, de wijzigingen zijn in feite nog niet definitief vastgelegd door transactie B, en kan ze op elk moment annuleren. En bijvoorbeeld afgebroken lezen is slechts ons voorbeeld met een geannuleerde opnametransactie

Er zijn verschillende mogelijke afwijkingen. Dat wil zeggen, anomalieën zijn een soort ongewenste gegevensstatus die kan optreden tijdens concurrerende toegang tot de database. En om bepaalde ongewenste toestanden te vermijden, gebruiken databases verschillende niveaus van isolatie - dat wil zeggen verschillende niveaus van gegevensbescherming tegen ongewenste toestanden. Deze niveaus (4 stuks) werden vermeld in de ANSI SQL-92-standaard.

De beschrijving van deze niveaus lijkt voor sommige onderzoekers vaag en ze bieden hun eigen, meer gedetailleerde classificaties. Ik raad je aan om aandacht te besteden aan de reeds genoemde Jepsen, evenals aan het Hermitage-project, dat tot doel heeft precies te verduidelijken welke isolatieniveaus worden aangeboden door specifieke DBMS, zoals MySQL of PostgreSQL. Als je de bestanden uit deze repository opent, kun je zien welke volgorde van SQL-commando's ze gebruiken om de database te testen op bepaalde anomalieën, en je kunt iets soortgelijks doen voor de databases waarin je geïnteresseerd bent). Hier is een voorbeeld uit de repository om je geïnteresseerd te houden:

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

Het is belangrijk om te begrijpen dat u voor dezelfde database in de regel een van de verschillende soorten isolatie kunt kiezen. Waarom niet kiezen voor de sterkste isolatie? Omdat, zoals alles in de informatica, het gekozen isolatieniveau moet overeenkomen met een afweging die we willen maken - in dit geval een afweging in uitvoeringssnelheid: hoe sterker het isolatieniveau, hoe langzamer de verzoeken zullen zijn verwerkt. Om te begrijpen welk isolatieniveau u nodig heeft, moet u de vereisten voor uw toepassing begrijpen en om te begrijpen of de database die u hebt gekozen dit niveau biedt, moet u de documentatie bekijken - voor de meeste toepassingen is dit voldoende, maar als je bijzonder strenge eisen hebt, is het beter om een ​​test te regelen zoals de jongens van het Hermitage-project doen.

5.3 "I" en andere letters in ACID

Isolatie is eigenlijk wat mensen bedoelen als ze over ACID in het algemeen praten. En het is om deze reden dat ik de analyse van dit acroniem met isolatie begon, en niet op volgorde ging, zoals degenen die dit concept proberen uit te leggen gewoonlijk doen. Laten we nu eens kijken naar de overige drie letters.

Denk nog eens terug aan ons voorbeeld met een bankoverschrijving. Een transactie om geld van de ene naar de andere rekening over te schrijven omvat een opnameoperatie van de eerste rekening en een aanvullingsoperatie op de tweede. Als de aanvullingsoperatie van de tweede rekening is mislukt, wilt u waarschijnlijk niet dat de opname van de eerste rekening plaatsvindt. Met andere woorden, ofwel slaagt de transactie volledig, ofwel vindt ze helemaal niet plaats, maar kan ze niet slechts voor een deel worden uitgevoerd. Deze eigenschap wordt "atomiciteit" genoemd en is een "A" in ACID.

Wanneer onze transactie wordt uitgevoerd, wordt de database, zoals bij elke bewerking, van de ene geldige toestand naar de andere overgedragen. Sommige databases bieden zogenaamde beperkingen - dat zijn regels die van toepassing zijn op de opgeslagen gegevens, bijvoorbeeld met betrekking tot primaire of secundaire sleutels, indexen, standaardwaarden, kolomtypen, enz. Bij het doen van een transactie moeten we er dus zeker van zijn dat aan al deze voorwaarden wordt voldaan.

Deze garantie wordt "consistentie" genoemd en een letter Cin ACID (niet te verwarren met consistentie uit de wereld van gedistribueerde applicaties, waar we het later over zullen hebben). Ik zal een duidelijk voorbeeld geven voor consistentie in de zin van ACID: een applicatie voor een online winkel wil een rij toevoegen ordersaan de tabel en de ID uit de tabel product_idwordt aangegeven in de kolom - typisch .productsforeign key

Als het product bijvoorbeeld uit het assortiment en dus uit de database is verwijderd, zou de bewerking voor het invoegen van rijen niet moeten plaatsvinden en krijgen we een foutmelding. Deze garantie is, in vergelijking met andere, naar mijn mening een beetje vergezocht - alleen al omdat het actieve gebruik van beperkingen uit de database een verschuiving van de verantwoordelijkheid voor de gegevens betekent (evenals een gedeeltelijke verschuiving van de bedrijfslogica, als we het hebben over een beperking als CHECK ) van de applicatie naar de database, wat, zoals ze nu zeggen, precies zo is.

En tot slot blijft het D- "weerstand" (duurzaamheid). Een systeemstoring of een andere storing mag niet leiden tot verlies van transactieresultaten of database-inhoud. Dat wil zeggen, als de database antwoordde dat de transactie succesvol was, betekent dit dat de gegevens zijn vastgelegd in een niet-vluchtig geheugen, bijvoorbeeld op een harde schijf. Dit wil overigens niet zeggen dat je bij het volgende leesverzoek de gegevens meteen te zien krijgt.

Onlangs werkte ik met DynamoDB van AWS (Amazon Web Services) en stuurde wat gegevens om op te slaan, en nadat ik een antwoord HTTP 200(OK) of iets dergelijks had ontvangen, besloot ik het te controleren - en ik zag dit niet gegevens in de database voor de volgende 10 seconden. Dat wil zeggen, DynamoDB heeft mijn gegevens vastgelegd, maar niet alle knooppunten zijn onmiddellijk gesynchroniseerd om de nieuwste kopie van de gegevens te krijgen (hoewel deze zich mogelijk in de cache bevonden). Hier zijn we opnieuw op het terrein van consistentie geklommen in de context van gedistribueerde systemen, maar de tijd om erover te praten is nog niet gekomen.

Dus nu weten we wat ACID-garanties zijn. En we weten zelfs waarom ze nuttig zijn. Maar hebben we ze echt nodig in elke toepassing? En zo nee, wanneer precies? Bieden alle DB's deze garanties, en zo niet, wat bieden ze in plaats daarvan?