Distribuerad lÄsning med Redis

Hej Habr!

Idag uppmÀrksammar vi en översÀttning av en komplex artikel om implementeringen av distribuerad lÄsning med Redis och inbjuder dig att prata om utsikterna för Redis som ett Àmne. Analys av Redlock-algoritmen i frÄga frÄn Martin Kleppmann, författare till boken "Högbelastningsapplikationer", givet hÀr.

Distribuerad lÄsning Àr en mycket anvÀndbar primitiv som anvÀnds i mÄnga miljöer dÀr olika processer mÄste arbeta pÄ delade resurser pÄ ett ömsesidigt uteslutande sÀtt.

Det finns ett antal bibliotek och inlÀgg dÀr ute som beskriver hur man implementerar DLM (Distributed Lock Manager) med Redis, men varje bibliotek har olika tillvÀgagÄngssÀtt och garantierna de ger Àr ganska svaga jÀmfört med vad som Àr möjligt med lite mer sofistikerad design.

I den hÀr artikeln kommer vi att försöka beskriva en villkorad kanonisk algoritm som visar hur man implementerar distribuerad lÄsning med Redis. Vi ska prata om en algoritm som heter redlock, implementerar den en distribuerad lÄshanterare och, enligt vÄr Äsikt, Àr denna algoritm sÀkrare Àn den vanliga engÄngsmetoden. Vi hoppas att samhÀllet kommer att analysera det, ge feedback och anvÀnda det som utgÄngspunkt för mer komplexa eller alternativa projekt.

Genomförande

