5.1 Frågan om samtidighet

Låt oss börja med en lite avlägsen teori.

Alla informationssystem (eller helt enkelt en applikation) som programmerare skapar består av flera typiska block, som vart och ett tillhandahåller en del av den nödvändiga funktionaliteten. Till exempel används cachen för att komma ihåg resultatet av en resurskrävande operation för att säkerställa snabbare läsning av data av klienten, strömbearbetningsverktyg låter dig skicka meddelanden till andra komponenter för asynkron bearbetning, och batchbearbetningsverktyg används för att " raka" de ackumulerade datavolymerna med viss periodicitet. .

Och i nästan varje applikation är databaser (DB) inblandade på ett eller annat sätt, som vanligtvis utför två funktioner: lagra data när de tas emot från dig och senare tillhandahålla dem till dig på begäran. Det är sällan någon tänker på att skapa sin egen databas, eftersom det redan finns många färdiga lösningar. Men hur väljer du rätt för din applikation?

Så låt oss föreställa oss att du har skrivit en applikation med ett mobilt gränssnitt som låter dig ladda en tidigare sparad lista med uppgifter runt huset - det vill säga läsa från databasen och komplettera den med nya uppgifter, samt prioritera varje specifik uppgift - från 1 (högst) till 3 (lägst). Låt oss säga att din mobilapplikation endast används av en person åt gången. Men nu vågade du berätta för din mamma om din skapelse, och nu har hon blivit den andra vanliga användaren. Vad händer om du samtidigt, precis inom samma millisekund, bestämmer dig för att sätta någon uppgift - "tvätta fönstren" - till en annan grad av prioritet?

I professionella termer kan din och mammas databasförfrågningar betraktas som 2 processer som gjorde en förfrågan till databasen. En process är en enhet i ett datorprogram som kan köras på en eller flera trådar. Vanligtvis har en process en maskinkodbild, minne, sammanhang och andra resurser. Med andra ord kan processen karakteriseras som exekvering av programinstruktioner på processorn. När din ansökan gör en förfrågan till databasen talar vi om att din databas behandlar förfrågan som tas emot över nätverket från en process. Om det finns två användare som sitter i applikationen samtidigt, kan det finnas två processer vid en viss tidpunkt.

När någon process gör en begäran till databasen, hittar den den i ett visst tillstånd. Ett tillståndssystem är ett system som minns tidigare händelser och lagrar viss information, som kallas "tillstånd". En variabel som deklareras som integerkan ha tillståndet 0, 1, 2 eller säg 42. Mutex (ömsesidig uteslutning) har två tillstånd: låst eller olåst , precis som en binär semafor ("krävs" kontra "frisläppt") och generellt binär (binära) datatyper och variabler som bara kan ha två tillstånd - 1 eller 0.

Baserat på begreppet tillstånd är flera matematiska och tekniska strukturer baserade, såsom en finit automat - en modell som har en ingång och en utgång och är i en av en ändlig uppsättning tillstånd vid varje tidpunkt - och "tillståndet" ” designmönster, där ett objekt ändrar beteende beroende på det interna tillståndet (till exempel beroende på vilket värde som tilldelas en eller annan variabel).

Så, de flesta objekt i maskinvärlden har något tillstånd som kan förändras över tiden: vår pipeline, som bearbetar ett stort datapaket, kastar ett fel och blir misslyckat, eller Wallet-objektegenskapen, som lagrar mängden pengar som finns kvar i användarens konto, ändringar efter lönekvitton.

En övergång ("övergång") från ett tillstånd till ett annat - säg från pågående till misslyckat - kallas en operation. Förmodligen känner alla till CRUD- operationerna - create, read, update, deleteeller liknande HTTP- metoder - POST, GET, PUT, DELETE. Men programmerare ger ofta andra namn till operationer i sin kod, eftersom operationen kan vara mer komplex än att bara läsa ett visst värde från databasen – den kan också kontrollera data, och sedan vår operation, som har tagit formen av en funktion, kommer att kallas, till exempel, Och validate()vem utför dessa operationer-funktioner? processer som redan beskrivits.

Lite mer, så förstår du varför jag beskriver termerna så detaljerat!

Varje operation - vare sig det är en funktion, eller, i distribuerade system, att skicka en begäran till en annan server - har 2 egenskaper: anropstiden och slutförandetiden (slutförandetiden) , som kommer att vara strikt längre än anropstiden (forskare från Jepsen utgå från de teoretiska antagandena att båda dessa tidsstämplar kommer att ges imaginära, helt synkroniserade, globalt tillgängliga klockor).

Låt oss föreställa oss vår att göra-lista-applikation. Du gör en förfrågan till databasen via det mobila gränssnittet i 14:00:00.014, och din mamma 13:59:59.678(det vill säga 336 millisekunder innan) uppdaterade att-göra-listan genom samma gränssnitt och lade till disk i den. Med hänsyn till nätverksfördröjningen och den möjliga kön av uppgifter för din databas, om, förutom du och din mamma, alla din mammas vänner också använder din applikation, kan databasen exekvera mammas begäran efter att den har behandlat din. Det finns med andra ord en chans att två av dina förfrågningar, samt förfrågningar från din mammas flickvänner, skickas till samma data samtidigt (samtidigt).

