Fem studenter och tre utdelade nyckelvärdebutiker

Eller hur vi skrev ett klient C++-bibliotek för ZooKeeper, etcd och Consul KV

I en värld av distribuerade system finns det ett antal typiska uppgifter: lagra information om klustrets sammansättning, hantera konfigurationen av noder, upptäcka felaktiga noder, välja en ledare och andra. För att lösa dessa problem har speciella distribuerade system skapats - samordningstjänster. Nu kommer vi att vara intresserade av tre av dem: ZooKeeper, etcd och Consul. Av all den rika funktionaliteten hos Consul kommer vi att fokusera på Consul KV.

Fem studenter och tre utdelade nyckelvärdebutiker

I huvudsak är alla dessa system feltoleranta, linjäriserbara nyckel-värde-lager. Även om deras datamodeller har betydande skillnader, som vi kommer att diskutera senare, löser de samma praktiska problem. Uppenbarligen är varje applikation som använder samordningstjänsten knuten till en av dem, vilket kan leda till att man behöver stödja flera system i ett datacenter som löser samma problem för olika applikationer.

Idén att lösa detta problem har sitt ursprung i en australiensisk konsultbyrå, och det föll på oss, ett litet team av studenter, att implementera det, vilket är vad jag ska prata om.

Vi lyckades skapa ett bibliotek som ger ett gemensamt gränssnitt för att arbeta med ZooKeeper, etcd och Consul KV. Biblioteket är skrivet i C++, men det finns planer på att portera det till andra språk.

Datamodeller

För att utveckla ett gemensamt gränssnitt för tre olika system måste du förstå vad de har gemensamt och hur de skiljer sig åt. Låt oss ta reda på det.

ZooKeeper

Fem studenter och tre utdelade nyckelvärdebutiker

Nycklarna är organiserade i ett träd och kallas noder. Följaktligen kan du för en nod få en lista över dess barn. Operationerna för att skapa en znode (skapa) och ändra ett värde (setData) är separerade: endast befintliga nycklar kan läsas och ändras. Klockor kan kopplas till operationerna att kontrollera förekomsten av en nod, läsa ett värde och skaffa barn. Watch är en engångstrigger som aktiveras när versionen av motsvarande data på servern ändras. Efemära noder används för att upptäcka fel. De är knutna till sessionen för klienten som skapade dem. När en klient stänger en session eller slutar meddela ZooKeeper om dess existens, raderas dessa noder automatiskt. Enkla transaktioner stöds - en uppsättning operationer som antingen alla lyckas eller misslyckas om detta inte är möjligt för minst en av dem.

ETCD

Fem studenter och tre utdelade nyckelvärdebutiker

Utvecklarna av detta system var tydligt inspirerade av ZooKeeper, och gjorde därför allt annorlunda. Det finns ingen hierarki av nycklar, utan de bildar en lexikografiskt ordnad uppsättning. Du kan hämta eller ta bort alla nycklar som tillhör ett visst område. Denna struktur kan verka märklig, men den är faktiskt väldigt uttrycksfull och en hierarkisk syn kan lätt efterliknas genom den.

etcd har inte en standard jämför-och-ställ-operation, men den har något bättre: transaktioner. Naturligtvis finns de i alla tre systemen, men etcd-transaktioner är särskilt bra. De består av tre block: kontroll, framgång, misslyckande. Det första blocket innehåller en uppsättning villkor, det andra och tredje - operationer. Transaktionen genomförs atomärt. Om alla villkor är sanna, exekveras framgångsblocket, annars exekveras felblocket. I API 3.3 kan framgångs- och misslyckandeblock innehålla kapslade transaktioner. Det vill säga, det är möjligt att atomiskt utföra villkorliga konstruktioner av nästan godtycklig häckningsnivå. Du kan lära dig mer om vad kontroller och operationer existerar från dokumentation.

Klockor finns här också, även om de är lite mer komplicerade och är återanvändbara. Det vill säga, efter att ha installerat en klocka på en nyckelserie kommer du att få alla uppdateringar i detta sortiment tills du avbryter klockan, och inte bara den första. I etcd är analogen av ZooKeeper-klientsessioner leasing.

Konsul K.V.

Det finns heller ingen strikt hierarkisk struktur här, men Consul kan skapa sken av att den existerar: du kan hämta och ta bort alla nycklar med det angivna prefixet, det vill säga arbeta med nyckelns "underträd". Sådana frågor kallas rekursiva. Dessutom kan Consul endast välja nycklar som inte innehåller det angivna tecknet efter prefixet, vilket motsvarar att erhålla omedelbara "barn". Men det är värt att komma ihåg att detta är just utseendet på en hierarkisk struktur: det är fullt möjligt att skapa en nyckel om dess förälder inte finns eller ta bort en nyckel som har barn, medan barnen kommer att fortsätta att lagras i systemet.