Innan vi gÄr vidare till beskrivningen av algoritmen tillhandahÄller vi flera lÀnkar till fÀrdiga implementeringar. De kan anvÀndas som referens.

  • Redlock-rb (implementering för Ruby). Det finns ocksĂ„ gaffel Redlock-rb, som lĂ€gger till ett paket (pĂ€rla) för enkel distribution, och inte bara för det.
  • Redlock-py (Python-implementering).
  • Aioredlock (implementering för Asyncio Python).
  • Redlock-php (implementering för PHP).
  • PHPRedisMutex (en annan implementering för PHP)
  • cheprasov/php-redis-lock (PHP-bibliotek för lĂ„s)
  • Redsync (implementering för Go).
  • Redisson (implementering för Java).
  • Redis::DistLock (implementering för Perl).
  • Redlock-cpp (implementering för C++).
  • Redlock-cs (implementering för C#/.NET).
  • RedLock.net (implementering för C#/.NET). Med stöd för asynkron- och lĂ„stillĂ€gg.
  • ScarletLock (implementering för C# .NET med konfigurerbart datalager)
  • Redlock4Net (implementering för C# .NET)
  • nod-redlock (implementering för NodeJS). Inkluderar stöd för förlĂ€ngning av lĂ„s.

SÀkerhet och tillgÀnglighetsgarantier

Vi kommer att modellera vÄr design med bara tre egenskaper som vi tror ger de minsta garantier som krÀvs för att effektivt anvÀnda distribuerad lÄsning.

  1. SĂ€kerhetsegendom: Ömsesidig uteslutning. Vid varje given tidpunkt kan endast en klient hĂ„lla lĂ„set.
  2. TillgÀnglighet Egenskap A: Inga lÄsningar. Det Àr alltid möjligt att sÄ smÄningom skaffa ett lÄs, Àven om klienten som lÄste resursen misslyckas eller landar pÄ ett annat disksegment.
  3. TillgÀnglighet Egenskap B: Feltolerans. SÄ lÀnge som majoriteten av Redis-noderna Àr igÄng kan klienter skaffa och slÀppa lÄs.

Varför implementering baserat pÄ felÄterstÀllning inte rÀcker i det hÀr fallet
För att förstÄ vad vi ska förbÀttra, lÄt oss analysera det aktuella lÀget med de flesta distribuerade lÄsbibliotek baserade pÄ Redis.

Det enklaste sÀttet att lÄsa en resurs med Redis Àr att skapa en nyckel i instansen. Vanligtvis skapas en nyckel med en begrÀnsad livslÀngd, detta uppnÄs med hjÀlp av expires-funktionen i Redis, sÄ förr eller senare slÀpps denna nyckel (egenskap 2 i vÄr lista). NÀr klienten behöver slÀppa resursen tar den bort nyckeln.

Vid första anblicken fungerar den hÀr lösningen ganska bra, men det finns ett problem: vÄr arkitektur skapar en enda punkt av misslyckande. Vad hÀnder om vÀrdens Redis-instans misslyckas? LÄt oss lÀgga till en slav dÄ! Och vi kommer att anvÀnda den om presentatören inte Àr tillgÀnglig. TyvÀrr Àr det hÀr alternativet inte genomförbart. Genom att göra detta kommer vi inte att kunna implementera den ömsesidiga uteslutningsegenskapen som vi behöver för att sÀkerstÀlla sÀkerheten, eftersom replikering i Redis Àr asynkron.

Uppenbarligen uppstÄr ett rastillstÄnd i en sÄdan modell:

  1. Klient A skaffar ett lÄs pÄ mastern.
  2. Mastern misslyckas innan nyckelinmatningen överförs till slaven.
  3. Följaren befordras till ledare.
  4. Klient B skaffar ett lĂ„s pĂ„ samma resurs som A redan har lĂ„st. SÄKERHETSKROTT!

Ibland Àr det helt normalt att mÄnga klienter kan hÄlla i lÄset samtidigt under speciella omstÀndigheter, till exempel ett fel. I sÄdana fall kan en replikeringsbaserad lösning anvÀndas. Annars rekommenderar vi lösningen som beskrivs i den hÀr artikeln.

Korrekt implementering med en enda instans

Innan vi försöker övervinna bristerna med konfigurationen för en instans som beskrivs ovan, lÄt oss förstÄ hur man korrekt hanterar detta enkla fall, eftersom den hÀr lösningen faktiskt Àr giltig i applikationer dÀr ett tÀvlingstillstÄnd Àr acceptabelt frÄn tid till annan, och Àven eftersom blockering pÄ en enskild instans fungerar som basen som anvÀnds i den distribuerade algoritmen som beskrivs hÀr.

För att skaffa ett lÄs, gör sÄ hÀr:

SET resource_name my_random_value NX PX 30000

Detta kommando installerar en nyckel endast om den inte redan finns (NX-alternativ), med en giltighetstid pÄ 30000 XNUMX millisekunder (PX-alternativ). Nyckeln Àr instÀlld pÄ "myrandomvalue" Detta vÀrde mÄste vara unikt för alla klienter och alla lÄsförfrÄgningar.
I grund och botten anvÀnds ett slumpmÀssigt vÀrde för att sÀkert frigöra lÄset, med ett skript som sÀger till Redis: ta bara bort nyckeln om den finns och vÀrdet som lagras i det Àr exakt vad som förvÀntades. Detta uppnÄs med hjÀlp av följande Lua-skript:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

Detta Àr viktigt för att förhindra att ett lÄs som innehas av en annan klient tas bort. Till exempel kan en klient skaffa ett lÄs, sedan bli lÄst i nÄgon operation som varar lÀngre Àn det första lÄset (sÄ att nyckeln hinner gÄ ut) och senare ta bort lÄset som nÄgon annan klient hade placerat.
Att anvÀnda en enkel DEL Àr osÀker eftersom en klient kan ta bort ett lÄs som innehas av en annan klient. DÀremot, nÀr du anvÀnder skriptet ovan, "signeras" varje lÄs med en slumpmÀssig strÀng, sÄ bara klienten som tidigare placerat det kan ta bort det.

Vad ska denna slumpmÀssiga strÀng vara? Jag antar att det borde vara 20 byte frÄn /dev/urandom, men du kan hitta billigare sÀtt att göra strÀngen tillrÀckligt unik för dina syften. Till exempel skulle det vara bra att seed RC4 med /dev/urandom och sedan generera en pseudoslumpmÀssig ström frÄn den. En enklare lösning innebÀr en kombination av unix-tid i mikrosekundsupplösning plus klient-ID; det Àr inte lika sÀkert, men det Àr nog upp till uppgiften i de flesta sammanhang.

Den tid vi anvÀnder som mÄtt pÄ nyckelns livslÀngd kallas "lÄsets livslÀngd". Detta vÀrde Àr bÄde hur lÄng tid innan lÄset slÀpps automatiskt och hur lÄng tid en klient har pÄ sig att slutföra en operation innan en annan klient i sin tur kan lÄsa den resursen, utan att faktiskt bryta mot ömsesidiga uteslutningsgarantier. Denna garanti Àr begrÀnsad endast till en viss tidsperiod, som börjar frÄn det ögonblick som lÄset köps.

SÄ vi har diskuterat ett bra sÀtt att skaffa och slÀppa ett lÄs. Systemet (om vi pratar om ett icke-distribuerat system som bestÄr av en enda och alltid tillgÀnglig instans) Àr sÀkert. LÄt oss utöka detta koncept till ett distribuerat system, dÀr vi inte har nÄgra sÄdana garantier.

Redlock-algoritm

Den distribuerade versionen av algoritmen antar att vi har N Redis-mÀstare. Dessa noder Àr helt oberoende av varandra, sÄ vi anvÀnder inte replikering eller nÄgot annat implicit koordinationssystem. Vi har redan tagit upp hur man sÀkert skaffar och frigör ett lÄs pÄ en enskild instans. Vi tar för givet att algoritmen, nÀr man arbetar med en enda instans, kommer att anvÀnda exakt denna metod. I vÄra exempel sÀtter vi N till 5, vilket Àr ett rimligt vÀrde. SÄledes kommer vi att behöva anvÀnda 5 Redis-masters pÄ olika datorer eller virtuella maskiner för att sÀkerstÀlla att de agerar i stort sett oberoende av varandra.

För att skaffa ett lÄs utför klienten följande operationer:

  1. HĂ€mtar aktuell tid i millisekunder.
  2. Försöker sekventiellt att fÄ ett lÄs pÄ alla N instanser, med samma nyckelnamn och slumpmÀssiga vÀrden i alla fall. I steg 2, nÀr klienten sÀtter upp ett lÄs per instans, anvÀnder klienten en fördröjning för att skaffa det som Àr tillrÀckligt kort jÀmfört med den tid efter vilken lÄset automatiskt slÀpps. Till exempel, om blockeringens varaktighet Àr 10 sekunder, kan fördröjningen vara i intervallet ~5-50 millisekunder. Detta eliminerar situationen dÀr klienten kan förbli blockerad under lÄng tid nÀr han försöker nÄ en misslyckad Redis-nod: om instansen inte Àr tillgÀnglig försöker vi ansluta till en annan instans sÄ snart som möjligt.
  3. För att ta ett lÄs rÀknar klienten ut hur lÄng tid som har förflutit; För att göra detta subtraherar den frÄn det faktiska tidsvÀrdet tidsstÀmpeln som erhölls i steg 1. Om och endast om klienten kunde erhÄlla lÄset pÄ de flesta instanser (minst 3), och den totala tiden det tog att skaffa lÄset, mindre Àn lÄstiden, anses lÄset ha erhÄllits.
  4. Om ett lÄs har förvÀrvats, anses lÄstiden vara den ursprungliga lÄstiden minus den förflutna tiden som berÀknats i steg 3.
  5. Om klienten misslyckas med att fÄ lÄset av nÄgon anledning (antingen kunde den inte lÄsa N/2+1-instanser eller lÄstiden var negativ), kommer den att försöka lÄsa upp alla instanser (Àven de som den trodde att den inte kunde blockera) ).

Är algoritmen asynkron?

Denna algoritm Àr baserad pÄ antagandet att Àven om det inte finns nÄgon synkroniserad klocka pÄ vilken alla processer skulle fungera, flyter lokal tid i varje process fortfarande i ungefÀr samma takt, och felet Àr litet jÀmfört med den totala tiden efter vilken lÄset Àr slÀpps automatiskt. Detta antagande Àr mycket likt den situation som Àr typisk för vanliga datorer: varje dator har en lokal klocka, och vi kan vanligtvis rÀkna med att tidsskillnaden mellan olika datorer Àr liten.

Vid denna tidpunkt mÄste vi formulera vÄr ömsesidiga uteslutningsregel noggrannare: ömsesidig uteslutning garanteras endast om klienten som hÄller lÄset gÄr ut under tiden som lÄset Àr giltigt (detta vÀrde erhÄllits i steg 3), minus ytterligare tid (totalt nÄgra fÄ millisekunder för att kompensera för tidsskillnaden mellan processer).

Följande intressanta artikel berÀttar mer om sÄdana system som krÀver samordning av tidsintervall: Leasingavtal: en effektiv feltolerant mekanism för distribuerad filcachekonsistens.

Försök igen vid misslyckande

NÀr en klient inte lyckas skaffa ett lÄs mÄste den försöka igen efter en slumpmÀssig fördröjning; detta görs för att avsynkronisera flera klienter som försöker skaffa ett lÄs pÄ samma resurs samtidigt (vilket kan leda till en "split-brain"-situation dÀr det inte finns nÄgra vinnare). Dessutom, ju snabbare en klient försöker fÄ ett lÄs pÄ en majoritet av Redis-instanserna, desto snÀvare fönster dÀr en split-brain-situation kan uppstÄ (och desto mindre blir behovet av Äterförsök). DÀrför bör klienten helst försöka skicka SET-kommandon till N instanser samtidigt med multiplexering.

Det Àr vÀrt att betona hÀr hur viktigt det Àr för kunder som misslyckas med att skaffa majoriteten av lÄsen att frigöra de (delvis) förvÀrvade lÄsen, sÄ att de inte behöver vÀnta pÄ att nyckeln ska löpa ut innan lÄset pÄ resursen kan skaffas igen (men om nÀtverksfragmentering intrÀffar och klienten förlorar kontakten med Redis-instanserna, mÄste du betala en tillgÀnglighetsstraff medan du vÀntar pÄ att nyckeln ska upphöra att gÀlla).

SlÀpp lÄset

Att slÀppa ett lÄs Àr en enkel operation som helt enkelt krÀver att alla instanser lÄses upp, oavsett om klienten verkar ha 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.

Till att börja med, lÄt oss anta att klienten kunde fÄ ett lÄs pÄ de flesta fall. Varje instans kommer att innehÄlla en nyckel med samma livslÀngd för alla. Men var och en av dessa nycklar installerades vid olika tidpunkter, sÄ de kommer att förfalla vid olika tidpunkter. Men om den första nyckeln installerades vid en tidpunkt som inte var sÀmre Àn T1 (den tid som vi vÀljer innan vi kontaktar den första servern), och den sista nyckeln installerades vid en tidpunkt som inte var sÀmre Àn T2 (den tidpunkt dÄ svaret togs emot frÄn den sista servern), sÄ Àr vi sÀkra pÄ att den första nyckeln i setet som löper ut kommer att överleva Ätminstone MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT. Alla andra nycklar kommer att upphöra att gÀlla senare, sÄ vi kan vara sÀkra pÄ att alla nycklar kommer att vara giltiga samtidigt under Ätminstone denna tid.

Under den tid som de flesta nycklar Àr giltiga kommer en annan klient inte att kunna förvÀrva lÄset, eftersom N/2+1 SET NX-operationer inte kan lyckas om N/2+1-nycklar redan finns. DÀrför, nÀr ett lÄs har förvÀrvats, Àr det omöjligt att förvÀrva det igen i samma ögonblick (detta skulle bryta mot den ömsesidiga uteslutningsegenskapen).
Vi vill dock se till att flera klienter som försöker skaffa ett lÄs samtidigt inte kan lyckas samtidigt.

Om klienten har lÄst majoriteten av instanserna i ungefÀr eller mer Àn den maximala lÄstiden, kommer den att betrakta lÄset som ogiltigt och lÄsa upp instanserna. DÀrför behöver vi bara ta hÀnsyn till det fall dÀr klienten lyckats blockera flertalet instanser pÄ en kortare tid Àn utgÄngsdatumet. I det hÀr fallet, angÄende ovanstÄende argument, under tiden MIN_VALIDITY ingen klient ska kunna Äteranskaffa lÄset. DÀrför kommer mÄnga klienter att kunna lÄsa N/2+1-instanser samtidigt (som slutar i slutet av steg 2) endast nÀr tiden för att lÄsa majoriteten var lÀngre Àn TTL-tiden, vilket gör lÄset ogiltigt.

Kan du tillhandahÄlla ett formellt sÀkerhetsbevis, ange befintliga liknande algoritmer eller hitta en bugg i ovanstÄende?

TillgÀnglighetsövervÀganden

SystemtillgÀnglighet beror pÄ tre huvudegenskaper:

  1. Frigör lÄs automatiskt (nÀr nycklar upphör): Nycklar kommer sÄ smÄningom att vara tillgÀngliga igen för att anvÀndas för lÄs.
  2. Det faktum att klienter brukar hjÀlpa varandra genom att ta bort lÄs nÀr det önskade lÄset inte har skaffats, eller har införskaffats och jobbet Àr klart; sÄ det Àr troligt att vi inte behöver vÀnta pÄ att nycklarna ska ta slut för att fÄ tillbaka lÄset.
  3. Det faktum att nÀr en klient behöver försöka igen för att skaffa ett lÄs, vÀntar den i jÀmförelsevis lÀngre tid Àn den tid som krÀvs för att skaffa de flesta lÄs. Detta minskar sannolikheten för att en split-brain-situation uppstÄr nÀr man konkurrerar om resurser.

Det finns dock en tillgÀnglighetsstraff som Àr lika med TTL för nÀtverkssegmenten, sÄ om det finns sammanhÀngande segment kan straffavgiften vara obestÀmd. Detta intrÀffar nÀrhelst en klient skaffar ett lÄs och sedan river till ett annat segment innan det kan slÀppa det.

I princip, givet oÀndliga angrÀnsande nÀtverkssegment, kan ett system förbli otillgÀngligt under en oÀndlig tidsperiod.

Prestanda, failover och fsync

MÄnga anvÀnder Redis för att de behöver hög lÄsserverprestanda vad gÀller den latens som krÀvs för att förvÀrva och slÀppa lÄs, och antalet förvÀrv/slÀpp som kan genomföras per sekund. För att möta detta krav finns det en strategi för att kommunicera med N Redis-servrar för att minska latensen. Detta Àr en multiplexeringsstrategi (eller "fattigmansmultiplexering", dÀr socket sÀtts i icke-blockerande lÀge, skickar alla kommandon och lÀser kommandona senare, förutsatt att tiden för tur och retur mellan klienten och varje instans Àr liknande) .

Men vi mÄste ocksÄ ta hÀnsyn till den hÀnsyn som Àr förknippad med lÄngsiktig datalagring om vi strÀvar efter att skapa en modell med tillförlitlig ÄterhÀmtning frÄn fel.

I grund och botten, för att klargöra problemet, lÄt oss anta att vi konfigurerar Redis utan nÄgon lÄngvarig datalagring alls. Klienten lyckas blockera 3 av 5 instanser. En av instanserna som klienten lyckades blockera startas om, och i detta ögonblick finns det 3 instanser igen för samma resurs, som vi kan blockera, och en annan klient kan i sin tur blockera den omstartade instansen, vilket bryter mot sÀkerhetsegenskapen som förutsÀtter exklusivitet för lÄs.

Om du aktiverar data framÄt (AOF) kommer situationen att förbÀttras nÄgot. Du kan till exempel marknadsföra en server genom att skicka kommandot SHUTDOWN och starta om den. Eftersom utgÄngsoperationer i Redis Àr semantiskt implementerade pÄ ett sÄdant sÀtt att tiden fortsÀtter att flyta Àven nÀr servern Àr avstÀngd, Àr alla vÄra krav bra. Detta Àr normalt sÄ lÀnge som en normal avstÀngning sÀkerstÀlls. Vad ska man göra vid strömavbrott? Om Redis Àr konfigurerat som standard, med fsync som synkroniserar pÄ disken varje sekund, Àr det möjligt att vi inte kommer att ha vÄr nyckel efter en omstart. Teoretiskt, om vi vill garantera lÄssÀkerhet under en omstart av en instans, bör vi aktivera fsync=always i instÀllningarna för lÄngtidslagring av data. Detta kommer att helt döda prestandan, ner till nivÄn för CP-system som traditionellt anvÀnds för att sÀkert implementera distribuerade lÄs.

Men lÀget Àr bÀttre Àn det verkar vid första anblicken. I princip Àr sÀkerheten för algoritmen bevarad eftersom nÀr instansen startas om efter ett misslyckande deltar den inte lÀngre i nÄgot lÄs som för nÀrvarande Àr aktivt.

För att sÀkerstÀlla detta behöver vi bara se till att instansen efter ett misslyckande förblir otillgÀnglig under en tidsperiod som nÄgot överskrider den maximala TTL som vi anvÀnder. PÄ sÄ sÀtt kommer vi att vÀnta tills utgÄngsdatumet och automatiskt frislÀppande av alla nycklar som var aktiva vid tidpunkten för felet.

Genom att anvÀnda fördröjda omstarter Àr det i princip möjligt att uppnÄ sÀkerhet Àven om det inte finns nÄgon lÄngvarig bestÀndighet i Redis. Observera dock att detta kan ge böter för brott mot tillgÀngligheten. Till exempel, om majoriteten av instanserna misslyckas, kommer systemet att bli globalt otillgÀngligt för TTL (och ingen resurs kan blockeras under denna tid).

Vi ökar tillgÀngligheten för algoritmen: vi utökar blockeringen

Om arbetet som utförs av klienter bestÄr av smÄ steg, Àr det möjligt att minska standardlÄsets varaktighet och implementera en mekanism för att förlÀnga lÄs. I princip, om klienten Àr upptagen med datoranvÀndning och lÄsets utgÄngsvÀrde Àr farligt lÄgt, kan du skicka ett Lua-skript till alla instanser som utökar nyckelns TTL om nyckeln fortfarande finns och dess vÀrde fortfarande Àr ett slumpmÀssigt vÀrde som erhÄlls nÀr lÄs införskaffades.

En klient bör endast övervÀga att ett lÄs ska Äteranskaffas om den har lyckats lÄsa de flesta instanser inom giltighetstiden.

Det Àr sant, tekniskt sett förÀndras inte algoritmen, sÄ det maximala antalet upprepade försök att skaffa lÄs mÄste begrÀnsas, annars kommer tillgÀnglighetsegenskaperna att krÀnkas.

KĂ€lla: will.com

LĂ€gg en kommentar