Modele arhitecturale convenabile

Hei Habr!

În lumina evenimentelor curente din cauza coronavirusului, o serie de servicii de internet au început să primească o sarcină crescută. De exemplu, Unul dintre lanțurile de retail din Marea Britanie și-a oprit pur și simplu site-ul de comenzi online., pentru că nu era suficientă capacitate. Și nu este întotdeauna posibil să accelerați un server prin simpla adăugare de echipamente mai puternice, dar cererile clienților trebuie procesate (sau vor ajunge la concurenți).

În acest articol voi vorbi pe scurt despre practici populare care vă vor permite să creați un serviciu rapid și tolerant la erori. Totuși, dintre posibilele scheme de dezvoltare, le-am selectat doar pe cele care sunt în prezent ușor de folosit. Pentru fiecare articol, fie aveți biblioteci gata făcute, fie aveți posibilitatea de a rezolva problema folosind o platformă cloud.

Scalare orizontală

Cel mai simplu și mai cunoscut punct. În mod convențional, cele mai comune două scheme de distribuție a sarcinii sunt scalarea orizontală și verticală. În primul caz permiteți serviciilor să ruleze în paralel, distribuind astfel sarcina între ele. În al doilea comandați servere mai puternice sau optimizați codul.

De exemplu, voi lua stocarea fișierelor în cloud abstracte, adică un analog de OwnCloud, OneDrive și așa mai departe.

O imagine standard a unui astfel de circuit este mai jos, dar demonstrează doar complexitatea sistemului. La urma urmei, trebuie să sincronizăm cumva serviciile. Ce se întâmplă dacă utilizatorul salvează un fișier de pe tabletă și apoi dorește să-l vizualizeze de pe telefon?

Modele arhitecturale convenabile
Diferența dintre abordări: în scalarea verticală, suntem gata să creștem puterea nodurilor, iar în scalarea orizontală, suntem gata să adăugăm noi noduri pentru a distribui sarcina.

CQRS

Segregarea responsabilității de interogare de comandă Un model destul de important, deoarece permite clienților diferiți nu numai să se conecteze la servicii diferite, ci și să primească aceleași fluxuri de evenimente. Beneficiile sale nu sunt atât de evidente pentru o aplicație simplă, dar este extrem de important (și simplu) pentru un serviciu aglomerat. Esența sa: fluxurile de date de intrare și de ieșire nu ar trebui să se intersecteze. Adică, nu puteți trimite o solicitare și așteptați un răspuns; în schimb, trimiteți o solicitare către serviciul A, dar primiți un răspuns de la serviciul B.

Primul bonus al acestei abordări este capacitatea de a întrerupe conexiunea (în sensul larg al cuvântului) în timp ce se execută o cerere lungă. De exemplu, să luăm o secvență mai mult sau mai puțin standard:

  1. Clientul a trimis o cerere către server.
  2. Serverul a început un timp lung de procesare.
  3. Serverul a răspuns clientului cu rezultatul.

Să ne imaginăm că la punctul 2 s-a întrerupt conexiunea (sau s-a reconectat rețeaua, sau utilizatorul a mers pe altă pagină, rupând conexiunea). În acest caz, serverul va fi dificil să trimită utilizatorului un răspuns cu informații despre ce anume a fost procesat. Folosind CQRS, secvența va fi ușor diferită:

  1. Clientul s-a abonat la actualizări.
  2. Clientul a trimis o cerere către server.
  3. Serverul a răspuns „solicitare acceptată”.
  4. Serverul a răspuns cu rezultatul prin canalul de la punctul „1”.

Modele arhitecturale convenabile

După cum puteți vedea, schema este puțin mai complicată. Mai mult, abordarea intuitivă cerere-răspuns lipsește aici. Cu toate acestea, după cum puteți vedea, o întrerupere a conexiunii în timpul procesării unei cereri nu va duce la o eroare. Mai mult, dacă de fapt utilizatorul este conectat la serviciu de pe mai multe dispozitive (de exemplu, de pe un telefon mobil și de pe o tabletă), te poți asigura că răspunsul vine la ambele dispozitive.

