TSM - Resilience

Lucian Condescu - Java Developer @ NTT DATA Romania

Caracteristica principală a unui sistem distribuit o reprezintă numărul mare de componente independente care colaborează în vederea obținerii unor funcționalități comune. Comunicarea între ele se realizează, în general, prin intermediul rețelei (network). Pe lângă numeroasele avantaje, o astfel de abordare aduce de la sine și o serie de probleme. Cea mai importantă dintre ele și cea pe care o voi analiza în acest articol, este gestionarea eficientă a situațiilor în care una sau mai multe componente distribuite nu mai funcționează sau funcționează deficitar.

Altfel spus, voi prezenta câteva patternuri care fac ca un sistem software să fie cu adevărat rezistent (resilient) la diferitele tipuri de eșecuri (failures) ce pot apărea și să rămână în picioare până în repriza a 12-a, asemenea unui pugilist.

Referință imagine

Într-un sistem distribuit, cel mai des întâlnite probleme sunt problemele cauzate de rețea. Ele pot apărea în orice moment și pot fi determinate de defecțiuni hardware/software, atacuri de securitate, configurarea greșită a device-urilor precum și multe alte cauze. Există însă și alte moduri în care o componentă distribuită poate eșua. Fie că vorbim despre eșecuri cauzate de timpi foarte mari de răspuns, fie despre răspunsuri eronate sau invalide, din perspectiva clientului unei componente sau al unui serviciu, lucrurile nu sunt roz.

În consecință, ne punem întrebarea cum am putea dezvolta o aplicație într-un mod în care aceasta să fie rezistentă la multitudinea de situații excepționale ce pot apărea, fără ca utilizatorul final să sesizeze (ideal) sau fără a-i degrada în mod dramatic experiența. Mi-am propus să vorbesc în continuare despre câteva mijloace de protecție și de răspuns care ne ajută în tratarea comportamentelor neprevăzute dintr-un sistem distribuit și care oferă aplicației noastre un mai mare grad de rezistență (resilience) și de stabilitate.

Timeout

Poate cel mai simplu și totodată cel mai neglijat mecanism de protecție este folosirea timeout-urilor. Un serviciu nu ar trebui să aștepte la nesfârșit rezultatul unei operații ce implică apeluri de rețea, în schimb ar trebui să renunțe la request după o perioadă rezonabilă de timp. Întrebarea firească care se pune este: ce ar trebui să se întâmple în continuare ? O variantă ar fi integrarea unui mecanism de retry care să repete operația precedentă. Din păcate, în cazul problemelor de rețea mai sus menționate, aceasta nu ajută, pentru că de cele mai multe ori acestea nu se rezolvă de la sine, ci necesită timp sau intervenții. O altă variantă ar putea fi adăugarea operației într-o coadă de așteptare pentru o procesare ulterioară și notificarea utilizatorului în acest sens. Cum probabil v-ați dat seama, o astfel de abordare funcționează doar pentru anumite tipuri de servicii, spre exemplu tranzacții bancare sau servicii de mesagerie. Pentru altele, cea mai bună variantă este întoarcerea unui comportament de fallback.

Fallbackul a fost introdus ca o alternativă la mecanismele de retry și queue, acolo unde nici unul din cele două nu se pretează. Poate fi văzut ca un comportament default folosit atunci când un serviciu eșuează. Trebuie să fim însă foarte atenți la alegerea fallbackului potrivit pentru un serviciu. Există situații în care singurul comportament rezonabil ar putea fi întoarcerea unui mesaj de eroare către utilizator (real-time systems) dar și situații în care un fallback potrivit ar putea trece neobservat din perspectiva utilizatorului final. Spre exemplu, în cazul unei aplicații de comerț online, un fallback potrivit pentru serviciul de autentificare ar putea fi întoarcerea un utilizator guest, ceea ce va face ca experiența clientului să nu aibă prea mult de suferit, acesta putând în continuare să facă cumpărături.

Fail Fast

Ce poate fi mai frustrant decât așteptarea îndelungată după un serviciu care întârzie să furnizeze datele solicitate ? Așteptarea îndelungată cumulată cu funcționarea improprie sau cu aflarea că date introduse de noi sunt incorecte. E ca și cum am aștepta foarte mult timp la un ghișeu pentru a descoperi că ne lipsește un formular. Nu putea să ne spună cineva asta, înainte să ne punem la coadă ?

