5.1 Spørsmålet om samtidighet

La oss starte med en litt fjern teori.

Ethvert informasjonssystem (eller ganske enkelt en applikasjon) som programmerere lager består av flere typiske blokker, som hver gir en del av den nødvendige funksjonaliteten. For eksempel brukes cachen til å huske resultatet av en ressurskrevende operasjon for å sikre raskere lesing av data av klienten, strømbehandlingsverktøy lar deg sende meldinger til andre komponenter for asynkron behandling, og batchbehandlingsverktøy brukes til å " rake" de akkumulerte datavolumene med en viss periodisitet. .

Og i nesten alle applikasjoner er databaser (DBer) involvert på en eller annen måte, som vanligvis utfører to funksjoner: lagre data når de mottas fra deg og senere gi dem til deg på forespørsel. Det er sjelden noen tenker på å lage sin egen database, for det finnes allerede mange ferdige løsninger. Men hvordan velger du den rette for søknaden din?

Så, la oss forestille oss at du har skrevet en applikasjon med et mobilt grensesnitt som lar deg laste en tidligere lagret liste over oppgaver rundt i huset - det vil si lese fra databasen, og supplere den med nye oppgaver, samt prioritere hver spesifikke oppgave - fra 1 (høyest) til 3 (lavest). La oss si at mobilapplikasjonen din bare brukes av én person om gangen. Men nå våget du å fortelle moren din om skapelsen din, og nå har hun blitt den andre vanlige brukeren. Hva skjer hvis du bestemmer deg samtidig, rett i samme millisekund, for å sette en oppgave – «vask vinduene» – til en annen grad av prioritet?

Profesjonelt sett kan din og mors databasespørringer betraktes som 2 prosesser som gjorde en spørring til databasen. En prosess er en enhet i et dataprogram som kan kjøre på en eller flere tråder. Vanligvis har en prosess et maskinkodebilde, minne, kontekst og andre ressurser. Med andre ord kan prosessen karakteriseres som utførelse av programinstruksjoner på prosessoren. Når søknaden din sender en forespørsel til databasen, snakker vi om det faktum at databasen din behandler forespørselen mottatt over nettverket fra én prosess. Hvis det er to brukere som sitter i applikasjonen samtidig, kan det være to prosesser til enhver tid.

Når en prosess sender en forespørsel til databasen, finner den den i en bestemt tilstand. Et stateful system er et system som husker tidligere hendelser og lagrer noe informasjon, som kalles "state". En variabel som er erklært som integerkan ha en tilstand på 0, 1, 2, eller si 42. Mutex (gjensidig ekskludering) har to tilstander: låst eller ulåst , akkurat som en binær semafor ("påkrevd" vs. "frigitt") og generelt binær (binære) datatyper og variabler som bare kan ha to tilstander - 1 eller 0.

Basert på tilstandsbegrepet er flere matematiske og tekniske strukturer basert, for eksempel en begrenset automat - en modell som har en inngang og en utgang og er i en av et begrenset sett av tilstander i hvert øyeblikk - og "tilstanden ” designmønster, der et objekt endrer atferd avhengig av den interne tilstanden (for eksempel avhengig av hvilken verdi som er tildelt en eller annen variabel).

Så, de fleste objekter i maskinverdenen har en tilstand som kan endre seg over tid: vår pipeline, som behandler en stor datapakke, kaster en feil og blir mislykket, eller Wallet-objektegenskapen, som lagrer beløpet som er igjen i brukerens konto, endringer etter lønnskvitteringer.

En overgang ("overgang") fra en tilstand til en annen - for eksempel fra pågående til mislykket - kalles en operasjon. Sannsynligvis kjenner alle CRUD- operasjonene - create, read, update, deleteeller lignende HTTP- metoder - POST, GET, PUT, DELETE. Men programmerere gir ofte andre navn til operasjoner i koden sin, fordi operasjonen kan være mer kompleks enn bare å lese en viss verdi fra databasen - den kan også sjekke dataene, og deretter operasjonen vår, som har tatt form av en funksjon, vil for eksempel bli kalt Og validate()hvem utfører disse operasjonene-funksjonene? prosesser som allerede er beskrevet.