Interesant este că codul de procesare a mesajelor primite devine același (nu 100%) atât pentru evenimentele care au fost influențate de client însuși, cât și pentru alte evenimente, inclusiv cele de la alți clienți.

Cu toate acestea, în realitate obținem un bonus suplimentar datorită faptului că fluxul unidirecțional poate fi gestionat într-un stil funcțional (folosind RX și similar). Și acesta este deja un plus serios, deoarece, în esență, aplicația poate fi făcută complet reactivă și, de asemenea, folosind o abordare funcțională. Pentru programele grase, acest lucru poate economisi semnificativ resursele de dezvoltare și sprijin.

Dacă combinăm această abordare cu scalarea orizontală, atunci ca bonus obținem posibilitatea de a trimite cereri către un server și de a primi răspunsuri de la altul. Astfel, clientul poate alege serviciul care îi este convenabil, iar sistemul din interior va putea în continuare să proceseze corect evenimentele.

Aprovizionare pentru evenimente

După cum știți, una dintre principalele caracteristici ale unui sistem distribuit este absența unui timp comun, a unei secțiuni critice comune. Pentru un proces, puteți face o sincronizare (pe aceleași mutexuri), în cadrul căreia sunteți sigur că nimeni altcineva nu execută acest cod. Cu toate acestea, acest lucru este periculos pentru un sistem distribuit, deoarece va necesita supraîncărcare și, de asemenea, va distruge toată frumusețea scalării - toate componentele vor aștepta în continuare una.

De aici obținem un fapt important - un sistem rapid distribuit nu poate fi sincronizat, pentru că atunci vom reduce performanța. Pe de altă parte, de multe ori avem nevoie de o anumită consistență între componente. Și pentru asta poți folosi abordarea cu eventuala consistenta, unde este garantat că, dacă nu există modificări ale datelor pentru o anumită perioadă de timp după ultima actualizare („eventual”), toate interogările vor returna ultima valoare actualizată.

Este important de înțeles că pentru bazele de date clasice este destul de des folosit consistenta puternica, unde fiecare nod are aceleași informații (acest lucru se realizează adesea în cazul în care tranzacția este considerată stabilită doar după ce al doilea server răspunde). Există aici câteva relaxări din cauza nivelurilor de izolare, dar ideea generală rămâne aceeași - poți trăi într-o lume complet armonizată.

Cu toate acestea, să revenim la sarcina inițială. Dacă o parte a sistemului poate fi construită cu eventuala consistenta, atunci putem construi următoarea diagramă.

Modele arhitecturale convenabile

Caracteristici importante ale acestei abordări:

  • Fiecare cerere primită este plasată într-o singură coadă.
  • În timpul procesării unei cereri, serviciul poate plasa sarcini și în alte cozi.
  • Fiecare eveniment primit are un identificator (care este necesar pentru deduplicare).
  • Coada funcționează ideologic conform schemei „numai adăugați”. Nu puteți elimina elemente din acesta sau le rearanja.
  • Coada funcționează conform schemei FIFO (scuze pentru tautologie). Dacă trebuie să faceți execuție paralelă, atunci la o etapă ar trebui să mutați obiectele în cozi diferite.

Permiteți-mi să vă reamintesc că luăm în considerare cazul stocării de fișiere online. În acest caz, sistemul va arăta cam așa:

Modele arhitecturale convenabile

Este important ca serviciile din diagramă să nu însemne neapărat un server separat. Chiar și procesul poate fi același. Un alt lucru este important: ideologic, aceste lucruri sunt separate în așa fel încât scalarea orizontală să poată fi aplicată cu ușurință.

Și pentru doi utilizatori diagrama va arăta astfel (serviciile destinate diferiților utilizatori sunt indicate în culori diferite):

Modele arhitecturale convenabile

