Tendințele tehnologice din ultimii ani s-au îndreptat tot mai mult înspre dezvoltarea unor servicii cât mai mici, cât mai rapide și care să consume cât mai puține resurse. Astfel, s-a ajuns la "spargerea" aplicațiilor vechi, greoaie, de tip monolit, în bucățele numite microservicii, care deservesc o singură funcționalitate a unei platforme. Ne-am întors, așadar, la filozofia de tip divide et impera.
Ce se poate face, totuși, atunci când, orice s-ar încerca, performanțele microserviciului nu se apropie de cele scontate? Ba, mai mult, chiar influențează negativ mediul în care rulează?
Acest articol prezintă încercări practice de îmbunătățire a performanțelor unui microserviciu real, care au fost lansate în producție împreună cu avantajele și dezavantajele aferente. Sperăm ca strategiile noastre să fie aplicabile și în alte contexte sau dacă nu, măcar să aprindă scânteia inspirației pentru cei care se confruntă cu situații asemănătoare.
Așa cum am învățat de mici, orice problemă trebuie să aibă ipoteză și concluzie. Deși în titlu am promis o poveste, ne iertați dacă vom îmbina literatura cu matematica. Ipoteza noastră sună cam așa:
A fost odată un microserviciu (să-i spunem Micron) trecut de prima tinerețe, scris în Java 11, care expunea un API de tip REST, dispunea de un mecanism de caching, funcționând în producție pe 6 VM-uri (virtual machines). Pentru stocarea datelor se folosea un singur nod al unei baze de date de tip MySQL, care era, de asemenea, instalată în mașină virtuală. La nivel de platformă, acest microserviciu era cel mai solicitat de către utilizatori (primind, în medie, un milion de cereri pe oră), fiind esențial navigării acestora în cadrul site-ului. Totodată, requesturile tuturor clienților de pe mapamond erau redirecționate din diferite environment-uri de tip spoke către environmentul principal, de tip hub, deci către cele 6 VM-uri disponibile. Distribuția cererilor înspre VM-uri se făcea printr-o "poartă", un proxy, care funcționa după un algoritm de tip least connections, al cărui ultim update de software data din 2017. În intervalele de timp în care platforma avea cele mai multe accesări, Micron avea performanțe foarte slabe, timpi de răspuns crescuți, numărul de erori returnate era semnificativ, iar nivelul de utilizare al CPU pentru baza de date se apropia, uneori chiar atingând, maximul alocat. Un alt microserviciu, reactiv de data aceasta - Micron 2.0 - care îndeplinește aceleași funcționalități și va veni ca un înlocuitor pentru Micron era în curs de dezvoltare. Dar soluția la problema supraîncărcării trebuia să se găsească urgent.
Pentru o mai bună experiență de utilizare a platformei atât la ore de vârf, cât și în afara acestora, timpii de răspuns ai lui Micron trebuiau neapărat îmbunătățiți, numărul erorilor redus cât mai mult sau chiar complet, iar gradul de folosire a bazei de date trebuia minimizat, cu scopul de a preveni posibile incidente care ar implica pierderea datelor stocate.
Analiza textului ne duce cu gândul la un Făt-Frumos la ananghie. Precum Harap-Alb a avut nevoie să treacă prin diverse aventuri alături de prietenii săi pentru a se descoperi pe sine, Micron are nevoie de o reînnoire pentru a-și îmbunătăți forma, dar, deși este eroul principal, are nevoie și de ajutor din partea celorlalte personaje.
La un prim brainstorming, se observă clar că problema are o componentă prioritară: reducerea numărului de interogări pe baza de date, în același timp țintind către creșterea vitezei serviciului și scăderea procentului de răspunsuri eronate. Există diverse modalități prin care aceste rezultate ar putea fi obținute. Dintre acestea menționăm:
Transformarea serviciului într-unul reactiv, asincron. Astfel, s-ar permite execuția mai multor fire în paralel, scăzând timpul de răspuns. Totuși, această idee este invalidată, deoarece inițiativa pentru Micron 2.0 este deja în curs, iar efortul suplimentar ar fi în van.
Limitarea numărului de conexiuni deschise. Deși pare o soluție fezabilă pentru a asigura corectitudinea informațiilor returnate și a scădea rata de interogări trimise spre baza de date, nu ne permitem să riscăm înrăutățirea drastică a timpului de răspuns, tot comportamentul platformei depinzând de cererile către Micron.
Rescrierea tuturor query-urilor SQL pentru a evita problema N+1. Aceasta ar reprezenta soluția uneia dintre cele mai întâlnite cauze ale scăderii performanțelor serviciilor care folosesc baze de date relaționale. Practic, să ne imaginăm că într-un tabel avem o listă cu mașini, iar într-un alt tabel o listă cu roți, între cele două existând o relație de tip One-to-Many (mai multe roți corespund unei mașini). Pentru a afla anumite informații despre roți, serviciul va efectua o interogare spre baza de date care va returna lista completă a mașinilor, iar pentru fiecare dintre cele N mașini, va crea un alt query ca să obțină lista de roți corespunzătoare, pe care mai apoi o va prelucra. În total, se efectuează N+1 căutări, care ar putea fi reduse la una singură: lista completă a roților, obținută direct din tabelul corespunzător, care să fie salvată în memorie și mai apoi prelucrată.
Scalarea serviciului. Cu cât numărul de VM-uri este mai mare, cu atât raportul requesturi/mașină virtuală este mai scăzut. Totuși, nu putem să creștem numărul de mașini virtuale la fel de ușor cum am crește numărul de poduri în Kubernetes, pentru că procesul este unul costisitor pentru companie, atât din punct de vedere financiar, cât și din perspectiva muncii depuse de către mai multe echipe, rămânând posibilitatea ca performanțele să nu se ridice la nivelul așteptărilor. Cum spun englezii, penny wise, but pound foolish. De asemenea, această inițiativă nu ar atinge scopul principal, reducerea efortului depus de baza de date.
Așa cum am menționat în ipoteza noastră, Micron a ajuns la o vârstă înaintată, când nu mai acceptă cu ușurință schimbări majore. Drept urmare, s-a recurs la variantele care ar avea cele mai bune rezultate în contextul actual: rescrierea manuală a interogărilor, pentru a reduce numărul de interacțiuni cu baza de date și implementarea unui mecanism de conectare a VM-urilor la toate cele trei noduri MySQL disponibile.
Pe de o parte, rezolvarea aspectului celor N+1 query-uri este o muncă de durată raportându-ne la complexitatea serviciului, pentru care avem la dispoziție toate informațiile necesare. Cu alte cuvinte, vorbim de un proces care presupune modificări la nivelul claselor din proiect care se ocupă direct cu solicitarea informațiilor. Așa trebuia și Harap-Alb să separe nisipul de mac...
Pe de altă parte, conectarea unui microserviciu la toate nodurile unei baze de date ridică mai multe probleme:
Cum evităm situațiile de tip deadlock, când se intenționează efectuarea unor modificări asupra acelorași date de către VM-uri diferite, pe noduri diferite?
Cum va decide sistemul la care nod să se conecteze, dintre cele trei?
Mai mult ca sigur, există și alți factori pe care nu i-am luat în calcul sau nu i-am detaliat, dar asta nu face altceva decât să denote lipsa importanței acestora. Chestiunile arzătoare au fost mai mult ca sigur întoarse pe toate părțile, fiți fără grijă.
Așadar, luând în calcul ecosistemul în care rulează Micron și opțiunile de care dispunem, vom recurge la modificări atât în codul microserviciului - se implementează un mecanism de retry pentru a face posibilă reîncercarea executării tuturor operațiilor de scriere în baza de date în cazul în care se produc conflicte - cât și în afara acestuia. Cu toate că riscăm ca, prin logica adițională, să înrăutățim viteza sistemului, se consideră că situațiile în care apar conflicte vor fi rare, iar integritatea datelor și lipsa erorilor sunt mai importante decât atingerea timpului de răspuns minim posibil.
Cu privire la problema folosirii tuturor nodurilor MySQL disponibile, există posibilitatea de a suprascrie, pentru fiecare VM, proprietățile referitoare la URL-ul sursei de date. Astfel, știind că avem 6 mașini virtuale și 3 noduri disponibile, intenționăm ca un nod să fie folosit de către două VM-uri. Deși această soluție nu garantează stabilitate permanentă sau balansarea perfectă a numărului de cereri trimise spre fiecare nod, este un pas important în direcția potrivită și nu prezintă aceeași complexitate pe care ar avea-o, spre exemplu, crearea unui algoritm de decizie la nivel de codebase.
Pentru a aborda cel de-al treilea aspect problematic, se optează pentru scoaterea din uz a proxy-ului actual și crearea unuia nou, de tip NGINX, care va rula în Kubernetes și se va comporta ca un demultiplexor. Acesta va asigura existența sesiunilor "lipicioase" atât între client și unul dintre pod-urile sale (sticky session cookies la nivel de Ingress), cât și mai departe, între sine și unul dintre VM-uri. Algoritmul de distribuție folosit este de tip hashing - se bazează pe IP-ul atașat cererii, împreună cu alte date relevante - și redirecționează cererile care vin de la client către una dintre mașinile virtuale în funcție de codul rezultat. Drept urmare, cererile care provin din același loc vor merge spre aceeași destinație în cadrul aceleiași sesiuni. Odată cu alegerea utilizării unui asemenea software, se ocolește riscul pe care l-ar fi presupus schimbarea algoritmului de distribuție în cadrul proxy-ului existent, adică necesitatea actualizării acestuia, după șase ani în care a fost ignorat. Nu s-a dorit trezirea ursului, ca să putem ajunge la salățile prețioase din grădina lui.
Ordinea în care s-au adus modificări serviciului nu a fost una întâmplătoare. Mai întâi, s-au implementat schimbările în codul lui Micron, cele legate de problema N+1 și de mecanismul de retry. În a doua fază, s-a creat noul proxy, iar cea de-a treia etapă a fost reprezentată de redirecționarea VM-urilor microserviciului către toate nodurile bazei de date. S-au efectuat teste de performanță la sfârșitul fiecărei etape, urmărindu-se comportamentul sistemului în fiecare situație. Continuând paralela cu Harap-Alb, Micron a trebuit să treacă prin diverse probe de foc până la atingerea finalului fericit...
Datele rezultate în urma fiecărui test de anduranță (rulat pe parcursul a 8 ore, cu un număr de 3810 utilizatori în total) se pot regăsi în tabelul de mai jos.
Software testat | Timp mediu de răspuns (ms) | Procentaj + număr total de erori de tip 500 | Număr total de cereri (mil) |
---|---|---|---|
Micron - varianta inițială + proxy | 67.66 | 0.00001% (924) | 54.29 |
inițial + 1 nod MySQL | |||
Micron - varianta finală + proxy | 39.84 | 0% (0) | 54.32 |
inițial + 1 nod MySQL | |||
Micron - varianta finală + NGINX + 1 | 38.26 | 0% (0) | 54.32 |
nod MySQL | |||
Micron - varianta finală + NGINX + 3 | 30.51 | 0% (0) | 54.34 |
noduri MySQL |
Se observă cu ușurință faptul că îmbunătățirea cea mai mare a venit prin intermediul ajustărilor din codebase, în prima fază de testare. Pe lângă scăderea timpului mediu de răspuns cu un răsunător 41%, s-a reușit anularea totală a erorilor de server. Cu toate acestea, o comparație mai detaliată a relevat faptul că unele API-uri, mai ales cele care execută operații de scriere în baza de date, au avut un timp de răspuns mai slab decât cel obținut în testul inițial. Acest fapt denotă caracterul de "rău necesar" al mecanismului de retry, compromisul făcut în detrimentul vitezei, pentru a scădea procentajul de erori.
A doua tură de teste, rulate, de această dată, împreună cu noul demultiplexor a avut rezultate asemănătoare celor din cazul precedent. Totuși, efectul scontat a fost atins, testul dovedind capacitatea proxy-ului de a-și îndeplini cu brio atribuțiile, fără a produce vreo defecțiune. Deși distribuția requesturilor către VM-uri nu a fost perfect balansată din cauza faptului că motoarele de testare au IP-uri apropiate care se încadrează, de cele mai multe ori, în aceeași categorie, fiecare dintre cele șase mașini virtuale pe care rulează microserviciul a funcționat la o capacitate de maximum 50%. Astfel, se poate spune cu încredere că, în situații reale de folosire a platformei, nu se va ajunge la suprasolicitarea serviciului pe niciuna dintre instanțe.
Ultima rundă de teste de anduranță la care a fost supus Micron a fost "cireașa de pe tort", combinând toate eforturile depuse până atunci. Prin distribuirea încărcăturii pe toate cele trei noduri de MySQL, s-a obținut un timp de răspuns cu 54.9% (!!!) mai bun decât în primul test și cu 20.5% mai bun decât în cea mai recentă etapă de testare. Cea mai mare realizare a fost, însă, reducerea nivelului de utilizare a procesorului alocat bazei de date, de la 100% pe singurul nod folosit în situația inițială, la aproximativ 30% pe fiecare dintre cele trei noduri.
Prințul s-a căsătorit cu prințesa, unind astfel cele două regate, și au trăit fericiți până la adânci bătrâneți. Sau, cel puțin, o variantă relativ asemănătoare, care se poate aplica în domeniul tehnologiei:
Ca urmare a testelor de performanță cu rezultate excelente, s-a decis lansarea noii variante a serviciului, alături de toate configurările adiționale. Chiar dacă în release s-a mai împotmolit pe alocuri și a dat emoții celor prezenți, Micron a rezistat intemperiilor și a primit cu succes un update de versiune. De atunci și până în ziua de astăzi, nu au existat incidente legate de suprasolicitarea bazei de date sau de pierderea integrității datelor. Din partea clienților nu au mai existat plângeri, iar platforma nu mai necesită refreshuri ocazionale.
Și-am încălecat pe-o șa, și v-am spus povestea așa. Și-am încălecat pe-o căpșună, și v-am spus o mare minciună...