Litt mer, og du vil forstå hvorfor jeg beskriver begrepene så detaljert!

Enhver operasjon - det være seg en funksjon, eller, i distribuerte systemer, å sende en forespørsel til en annen server - har 2 egenskaper: påkallingstiden og fullføringstiden (gjennomføringstiden) , som vil være strengt tatt lengre enn påkallingstiden (forskere fra Jepsen gå ut fra de teoretiske antakelsene om at begge disse tidsstemplene vil bli gitt imaginære, fullt synkroniserte, globalt tilgjengelige klokker).

La oss forestille oss oppgavelisten vår. Du sender en forespørsel til databasen gjennom mobilgrensesnittet i 14:00:00.014, og moren din i 13:59:59.678(det vil si 336 millisekunder før) oppdaterte gjøremålslisten gjennom samme grensesnitt, og la til oppvask. Tatt i betraktning nettverksforsinkelsen og den mulige køen av oppgaver for databasen din, hvis, i tillegg til deg og din mor, alle morens venner også bruker applikasjonen din, kan databasen utføre mors forespørsel etter at den har behandlet din. Det er med andre ord en sjanse for at to av dine forespørsler, samt forespørsler fra din mors kjærester, blir sendt til samme data på samme tid (samtidig).

Så vi har kommet til det viktigste begrepet innen databaser og distribuerte applikasjoner - samtidighet. Hva kan egentlig samtidigheten av to operasjoner bety? Hvis noen operasjon T1 og noen operasjon T2 er gitt, så:

  • T1 kan startes før starttidspunktet for utførelse T2, og avsluttes mellom start- og sluttid for T2
  • T2 kan startes før starttidspunktet for T1, og avsluttes mellom starten og slutten av T1
  • T1 kan startes og avsluttes mellom start- og sluttid for T1-utførelse
  • og ethvert annet scenario der T1 og T2 har en viss utførelsestid

Det er klart at innenfor rammen av dette foredraget snakker vi først og fremst om spørringer som kommer inn i databasen og hvordan databasestyringssystemet oppfatter disse spørringene, men begrepet samtidighet er viktig for eksempel i operativsystemsammenheng. Jeg skal ikke avvike for langt fra temaet for denne artikkelen, men jeg tror det er viktig å nevne at samtidigheten vi snakker om her ikke er relatert til dilemmaet med samtidighet og samtidighet og deres forskjell, som diskuteres i sammenhengen. av operativsystemer og høyytelses databehandling. Parallellisme er en måte å oppnå samtidighet i et miljø med flere kjerner, prosessorer eller datamaskiner. Vi snakker om samtidighet i betydningen samtidig tilgang av ulike prosesser til felles data.

Og hva kan egentlig gå galt, rent teoretisk?

Når du arbeider med delte data, kan det oppstå en rekke problemer knyttet til samtidighet, også kalt "raseforhold". Det første problemet oppstår når en prosess mottar data som den ikke burde ha mottatt: ufullstendige, midlertidige, kansellerte eller på annen måte "feil" data. Det andre problemet er når prosessen mottar foreldede data, det vil si data som ikke samsvarer med den sist lagrede tilstanden til databasen. La oss si at en applikasjon har trukket penger fra en brukers konto med nullsaldo, fordi databasen returnerte kontostatusen til applikasjonen, uten å ta hensyn til det siste uttaket av penger fra den, som skjedde for bare et par millisekunder siden. Situasjonen er så som så, er det ikke?

5.2 Transaksjoner kom for å redde oss

For å løse slike problemer dukket konseptet med en transaksjon opp - en viss gruppe sekvensielle operasjoner (tilstandsendringer) med en database, som er en logisk enkelt operasjon. Jeg vil gi et eksempel med en bank igjen - og ikke ved en tilfeldighet, fordi konseptet med en transaksjon dukket opp, tilsynelatende, nettopp i sammenheng med å jobbe med penger. Det klassiske eksemplet på en transaksjon er overføring av penger fra en bankkonto til en annen: du må først ta ut beløpet fra kildekontoen og deretter sette det inn på målkontoen.