Bonusuri dintr-o astfel de combinație:

  • Serviciile de prelucrare a informațiilor sunt separate. Cozile sunt de asemenea separate. Dacă trebuie să creștem debitul sistemului, atunci trebuie doar să lansăm mai multe servicii pe mai multe servere.
  • Când primim informații de la un utilizator, nu trebuie să așteptăm până când datele sunt complet salvate. Dimpotrivă, trebuie doar să răspundem „ok” și apoi să începem treptat să lucrăm. În același timp, coada netezește vârfurile, deoarece adăugarea unui nou obiect are loc rapid, iar utilizatorul nu trebuie să aștepte o trecere completă prin întregul ciclu.
  • De exemplu, am adăugat un serviciu de deduplicare care încearcă să îmbine fișiere identice. Dacă funcționează timp îndelungat în 1% din cazuri, clientul cu greu îl va observa (vezi mai sus), ceea ce este un mare plus, deoarece nu mai suntem obligați să avem viteză și fiabilitate sută la sută.

Cu toate acestea, dezavantajele sunt imediat vizibile:

  • Sistemul nostru și-a pierdut consistența strictă. Aceasta înseamnă că dacă, de exemplu, vă abonați la servicii diferite, atunci teoretic puteți obține o stare diferită (din moment ce unul dintre servicii poate să nu aibă timp să primească o notificare din coada internă). Ca o altă consecință, sistemul nu are acum un timp comun. Adică, este imposibil, de exemplu, să sortați toate evenimentele doar după ora de sosire, deoarece ceasurile dintre servere pot să nu fie sincrone (mai mult, aceeași oră pe două servere este o utopie).
  • Niciun eveniment nu poate fi acum pur și simplu anulat (așa cum s-ar putea face cu o bază de date). În schimb, trebuie să adăugați un nou eveniment − eveniment de compensare, care va schimba ultima stare în cea necesară. Ca exemplu dintr-o zonă similară: fără a rescrie istoricul (ceea ce este rău în unele cazuri), nu puteți derula înapoi un commit în git, dar puteți face o specială rollback commit, care în esență doar returnează starea veche. Cu toate acestea, atât comiterea eronată, cât și rollback-ul vor rămâne în istorie.
  • Schema de date se poate modifica de la o ediție la alta, dar evenimentele vechi nu vor mai putea fi actualizate la noul standard (deoarece evenimentele nu pot fi modificate în principiu).

După cum puteți vedea, Event Sourcing funcționează bine cu CQRS. Mai mult, implementarea unui sistem cu cozi eficiente și convenabile, dar fără a separa fluxurile de date, este deja dificilă în sine, deoarece va trebui să adăugați puncte de sincronizare care să neutralizeze întregul efect pozitiv al cozilor. Aplicând ambele abordări simultan, este necesar să ajustați ușor codul programului. În cazul nostru, atunci când trimiteți un fișier către server, răspunsul vine doar „ok”, ceea ce înseamnă doar că „operația de adăugare a fișierului a fost salvată”. Formal, acest lucru nu înseamnă că datele sunt deja disponibile pe alte dispozitive (de exemplu, serviciul de deduplicare poate reconstrui indexul). Cu toate acestea, după ceva timp, clientul va primi o notificare în stilul „fișierul X a fost salvat”.

Ca urmare:

  • Numărul de stări de trimitere a fișierelor este în creștere: în loc de clasicul „fișier trimis”, obținem două: „fișierul a fost adăugat în coada de pe server” și „fișierul a fost salvat în stocare”. Aceasta din urmă înseamnă că alte dispozitive pot începe deja să primească fișierul (ajustat pentru faptul că cozile funcționează la viteze diferite).
  • Datorită faptului că informațiile transmise acum vin prin diferite canale, trebuie să venim cu soluții pentru a primi starea de procesare a dosarului. Ca o consecință a acestui fapt: spre deosebire de cererea-răspuns clasic, clientul poate fi repornit în timpul procesării fișierului, dar starea acestei procesări în sine va fi corectă. Mai mult, acest articol funcționează, în esență, din cutie. Drept consecință: acum suntem mai toleranți la eșecuri.

