Responsabili:
În urma realizării acestei teme veţi:
După ce a învățat să implementeze propriul alocator de memorie, Marcel, student în anul I, își dorește să iasă temporar din lumea sistemelor de operare și să aibă o privire de ansamblu asupra modului în care companiile mari oferă servicii de stocare în cloud. Om simplu, el a decis să rivalizeze cu AWS și să își creeze propriul sistem distribuit de stocare, pe care să îl poată folosi împreună cu colegii de facultate.
Având un volum foarte mare de date, Marcel a intuit că trebuie să folosească mai multe servere, scopul fiecăruia fiind să stocheze un subset din datele pe care vrea să le păstreze. Lovindu-se pentru prima dată de un sistem format din mai multe entități în afara de propriul laptop, Marcel a început să citească despre concepte de system design folosite frecvent în proiectarea eficientă a sistemelor distribuite: caching, load balancing, consistent hashing, task queues. Rolul vostru este să îl ajutați pe Marcel să pună în practică tot ceea ce a aflat în incursiunea lui prin lumea sistemelor distribuite.
Scopul proiectului este de a dezvolta o bază de date distribuită în care se păstrează documente. Pentru a optimiza accesul la documentele utilizate frecvent, se dorește implementarea unui sistem de caching bazat pe algoritmul LRU (Least Recently Used). Acesta este mecanism de evacuare a intrărilor din cache, conform căruia, în momentul în care nu mai există spațiu suficient pentru a insera noi date, sunt eliminate cele mai vechi valori existente.
În plus, pentru a concepe un sistem cât mai eficient, ne dorim ca baza noastra de date sa foloseasca multiple servere, între care sa se distribuie uniform documentele stocate. Pentru aceasta, vom folosi conceptul de Load Balancing, folosind Consistent Hashing. Acesta este un mecanism frecvent utilizat în cadrul sistemelor distribuite și are avantajul de a îndeplini minimal disruption constraint, adică minimizarea numărului de transferuri necesare atunci când un server este oprit sau pornit. Mai exact, când un server este oprit, doar obiectele aflate pe acesta trebuie redistribuite către servere apropiate. Analog, când un nou server este adăugat, va prelua obiecte doar de la un număr limitat de servere, cele vecine.
<doc_name, doc_content>
unde cheia va fi numele unui document, iar valoarea va salva conţinutul acestuia. Load balancer-ul e responsabil de a decide pe ce server va fi salvat un obiect în funcție de cheia acestuia. Mecanismul eficient prin care acesta mapează un obiect <doc_name, doc_content> unui server_id poartă numele de Consistent Hashing şi este descris în detaliu într-o secțiune următoare.
server_id = hash(data) % NUM_SERVERS
Astfel, load balancer-ul îşi îndeplineşte scopul şi reuşeşte să distribuie uniform datele spre toate cele NUM_SERVERS servere din sistem. În schimb, dezavantajul acestei metode este faptul că într-un sistem real numărul de servere nu este niciodata constant. Dacă unul din servere dispare, din cauza acestei metode simpliste ar trebui să redirijam toate datele din sistem.
Exemplu:
NUM_SERVERS = 10
server_id = 0x65b71f5b % 10 = 1
NUM_SERVERS = 9
Vor trebui verificate toate obiectele din sistem pentru că hash(data) % NUM_SERVERS va avea altă valoare față de cea precedentă.server_id = hash(“123”) % NUM_SERVERS = 0x65b71f5b % 9 = 4
⇒ În trecut era salvat pe serverul 1, dar acum va trebui mutat pe serverul cu id-ul 4.Observăm că această metodă este foarte ineficientă.
În cadrul acestei teme, vă propunem organizarea întregului proiect sub forma unei ierarhii cu următoarele componente: un client, un load balancer și mai multe servere.
Load balancer-ul este prima componentă din sistem. Acesta se ocupă de distribuirea request-urilor primite de la clienți. În cazul nostru, există 2 tipuri de request-uri:
În primul caz, load balancer-ul decide spre ce server trebuie redirecționat request-ul, prelucrarea acestuia fiind făcută la nivelul serverului.
În cel de-al doilea caz, se adaugă sau se șterg servere din sistem și se rebalansează stocarea serverelor astfel încât să nu se piardă documentele, utilizând logica explică la Consistent Hashing.
Pentru fiecare server se respectă următoarea arhitectură, bazată pe o coadă de task-uri, un cache și o memorie principală (o bază de date):
Interacțiunea cu server-ul se face prin intermediul unui request. Există două tipuri de request-uri. Pentru ca server-ul să facă minimul de operații necesare, vom folosi o coadă, a cărei dimensiune maximă este TASK_QUEUE_SIZE = 1000, în care păstram request-urile care ar trebui să modifice conținutul unui document. Astfel, execuția lor este amânată până în momentul în care un request de tip GET_DOCUMENT este primit, urmând a fi rulate toate request-urile stocate până atunci în coadă, trebuind să se afișeze un răspuns corespunzător pentru fiecare request.
Odată ce un request este executat, server-ul returnează o structura de tip response, cu următoarele câmpuri:
La nivelul server-ului sunt definite următoarele operații:
Când se primește orice request:
În continuare, explicăm flow-ul execuției fiecărui tip de request în momentul în care este rulat.
În cazul sistemului nostru, va trebui să implementăm un cache software, care să se bazeze pe un hashtable, în care numele documentelor reprezintă cheile, iar conținutul - valoarea. Fiecare server din topologie va avea o anumită dimensiune a cache-ului, exprimată ca număr de intrări, și pasată ca argument la crearea unui nou server (vezi operația de ADD_SERVER).
Având o dimensiune limitată, la un moment dat vom ajunge în situația în care dorim să adăugăm o nouă intrare în cache, însă acesta este deja plin. Pentru a trata acest scenariu, va trebui să decidem ce intrare eliminăm din cache pentru a face spațiu pentru noile date.
Least Recently Used (LRU) Cache se bazează pe principiul că există o probabilitate foarte mare ca datele accesate cel mai recent să fie accesate din nou în viitorul apropiat. Astfel, în momentul în care cache-ul s-a umplut, politica de evacuare a elementelor din cache specifică faptul că trebuie eliminată intrarea care a fost accesată în urmă cu cel mai mult timp.
Cea mai uzuală metodă de a implementa acest cache este prin:
În momentul in care o valoare asociata unei chei deja existente in cache trebuie actualizata, vom aplica o politica de scriere write-through, actualizand valoarea atat in cache, cat si in baza de date din memoria locala a serverului, pentru a pastra coerenta dintre cache si memorie.
Funcțiile principale pe care cache-ul vostru trebuie să le implementeze sunt:
Distribuția serverelor poate fi modificată folosind următoarele comenzi:
Inițial, nu există niciun server în sistem. În momentul în care load balancer-ul primește una dintre comenzi, anumite documente și task-uri sunt redistribuite între servere (vezi logica de la Consistent Hashing).
Astfel, flow-ul inerent fiecărei comenzi referitoare la topologie este:
în baza de date a serverului destinaţie (fară a fi adăugate în cache).
Prin server destinaţie, ne referim la:
Consistent Hashing este o metoda de hashing distribuit prin care atunci când tabelul este redimensionat, se vor remapa în medie doar n / m
chei, unde n este numărul curent de chei, iar m este numărul de slot-uri (în cazul nostru servere). În implementare, se va folosi un cerc imaginar numit hash ring pe care sunt mapate atât numele documentelor, cât și id-urile serverelor. Această mapare se realizează cu ajutorul unei funcții de hashing cu valori între MIN_HASH și MAX_HASH, punctele din acest interval fiind distribuite la distante egale în intervalul logic [0°, 360°] de pe cercul imaginar.
Fiecare document trebuie sa aparţină unui singur server. Astfel, serverul responsabil să stocheze un anumit document va fi cel mai apropiat de pe hash ring în direcţia acelor de ceas.
Să presupunem că avem următorul set de servere ce urmează să primească documente şi rezultatul funcției de hashing după ce a fost aplicată pe id-ul acestora:
ID Server | Hash |
---|---|
2 | 2269549488 |
0 | 5572014558 |
1 | 8077113362 |
Acum vom adăuga în sistem o mulţime de documente ce urmează a fi distribuite către serverele deja existente. În momentul în care un document este adăugat în sistem, va trebui să căutăm primul server al cărui ID hash-uit este mai “mare” (în sensul acelor de ceas, atenţie la logica circulară) decât hash-ul numelui documentului.
Pentru a produce interogări cât mai eficiente, hash ring-ul va fi reţinut în memorie ca un array ordonat crescător de etichete de servere. Ordonarea se va face în funcţie de hash, iar în cazul în care 2 etichete de servere au aceeaşi valoare hash, se va sorta crescător după ID-ul serverului.
Doc Name / ID Server | Hash | Stored on |
---|---|---|
Jade | 1633428562 | 2 |
2 | 2269549488 | - |
Pearl | 3421657995 | 0 |
Sapphire | 5000799124 | 0 |
0 | 5572014558 | - |
Amethyst | 7594634739 | 1 |
1 | 8077113362 | - |
Ruby | 9787173343 | 2 |
În cazul în care unul din servere este eliminat din sistem, toate replicile sale sunt eliminate de pe hash ring, iar documentele salvate pe acestea sunt remapate la cele mai apropiate servere ramase (în sensul acelor de ceas).
În exemplul nostru vom presupune că serverul cu id 0 va fi eliminat. În acest moment documentele denumite “Pearl” şi “Sapphire” vor fi redistribuite către serverul 1.
Doc Name / ID Server | Hash | Stored on |
---|---|---|
Jade | 1633428562 | 2 |
2 | 2269549488 | - |
Pearl | 3421657995 | 1 |
Sapphire | 5000799124 | 1 |
Amethyst | 7594634739 | 1 |
1 | 8077113362 | - |
Ruby | 9787173343 | 2 |
În cazul adăugării unui nou server în sistem, se vor lua toate documentele de pe serverele vecine (succesoare în sensul acelor de ceas) si se va verifica dacă vor fi remapate către serverul nou sau nu. Dacă un document trebuie să fie mapat pe serverul nou, valoarea sa va fi transferată de pe serverul vechi, iar serverul vechi o va şterge.
Să presupunem că se adaugă un server cu id 3. Serverul care va fi vecin celui cu id 3 pe hash ring (serverul 1, în cazul de faţă) va trebui să îşi redistribuie documentele:
Doc Name / ID Server | Hash | Stored on |
---|---|---|
Jade | 1633428562 | 2 |
2 | 2269549488 | - |
Pearl | 3421657995 | 3 |
3 | 4514215965 | - |
Sapphire | 5000799124 | 1 |
Amethyst | 7594634739 | 1 |
1 | 8077113362 | - |
Ruby | 9787173343 | 2 |
În practică, pentru a ne asigura că obiectele sunt distribuite cât mai uniform pe servere se foloseşte următorul artificiu: Fiecare server va fi adaugat de mai multe ori pe hash ring (Aceste servere se vor numi “replici” sau “noduri virtuale”). Acest mecanism se va realiza prin asocierea unei etichete artificiale fiecarei replici, plecând de la id-ul server-ului de bază:
eticheta = replica_id * 10^5 + server_id;
Fie următorul exemplu:
Doc Name / Tag Server | Hash | Stored on |
---|---|---|
100000 | 1093130520 | - |
Jade | 1633428562 | 2 |
000002 | 2269549488 | - |
200002 | 2717580620 | - |
Pearl | 3421657995 | 1 |
200001 | 4633428562 | - |
Sapphire | 5000799124 | 0 |
000000 | 5572014558 | - |
100002 | 6252163898 | - |
Amethyst | 7594634739 | 1 |
100001 | 7613173320 | - |
200000 | 7893130520 | - |
000001 | 8077113362 | - |
Ruby | 9787173343 | 0 |
În cazul cazul în care vrem să eliminăm serverul cu id 0, documentele denumite “Sapphire” şi “Ruby” vor fi redistribuite:
Doc Name / Tag Server | Hash | Stored on |
---|---|---|
Jade | 1633428562 | 2 |
000002 | 2269549488 | - |
200002 | 2717580620 | - |
Pearl | 3421657995 | 1 |
200001 | 4633428562 | - |
Sapphire | 5000799124 | 2 |
100002 | 6252163898 | - |
Amethyst | 7594634739 | 1 |
100001 | 7613173320 | - |
000001 | 8077113362 | - |
Ruby | 9787173343 | 2 |
Să presupunem că se adaugă un server cu id 4. Serverele care vor fi vecine celui cu id 4 (oricare replică a lui) vor trebui să îşi redistribuie documentele:
Doc Name / Tag Server | Hash | Stored on |
---|---|---|
Jade | 1633428562 | 2 |
000002 | 2269549488 | - |
200002 | 2717580620 | - |
Pearl | 3421657995 | 1 |
200001 | 4633428562 | - |
Sapphire | 5000799124 | 4 |
000004 | 5421603348 | - |
200004 | 6017580621 | - |
100002 | 6252163898 | - |
Amethyst | 7594634739 | 1 |
100001 | 7613173320 | - |
000001 | 8077113362 | - |
Ruby | 9787173343 | 4 |
100004 | 9945918562 | - |
În situaţia de mai sus, serverul 2 a trebuit să distribuie serverului 4 documentele cu numele “Ruby” si “Sapphire”. Este o coincidenţă că ambele documente redistribuite vin de la acelasi server, puteau veni de la replici ale unor servere diferite.
În fișierul main.c din schelet este deja implementată partea ce ține de client si modul în care acesta apelează sistemul distribuit, dar și partea de gestiune a sistemului ce va apela funcțiile de pornire si oprire a serverelor atunci când este cazul. Trebuie să implementați doar funcționalitățile din load_balancer.c, server.c şi lru_cache.c, entry point-ul către sistemul distribuit fiind prin Load Balancer, iar toate apelurile către acesta fiind deja implementate în schelet.
În headere, veţi întâlni macro-uri pentru mesajele pe care trebuie sa le printaţi, cât şi alte macro-uri utile:
Load balancer-ul ar trebui sa interactioneze cu serverele folosind functia server_handle_request. Functiile specifice executiei fiecarui tip de request (server_edit_document, server_get_document) nu sunt expuse in server.h, si nu ar trebui sa fie apelate direct de catre o alta entitate, in afara de server.
server_handle_request va returna load balancer-ului o structura response, care va fi returnata la randul ei in main, acolo unde este printata.
Structura response va fi folosita pentru a pastra atat mesajul care trebuie printat de server la finalul executiei unei operatii, cat si alte date auxiliare pe care vi se pare important sa le memorati.
Aceasta poate fi folosită pentru anumite mesaje de răspuns ce necesită folosirea tipului request-ului.
Puteţi începe implementând logica de tratare a request-urilor în server, şi folosind câmpul temporar test_server din structura de load_balancer.
Odată ce aţi finalizat implementarea serverului, puteţi elimina field-ul test_server, şi să vă implementaţi propria logică de load balancing.
Testele sunt formate dintr-o linie unde se specifică numărul total de request-uri, urmat de ENABLE_VNODES (opţional, doar pentru testele bonus, în care se verifică utilizarea replicilor), respectiv cate o linie pentru fiecare request.
Scheletul și checker-ul se regăsesc în acest repository de Github.
Q: Putem modifica scheletul / adăuga funcţii?
A: Da.
Q: Ce funcţii de hashing putem folosi pentru server?
A: Puteţi folosi orice funcţie doriţi atât pentru baza de date a serverului, cât şi pentru cache (inclusiv pe cele din laborator/schelet).