For at denne transaksjonen skal utføres, må applikasjonen utføre flere handlinger i databasen: sjekke avsenderens saldo, blokkere beløpet på avsenderens konto, legge beløpet til mottakerens konto og trekke beløpet fra avsenderen. Det vil være flere krav til en slik transaksjon. For eksempel kan ikke søknaden motta utdatert eller feil informasjon om saldo - for eksempel hvis samtidig en parallell transaksjon endte med en feil halvveis, og midlene ikke ble trukket fra kontoen - og vår søknad allerede har mottatt informasjon at midlene ble avskrevet.

For å løse dette problemet ble en slik egenskap for en transaksjon som "isolasjon" kalt: transaksjonen vår utføres som om det ikke var andre transaksjoner som ble utført i samme øyeblikk. Databasen vår utfører samtidige operasjoner som om den skulle utføre dem etter hverandre, sekvensielt - faktisk kalles det høyeste isolasjonsnivået Strict Serializable . Ja, det høyeste, som betyr at det er flere nivåer.

«Stopp», sier du. Hold hestene dine, sir.

La oss huske hvordan jeg beskrev at hver operasjon har en samtaletid og en utførelsestid. For enkelhets skyld kan du vurdere å ringe og utføre som to handlinger. Deretter kan den sorterte listen over alle anrop og utførelseshandlinger kalles databasens historie. Da er transaksjonsisolasjonsnivået et sett med historier. Vi bruker isolasjonsnivåer for å finne ut hvilke historier som er "gode". Når vi sier at en historie "bryter serialiserbarhet" eller "kan ikke serialiseres", mener vi at historien ikke er i settet med serialiserbare historier.

For å tydeliggjøre hva slags historier vi snakker om, vil jeg gi eksempler. For eksempel finnes det en slik type historie - mellomlesning . Det oppstår når transaksjon A har tillatelse til å lese data fra en rad som har blitt modifisert av en annen løpende transaksjon B og ennå ikke er forpliktet ("ikke forpliktet") - det vil si at endringene ennå ikke er endelig forpliktet av transaksjon B, og den kan når som helst kansellere dem. Og, for eksempel, avbrutt lesing er bare vårt eksempel med en kansellert uttakstransaksjon

Det er flere mulige anomalier. Det vil si at anomalier er en slags uønsket datatilstand som kan oppstå under konkurrerende tilgang til databasen. Og for å unngå visse uønskede tilstander, bruker databaser ulike nivåer av isolasjon – det vil si ulike nivåer av databeskyttelse fra uønskede tilstander. Disse nivåene (4 stykker) ble oppført i ANSI SQL-92-standarden.

Beskrivelsen av disse nivåene virker vag for noen forskere, og de tilbyr sine egne, mer detaljerte klassifikasjoner. Jeg anbefaler deg å ta hensyn til den allerede nevnte Jepsen, samt Hermitage-prosjektet, som har som mål å avklare nøyaktig hvilke isolasjonsnivåer som tilbys av spesifikke DBMS, som MySQL eller PostgreSQL. Hvis du åpner filene fra dette depotet, kan du se hvilken sekvens av SQL-kommandoer de bruker for å teste databasen for visse anomalier, og du kan gjøre noe lignende for databasene du er interessert i). Her er ett eksempel fra depotet for å holde deg interessert:

-- 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 er viktig å forstå at for den samme databasen kan du som regel velge en av flere typer isolasjon. Hvorfor ikke velge den sterkeste isolasjonen? For, som alt innen informatikk, bør det valgte isolasjonsnivået tilsvare en avveining som vi er klare til å gjøre - i dette tilfellet en avveining i utførelseshastighet: jo sterkere isolasjonsnivå, jo langsommere vil forespørslene være Bearbeidet. For å forstå hvilket nivå av isolasjon du trenger, må du forstå kravene til applikasjonen din, og for å forstå om databasen du har valgt tilbyr dette nivået, må du se nærmere på dokumentasjonen - for de fleste applikasjoner vil dette være nok, men hvis du har noen spesielt strenge krav, er det bedre å arrangere en test som det gutta fra Hermitage-prosjektet gjør.