Sharding

După cum este descris mai sus, sistemelor de aprovizionare cu evenimente le lipsește o consistență strictă. Aceasta înseamnă că putem folosi mai multe stocări fără nicio sincronizare între ele. Abordând problema noastră, putem:

  • Separați fișierele după tip. De exemplu, imaginile/videoclipurile pot fi decodate și poate fi selectat un format mai eficient.
  • Conturi separate în funcție de țară. Din cauza multor legi, acest lucru poate fi necesar, dar această schemă de arhitectură oferă automat o astfel de oportunitate

Modele arhitecturale convenabile

Dacă doriți să transferați date dintr-un spațiu de stocare în altul, mijloacele standard nu mai sunt suficiente. Din păcate, în acest caz, trebuie să opriți coada, să faceți migrarea și apoi să o porniți. În cazul general, datele nu pot fi transferate „din zbor”, totuși, dacă coada de evenimente este stocată complet și aveți instantanee ale stărilor anterioare de stocare, atunci putem reda evenimentele după cum urmează:

  • În Event Source, fiecare eveniment are propriul său identificator (ideal, nedescrescător). Aceasta înseamnă că putem adăuga un câmp la stocare - id-ul ultimului element procesat.
  • Duplicăm coada astfel încât toate evenimentele să poată fi procesate pentru mai multe stocări independente (prima este cea în care datele sunt deja stocate, iar a doua este nouă, dar încă goală). A doua coadă, desigur, nu este încă procesată.
  • Lansăm a doua coadă (adică începem reluarea evenimentelor).
  • Când noua coadă este relativ goală (adică diferența de timp medie dintre adăugarea unui element și preluarea acestuia este acceptabilă), puteți începe să comutați cititorii la noua stocare.

După cum puteți vedea, nu am avut și încă nu avem o consistență strictă în sistemul nostru. Există doar o eventuală consistență, adică o garanție că evenimentele sunt procesate în aceeași ordine (dar eventual cu întârzieri diferite). Și, folosind aceasta, putem transfera relativ ușor date fără a opri sistemul în cealaltă parte a globului.

Astfel, continuând exemplul nostru despre stocarea online pentru fișiere, o astfel de arhitectură ne oferă deja o serie de bonusuri:

  • Putem muta obiectele mai aproape de utilizatori într-un mod dinamic. În acest fel, puteți îmbunătăți calitatea serviciilor.
  • Este posibil să stocăm unele date în cadrul companiilor. De exemplu, utilizatorii Enterprise solicită adesea ca datele lor să fie stocate în centre de date controlate (pentru a evita scurgerile de date). Prin sharding putem susține cu ușurință acest lucru. Și sarcina este și mai ușoară dacă clientul are un cloud compatibil (de exemplu, Azure auto-găzduit).
  • Și cel mai important lucru este că nu trebuie să facem asta. La urma urmei, pentru început, am fi destul de mulțumiți de un spațiu de stocare pentru toate conturile (pentru a începe să lucrăm rapid). Și caracteristica cheie a acestui sistem este că, deși este extensibil, în stadiul inițial este destul de simplu. Pur și simplu nu trebuie să scrieți imediat cod care funcționează cu un milion de cozi independente separate etc. Dacă este necesar, acest lucru se poate face în viitor.

Găzduire de conținut static

Acest punct poate părea destul de evident, dar este totuși necesar pentru o aplicație încărcată mai mult sau mai puțin standard. Esența sa este simplă: tot conținutul static este distribuit nu de pe același server pe care se află aplicația, ci de la unele speciale dedicate special acestei sarcini. Drept urmare, aceste operațiuni sunt efectuate mai rapid (nginx condiționat servește fișierele mai rapid și mai puțin costisitor decât un server Java). Plus arhitectura CDN (Content Delivery Network) ne permite să ne localizăm fișierele mai aproape de utilizatorii finali, ceea ce are un efect pozitiv asupra confortului de a lucra cu serviciul.

