Indførelse af konsistent Hashing
Fordelen ved denne tilgang er dens tilstandsløshed. Vi behøver ikke at bevare nogen tilstand for at minde os om, at foo
blev videresendt til knude 2. Alligevel skal vi vide, hvor mange knuder der er konfigureret til at anvende modulo-operationen.
Hvordan fungerer mekanismen så i tilfælde af ud- eller indskalering (tilføjelse eller fjernelse af knuder)? Hvis vi tilføjer endnu en knude, er modulo-operationen nu baseret på 4 i stedet for 3:
Som vi kan se, er nøglerne foo
og baz
ikke længere knyttet til den samme knude. Med mod-n hashing er der ingen garanti for at opretholde nogen konsistens i tilknytningen nøgle/knude. Er dette et problem? Det kan det måske.
Hvad sker der, hvis vi implementerer et datastore ved hjælp af sharding og baseret på mod-n-strategien til at distribuere data? Hvis vi skalerer antallet af knuder, skal vi udføre en rebalancering. I det foregående eksempel betyder rebalancering:
- Flytning af
foo
fra knude 2 til knude 0. - Flytning af
baz
fra knude 2 til knude 3.
Hvad sker der nu, hvis vi havde lagret millioner eller endda milliarder af data, og at vi har brug for at rebalancere næsten alt? Som vi kan forestille os, ville det være en tung proces. Derfor skal vi ændre vores belastningsfordelingsteknik for at sikre, at vi ved rebalancering:
- Fordelingen forbliver så ensartet som muligt baseret på det nye antal knudepunkter.
- Antallet af nøgler, som vi skal migrere, bør være begrænset. Ideelt set ville det kun være 1/n procent af nøglerne, hvor n er antallet af knuder.
Det er præcis formålet med konsistente hashing-algoritmer.
Begrebet konsistent kan dog være noget forvirrende. Jeg mødte ingeniører, der gik ud fra, at sådanne algoritmer blev ved med at knytte en given nøgle til den samme knude, selv i lyset af skalerbarhed. Dette er ikke tilfældet. Den skal være konsistent indtil et vist punkt for at holde fordelingen ensartet.
Nu er det tid til at grave i nogle løsninger.
Rendezvous
Rendezvous var den første algoritme, der nogensinde blev foreslået til at løse vores problem. Selv om den oprindelige undersøgelse, der blev offentliggjort i 1996, ikke nævnte udtrykket “consistent hashing”, giver den en løsning på de udfordringer, vi beskrev. Lad os se en mulig implementering i Go:
Hvordan fungerer den? Vi gennemløber hver knude og beregner dens hash-værdi. Hashværdien returneres af en hash
-funktion, der producerer et heltal baseret på en nøgle (vores input) og en nodeidentifikator (den nemmeste tilgang er at hashe sammenkædningen af de to strenge). Derefter returnerer vi den node med den højeste hashværdi. Dette er grunden til, at algoritmen også er kendt som hashing med højeste tilfældige vægt.
Den største ulempe ved rendezvous er dens O(n)-tidskompleksitet, hvor n er antallet af knuder. Den er meget effektiv, hvis vi har brug for at have et begrænset antal knuder. Men hvis vi begynder at vedligeholde tusindvis af knuder, kan det begynde at give problemer med ydeevnen.
Ring Consistent Hash
Den næste algoritme blev frigivet i 1997 af Karger et al. i denne artikel. I denne undersøgelse nævnes for første gang udtrykket consistent hashing.
Den er baseret på en ring (en end-to-end forbundet matrix). Selv om det er den mest populære konsistente hashing-algoritme (eller i det mindste den mest kendte), er princippet ikke altid velforstået. Lad os dykke ned i det.
Den første operation er at oprette ringen. En ring har en fast længde. I vores eksempel opdeler vi den i 12 dele:
Dernæst placerer vi vores knuder. I vores eksempel vil vi definere N0
, N1
og N2
.
Noderne fordeles jævnt indtil videre. Vi vender tilbage til dette punkt senere.
Derpå er det tid til at se på, hvordan nøglerne skal repræsenteres. Først har vi brug for en funktion f
, der returnerer et ringindeks (fra 0 til 11) baseret på en nøgle. Vi kan bruge mod-n hashing til det. Da ringlængden er konstant, vil det ikke volde os nogen problemer.
I vores eksempel vil vi definere 3 nøgler: a
, b
og c
. Vi anvender f
på hver af dem. Lad os antage, at vi har følgende resultater:
f(a) = 1
f(a) = 5
f(a) = 7
Så kan vi placere nøglerne på ringen på denne måde:
Hvordan tilknytter vi en given nøgle til en node? Den vigtigste logik er at bevæge sig fremad. Fra en given nøgle returnerer vi den første knude, som vi finder næste gang, mens vi bevæger os fremad:
I dette eksempel tilknytter vi a
til N1
, b
og c
til N2
.
Nu skal vi se, hvordan rebalancering håndteres. Vi definerer endnu et knudepunkt N3
. Hvor skal vi placere den? Der er ikke længere plads til, at den samlede fordeling skal være ensartet. Skal vi omorganisere knuderne? Nej, ellers ville vi jo ikke længere være ensartede, ikke sandt? For at placere en node genbruger vi den samme hashing-funktion f
, som vi introducerede. I stedet for at blive kaldt med en nøgle kan den kaldes med en nodeidentifikator. Så placeringen af den nye node bestemmes tilfældigt.
Der opstår så et spørgsmål: Hvad skal vi gøre med a
, da den næste knude ikke længere er N1
:
Løsningen er følgende: Vi skal ændre dens tilknytning og få a
tilknyttet N3
:
Som vi diskuterede tidligere, bør en ideel algoritme ombalancere 1/n procent af nøglerne i gennemsnit. I vores eksempel, da vi tilføjer en fjerde knude, bør vi have 25 % af de mulige nøgler omforbundet til N3
. Er dette tilfældet?
-
N0
fra indeks 8 til 12: 33,3 pct. af de samlede nøgler -
N1
fra indeks 2 til 4: 16,6 pct. af de samlede nøgler -
N2
fra indeks 4 til 8: 33.3% af de samlede nøgler -
N3
fra indeks 0 til 2: 16,6% af de samlede nøgler
Denne fordeling er ikke ensartet. Hvordan kan vi forbedre det? Løsningen er at bruge virtuelle knuder.
Lad os sige, at vi i stedet for at placere et enkelt punkt pr. knude kan placere tre i stedet. Desuden skal vi definere tre forskellige hashing-funktioner. Hver knude hashes tre gange, så vi får tre forskellige indeks:
Vi kan anvende den samme algoritme ved at bevæge os fremad. Dog ville en nøgle blive tilknyttet en knude uanset hvilken virtuel knude den mødte.
I netop dette eksempel ville fordelingen nu være følgende:
-
N0
: 33,3 % -
N1
: 25 % -
N2
: 41,6 %
Desto flere virtuelle knuder vi definerer pr. knude, jo mere ensartet skulle fordelingen være. I gennemsnit med 100 virtuelle knuder pr. server er standardfordelingen ca. 10 %. Med 1000 er den ca. 3,2 %.
Denne mekanisme er også nyttig, hvis vi har knuder med forskellige størrelser. Hvis en knude f.eks. er konfigureret til teoretisk set at kunne håndtere to gange så stor belastning som de andre, kan vi spinne dobbelt så mange virtuelle knuder.
Den største ulempe ved de virtuelle knuder er dog hukommelsesaftrykket. Hvis vi skal håndtere tusindvis af servere, ville det kræve megabyte hukommelse.
Hvor vi går videre, er det interessant at bemærke, at en algoritme nogle gange kan forbedres væsentligt ved at ændre en lille del. Dette er f.eks. tilfældet med en algoritme offentliggjort af Google i 2017 kaldet consistent hashing with bounded loads (konsistent hashing med afgrænsede belastninger). Denne version er baseret på den samme ringidé, som vi beskrev. Alligevel er deres tilgang at foretage en lille afbalancering ved enhver opdatering (en ny nøgle eller knude, der tilføjes/slettes). Denne version udkonkurrerer den oprindelige med hensyn til standardafvigelse med den afvejning af en værste tidskompleksitet.
Jump Consistent Hash
I 2007 offentliggjorde to ingeniører fra Google jump consistent hash. Sammenlignet med den ringbaserede algoritme “kræver denne implementering ingen lagring, er hurtigere og gør et bedre stykke arbejde med at fordele nøglepladsen jævnt mellem buckets og med at fordele arbejdsbyrden jævnt, når antallet af buckets ændres”. Sagt på en anden måde forbedrer den fordelingen af arbejdsbyrden mellem noderne (en bucket er det samme begreb som en node) uden noget hukommelsesoverhead.
Her er algoritmen i C++ (7 linjer kode 🤯):
I ringkonsistent hash var standardafvigelsen med 1000 virtuelle noder ca. 3,2 % med 1000 virtuelle noder. I jump consistent hash har vi ikke længere brug for begrebet virtuelle knuder. Alligevel er standardafvigelsen fortsat mindre end 0,0000001%.
Der er dog en begrænsning. Knuderne skal nummereres fortløbende. Hvis vi har en liste med servere foo
, bar
og baz
, kan vi f.eks. ikke fjerne bar
. Alligevel kan vi, hvis vi konfigurerer et datalager, anvende algoritmen til at få shard-indekset baseret på det samlede antal shards. Derfor kan jump consistent hash være nyttig i forbindelse med sharding, men ikke i forbindelse med load balancing.
Hvad er Perfect Consistent Hashing Algorithm?
Da vi nu har en vis erfaring med konsistent hashing, lad os tage et skridt tilbage og se, hvad der ville være den perfekte algoritme:
- Kun 1/n procent af nøglerne ville blive remapped i gennemsnit, hvor n er antallet af knuder.
- En O(n) rumkompleksitet, hvor n er antallet af knuder.
- En O(1) tidskompleksitet pr. indsættelse/fjernelse af en knude og pr. nøgleopslag.
- En minimal standardafvigelse for at sikre, at en knude ikke er overbelastet i forhold til en anden knude.
- Den ville gøre det muligt at tilknytte en vægt til en node for at klare forskellige node dimensioner.
- Den ville tillade vilkårlige node navne (ikke nummereret sekventielt) for at understøtte både load balancing og sharding.
Der findes desværre ikke en sådan algoritme. Baseret på det, vi så:
- Rendezvous har en lineær tidskompleksitet pr. opslag.
- Ring consistent hash har en dårlig minimal standardafvigelse uden begrebet virtuelle knudepunkter. Med virtuelle knuder er rumkompleksiteten O(n*v), hvor n er antallet af knuder og v er antallet af virtuelle knuder pr. knude.
- Jump konsistent hash har ikke en konstant tidskompleksitet, og den understøtter ikke vilkårlige knuder.
Området er stadig åbent, og der er nyere undersøgelser, som er værd at kigge på. For eksempel den multiprobe konsistente hash udgivet i 2005. Den understøtter O(1) rumkompleksitet. Men for at opnå en standardafvigelse på ε kræver den O(1/ε) tid pr. opslag. Hvis vi f.eks. ønsker at opnå en standardafvigelse på mindre end 0,5 %, kræver det, at nøglen skal hashes ca. 20 gange. Derfor kan vi opnå en minimal standardafvigelse, men i bestræbelserne på en højere opslagstidskompleksitet.
Som vi sagde i indledningen, er konsistente hashing-algoritmer blevet mainstream. Det bruges nu i utallige systemer såsom MongoDB, Cassandra, Riak, Akka osv. hvad enten det er i forbindelse med balancering af belastning eller fordeling af data. Men som ofte inden for datalogi har enhver løsning kompromiser.
Som vi taler om kompromiser, kan du, hvis du har brug for en opfølgning, tage et kig på det fremragende indlæg skrevet af Damian Gryski:
Leave a Reply