Så vi har kommit till den viktigaste termen inom området databaser och distribuerade applikationer - samtidighet. Vad exakt kan samtidigt två operationer betyda? Om någon operation T1 och någon operation T2 ges, då:

  • T1 kan startas före starttiden för utförandet T2, och avslutas mellan start- och sluttid för T2
  • T2 kan startas före starttiden för T1, och avslutas mellan början och slutet av T1
  • T1 kan startas och avslutas mellan start- och sluttid för T1-körning
  • och alla andra scenarier där T1 och T2 har någon gemensam exekveringstid

Det är tydligt att vi inom ramen för denna föreläsning främst talar om frågor som kommer in i databasen och hur databashanteringssystemet uppfattar dessa frågor, men begreppet samtidighet är viktigt till exempel i operativsystemsammanhang. Jag ska inte avvika alltför långt från ämnet för den här artikeln, men jag tycker att det är viktigt att nämna att den samtidighet vi talar om här inte är relaterad till dilemmat med samtidighet och samtidighet och deras skillnad, som diskuteras i sammanhanget av operativsystem och högpresterande datoranvändning. Parallellism är ett sätt att uppnå samtidighet i en miljö med flera kärnor, processorer eller datorer. Vi talar om samtidighet i betydelsen av samtidig åtkomst av olika processer till gemensamma data.

Och vad kan egentligen gå fel, rent teoretiskt?

När man arbetar med delad data kan många problem relaterade till samtidighet, även kallade "raceförhållanden", uppstå. Det första problemet uppstår när en process tar emot data som den inte borde ha tagit emot: ofullständiga, tillfälliga, avbrutna eller på annat sätt "felaktiga" data. Det andra problemet är när processen tar emot inaktuella data, det vill säga data som inte motsvarar det senast sparade tillståndet i databasen. Låt oss säga att någon applikation har tagit ut pengar från en användares konto med ett saldo på noll, eftersom databasen returnerade kontostatusen till applikationen, utan att ta hänsyn till det senaste uttaget av pengar från den, som hände för bara ett par millisekunder sedan. Situationen är så som så, eller hur?

5.2 Transaktioner kom för att rädda oss

För att lösa sådana problem dök konceptet med en transaktion upp - en viss grupp av sekventiella operationer (tillståndsändringar) med en databas, vilket är en logiskt enkel operation. Jag kommer att ge ett exempel med en bank igen - och inte av en slump, eftersom begreppet transaktion dök upp, tydligen, just i samband med att arbeta med pengar. Det klassiska exemplet på en transaktion är överföringen av pengar från ett bankkonto till ett annat: du måste först ta ut beloppet från källkontot och sedan sätta in det på målkontot.

För att denna transaktion ska kunna utföras kommer applikationen att behöva utföra flera åtgärder i databasen: kontrollera avsändarens saldo, spärra beloppet på avsändarens konto, lägga till beloppet på mottagarens konto och dra av beloppet från avsändaren. Det kommer att finnas flera krav för en sådan transaktion. Till exempel kan ansökan inte få inaktuella eller felaktiga uppgifter om saldot - till exempel om samtidigt en parallell transaktion slutade med ett fel halvvägs, och medlen inte debiterades kontot - och vår ansökan redan har fått information att medlen skrevs av.

För att lösa detta problem användes en sådan egenskap hos en transaktion som "isolering": vår transaktion utförs som om det inte fanns några andra transaktioner som utfördes i samma ögonblick. Vår databas utför samtidiga operationer som om den körde dem en efter en, sekventiellt - i själva verket kallas den högsta isoleringsnivån Strict Serializable . Ja, den högsta, vilket betyder att det finns flera nivåer.

"Stopp", säger du. Håll era hästar, sir.

Låt oss komma ihåg hur jag beskrev att varje operation har en samtalstid och en exekveringstid. För enkelhetens skull kan du överväga att anropa och utföra som två åtgärder. Sedan kan den sorterade listan över alla anrops- och exekveringsåtgärder kallas databasens historik. Då är transaktionsisoleringsnivån en uppsättning historiker. Vi använder isoleringsnivåer för att avgöra vilka historier som är "bra". När vi säger att en berättelse "bryter serialiserbarhet" eller "inte går att serialisera", menar vi att berättelsen inte finns med i uppsättningen av serialiserbara berättelser.

För att tydliggöra vilken typ av berättelser vi pratar om kommer jag att ge exempel. Det finns till exempel en sådan sorts historia - mellanläsning . Det inträffar när transaktion A tillåts läsa data från en rad som har modifierats av en annan pågående transaktion B och som ännu inte har begåtts ("not committed") - det vill säga att ändringarna ännu inte har slutgiltigt commited av transaktion B, och den kan när som helst avbryta dem. Och, till exempel, avbruten läsning är bara vårt exempel med en annullerad uttagstransaktion