Cel mai simplu și cel mai standard exemplu de conținut static este un set de scripturi și imagini pentru un site web. Totul este simplu cu ele - sunt cunoscute dinainte, apoi arhiva este încărcată pe serverele CDN, de unde sunt distribuite utilizatorilor finali.

Cu toate acestea, în realitate, pentru conținut static, puteți utiliza o abordare oarecum similară cu arhitectura lambda. Să revenim la sarcina noastră (stocare online de fișiere), în care trebuie să distribuim fișiere utilizatorilor. Cea mai simplă soluție este să facem un serviciu care, pentru fiecare cerere de utilizator, face toate verificările necesare (autorizare etc.), iar apoi să descarce fișierul direct din stocarea noastră. Principalul dezavantaj al acestei abordări este că conținutul static (și un fișier cu o anumită revizuire este, de fapt, conținut static) este distribuit de același server care conține logica de business. În schimb, puteți face următoarea diagramă:

  • Serverul oferă o adresă URL de descărcare. Poate avea forma file_id + key, unde key este o mini-semnătură digitală care dă dreptul de acces la resursă pentru următoarele XNUMX de ore.
  • Fișierul este distribuit de nginx simplu cu următoarele opțiuni:
    • Memorarea în cache a conținutului. Deoarece acest serviciu poate fi localizat pe un server separat, ne-am lăsat o rezervă pentru viitor cu posibilitatea de a stoca pe disc toate cele mai recente fișiere descărcate.
    • Verificarea cheii în momentul creării conexiunii
  • Opțional: procesarea conținutului în flux. De exemplu, dacă comprimăm toate fișierele din serviciu, atunci putem face dezarhivarea direct în acest modul. Ca o consecință: operațiunile IO sunt efectuate acolo unde le este locul. Un arhivator în Java va aloca cu ușurință multă memorie suplimentară, dar rescrierea unui serviciu cu logica de afaceri în condițiile Rust/C++ poate fi, de asemenea, ineficientă. În cazul nostru, sunt utilizate diferite procese (sau chiar servicii) și, prin urmare, putem separa destul de eficient logica de afaceri și operațiunile IO.

Modele arhitecturale convenabile

Această schemă nu seamănă prea mult cu distribuirea conținutului static (din moment ce nu încărcăm undeva întregul pachet static), dar, în realitate, această abordare se preocupă tocmai de distribuirea datelor imuabile. Mai mult, această schemă poate fi generalizată și la alte cazuri în care conținutul nu este pur și simplu static, ci poate fi reprezentat ca un set de blocuri imuabile și neștergibile (deși pot fi adăugate).

Ca un alt exemplu (pentru consolidare): dacă ați lucrat cu Jenkins/TeamCity, atunci știți că ambele soluții sunt scrise în Java. Ambele sunt un proces Java care se ocupă atât de orchestrarea build-ului, cât și de managementul conținutului. În special, ambii au sarcini precum „transferă un fișier/dosar de pe server”. De exemplu: emiterea de artefacte, transferul codului sursă (când agentul nu descarcă codul direct din depozit, dar serverul o face pentru el), acces la jurnalele. Toate aceste sarcini diferă în ceea ce privește încărcarea lor IO. Adică, se dovedește că serverul responsabil pentru logica de afaceri complexă trebuie să poată, în același timp, să împingă efectiv fluxuri mari de date prin el însuși. Și ceea ce este cel mai interesant este că o astfel de operație poate fi delegată aceluiași nginx conform exact aceleiași scheme (cu excepția faptului că cheia de date ar trebui adăugată la cerere).

Cu toate acestea, dacă ne întoarcem la sistemul nostru, obținem o diagramă similară:

Modele arhitecturale convenabile

