Praktiske arkitektoniske mønstre

Hej Habr!

I lyset af aktuelle begivenheder på grund af coronavirus er en række internettjenester begyndt at få øget belastning. For eksempel, En af de britiske detailkæder stoppede simpelthen sin online bestillingsside., fordi der ikke var kapacitet nok. Og det er ikke altid muligt at fremskynde en server ved blot at tilføje mere kraftfuldt udstyr, men klientanmodninger skal behandles (ellers vil de gå til konkurrenter).

I denne artikel vil jeg kort fortælle om populær praksis, der giver dig mulighed for at skabe en hurtig og fejltolerant service. Men fra de mulige udviklingsordninger valgte jeg kun dem, der er pt let at bruge. For hvert emne har du enten færdige biblioteker, eller du har mulighed for at løse problemet ved hjælp af en cloud-platform.

Horisontal skalering

Den enkleste og mest kendte pointe. Konventionelt er de mest almindelige to belastningsfordelingsskemaer vandret og lodret skalering. I det første tilfælde du tillader tjenester at køre parallelt og fordeler derved belastningen mellem dem. I den anden du bestiller mere kraftfulde servere eller optimerer koden.

For eksempel vil jeg tage abstrakt skyfillagring, det vil sige en analog af OwnCloud, OneDrive og så videre.

Et standardbillede af et sådant kredsløb er nedenfor, men det demonstrerer kun systemets kompleksitet. Vi skal trods alt på en eller anden måde synkronisere tjenesterne. Hvad sker der, hvis brugeren gemmer en fil fra tabletten og derefter vil se den fra telefonen?

Praktiske arkitektoniske mønstre
Forskellen mellem tilgangene: i vertikal skalering er vi klar til at øge nodernes kraft, og i horisontal skalering er vi klar til at tilføje nye noder for at fordele belastningen.

CQRS

Kommandoforespørgsel Ansvarsadskillelse Et ret vigtigt mønster, da det tillader forskellige klienter ikke kun at oprette forbindelse til forskellige tjenester, men også at modtage de samme hændelsesstrømme. Dens fordele er ikke så indlysende for en simpel applikation, men den er ekstremt vigtig (og enkel) for en travl tjeneste. Dens essens: indgående og udgående datastrømme bør ikke krydse hinanden. Det vil sige, at du ikke kan sende en anmodning og forvente et svar; i stedet sender du en anmodning til tjeneste A, men modtager et svar fra tjeneste B.

Den første bonus ved denne tilgang er evnen til at bryde forbindelsen (i ordets brede betydning), mens du udfører en lang anmodning. Lad os for eksempel tage en mere eller mindre standardsekvens:

  1. Klienten sendte en anmodning til serveren.
  2. Serveren startede en lang behandlingstid.
  3. Serveren svarede klienten med resultatet.

Lad os forestille os, at forbindelsen i punkt 2 blev afbrudt (eller netværket blev genoprettet, eller brugeren gik til en anden side og afbrød forbindelsen). I dette tilfælde vil det være svært for serveren at sende et svar til brugeren med information om, hvad der præcist blev behandlet. Ved at bruge CQRS vil rækkefølgen være lidt anderledes:

  1. Kunden har abonneret på opdateringer.
  2. Klienten sendte en anmodning til serveren.
  3. Serveren svarede "anmodning accepteret."
  4. Serveren svarede med resultatet gennem kanalen fra punkt "1".

Praktiske arkitektoniske mønstre

Som du kan se, er ordningen lidt mere kompliceret. Desuden mangler den intuitive anmodning-svar tilgang her. Men som du kan se, vil et forbindelsesbrud under behandlingen af ​​en anmodning ikke føre til en fejl. Desuden, hvis brugeren faktisk er forbundet til tjenesten fra flere enheder (for eksempel fra en mobiltelefon og fra en tablet), kan du sikre dig, at svaret kommer til begge enheder.

Interessant nok bliver koden til behandling af indgående beskeder den samme (ikke 100 %) både for hændelser, der var påvirket af klienten selv, og for andre hændelser, inklusive dem fra andre klienter.

Men i virkeligheden får vi en ekstra bonus på grund af det faktum, at ensrettet flow kan håndteres i en funktionel stil (ved hjælp af RX og lignende). Og dette er allerede et seriøst plus, da applikationen i det væsentlige kan gøres fuldstændig reaktiv og også ved hjælp af en funktionel tilgang. For fede programmer kan dette betydeligt spare udviklings- og supportressourcer.