Det finns flera möjliga anomalier. Det vill säga, anomalier är något slags oönskat datatillstånd som kan uppstå under konkurrensutsatt åtkomst till databasen. Och för att undvika vissa oönskade tillstånd använder databaser olika nivåer av isolering – det vill säga olika nivåer av dataskydd från oönskade tillstånd. Dessa nivåer (4 stycken) listades i ANSI SQL-92-standarden.

Beskrivningen av dessa nivåer verkar vag för vissa forskare, och de erbjuder sina egna, mer detaljerade, klassificeringar. Jag råder dig att vara uppmärksam på den redan nämnda Jepsen, såväl som Hermitage-projektet, som syftar till att klargöra exakt vilka isoleringsnivåer som erbjuds av specifika DBMS, såsom MySQL eller PostgreSQL. Om du öppnar filerna från det här arkivet kan du se vilken sekvens av SQL-kommandon de använder för att testa databasen för vissa anomalier, och du kan göra något liknande för de databaser du är intresserad av). Här är ett exempel från förvaret för att hålla dig intresserad:

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

Det är viktigt att förstå att för samma databas, som regel, kan du välja en av flera typer av isolering. Varför inte välja den starkaste isoleringen? För som allt inom datavetenskap bör den valda isoleringsnivån motsvara en avvägning som vi är redo att göra - i det här fallet en avvägning i exekveringshastighet: ju starkare isoleringsnivån är, desto långsammare kommer förfrågningarna att bli bearbetas. För att förstå vilken nivå av isolering du behöver måste du förstå kraven för din applikation, och för att förstå om databasen du har valt erbjuder denna nivå måste du titta i dokumentationen - för de flesta applikationer kommer detta att räcka, men om du har några särskilt snäva krav är det bättre att ordna ett test som det killarna från Eremitageprojektet gör.

5.3 "I" och andra bokstäver i ACID

Isolering är i grunden vad folk menar när de pratar om SYRA i allmänhet. Och det är av denna anledning som jag började analysen av denna akronym med isolering, och gick inte i ordning, som de som försöker förklara detta koncept brukar göra. Låt oss nu titta på de återstående tre bokstäverna.

Minns återigen vårt exempel med en banköverföring. En transaktion för att överföra pengar från ett konto till ett annat inkluderar en uttagsoperation från det första kontot och en påfyllningsoperation på det andra. Om påfyllningen av det andra kontot misslyckades, vill du förmodligen inte att uttagsåtgärden från det första kontot ska ske. Med andra ord, antingen lyckas transaktionen helt eller så sker den inte alls, men den kan inte göras endast för en del. Denna egenskap kallas "atomicitet", och det är ett "A" i ACID.

När vår transaktion exekveras överför den, precis som vilken operation som helst, databasen från ett giltigt tillstånd till ett annat. Vissa databaser erbjuder så kallade constraints - det vill säga regler som gäller för den lagrade datan, till exempel avseende primära eller sekundära nycklar, index, standardvärden, kolumntyper etc. Så när vi gör en transaktion måste vi vara säkra på att alla dessa begränsningar kommer att uppfyllas.

Denna garanti kallas "konsistens" och en bokstav Ci ACID (inte att förväxla med konsistens från världen av distribuerade applikationer, som vi kommer att prata om senare). Jag kommer att ge ett tydligt exempel för konsekvens i betydelsen ACID: en applikation för en onlinebutik vill lägga till ordersen rad i tabellen, och ID:t från tabellen product_idkommer att anges i kolumnen - typisk .productsforeign key

Om produkten, säg, togs bort från sortimentet och följaktligen från databasen, bör radinsättningsoperationen inte ske, och vi kommer att få ett fel. Den här garantin, jämfört med andra, är lite långsökt, enligt min mening - om så bara för att den aktiva användningen av begränsningar från databasen innebär att ansvaret för data flyttas (liksom delvis förskjutning av affärslogik, om vi talar om en sådan begränsning som CHECK ) från applikationen till databasen, vilket, som de säger nu, är precis så.

Och slutligen återstår det D- "motstånd" (hållbarhet). Ett systemfel eller något annat fel bör inte leda till förlust av transaktionsresultat eller databasinnehåll. Det vill säga, om databasen svarade att transaktionen lyckades, betyder det att data registrerades i ett icke-flyktigt minne - till exempel på en hårddisk. Detta betyder förresten inte att du omedelbart kommer att se data vid nästa läsbegäran.

Häromdagen arbetade jag med DynamoDB från AWS (Amazon Web Services), och skickade lite data för att spara, och efter att ha fått ett svar ( HTTP 200OK), eller något liknande, bestämde jag mig för att kontrollera det - och såg inte detta data i databasen under de kommande 10 sekunderna. Det vill säga, DynamoDB begick mina data, men inte alla noder synkroniserades omedelbart för att få den senaste kopian av datan (även om den kan ha funnits i cachen). Här klättrade vi återigen in på konsistensens territorium i samband med distribuerade system, men tiden att prata om det har fortfarande inte kommit.

Så nu vet vi vad SYRA-garantier är. Och vi vet till och med varför de är användbara. Men behöver vi verkligen dem i varje applikation? Och om inte, när exakt? Erbjuder alla DB:er dessa garantier, och om inte, vad erbjuder de istället?