După cum puteți vedea, sistemul a devenit radical mai complex. Acum nu este doar un mini-proces care stochează fișierele local. Acum, ceea ce este necesar nu este cel mai simplu suport, controlul versiunii API etc. Prin urmare, după ce toate diagramele au fost desenate, cel mai bine este să evaluați în detaliu dacă extensibilitatea merită costul. Cu toate acestea, dacă doriți să puteți extinde sistemul (inclusiv să lucrați cu un număr și mai mare de utilizatori), atunci va trebui să alegeți soluții similare. Dar, ca rezultat, sistemul este pregătit din punct de vedere arhitectural pentru încărcare crescută (aproape fiecare componentă poate fi clonată pentru scalare orizontală). Sistemul poate fi actualizat fără a-l opri (pur și simplu unele operațiuni vor fi ușor încetinite).

După cum am spus la început, acum o serie de servicii de Internet au început să primească o sarcină crescută. Și unii dintre ei pur și simplu au început să nu mai funcționeze corect. De fapt, sistemele au eșuat tocmai în momentul în care afacerea trebuia să facă bani. Adică, în loc de livrare amânată, în loc să sugereze clienților „planificați-vă livrarea pentru lunile următoare”, sistemul a spus pur și simplu „mergeți la concurenții tăi”. De fapt, acesta este prețul productivității scăzute: pierderile vor avea loc exact atunci când profiturile ar fi cele mai mari.

Concluzie

Toate aceste abordări erau cunoscute înainte. Același VK folosește de multă vreme ideea de găzduire de conținut static pentru a afișa imagini. O mulțime de jocuri online folosesc schema Sharding pentru a împărți jucătorii în regiuni sau pentru a separa locațiile jocului (dacă lumea în sine este una). Abordarea Event Sourcing este utilizată în mod activ în e-mail. Majoritatea aplicațiilor de tranzacționare în care datele sunt primite în mod constant sunt construite de fapt pe o abordare CQRS pentru a putea filtra datele primite. Ei bine, scalarea orizontală a fost folosită în multe servicii de destul de mult timp.

Cu toate acestea, cel mai important, toate aceste modele au devenit foarte ușor de aplicat în aplicațiile moderne (dacă sunt adecvate, desigur). Cloud-urile oferă Sharding și scalare orizontală imediat, ceea ce este mult mai ușor decât să comandați singuri diferite servere dedicate în diferite centre de date. CQRS a devenit mult mai ușor, chiar dacă numai datorită dezvoltării bibliotecilor precum RX. Cu aproximativ 10 ani în urmă, un site web rar ar putea susține acest lucru. Event Sourcing este, de asemenea, incredibil de ușor de configurat datorită containerelor gata făcute cu Apache Kafka. Acum 10 ani ar fi fost o inovație, acum este un lucru obișnuit. La fel este și cu găzduirea de conținut static: datorită tehnologiilor mai convenabile (inclusiv faptului că există documentație detaliată și o bază de date mare de răspunsuri), această abordare a devenit și mai simplă.

Drept urmare, implementarea unui număr de modele arhitecturale destul de complexe a devenit acum mult mai simplă, ceea ce înseamnă că este mai bine să o priviți mai atent în prealabil. Dacă într-o aplicație veche de zece ani una dintre soluțiile de mai sus a fost abandonată din cauza costului ridicat de implementare și exploatare, acum, într-o aplicație nouă, sau după refactorizare, puteți crea un serviciu care va fi deja arhitectural atât extensibil ( în ceea ce privește performanța) și gata făcute la noile solicitări din partea clienților (de exemplu, pentru a localiza datele personale).

Și cel mai important: vă rugăm să nu utilizați aceste abordări dacă aveți o aplicație simplă. Da, sunt frumoase și interesante, dar pentru un site cu o vizită de vârf de 100 de persoane, te poți descurca adesea cu un monolit clasic (cel puțin la exterior, totul în interior poate fi împărțit în module etc.).

Sursa: www.habr.com

Adauga un comentariu