Hvis vi kombinerer denne tilgang med horisontal skalering, så får vi som en bonus mulighed for at sende anmodninger til én server og modtage svar fra en anden. Dermed kan klienten vælge den service, der er praktisk for ham, og systemet inde vil stadig være i stand til at behandle hændelser korrekt.

Event sourcing

Som du ved, er et af hovedtrækkene i et distribueret system fraværet af en fælles tid, en fælles kritisk sektion. For én proces kan du lave en synkronisering (på de samme mutexes), inden for hvilken du er sikker på, at ingen andre udfører denne kode. Dette er dog farligt for et distribueret system, da det vil kræve overhead og vil også dræbe al skønheden ved skalering - alle komponenter vil stadig vente på en.

Herfra får vi en vigtig kendsgerning - et hurtigt distribueret system kan ikke synkroniseres, for så vil vi reducere ydeevnen. På den anden side har vi ofte brug for en vis sammenhæng mellem komponenter. Og til dette kan du bruge tilgangen med eventuel konsistens, hvor det er garanteret, at hvis der ikke er nogen dataændringer i et stykke tid efter den sidste opdatering ("til sidst"), vil alle forespørgsler returnere den sidst opdaterede værdi.

Det er vigtigt at forstå, at det bruges ret ofte til klassiske databaser stærk konsistens, hvor hver knude har den samme information (dette opnås ofte i det tilfælde, hvor transaktionen kun anses for etableret efter den anden server har reageret). Der er nogle lempelser her på grund af isolationsniveauerne, men den generelle idé forbliver den samme - du kan leve i en fuldstændig harmoniseret verden.

Men lad os vende tilbage til den oprindelige opgave. Hvis en del af systemet kan bygges med eventuel konsistens, så kan vi konstruere følgende diagram.

Praktiske arkitektoniske mønstre

Vigtige træk ved denne tilgang:

  • Hver indkommende anmodning placeres i én kø.
  • Under behandling af en anmodning kan tjenesten også placere opgaver i andre køer.
  • Hver indkommende hændelse har en identifikator (som er nødvendig for deduplikering).
  • Køen fungerer ideologisk efter "tilføj kun"-ordningen. Du kan ikke fjerne elementer fra den eller omarrangere dem.
  • Køen fungerer efter FIFO-ordningen (beklager tautologien). Hvis du har brug for at udføre parallel eksekvering, skal du på et tidspunkt flytte objekter til forskellige køer.

Lad mig minde dig om, at vi overvejer tilfældet med online fillagring. I dette tilfælde vil systemet se sådan ud:

Praktiske arkitektoniske mønstre

Det er vigtigt, at tjenesterne i diagrammet ikke nødvendigvis betyder en separat server. Selv processen kan være den samme. En anden ting er vigtig: ideologisk er disse ting adskilt på en sådan måde, at horisontal skalering let kan anvendes.

Og for to brugere vil diagrammet se sådan ud (tjenester beregnet til forskellige brugere er angivet i forskellige farver):

Praktiske arkitektoniske mønstre

Bonusser fra sådan en kombination:

  • Informationsbehandlingstjenester er adskilt. Køerne er også adskilt. Hvis vi skal øge systemgennemstrømningen, så skal vi bare lancere flere tjenester på flere servere.
  • Når vi modtager oplysninger fra en bruger, skal vi ikke vente, indtil dataene er fuldstændigt gemt. Tværtimod skal vi bare svare "ok" og så gradvist begynde at arbejde. Samtidig udglatter køen peaks, da tilføjelse af et nyt objekt sker hurtigt, og brugeren behøver ikke at vente på en komplet gennemgang gennem hele cyklussen.
  • Som et eksempel tilføjede jeg en deduplikeringstjeneste, der forsøger at flette identiske filer. Virker det i længere tid i 1% af tilfældene, vil klienten næsten ikke mærke det (se ovenfor), hvilket er et stort plus, da vi ikke længere er forpligtet til at være XNUMX% hurtige og pålidelige.