5.3 "I" og andre bokstaver i ACID

Isolasjon er i grunnen det folk mener når de snakker om ACID generelt. Og det er av denne grunn at jeg begynte analysen av dette akronymet med isolasjon, og gikk ikke i orden, slik de som prøver å forklare dette konseptet vanligvis gjør. La oss nå se på de resterende tre bokstavene.

Husk igjen vårt eksempel med en bankoverføring. En transaksjon for å overføre midler fra en konto til en annen inkluderer en uttaksoperasjon fra den første kontoen og en påfyllingsoperasjon på den andre. Hvis påfyllingsoperasjonen for den andre kontoen mislyktes, vil du sannsynligvis ikke at uttaksoperasjonen fra den første kontoen skal skje. Med andre ord, enten lykkes transaksjonen fullstendig, eller så skjer den ikke i det hele tatt, men den kan ikke bare gjøres for en del. Denne egenskapen kalles "atomisitet", og den er en "A" i ACID.

Når transaksjonen vår er utført, overfører den, som enhver operasjon, databasen fra en gyldig tilstand til en annen. Noen databaser tilbyr såkalte begrensninger – det vil si regler som gjelder for de lagrede dataene, for eksempel angående primær- eller sekundærnøkler, indekser, standardverdier, kolonnetyper osv. Så når vi foretar en transaksjon, må vi være sikre på at alle disse begrensningene vil bli oppfylt.

Denne garantien kalles "konsistens" og en bokstav Ci ACID (ikke å forveksle med konsistens fra verden av distribuerte applikasjoner, som vi skal snakke om senere). Jeg vil gi et tydelig eksempel på konsistens i betydningen ACID: en applikasjon for en nettbutikk ønsker å legge til ordersen rad i tabellen, og ID-en fra tabellen product_idvil bli indikert i kolonnen - typisk .productsforeign key

Hvis produktet, for eksempel, ble fjernet fra sortimentet, og følgelig fra databasen, bør radinnsettingsoperasjonen ikke skje, og vi får en feil. Denne garantien, sammenlignet med andre, er etter min mening litt langsøkt - om ikke annet fordi aktiv bruk av begrensninger fra databasen betyr å flytte ansvaret for dataene (samt delvis forskyvning av forretningslogikk, hvis vi snakker om en slik begrensning som CHECK ) fra applikasjonen til databasen, som, som de sier nå, er akkurat slik.

Og til slutt gjenstår det D- "motstand" (holdbarhet). En systemfeil eller annen feil skal ikke føre til tap av transaksjonsresultater eller databaseinnhold. Det vil si at hvis databasen svarte at transaksjonen var vellykket, betyr dette at dataene ble registrert i ikke-flyktig minne - for eksempel på en harddisk. Dette betyr forresten ikke at du umiddelbart vil se dataene ved neste leseforespørsel.

Forleden dag jobbet jeg med DynamoDB fra AWS (Amazon Web Services), og sendte noen data for lagring, og etter å ha mottatt et svar HTTP 200(OK), eller noe sånt, bestemte jeg meg for å sjekke det - og så ikke dette data i databasen for de neste 10 sekundene. Det vil si at DynamoDB forpliktet dataene mine, men ikke alle noder ble umiddelbart synkronisert for å få den siste kopien av dataene (selv om det kan ha vært i cachen). Her klatret vi igjen inn på konsistensens territorium i sammenheng med distribuerte systemer, men tiden for å snakke om det har fortsatt ikke kommet.

Så nå vet vi hva SYRE-garantier er. Og vi vet til og med hvorfor de er nyttige. Men trenger vi dem virkelig i hver applikasjon? Og hvis ikke, når nøyaktig? Tilbyr alle DB-er disse garantiene, og hvis ikke, hva tilbyr de i stedet?