Fem studenter och tre utdelade nyckelvärdebutiker
Istället för klockor har Consul blockerande HTTP-förfrågningar. I huvudsak är dessa vanliga anrop till dataavläsningsmetoden, för vilka, tillsammans med andra parametrar, den senast kända versionen av datan indikeras. Om den aktuella versionen av motsvarande data på servern är större än den angivna, returneras svaret omedelbart, annars - när värdet ändras. Det finns även sessioner som kan kopplas till nycklar när som helst. Det är värt att notera att till skillnad från etcd och ZooKeeper, där borttagning av sessioner leder till radering av tillhörande nycklar, finns det ett läge där sessionen helt enkelt kopplas bort från dem. Tillgängliga transaktioner, utan grenar, men med alla typer av kontroller.

Få alltid att falla på plats

ZooKeeper har den mest rigorösa datamodellen. De uttrycksfulla intervallfrågor som finns tillgängliga i etcd kan inte effektivt emuleras i varken ZooKeeper eller Consul. I ett försök att införliva det bästa från alla tjänster, slutade vi med ett gränssnitt nästan likvärdigt med ZooKeeper-gränssnittet med följande betydande undantag:

  • sekvens-, container- och TTL-noder stöds inte
  • ACL:er stöds inte
  • setmetoden skapar en nyckel om den inte finns (i ZK returnerar setData ett fel i detta fall)
  • set- och cas-metoder är separerade (i ZK är de i huvudsak samma sak)
  • raderingsmetoden tar bort en nod tillsammans med dess underträd (i ZK returnerar delete ett fel om noden har barn)
  • För varje nyckel finns det bara en version - värdeversionen (i ZK det finns tre av dem)

Avvisandet av sekventiella noder beror på att etcd och Consul inte har inbyggt stöd för dem, och de kan enkelt implementeras av användaren ovanpå det resulterande biblioteksgränssnittet.

Att implementera beteende som liknar ZooKeeper vid borttagning av en vertex skulle kräva att man upprätthåller en separat underordnad räknare för varje nyckel i etcd och Consul. Eftersom vi försökte undvika att lagra metainformation beslutades det att ta bort hela underträdet.

Subtiliteter i genomförandet

Låt oss ta en närmare titt på några aspekter av att implementera biblioteksgränssnittet i olika system.

Hierarki i etcd

Att upprätthålla en hierarkisk syn i etcd visade sig vara en av de mest intressanta uppgifterna. Områdesfrågor gör det enkelt att hämta en lista med nycklar med ett specificerat prefix. Till exempel om du behöver allt som börjar med "/foo", du frågar efter ett intervall ["/foo", "/fop"). Men detta skulle returnera hela underträdet av nyckeln, vilket kanske inte är acceptabelt om underträdet är stort. Först planerade vi att använda en viktig översättningsmekanism, implementerad i zetcd. Det innebär att lägga till en byte i början av nyckeln, lika med djupet på noden i trädet. Låt mig ge dig ett exempel.

"/foo" -> "u01/foo"
"/foo/bar" -> "u02/foo/bar"

Skaffa sedan alla omedelbara barn till nyckeln "/foo" möjligt genom att begära ett sortiment ["u02/foo/", "u02/foo0"). Ja, i ASCII "0" står strax efter "/".

Men hur implementerar man borttagningen av en vertex i det här fallet? Det visar sig att du måste ta bort alla intervall av typen ["uXX/foo/", "uXX/foo0") för XX från 01 till FF. Och så sprang vi på operationsnummerbegränsning inom en transaktion.

Som ett resultat uppfanns ett enkelt nyckelkonverteringssystem, som gjorde det möjligt att effektivt implementera både radering av en nyckel och få en lista över barn. Det räcker med att lägga till ett specialtecken före den sista token. Till exempel:

"/very" -> "/u00very"
"/very/long" -> "/very/u00long"
"/very/long/path" -> "/very/long/u00path"

Radera sedan nyckeln "/very" förvandlas till radering "/u00very" och räckvidd ["/very/", "/very0"), och få alla barn - i en begäran om nycklar från sortimentet ["/very/u00", "/very/u01").

Ta bort en nyckel i ZooKeeper

Som jag redan nämnt, i ZooKeeper kan du inte ta bort en nod om den har barn. Vi vill ta bort nyckeln tillsammans med underträdet. Vad ska jag göra? Vi gör detta med optimism. Först korsar vi underträdet rekursivt och erhåller barnen till varje vertex med en separat fråga. Sedan bygger vi en transaktion som försöker ta bort alla noder i underträdet i rätt ordning. Naturligtvis kan förändringar ske mellan att läsa ett underträd och att ta bort det. I det här fallet kommer transaktionen att misslyckas. Dessutom kan underträdet ändras under läsningsprocessen. En begäran till nästa nods underordnade kan returnera ett fel om till exempel denna nod redan har tagits bort. I båda fallen upprepar vi hela processen igen.