Men ulemperne er umiddelbart synlige:

  • Vores system har mistet sin strenge sammenhæng. Det betyder, at hvis du for eksempel abonnerer på forskellige tjenester, så kan du teoretisk set få en anden tilstand (da en af ​​tjenesterne måske ikke har tid til at modtage en notifikation fra den interne kø). Som en anden konsekvens har systemet nu ingen fælles tid. Det vil sige, at det for eksempel er umuligt at sortere alle hændelser blot efter ankomsttidspunkt, da urene mellem servere muligvis ikke er synkrone (desuden er den samme tid på to servere en utopi).
  • Ingen begivenheder kan nu blot rulles tilbage (som det kunne gøres med en database). I stedet skal du tilføje en ny begivenhed − erstatningsbegivenhed, hvilket vil ændre den sidste tilstand til den påkrævede. Som et eksempel fra et lignende område: uden at omskrive historien (hvilket er dårligt i nogle tilfælde), kan du ikke rulle en commit tilbage i git, men du kan lave en speciel rollback commit, som i det væsentlige bare returnerer den gamle tilstand. Imidlertid vil både den fejlagtige forpligtelse og tilbagerulningen forblive i historien.
  • Dataskemaet kan ændre sig fra udgivelse til udgivelse, men gamle hændelser vil ikke længere kunne opdateres til den nye standard (da hændelser i princippet ikke kan ændres).

Som du kan se, fungerer Event Sourcing godt med CQRS. Desuden er implementering af et system med effektive og bekvemme køer, men uden at adskille datastrømme, allerede vanskeligt i sig selv, fordi du bliver nødt til at tilføje synkroniseringspunkter, der vil neutralisere hele den positive effekt af køerne. Ved at anvende begge tilgange på én gang er det nødvendigt at justere programkoden lidt. I vores tilfælde, når du sender en fil til serveren, kommer svaret kun "ok", hvilket kun betyder, at "operationen med at tilføje filen blev gemt." Formelt betyder det ikke, at dataene allerede er tilgængelige på andre enheder (f.eks. kan deduplikeringstjenesten genopbygge indekset). Efter nogen tid vil klienten dog modtage en meddelelse i stil med "fil X er blevet gemt."

Som resultat:

  • Antallet af filafsendelsesstatusser er stigende: i stedet for den klassiske "fil sendt" får vi to: "filen er blevet tilføjet til køen på serveren" og "filen er blevet gemt på lager." Sidstnævnte betyder, at andre enheder allerede kan begynde at modtage filen (justeret for, at køerne kører med forskellige hastigheder).
  • På grund af det faktum, at indsendelsesoplysningerne nu kommer gennem forskellige kanaler, er vi nødt til at komme med løsninger for at modtage behandlingsstatus for filen. Som en konsekvens af dette: i modsætning til det klassiske anmodningssvar, kan klienten genstartes, mens filen behandles, men status for denne behandling i sig selv vil være korrekt. Desuden fungerer denne genstand i det væsentlige ud af æsken. Som en konsekvens: vi er nu mere tolerante over for fiaskoer.

sharding

Som beskrevet ovenfor mangler event sourcing-systemer streng konsistens. Det betyder, at vi kan bruge flere lagerpladser uden nogen synkronisering mellem dem. Når vi nærmer os vores problem, kan vi:

  • Adskil filer efter type. For eksempel kan billeder/videoer afkodes, og et mere effektivt format kan vælges.
  • Adskil konti efter land. På grund af mange love kan dette være påkrævet, men denne arkitekturordning giver en sådan mulighed automatisk

Praktiske arkitektoniske mønstre

Hvis du ønsker at overføre data fra et lager til et andet, er standardmidler ikke længere nok. Desværre skal du i dette tilfælde stoppe køen, udføre migreringen og derefter starte den. I det generelle tilfælde kan data ikke overføres "on the fly", men hvis begivenhedskøen er gemt fuldstændigt, og du har snapshots af tidligere lagringstilstande, så kan vi afspille begivenhederne som følger:

  • I Event Source har hver hændelse sin egen identifikator (ideelt set ikke-faldende). Det betyder, at vi kan tilføje et felt til lageret - id'et for det sidst behandlede element.
  • Vi duplikerer køen, så alle hændelser kan behandles for flere uafhængige lagre (den første er den, hvor dataene allerede er gemt, og den anden er ny, men stadig tom). Den anden kø er naturligvis ikke under behandling endnu.
  • Vi starter den anden kø (det vil sige, vi begynder at afspille begivenheder igen).
  • Når den nye kø er relativt tom (det vil sige den gennemsnitlige tidsforskel mellem tilføjelse af et element og hentning af det er acceptabelt), kan du begynde at skifte læsere til det nye lager.

Som du kan se, havde vi ikke, og har stadig ikke, streng konsistens i vores system. Der er kun en eventuel konsistens, det vil sige en garanti for, at begivenheder behandles i samme rækkefølge (men muligvis med forskellige forsinkelser). Og ved at bruge dette kan vi relativt nemt overføre data uden at stoppe systemet til den anden side af kloden.

