Hej, Habr!
Idag erbjuder vi er en översÀttning av en komplex artikel om implementering av distribuerade lÄs med hjÀlp av Redis och vi föreslÄr att diskutera Redis framtid som Àmne. Analysen av den diskuterade algoritmen Redlock av Martin Kleppmann, författare till boken "", presenteras .
Distribuerade lÄs Àr en mycket anvÀndbar primitiv, som tillÀmpas i mÄnga miljöer dÀr olika processer mÄste arbeta med delade resurser pÄ grundval av ömsesidig uteslutning.
Det finns flera bibliotek och inlÀgg som beskriver hur man implementerar DLM (manager för distribuerade lÄs) med hjÀlp av Redis, men varje bibliotek anvÀnder sig av sin egen metod, och de garantier som erbjuds Àr ganska svaga jÀmfört med vad som kan Ästadkommas med en nÄgot mer komplex design.
I denna artikel ska vi försöka beskriva en sÄ kallad kanonisk algoritm som demonstrerar hur man implementerar distribuerade lÄs med hjÀlp av Redis. Vi kommer att prata om algoritmen som kallas Redlock, som implementerar en manager för distribuerade lÄs och, enligt vÄr Äsikt, Àr denna algoritm sÀkrare Àn den vanliga metoden med en enda instans. Vi hoppas att gemenskapen kommer att analysera den, ge feedback och börja anvÀnda den som en utgÄngspunkt för att implementera mer komplexa eller alternativa projekt.
Implementeringar
Innan vi gÄr vidare till beskrivningen av algoritmen, lÄt oss ge nÄgra lÀnkar till redan fÀrdiga implementeringar. De kan anvÀndas som referens.
- (implementering för Ruby). Det finns ocksÄ Redlock-rb, som lÀgger till ett paket (gem) för att underlÀtta distributionen, och inte bara för detta.
- (implementering för Python).
- (implementering för Asyncio Python).
- (implementering för PHP).
- (Ànnu en implementering för PHP)
- (PHP-bibliotek för lÄsning)
- (implementering för Go).
- (implementering för Java).
- (implementering för Perl).
- (implementering för C++).
- (implementering för C#/.NET).
- (implementering för C#/.NET). Med stöd för async- och lock-tillÀgg.
- (implementering för C# .NET med konfigurerbart datalager)
- (implementering för C# .NET)
- (implementering för NodeJS). Inkluderar stöd för förlÀngning av lÄs.
SÀkerhets- och tillgÀnglighetsgarantier
Vi planerar att modellera vÄrt projekt med bara tre egenskaper som vi anser ger de minimi garantier som krÀvs för effektiv anvÀndning av distribuerade lÄs.
- SĂ€kerhetsegenskap: Ămsesidigt uteslutande. Vid varje given tid kan endast en klient hĂ„lla ett lĂ„s.
- TillgÀnglighetsegenskap A: FrÄnvaro av dödlÀgen. I slutÀndan kan ett lÄs alltid förvÀrvas, Àven om klienten som lÄste resursen avbryter eller gÄr in i ett annat disksegment.
- TillgÀnglighetsegenskap B: Felresilens. SÄ lÀnge majoriteten av Redis-noderna Àr i drift, kan kunder förvÀrva och frigöra lÄs.
Varför Àr implementeringar baserade pÄ ÄterhÀmtning efter fel otillrÀckliga i detta fall?
För att förstÄ vad vi ska förbÀttra, lÄt oss analysera den nuvarande situationen kring de flesta biblioteken för distribuerade lÄs som Àr baserade pÄ Redis.
Det enklaste sÀttet att lÄsa en resurs med Redis Àr att skapa en nyckel i instansen. Nyckeln skapas vanligtvis med en begrÀnsad livslÀngd, vilket Ästadkoms med Redis-funktionen expires, sÄ smÄningom frigörs denna nyckel (egenskap 2 pÄ vÄr lista). NÀr kunden behöver frigöra resursen raderar de nyckeln.
Vid första anblick kan denna lösning tyckas fungera, men det finns ett problem: i vÄr arkitektur uppstÄr en enskild felpunkt. Vad hÀnder om den ledande Redis-instansen gÄr ner? LÄt oss dÄ lÀgga till en följdinstans! Och anvÀnda den om den ledande inte Àr tillgÀnglig. TyvÀrr Àr detta alternativohÄllbart. Om vi gör sÄ kan vi inte korrekt implementera den ömsesidiga uteslutningen, som vi behöver för att sÀkerstÀlla sÀkerheten, eftersom replikationen i Redis Àr asynkron.
Det Àr uppenbart att en kapplöpningstillstÄnd uppstÄr i en sÄdan modell:
- Klient A förvÀrvar lÄset pÄ den ledande.
- Den ledande gÄr ner innan skrivningen av nyckeln överförs till följdinstansen.
- Följdinstansen uppgraderas till ledande.
- Klient B förvĂ€rvar lĂ„set pĂ„ samma resurs som redan Ă€r lĂ„st av A. SĂKERHETSĂVERTRĂDELS!
Ibland Àr det helt okej att flera kunder kan hÄlla en lÄs samtidigt under speciella omstÀndigheter, som vid ett fel. I sÄdana fall kan en lösning baserad pÄ replikering tillÀmpas. I andra fall rekommenderar vi den lösning som beskrivs i denna artikel.
Korrekt implementering med en enda instans
Innan vi försöker övervinna begrÀnsningarna av konfigurationen med en enda instans som beskrivits ovan, lÄt oss klargöra hur man agerar korrekt i detta enkla fall, eftersom en sÄdan lösning faktiskt Àr acceptabel i applikationer dÀr race conditions ibland Àr tillÄtna, och Àven eftersom lÄsningen pÄ en enda instans Àr den grund som anvÀnds i den distribuerade algoritm som beskrivs hÀr.
För att erhÄlla en lÄs, gör följande:
SET resource_name my_random_value NX PX 30000
Detta kommando sĂ€tter nyckeln, endast om den Ă€n sĂ„ lĂ€nge inte existerar (NX-alternativet), med en livslĂ€ngd pĂ„ 30000 millisekunder (PX-alternativet). För nyckeln sĂ€tts vĂ€rdet âmyrandomvalueâ. Detta vĂ€rde mĂ„ste vara unikt bland alla kunder och alla lĂ„sförfrĂ„gningar.
I princip anvÀnds ett slumpmÀssigt vÀrde för att sÀkert frigöra lÄsen, genom ett skript som meddelar Redis: ta bort nyckeln endast om den existerar, och vÀrdet som sparats i den Àr precis det som förvÀntades. Detta uppnÄs via följande Lua-skript:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
endDetta Àr viktigt för att förhindra att lÄsningen tas bort av en annan kund. Till exempel kan en kund fÄ en lÄs, sedan blockeras under en operation som tar lÀngre tid Àn livslÀngden pÄ den första lÄsningen (sÄ att livslÀngden för nyckeln hinner löpa ut), och senare ta bort lÄsningen som en annan kund har stÀllt.
Att anvÀnda en enkel DEL Àr osÀkert, eftersom en kund kan ta bort en lÄs som stÀllts av en annan kund. DÀremot, vid anvÀndning av ovanstÄende skript, Àr varje lÄs "undertecknad" med en slumpmÀssig strÀng, vilket gör att endast den kund som tidigare har stÀllt lÄset kan ta bort det.
Hur ska denna slumpmÀssiga strÀng se ut? Jag tror att den borde bestÄ av 20 byte frÄn /dev/urandom, men det finns ocksÄ mindre kostsamma sÀtt att göra strÀngen tillrÀckligt unik för de syften ni har. Till exempel skulle det vara okej att sÀkra RC4 med /dev/urandom och sedan generera en pseudorandomström baserat pÄ det. En enklare lösning Àr att kombinera unix-tid i mikrosekundsupplösning med kund-ID; det Àr inte lika sÀkert men kanske tillrÀckligt för de flesta sammanhang.
Tiden som vi anvÀnder som indikator pÄ nyckelns livslÀngd kallas "lÄsets giltighetstid". Detta vÀrde Àr bÄde tidsgrÀnsen efter vilken lÄset automatiskt kommer att frigöras, och tiden som kunden har pÄ sig att genomföra operationen innan en annan kund kan blockera denna resurs utan att faktiskt bryta garantin för ömsesidig uteslutning. SÄdan garanti Àr begrÀnsad till ett visst tidsspann som börjar frÄn det ögonblick lÄset erhÄlls.
SÄ vi har diskuterat ett bra sÀtt att förvÀrva och frigöra lÄs. Systemet (om vi talar om ett icke-distribuerat system med en enda och alltid tillgÀnglig instans) Àr sÀkert. LÄt oss utvidga detta koncept till ett distribuerat system, dÀr vi inte har sÄdana garantier.
Redlock-algoritmen
I den distribuerade versionen av algoritmen förutsÀtter vi att vi har N primÀra Redis. Dessa noder Àr helt oberoende av varandra, sÄ vi anvÀnder inte replikering eller nÄgot annat underförstÄtt koordineringssystem. Vi har redan förklarat hur man sÀkert kan förvÀrva och frigöra lÄs pÄ en enda instans. Vi utgÄr frÄn att algoritmen kommer att anvÀnda just denna metod nÀr den arbetar med en enda instans. I vÄra exempel sÀtter vi N till 5, vilket Àr ett rimligt vÀrde. SÄledes behöver vi anvÀnda 5 primÀra Redis pÄ olika datorer eller virtuella maskiner för att sÀkerstÀlla att de fungerar i huvudsak oberoende av varandra.
För att förvÀrva ett lÄs utför kunden följande operationer:
- FÄr den aktuella tiden i millisekunder.
- Sekventiellt försöker klienten att erhÄlla lÄset pÄ alla N-instansar, och anvÀnder samma nyckelnamn och slumpmÀssiga vÀrden i alla fall. Vid steg 2, nÀr lÄset sÀtts för varje instans, anvÀnder klienten en fördröjning som Àr ganska kort i förhÄllande till den tid efter vilken lÄset automatiskt tas bort. Till exempel, om lÄsets varaktighet Àr 10 sekunder, kan fördröjningen ligga i intervallet ~ 5-50 millisekunder. PÄ sÄ sÀtt undviks situationen dÀr klienten kan förbli lÄst under en lÄng tid medan den försöker nÄ den misslyckade Redis-noden: om instansen inte Àr tillgÀnglig, försöker vi sÄ snabbt som möjligt att ansluta till en annan instans.
- För att ta lÄset berÀknar klienten hur mycket tid som har gÄtt; för detta subtraherar den den aktuella tidsstÀmpeln frÄn den tidsstÀmpel som erhölls i steg 1. LÄset anses vara erhÄllet först nÀr klienten har fÄtt lÄset pÄ merparten av instanserna (minst 3) och den totala tid som det tog att fÄ lÄset Àr mindre Àn lÄsets livslÀngd.
- Om lÄset har erhÄllits, anses lÄnets varaktighet vara det ursprungliga lÄnets tidsvÀrde minus den tid som har passerat, berÀknad i steg 3.
- Om klienten av nÄgon anledning inte lyckas fÄ lÄset (antingen för att den inte kunde lÄsa N/2+1 instanser eller för att lÄnets livslÀngd visade sig vara negativ), kommer den att försöka lÄsa upp alla instanser (Àven de som den antogs inte kunna lÄsa).
Ăr algoritmen asynkron?
Denna algoritm baseras pÄ antagandet att, Àven om det inte finns nÄgra synkroniserade klockor, som alla processer arbetar med, sÄ flyter den lokala tiden i varje process ungefÀr i samma takt, och felmarginalen Àr liten i förhÄllande till den totala tiden efter vilken lÄset automatiskt tas bort. Detta antagande liknar situationen hos vanliga datorer: varje dator har lokala klockor och vanligtvis kan vi rÀkna med att tidsavvikelsen mellan olika datorer Àr liten.
I detta skede mÄste vi formulera vÄr regel om ömsesidig uteslutning mer noggrant: ömsesidig uteslutning garanteras endast om klienten som hÄller lÄset avslutar sitt arbete inom den tid som lÄset Àr giltigt (detta vÀrde erhölls i steg 3), minus lite extra tid (bara nÄgra millisekunder för att kompensera för tidsavvikelsen mellan processerna).
Mer information om sÄdana system som krÀver samordning av tidsavvikelser finns i följande intressanta artikel: .
à terförsök vid fel
NÀr klienten inte lyckas fÄ ett lÄs bör den försöka igen efter att ha avvaktat en slumpmÀssig fördröjning; detta görs för att desynkronisera flera klienter som samtidigt försöker fÄ lÄset pÄ samma resurs (vilket kan leda till en situation med "split brain" dÀr det inte finns nÄgra vinnare). Dessutom, ju snabbare klienten försöker att fÄ lÄs pÄ den största delen av Redis-instansarna, desto smalare blir fönstret dÀr en situation med split brain kan uppstÄ (och desto mindre behov av Äterförsök). DÀrför bör klienten idealt försöka skicka SET-kommandon till N instanser samtidigt genom multiplexering.
Det Àr vÀrt att betona hur viktigt det Àr att klienter som inte lyckats fÄ de flesta lÄsen frigör (delvis) de lÄs de har fÄtt, sÄ att det inte behöver vÀnta pÄ att nyckelns utgÄngstid ska gÄ ut innan lÄset pÄ resursen Äter kan erhÄllas (visserligen, om nÀtverket fragmenteras och klienten förlorar kontakten med Redis-instanserna, mÄste man betala ett pris för brott mot tillgÀngligheten medan man vÀntar pÄ att nyckelns utgÄngstid ska gÄ ut).
Frigörande av lÄs
Frigörande av lÄs Àr en enkel operation som bara krÀver att lÄset avlÀgsnas frÄn alla instanser, oavsett om klienten anser att den har lyckats lÄsa en viss instans.
SÀkerhetsövervÀganden
Ăr algoritmen sĂ€ker? LĂ„t oss försöka förestĂ€lla oss vad som hĂ€nder i olika scenarier.
LÄt oss först anta att klienten har lyckats fÄ ett lÄs över de flesta instanser. Varje instans kommer att innehÄlla en nyckel med samma livstid för alla. Men varje nyckel stÀlldes in vid olika tidpunkter, sÄ giltighetstiden för dem kommer att gÄ ut vid olika tillfÀllen. Men om den första nyckeln stÀlldes in vid en tidpunkt som inte Àr senare Àn T1 (tiden vi vÀljer före kontakten med den första servern), och den sista nyckeln stÀlldes in vid en tidpunkt som inte Àr senare Àn T2 (tiden dÄ svaret frÄn den senaste servern mottogs), sÄ Àr vi sÀkra pÄ att den första nyckeln i mÀngden som ska gÄ ut kommer att existera i minst MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT. Alla andra nycklar kommer att gÄ ut senare, sÄ vi kan vara sÀkra pÄ att alla nycklar kommer att vara giltiga samtidigt under Ätminstone denna tid.
Under den tid dÄ de flesta nycklar förblir giltiga kan en annan klient inte fÄ ett lÄs, eftersom N/2+1 SET NX-operationer inte kan avslutas med framgÄng om det redan finns N/2+1 nycklar. DÀrför, om lÄset har förvÀrvats, Àr det omöjligt att ÄterfÄ det vid samma tidpunkt (detta skulle bryta egenskapen av ömsesidig uteslutning).
Visserligen vill vi se till att en mÀngd klienter som samtidigt försöker fÄ ett lÄs inte kan lyckas samtidigt.
Om klienten har lÄst de flesta instanser och lagt tid som Àr ungefÀr eller mer Àn den maximala lÄstillstÄndets varaktighet, sÄ anser den lÄset ogiltigt och lÄser upp instanserna. SÄ vi mÄste ta hÀnsyn till det fall dÀr klienten lyckades lÄsa de flesta instanser pÄ en tid som Àr kortare Àn giltighetstiden. I det hÀr fallet, avseende det ovanstÄende argumentet, under tiden MIN_VALIDITY bör ingen klient ha möjlighet att ÄterfÄ ett lÄs. SÄ en mÀngd klienter kan lÄsa N/2+1 instanser pÄ samma tid (vilken avslutas vid slutet av steg 2), endast nÀr tiden för att lÄsa de flesta var lÀngre Àn TTL-tiden, vilket gör lÄset ogiltigt.
Kan du ge ett formellt bevis pÄ sÀkerhet, ange befintliga liknande algoritmer, eller hitta en bugg i det som har presenterats?
TillgĂ€nglighet ĂvervĂ€ganden
Systemets tillgÀnglighet beror pÄ tre grundlÀggande egenskaper:
- Automatisk avblockering (eftersom nycklarna gÄr ut): i slutÀndan kommer nycklarna Äterigen att vara tillgÀngliga för att anvÀndas för blockeringar.
- Det faktum att kunder vanligtvis hjÀlper varandra genom att ta bort blockeringar nÀr den nödvÀndiga blockeringen inte har köpts, eller har köpts men arbetet har avslutats; dÀrför Àr det mycket troligt att vi inte behöver vÀnta pÄ att nycklarna ska gÄ ut för att Äterigen kunna köpa en blockering.
- Det faktum att nÀr en kund behöver försöka igen för att fÄ en blockering, kan de behöva vÀnta en jÀmförelsevis lÀngre tid Àn den tid som krÀvs för att köpa de flesta blockeringarna. Detta minskar risken för en delad hjÀrnsituation vid tÀvling om resurser.
Men det finns en straff för den minskade tillgÀngligheten, motsvarande TTL-tiden i nÀtverkssegment, sÄ om det finns kontinuerliga segment, kan denna straff bli av obestÀmd storlek. Detta hÀnder varje gÄng en kund köper en blockering, men sedan kopplas bort i ett annat segment innan de hinner frigöra den.
Principiellt, med ett oÀndligt antal kontinuerliga nÀtverkssegment, kan systemet förbli otillgÀngligt under en oÀndlig tidsperiod.
Prestanda, ÄterhÀmtning frÄn misslyckanden och fsync
MÄnga anvÀnder Redis eftersom det krÀvs för att sÀkerstÀlla hög prestanda för blockeringstjÀnser, pÄ nivÄ med de latenser som krÀvs för att förvÀrva och frigöra blockeringar, samt antalet operationer för sÄdana förvÀrv/frigörningar som kan genomföras per sekund. För att uppfylla detta krav finns det en kommunikationsstrategi med N Redis-servrar för att minska latensen. Detta Àr en multiplexeringsstrategi (eller 'fattigmans multiplexering', dÀr socket sÀtts i icke-blockerande lÀge, skickar alla kommandon och lÀser kommandona senare, under antagande att svarstiden mellan klienten och varje instans Àr liknande).
Sanningen Àr att man ocksÄ mÄste beakta frÄga om lÄngsiktig datalagring om vi syftar pÄ att skapa en modell med pÄlitlig ÄterhÀmtning efter systemfel.
I princip, för att klargöra problemet, lÄt oss anta att vi konfigurerar Redis utan lÄngsiktig datalagring. Klienten lyckas lÄsa 3 av 5 instanser. En av instanserna som klienten lyckats lÄsa startas om, och dÄ uppstÄr Äterigen 3 instanser för samma resurs som vi kan lÄsa, och en annan klient kan i sin tur lÄsa den omstartade instansen, vilket bryter mot sÀkerhetsegenskapen som innebÀr exklusivitet i lÄsningen.
Om vi aktiverar förhandsbesparande av data (AOF) förbÀttras situationen nÄgot. Till exempel kan vi Äterstarta servern genom att skicka kommandot SHUTDOWN och sedan starta om den. Eftersom timeout-operatörer i Redis semantiskt implementeras pÄ sÄ sÀtt att tiden fortsÀtter att gÄ Àven nÀr servern Àr avstÀngd, Àr allt okej med vÄra krav. Allt Àr okej sÄ lÀnge som en ordentlig avstÀngning sker. Men vad ska vi göra vid strömavbrott? Om Redis Àr konfigurerad som standard, med synkronisering av fsync pÄ disken varje sekund, kan det hÀnda att vi inte hittar vÄr nyckel efter omstart. Teoretiskt sett, om vi vill garantera sÀkerheten för lÄs vid varje omstart av instansen, mÄste vi aktivera fsync=always i instÀllningarna för lÄngvarig datalagring. Detta kommer helt att döda prestandan, ner till nivÄn av sÄdana CP-system som traditionellt anvÀnds för sÀker implementering av distribuerade lÄs.
Men situationen Àr bÀttre Àn den verkar vid första ögonkastet. I princip kvarstÄr sÀkerheten i algoritmen, eftersom nÀr en instans startas om efter ett fel, deltar den inte lÀngre i nÄgra lÄs som Àr aktiva för tillfÀllet.
För att garantera detta behövs bara sÀkerstÀllas att instansen förblir otillgÀnglig under en tid som nÄgot överstiger max TTL som vi anvÀnder. DÄ kommer vi att vÀnta pÄ att tidsgrÀnsen ska löpa ut och att alla nycklar som var aktiva vid tidpunkten för felet automatiskt ska frigöras.
Genom att anvÀnda fördröjda omstarter Àr det möjligt att uppnÄ sÀkerhet Àven utan nÄgon lÄngvarig lagring i Redis. Det bör dock noteras att detta kan leda till böter för otillgÀnglighet. Till exempel, nÀr majoriteten av instanserna misslyckas, blir systemet globalt otillgÀngligt under TTL (och inga resurser kan lÄsas under denna tid).
Ăka tillgĂ€ngligheten för algoritmen: förlĂ€ng lĂ„sningen
Om arbetet som utförs av kunderna bestÄr av smÄ steg Àr det möjligt att korta ned den standardinstÀllda lÄsningstiden och implementera en mekanism för förlÀngning av lÄsningar. I princip, om kunden Àr upptagen med berÀkningar och lÄsningens giltighetstid riskerar att sÀnkas, kan ett Lua-skript skickas till alla instanser för att förlÀnga TTL för nyckeln, om nyckeln fortfarande existerar och dess vÀrde fortfarande Àr slumpmÀssigt, vilket erhölls nÀr lÄsningen förvÀrvades.
Kunden bör anse att lÄsningen har ÄterförvÀrvats endast om hen har lyckats lÄsa majoriteten av instanserna under giltighetstiden.
Tekniskt sett förblir dock algoritmen oförÀndrad, sÄ det maximala antalet försök att fÄ lÄsningar bör begrÀnsas, annars kommer tillgÀnglighetskraven att brytas.
KĂ€lla: habr.com