Detta tillvägagångssätt gör radering av en nyckel mycket ineffektiv om den har barn, och ännu mer om applikationen fortsätter att arbeta med underträdet, ta bort och skapa nycklar. Detta gjorde det dock möjligt för oss att undvika att komplicera implementeringen av andra metoder i etcd och Consul.

utspelar sig i ZooKeeper

I ZooKeeper finns det separata metoder som fungerar med trädstrukturen (create, delete, getChildren) och som fungerar med data i noder (setData, getData) Dessutom har alla metoder strikta förutsättningar: create returnerar ett fel om noden redan har skapats, raderat eller setData – om det inte redan finns. Vi behövde en fast metod som kan anropas utan att tänka på närvaron av en nyckel.

Ett alternativ är att ha ett optimistiskt förhållningssätt, som med radering. Kontrollera om det finns en nod. Om det finns, anrop setData, annars skapa. Om den senaste metoden returnerade ett fel, upprepa det igen. Det första att notera är att existenstestet är meningslöst. Du kan omedelbart ringa skapa. Ett framgångsrikt slutförande innebär att noden inte existerade och att den skapades. Annars returnerar create rätt fel, varefter du måste anropa setData. Naturligtvis, mellan samtalen, kan en vertex raderas av ett konkurrerande samtal, och setData skulle också returnera ett fel. I det här fallet kan du göra om allt igen, men är det värt det?

Om båda metoderna returnerar ett fel vet vi med säkerhet att en konkurrerande radering ägde rum. Låt oss föreställa oss att denna radering inträffade efter anropsset. Då är vilken mening vi än försöker etablera redan raderad. Detta betyder att vi kan anta att setet exekverades framgångsrikt, även om ingenting faktiskt skrevs.

Mer tekniska detaljer

I det här avsnittet tar vi en paus från distribuerade system och pratar om kodning.
Ett av kundens huvudkrav var plattformsoberoende: minst en av tjänsterna måste stödjas på Linux, MacOS och Windows. Till en början utvecklade vi bara för Linux och började testa på andra system senare. Detta orsakade en hel del problem, som under en tid var helt oklart hur man skulle ta sig an. Som ett resultat av detta stöds nu alla tre samordningstjänsterna på Linux och MacOS, medan endast Consul KV stöds på Windows.

Redan från början försökte vi använda färdiga bibliotek för att komma åt tjänster. I fallet ZooKeeper föll valet på ZooKeeper C++, som till slut misslyckades med att kompilera på Windows. Detta är dock inte förvånande: biblioteket är placerat som enbart linux. För konsul var det enda alternativet ppkonsul. Stöd måste läggas till det sessioner и transaktioner. För etcd hittades inte ett fullfjädrat bibliotek som stöder den senaste versionen av protokollet, så vi genererad grpc-klient.

Inspirerade av det asynkrona gränssnittet i ZooKeeper C++-biblioteket bestämde vi oss för att även implementera ett asynkront gränssnitt. ZooKeeper C++ använder framtids-/löftesprimitiver för detta. I STL är de tyvärr implementerade mycket blygsamt. Till exempel nej sedan metod, som tillämpar den godkända funktionen på resultatet av framtiden när den blir tillgänglig. I vårt fall är en sådan metod nödvändig för att konvertera resultatet till formatet på vårt bibliotek. För att komma runt detta problem var vi tvungna att implementera vår egen enkla trådpool, eftersom vi på kundens begäran inte kunde använda tunga tredjepartsbibliotek som Boost.

Vår dåvarande implementering fungerar så här. När det anropas skapas ett ytterligare löfte/framtidspar. Den nya framtiden returneras, och den godkända placeras tillsammans med motsvarande funktion och ett extra löfte i kön. En tråd från poolen väljer flera terminer från kön och pollar dem med wait_for. När ett resultat blir tillgängligt anropas motsvarande funktion och dess returvärde skickas till löftet.

Vi använde samma trådpool för att köra frågor till etcd och Consul. Detta innebär att de underliggande biblioteken kan nås av flera olika trådar. ppconsul är inte trådsäkert, så samtal till det skyddas av lås.
Du kan arbeta med grpc från flera trådar, men det finns finesser. I etcd implementeras klockor via grpc-strömmar. Dessa är dubbelriktade kanaler för meddelanden av en viss typ. Biblioteket skapar en enda tråd för alla klockor och en enda tråd som behandlar inkommande meddelanden. Så grpc förbjuder parallellskrivning för att streama. Det betyder att när du initierar eller tar bort en klocka måste du vänta tills den föregående begäran har skickats innan du skickar nästa. Vi använder för synkronisering betingade variabler.

Totalt

Se själv: liboffkv.

Vårt lag: Raed Romanov, Ivan Glushenkov, Dmitrij Kamaldinov, Victor Krapivensky, Vitaly Ivanin.

Källa: will.com

Lägg en kommentar