Hvis vi fortsætter vores eksempel om online-lagring af filer, giver en sådan arkitektur os allerede en række bonusser:

  • Vi kan flytte objekter tættere på brugerne på en dynamisk måde. På denne måde kan du forbedre kvaliteten af ​​servicen.
  • Vi kan gemme nogle data i virksomheder. For eksempel kræver Enterprise-brugere ofte, at deres data opbevares i kontrollerede datacentre (for at undgå datalækager). Gennem sharding kan vi nemt understøtte dette. Og opgaven er endnu nemmere, hvis kunden har en kompatibel sky (f.eks. Azure selv hostet).
  • Og det vigtigste er, at vi ikke behøver at gøre dette. Til at begynde med ville vi trods alt være ret tilfredse med én lagerplads til alle konti (for at komme hurtigt i gang med at arbejde). Og det vigtigste ved dette system er, at selvom det kan udvides, er det i den indledende fase ret simpelt. Du behøver bare ikke straks at skrive kode, der fungerer med en million separate uafhængige køer osv. Om nødvendigt kan dette gøres i fremtiden.

Statisk indholdshosting

Dette punkt kan virke ret indlysende, men det er stadig nødvendigt for en mere eller mindre standardindlæst applikation. Dens essens er enkel: alt statisk indhold distribueres ikke fra den samme server, hvor applikationen er placeret, men fra specielle, der er dedikeret specifikt til denne opgave. Som et resultat udføres disse operationer hurtigere (betinget nginx serverer filer hurtigere og billigere end en Java-server). Plus CDN-arkitektur (Content Delivery Network) giver os mulighed for at lokalisere vores filer tættere på slutbrugerne, hvilket har en positiv effekt på bekvemmeligheden ved at arbejde med tjenesten.

Det enkleste og mest standardeksempel på statisk indhold er et sæt scripts og billeder til et websted. Alt er enkelt med dem – de er kendt i forvejen, derefter uploades arkivet til CDN-servere, hvorfra de distribueres til slutbrugere.

Men i virkeligheden, for statisk indhold, kan du bruge en tilgang, der ligner lambda-arkitektur. Lad os vende tilbage til vores opgave (online fillagring), hvor vi skal distribuere filer til brugerne. Den enkleste løsning er at oprette en service, der for hver brugeranmodning udfører alle de nødvendige kontroller (autorisation osv.), og derefter downloader filen direkte fra vores lager. Den største ulempe ved denne tilgang er, at statisk indhold (og en fil med en vis revision i virkeligheden er statisk indhold) distribueres af den samme server, som indeholder forretningslogikken. I stedet kan du lave følgende diagram:

  • Serveren giver en download-URL. Det kan være af formen file_id + key, hvor key er en mini-digital signatur, der giver ret til adgang til ressourcen i de næste XNUMX timer.
  • Filen distribueres af simpel nginx med følgende muligheder:
    • Caching af indhold. Da denne service kan være placeret på en separat server, har vi efterladt os en reserve for fremtiden med muligheden for at gemme alle de seneste downloadede filer på disken.
    • Kontrol af nøglen på tidspunktet for oprettelse af forbindelse
  • Valgfrit: streaming af indholdsbehandling. For eksempel, hvis vi komprimerer alle filer i tjenesten, så kan vi lave unzipping direkte i dette modul. Som en konsekvens: IO-operationer udføres, hvor de hører hjemme. En arkiver i Java vil nemt tildele en masse ekstra hukommelse, men at omskrive en tjeneste med forretningslogik til Rust/C++-betingelser kan også være ineffektivt. I vores tilfælde bruges forskellige processer (eller endda tjenester), og derfor kan vi ganske effektivt adskille forretningslogik og IO-drift.

Praktiske arkitektoniske mønstre

Denne ordning minder ikke meget om at distribuere statisk indhold (da vi ikke uploader hele den statiske pakke et eller andet sted), men i virkeligheden handler denne tilgang netop om at distribuere uforanderlige data. Desuden kan dette skema generaliseres til andre tilfælde, hvor indholdet ikke blot er statisk, men kan repræsenteres som et sæt af uforanderlige og ikke-slettelige blokke (selvom de kan tilføjes).