Pe același principiu funcționează și patternul Fail Fast. Ideal ar fi că dacă un serviciu urmează să eșueze într-un mod sau altul, ar trebui să o facă cât mai repede cu putință, evitând folosirea inutilă a resurselor sistemului și evitând răspunsurile lente. Pentru cele mai multe din cazuri, Fail Fast poate fi implementat adăugând un nivel de validare a datelor de intrare în fața operațiilor costisitoare din punct de vedere al consumului de resurse (uzual, apeluri de servicii externe). Aceasta va crește probabilitatea ca operația să reușească și să nu fie executată în zadar. Dar ce se întâmplă dacă dintr-un motiv sau altul, serviciul apelat nu funcționează sau funcționează deficitar ? La un moment dat, cineva ar trebui să oprească apelurile inutile și să eșueze rapid. Aici intervine circuit breakerul.

Circuit Breaker

Conceptul de circuit breaker provine din ingineria electrică, modul de funcționare fiind relativ simplu: în momentul în care apare o problemă în rețeaua electrică (supra-sarcini sau scurtcircuite), circuit breakerul întrerupe curentul prin respectivul circuit, evitând compromiterea întregii rețele electrice sau a consumatorilor finali.

Într-un sistem software, circuit breakerul are rolul de a monitoriza operațiile ce implică apeluri de rețea și de a interveni acolo unde este cazul. De îndată ce un serviciu extern a atins un anumit număr de apelări considerate eșuate, circuit breakerul ajunge la concluzia că mai mult ca sigur serviciul nu mai este funcțional și oprește apelurile ulterioare, întorcând un comportament de fallback. În unele situații, aceasta ajută și serviciul respectiv să se recupereze, neîncărcându-l cu requesturi suplimentare, sortite eșecului. Dar avantajul major e dat de faptul că performanța generală a aplicației noastre, care altfel ar fi consumat inutil resurse, nu va avea de suferit.

Un circuit breaker are trei stări posibile: Închis, Deschis și Parțial-Deschis.

În practică, se recomandă folosirea a câte unui circuit breaker pentru toate interacțiunile între componentele unui sistem distribuit ce implică rețeaua. Citirea dintr-o bază de date, apelarea unui serviciu REST, chiar și apelarea unui alt serviciu dezvoltat tot de noi, toate acestea sunt situații în care un circuit breaker ne-ar putea salva aplicația de la o prăbușire generală.

Bulkhead

Iar dacă tot vorbim de lucruri ce ne-ar putea salva de la o prăbușire în cascadă a componentelor dintr-un sistem distribuit, merită să menționăm și patternul Bulkhead. Conceptul de bulkhead provine din ingineria navală și presupune împărțirea navelor/bărcilor în mai multe zone independente, astfel încât o eventuală fisură să nu compromită întreaga navă, ci doar un anumit compartiment, nava fiind încă funcțională (într-o oarecare măsură).

Extrapolând la software, folosirea bulkheadurilor ne ajută să compartimentăm o aplicație într-un mod în care un serviciu ce încetează să mai funcționeze corespunzător, să nu influențeze funcționalitatea restului de servicii. Cu alte cuvinte, dorim ca un eșec al unui anumit serviciu să nu se propage în întreg sistemul și să producă comportamente neașteptate ale altor servicii dar și a aplicației ca întreg.

În mod uzual, bulkheadul se implementează creând pentru fiecare serviciu câte un thread pool separat. Dacă o funcționalitate e supusă unui număr foarte mare de requesturi sau din diferite motive devine indisponibilă, doar thread poolul asociat va fi afectat, restul serviciilor funcționând la parametri normali. Presupunând că un serviciu depinde de alte două servicii iar unul din ele începe să funcționeze mai greoi, utilizarea unui singur thread pool va produce în cele din urmă și o degradare a rapidității funcționalităților puse la dispoziție de celălalt.

Concluzie

În contextul în care software-ul din ziua de azi se bazează din ce în ce mai mult pe componente distribuite, patternurile și bunele practici prezentate mai sus, devin extrem de importante. Aplicate, ele ne pot oferi siguranța că aplicația noastră va continua să funcționeze chiar și în condiții diferite față de cele ideale.

Bibliografie:

  1. https://www.slideshare.net/ufried/patterns-of-resilience

  2. Michael T. Nygard, Release It!

  3. https://martinfowler.com/bliki/CircuitBreaker.html

  4. https://docs.microsoft.com/en-us/azure/architecture/patterns/bulkhead

  5. https://www.javaworld.com/article/2824163/application-performance/stability-patterns-applied-in-a-restful-architecture.html?page=2