Som et andet eksempel (til forstærkning): hvis du har arbejdet med Jenkins/TeamCity, så ved du, at begge løsninger er skrevet i Java. Begge er en Java-proces, der håndterer både build-orkestrering og indholdsstyring. Især har de begge opgaver som "overfør en fil/mappe fra serveren." Som et eksempel: udstedelse af artefakter, overførsel af kildekode (når agenten ikke downloader koden direkte fra lageret, men serveren gør det for ham), adgang til logfiler. Alle disse opgaver adskiller sig i deres IO-belastning. Det vil sige, at det viser sig, at den server, der er ansvarlig for kompleks forretningslogik, samtidig skal være i stand til effektivt at skubbe store datastrømme igennem sig selv. Og det mest interessante er, at en sådan operation kan uddelegeres til den samme nginx i henhold til nøjagtig samme skema (bortset fra at datanøglen skal føjes til anmodningen).

Men hvis vi vender tilbage til vores system, får vi et lignende diagram:

Praktiske arkitektoniske mønstre

Som du kan se, er systemet blevet radikalt mere komplekst. Nu er det ikke kun en mini-proces, der gemmer filer lokalt. Nu er det, der kræves, ikke den enkleste support, API-versionskontrol osv. Derfor, efter at alle diagrammerne er blevet tegnet, er det bedst at vurdere i detaljer, om udvidelsesmuligheder er prisen værd. Men hvis du ønsker at kunne udvide systemet (også til at arbejde med et endnu større antal brugere), så skal du gå efter lignende løsninger. Men som et resultat er systemet arkitektonisk klar til øget belastning (næsten hver komponent kan klones til horisontal skalering). Systemet kan opdateres uden at stoppe det (simpelthen nogle operationer vil blive en smule langsommere).

Som jeg sagde i begyndelsen, er en række internettjenester nu begyndt at få øget belastning. Og nogle af dem begyndte simpelthen at holde op med at fungere korrekt. Faktisk svigtede systemerne netop i det øjeblik, hvor virksomheden skulle tjene penge. Det vil sige, i stedet for udskudt levering, i stedet for at foreslå kunderne "planlæg din levering for de kommende måneder", sagde systemet blot "gå til dine konkurrenter." Faktisk er dette prisen for lav produktivitet: tab vil opstå, præcis når profitten ville være størst.

Konklusion

Alle disse tilgange var kendt før. Den samme VK har længe brugt ideen om Static Content Hosting til at vise billeder. Mange onlinespil bruger Sharding-ordningen til at opdele spillere i regioner eller til at adskille spillokationer (hvis verden selv er en). Event Sourcing-tilgangen bruges aktivt i e-mail. De fleste handelsapplikationer, hvor der konstant modtages data, er faktisk bygget på en CQRS-tilgang for at kunne filtrere de modtagne data. Nå, horisontal skalering er blevet brugt i mange tjenester i ret lang tid.

Men vigtigst af alt er alle disse mønstre blevet meget nemme at anvende i moderne applikationer (hvis de er passende, selvfølgelig). Skyer tilbyder Sharding og horisontal skalering med det samme, hvilket er meget nemmere end selv at bestille forskellige dedikerede servere i forskellige datacentre. CQRS er blevet meget nemmere, om ikke andet på grund af udviklingen af ​​biblioteker som RX. For omkring 10 år siden kunne en sjælden hjemmeside understøtte dette. Event Sourcing er også utrolig nemt at sætte op takket være færdiglavede beholdere med Apache Kafka. For 10 år siden ville dette have været en nyskabelse, nu er det almindeligt. Det er det samme med Static Content Hosting: På grund af mere bekvemme teknologier (herunder det faktum, at der er detaljeret dokumentation og en stor database med svar), er denne tilgang blevet endnu enklere.

Som følge heraf er implementeringen af ​​en række ret komplekse arkitektoniske mønstre nu blevet meget enklere, hvilket betyder, at det er bedre at se nærmere på det på forhånd. Hvis en af ​​løsningerne ovenfor i en ti år gammel applikation blev forladt på grund af de høje omkostninger ved implementering og drift, kan du nu, i en ny applikation eller efter refactoring, oprette en tjeneste, der allerede arkitektonisk både kan udvides ( med hensyn til ydeevne) og klar til nye anmodninger fra kunder (for eksempel for at lokalisere personlige data).

Og vigtigst af alt: Brug venligst ikke disse metoder, hvis du har en simpel applikation. Ja, de er smukke og interessante, men for et websted med et peak-besøg på 100 personer, kan du ofte klare dig med en klassisk monolit (i hvert fald på ydersiden kan alt indeni opdeles i moduler osv.).

Kilde: www.habr.com

Tilføj en kommentar