Praktiske arkitektoniske mønstre

Hei Habr!

I lys av aktuelle hendelser på grunn av koronaviruset har en rekke internettjenester begynt å få økt belastning. For eksempel, En av de britiske detaljhandelskjedene stoppet ganske enkelt sin nettbestillingsside., fordi det ikke var nok kapasitet. Og det er ikke alltid mulig å øke hastigheten på en server ved å legge til kraftigere utstyr, men klientforespørsler må behandles (ellers vil de gå til konkurrenter).

I denne artikkelen vil jeg kort snakke om populær praksis som lar deg lage en rask og feiltolerant tjeneste. Men fra de mulige utviklingsordningene valgte jeg bare de som er for øyeblikket lett å bruke. For hvert element har du enten ferdige biblioteker, eller du har mulighet til å løse problemet ved hjelp av en skyplattform.

Horisontal skalering

Det enkleste og mest kjente poenget. Konvensjonelt er de vanligste to lastfordelingsskjemaene horisontal og vertikal skalering. I det første tilfellet du lar tjenester kjøre parallelt, og fordeler dermed belastningen mellom dem. I den andre du bestiller kraftigere servere eller optimerer koden.

For eksempel vil jeg ta abstrakt skyfillagring, det vil si en analog av OwnCloud, OneDrive og så videre.

Et standardbilde av en slik krets er nedenfor, men det viser bare kompleksiteten til systemet. Tross alt må vi på en eller annen måte synkronisere tjenestene. Hva skjer hvis brukeren lagrer en fil fra nettbrettet og deretter ønsker å se den fra telefonen?

Praktiske arkitektoniske mønstre
Forskjellen mellom tilnærmingene: i vertikal skalering er vi klare til å øke kraften til noder, og i horisontal skalering er vi klare til å legge til nye noder for å fordele belastningen.

CQRS

Kommandospørring Ansvarssegregering Et ganske viktig mønster, siden det lar forskjellige klienter ikke bare koble til forskjellige tjenester, men også motta de samme hendelsesstrømmene. Fordelene er ikke så åpenbare for en enkel applikasjon, men den er ekstremt viktig (og enkel) for en travel tjeneste. Dens essens: innkommende og utgående datastrømmer skal ikke krysse hverandre. Det vil si at du ikke kan sende en forespørsel og forvente svar; i stedet sender du en forespørsel til tjeneste A, men mottar svar fra tjeneste B.

Den første bonusen med denne tilnærmingen er muligheten til å bryte forbindelsen (i vid forstand av ordet) mens du utfører en lang forespørsel. La oss for eksempel ta en mer eller mindre standard sekvens:

  1. Klienten sendte en forespørsel til serveren.
  2. Serveren startet en lang behandlingstid.
  3. Serveren svarte klienten med resultatet.

La oss forestille oss at i punkt 2 ble forbindelsen brutt (eller nettverket ble koblet til igjen, eller brukeren gikk til en annen side og brøt forbindelsen). I dette tilfellet vil det være vanskelig for serveren å sende et svar til brukeren med informasjon om nøyaktig hva som ble behandlet. Ved å bruke CQRS vil sekvensen være litt annerledes:

  1. Kunden har abonnert på oppdateringer.
  2. Klienten sendte en forespørsel til serveren.
  3. Serveren svarte "forespørsel akseptert."
  4. Serveren svarte med resultatet gjennom kanalen fra punkt "1".

Praktiske arkitektoniske mønstre

Som du kan se, er ordningen litt mer komplisert. Dessuten mangler den intuitive forespørsel-svar-tilnærmingen her. Men som du kan se, vil et tilkoblingsbrudd under behandling av en forespørsel ikke føre til en feil. Dessuten, hvis brukeren faktisk er koblet til tjenesten fra flere enheter (for eksempel fra en mobiltelefon og fra et nettbrett), kan du sørge for at svaret kommer til begge enhetene.

Interessant nok blir koden for behandling av innkommende meldinger den samme (ikke 100%) både for hendelser som ble påvirket av klienten selv, og for andre hendelser, inkludert de fra andre klienter.

Men i virkeligheten får vi en ekstra bonus på grunn av at ensrettet flyt kan håndteres i en funksjonell stil (ved hjelp av RX og lignende). Og dette er allerede et seriøst pluss, siden applikasjonen i hovedsak kan gjøres fullstendig reaktiv, og også ved hjelp av en funksjonell tilnærming. For fettprogrammer kan dette spare utviklings- og støtteressurser betydelig.

Hvis vi kombinerer denne tilnærmingen med horisontal skalering, får vi som en bonus muligheten til å sende forespørsler til en server og motta svar fra en annen. Dermed kan klienten velge den tjenesten som er praktisk for ham, og systemet inne vil fortsatt kunne behandle hendelser riktig.

Event sourcing

Som du vet, er en av hovedtrekkene til et distribuert system fraværet av en felles tid, en felles kritisk seksjon. For én prosess kan du gjøre en synkronisering (på de samme mutexes), der du er sikker på at ingen andre kjører denne koden. Dette er imidlertid farlig for et distribuert system, siden det vil kreve overhead, og vil også drepe all skjønnheten ved skalering - alle komponentene vil fortsatt vente på en.

Herfra får vi et viktig faktum – et raskt distribuert system kan ikke synkroniseres, for da vil vi redusere ytelsen. På den annen side trenger vi ofte en viss konsistens mellom komponenter. Og for dette kan du bruke tilnærmingen med eventuell konsistens, hvor det er garantert at hvis det ikke er dataendringer i en periode etter siste oppdatering ("eventuelt"), vil alle spørringer returnere den sist oppdaterte verdien.

Det er viktig å forstå at for klassiske databaser brukes det ganske ofte sterk konsistens, hvor hver node har samme informasjon (dette oppnås ofte i tilfellet der transaksjonen anses å være etablert først etter at den andre serveren svarer). Det er noen avslapninger her på grunn av isolasjonsnivåene, men den generelle ideen forblir den samme - du kan leve i en fullstendig harmonisert verden.

La oss imidlertid gå tilbake til den opprinnelige oppgaven. Hvis en del av systemet kan bygges med eventuell konsistens, så kan vi konstruere følgende diagram.

Praktiske arkitektoniske mønstre

Viktige funksjoner ved denne tilnærmingen:

  • Hver innkommende forespørsel plasseres i én kø.
  • Under behandling av en forespørsel kan tjenesten også plassere oppgaver i andre køer.
  • Hver innkommende hendelse har en identifikator (som er nødvendig for deduplisering).
  • Køen fungerer ideologisk i henhold til "bare vedlegg"-ordningen. Du kan ikke fjerne elementer fra den eller omorganisere dem.
  • Køen fungerer etter FIFO-ordningen (beklager tautologien). Hvis du trenger å utføre parallell utførelse, bør du på et tidspunkt flytte objekter til forskjellige køer.

La meg minne deg på at vi vurderer tilfellet med online fillagring. I dette tilfellet vil systemet se omtrent slik ut:

Praktiske arkitektoniske mønstre

Det er viktig at tjenestene i diagrammet ikke nødvendigvis betyr en egen server. Selv prosessen kan være den samme. En annen ting er viktig: ideologisk er disse tingene adskilt på en slik måte at horisontal skalering lett kan brukes.

Og for to brukere vil diagrammet se slik ut (tjenester beregnet på forskjellige brukere er angitt i forskjellige farger):

Praktiske arkitektoniske mønstre

Bonuser fra en slik kombinasjon:

  • Informasjonsbehandlingstjenester er atskilt. Køene er også skilt. Hvis vi trenger å øke systemgjennomstrømningen, trenger vi bare å lansere flere tjenester på flere servere.
  • Når vi mottar informasjon fra en bruker, trenger vi ikke vente til dataene er fullstendig lagret. Tvert imot, vi trenger bare å svare "ok" og så gradvis begynne å jobbe. Samtidig jevner køen ut topper, siden å legge til et nytt objekt skjer raskt, og brukeren trenger ikke å vente på en fullstendig gjennomgang gjennom hele syklusen.
  • Som et eksempel la jeg til en dedupliseringstjeneste som prøver å slå sammen identiske filer. Hvis det fungerer lenge i 1 % av tilfellene, vil klienten nesten ikke merke det (se over), noe som er et stort pluss, siden vi ikke lenger er pålagt å være XNUMX % hurtige og pålitelige.

Imidlertid er ulempene umiddelbart synlige:

  • Systemet vårt har mistet sin strenge konsistens. Dette betyr at hvis du for eksempel abonnerer på forskjellige tjenester, så kan du teoretisk sett få en annen tilstand (siden en av tjenestene kanskje ikke har tid til å motta et varsel fra den interne køen). Som en annen konsekvens har systemet nå ingen felles tid. Det vil si at det for eksempel er umulig å sortere alle hendelser rett og slett etter ankomsttid, siden klokkene mellom servere kanskje ikke er synkrone (dessuten er samme tid på to servere en utopi).
  • Ingen hendelser kan nå bare rulles tilbake (som kan gjøres med en database). I stedet må du legge til en ny hendelse − kompensasjonshendelse, som vil endre den siste tilstanden til den nødvendige. Som et eksempel fra et lignende område: uten å omskrive historien (som er dårlig i noen tilfeller), kan du ikke rulle tilbake en commit i git, men du kan lage en spesiell tilbakeføringsbekreftelse, som egentlig bare returnerer den gamle tilstanden. Imidlertid vil både den feilaktige forpliktelsen og tilbakeføringen forbli i historien.
  • Dataskjemaet kan endres fra utgivelse til utgivelse, men gamle hendelser vil ikke lenger kunne oppdateres til den nye standarden (siden hendelser i prinsippet ikke kan endres).

Som du kan se, fungerer Event Sourcing godt med CQRS. Dessuten er implementering av et system med effektive og praktiske køer, men uten å skille datastrømmer, allerede vanskelig i seg selv, fordi du må legge til synkroniseringspunkter som vil nøytralisere hele den positive effekten av køene. Ved å bruke begge tilnærmingene samtidig, er det nødvendig å justere programkoden litt. I vårt tilfelle, når du sender en fil til serveren, kommer svaret bare "ok", som bare betyr at "operasjonen med å legge til filen ble lagret." Formelt betyr ikke dette at dataene allerede er tilgjengelige på andre enheter (for eksempel kan dedupliseringstjenesten gjenoppbygge indeksen). Imidlertid vil klienten etter en tid motta et varsel i stil med "fil X er lagret."

Som et resultat:

  • Antallet filsendingsstatuser øker: i stedet for den klassiske "filen sendt", får vi to: "filen har blitt lagt til køen på serveren" og "filen har blitt lagret i lagring." Det siste betyr at andre enheter allerede kan begynne å motta filen (justert for at køene opererer med ulik hastighet).
  • På grunn av at innsendingsinformasjonen nå kommer gjennom ulike kanaler, må vi komme opp med løsninger for å motta behandlingsstatus for filen. Som en konsekvens av dette: i motsetning til det klassiske forespørselssvaret, kan klienten startes på nytt mens filen behandles, men statusen til selve behandlingen vil være korrekt. Dessuten fungerer denne gjenstanden i hovedsak ut av esken. Som en konsekvens: vi er nå mer tolerante overfor feil.

Sharding

Som beskrevet ovenfor mangler systemer for hendelseskilder streng konsistens. Dette betyr at vi kan bruke flere lagringer uten noen synkronisering mellom dem. Når vi nærmer oss problemet vårt, kan vi:

  • Skill filer etter type. For eksempel kan bilder/videoer dekodes og et mer effektivt format kan velges.
  • Skill kontoer etter land. På grunn av mange lover kan dette være påkrevd, men denne arkitekturordningen gir en slik mulighet automatisk

Praktiske arkitektoniske mønstre

Hvis du ønsker å overføre data fra en lagring til en annen, er standardmidler ikke lenger nok. Dessverre, i dette tilfellet, må du stoppe køen, gjøre migreringen og deretter starte den. I det generelle tilfellet kan ikke data overføres "on the fly", men hvis hendelseskøen er lagret fullstendig, og du har øyeblikksbilder av tidligere lagringstilstander, kan vi spille av hendelsene som følger:

  • I hendelseskilden har hver hendelse sin egen identifikator (ideelt sett ikke-avtagende). Dette betyr at vi kan legge til et felt til lagringen - IDen til det sist behandlede elementet.
  • Vi dupliserer køen slik at alle hendelser kan behandles for flere uavhengige lagringer (den første er den der dataene allerede er lagret, og den andre er ny, men fortsatt tom). Den andre køen blir selvfølgelig ikke behandlet ennå.
  • Vi starter den andre køen (det vil si at vi begynner å spille av hendelser på nytt).
  • Når den nye køen er relativt tom (det vil si at gjennomsnittlig tidsforskjell mellom å legge til et element og hente det er akseptabelt), kan du begynne å bytte lesere til den nye lagringen.

Som du kan se, hadde vi ikke, og har fortsatt ikke, streng konsistens i systemet vårt. Det er kun eventuell konsistens, det vil si en garanti for at hendelser behandles i samme rekkefølge (men muligens med forskjellige forsinkelser). Og ved å bruke dette kan vi relativt enkelt overføre data uten å stoppe systemet til den andre siden av kloden.

For å fortsette vårt eksempel om nettlagring for filer, gir en slik arkitektur oss allerede en rekke bonuser:

  • Vi kan flytte objekter nærmere brukerne på en dynamisk måte. På denne måten kan du forbedre kvaliteten på tjenesten.
  • Vi kan lagre noen data i selskaper. For eksempel krever Enterprise-brukere ofte at dataene deres lagres i kontrollerte datasentre (for å unngå datalekkasjer). Gjennom skjæring kan vi enkelt støtte dette. Og oppgaven er enda enklere hvis kunden har en kompatibel sky (f.eks. Azure selv vert).
  • Og det viktigste er at vi ikke trenger å gjøre dette. Tross alt, til å begynne med, ville vi være ganske fornøyd med én lagringsplass for alle kontoer (for å komme raskt i gang). Og nøkkelfunksjonen til dette systemet er at selv om det er utvidbart, er det i det innledende stadiet ganske enkelt. Du trenger bare ikke umiddelbart å skrive kode som fungerer med en million separate uavhengige køer, etc. Om nødvendig kan dette gjøres i fremtiden.

Hosting av statisk innhold

Dette punktet kan virke ganske åpenbart, men det er fortsatt nødvendig for en mer eller mindre standard lastet applikasjon. Essensen er enkel: alt statisk innhold distribueres ikke fra samme server der applikasjonen er plassert, men fra spesielle dedikert spesifikt til denne oppgaven. Som et resultat utføres disse operasjonene raskere (betinget nginx serverer filer raskere og rimeligere enn en Java-server). Pluss CDN-arkitektur (Content Delivery Network) lar oss finne filene våre nærmere sluttbrukere, noe som har en positiv effekt på bekvemmeligheten av å jobbe med tjenesten.

Det enkleste og mest standard eksemplet på statisk innhold er et sett med skript og bilder for et nettsted. Alt er enkelt med dem - de er kjent på forhånd, deretter lastes arkivet opp til CDN-servere, hvorfra de distribueres til sluttbrukere.

Men i virkeligheten, for statisk innhold, kan du bruke en tilnærming som ligner litt på lambda-arkitektur. La oss gå tilbake til oppgaven vår (online fillagring), der vi trenger å distribuere filer til brukere. Den enkleste løsningen er å lage en tjeneste som for hver brukerforespørsel gjør alle nødvendige kontroller (autorisasjon osv.), og deretter laster ned filen direkte fra vårt lager. Den største ulempen med denne tilnærmingen er at statisk innhold (og en fil med en viss revisjon er faktisk statisk innhold) distribueres av den samme serveren som inneholder forretningslogikken. I stedet kan du lage følgende diagram:

  • Serveren gir en nedlastings-URL. Den kan ha formen file_id + key, hvor nøkkel er en mini-digital signatur som gir rett til tilgang til ressursen de neste XNUMX timene.
  • Filen distribueres av enkel nginx med følgende alternativer:
    • Innholdsbufring. Siden denne tjenesten kan ligge på en egen server, har vi lagt igjen en reserve for fremtiden med muligheten til å lagre alle de siste nedlastede filene på disk.
    • Kontrollerer nøkkelen når tilkoblingen opprettes
  • Valgfritt: behandling av strømmeinnhold. For eksempel, hvis vi komprimerer alle filene i tjenesten, kan vi gjøre unzipping direkte i denne modulen. Som en konsekvens: IO-operasjoner gjøres der de hører hjemme. En arkiver i Java vil lett tildele mye ekstra minne, men å omskrive en tjeneste med forretningslogikk til Rust/C++ betingelser kan også være ineffektivt. I vårt tilfelle brukes forskjellige prosesser (eller til og med tjenester), og derfor kan vi ganske effektivt skille forretningslogikk og IO-operasjoner.

Praktiske arkitektoniske mønstre

Denne ordningen er ikke veldig lik distribusjon av statisk innhold (siden vi ikke laster opp hele den statiske pakken et sted), men i virkeligheten er denne tilnærmingen nettopp opptatt av å distribuere uforanderlig data. Dessuten kan denne ordningen generaliseres til andre tilfeller der innholdet ikke bare er statisk, men kan representeres som et sett med uforanderlige og ikke-slettbare blokker (selv om de kan legges til).

Som et annet eksempel (til forsterkning): hvis du har jobbet med Jenkins/TeamCity, så vet du at begge løsningene er skrevet i Java. Begge er en Java-prosess som håndterer både byggeorkestrering og innholdsstyring. Spesielt har de begge oppgaver som "overføre en fil/mappe fra serveren." Som et eksempel: utstede artefakter, overføre kildekode (når agenten ikke laster ned koden direkte fra depotet, men serveren gjør det for ham), tilgang til logger. Alle disse oppgavene er forskjellige i deres IO-belastning. Det vil si at det viser seg at serveren som er ansvarlig for kompleks forretningslogikk, samtidig må være i stand til å effektivt skyve store datastrømmer gjennom seg selv. Og det som er mest interessant er at en slik operasjon kan delegeres til samme nginx i henhold til nøyaktig samme skjema (bortsett fra at datanøkkelen skal legges til forespørselen).

Men hvis vi går tilbake til systemet vårt, får vi et lignende diagram:

Praktiske arkitektoniske mønstre

Som du kan se, har systemet blitt radikalt mer komplekst. Nå er det ikke bare en miniprosess som lagrer filer lokalt. Nå er det som kreves ikke den enkleste støtten, API-versjonskontroll osv. Derfor, etter at alle diagrammene er tegnet, er det best å vurdere i detalj om utvidbarhet er verdt kostnaden. Men hvis du ønsker å kunne utvide systemet (inkludert for å jobbe med et enda større antall brukere), så må du gå for lignende løsninger. Men som et resultat er systemet arkitektonisk klart for økt belastning (nesten hver komponent kan klones for horisontal skalering). Systemet kan oppdateres uten å stoppe det (bare noen operasjoner vil bli noe bremset).

Som jeg sa helt i starten, nå har en rekke internettjenester begynt å få økt belastning. Og noen av dem begynte rett og slett å slutte å fungere riktig. Faktisk sviktet systemene nettopp i det øyeblikket virksomheten skulle tjene penger. Det vil si, i stedet for utsatt levering, i stedet for å foreslå for kundene "planlegg leveringen for de kommende månedene", sa systemet ganske enkelt "gå til konkurrentene dine." Faktisk er dette prisen for lav produktivitet: tap vil oppstå akkurat når fortjenesten vil være høyest.

Konklusjon

Alle disse tilnærmingene var kjent fra før. Den samme VK har lenge brukt ideen om Static Content Hosting for å vise bilder. Mange nettspill bruker Sharding-ordningen for å dele spillere inn i regioner eller for å skille spilllokasjoner (hvis verden selv er en). Event Sourcing-tilnærming brukes aktivt i e-post. De fleste handelsapplikasjoner hvor data stadig mottas er faktisk bygget på en CQRS-tilnærming for å kunne filtrere dataene som mottas. Vel, horisontal skalering har blitt brukt i mange tjenester i ganske lang tid.

Men viktigst av alt, alle disse mønstrene har blitt veldig enkle å bruke i moderne applikasjoner (hvis de er passende, selvfølgelig). Skyer tilbyr Sharding og horisontal skalering med en gang, noe som er mye enklere enn å bestille forskjellige dedikerte servere i forskjellige datasentre selv. CQRS har blitt mye enklere, om så bare på grunn av utviklingen av biblioteker som RX. For omtrent 10 år siden kunne en sjelden nettside støtte dette. Event Sourcing er også utrolig enkelt å sette opp takket være ferdige beholdere med Apache Kafka. For 10 år siden ville dette vært en innovasjon, nå er det vanlig. Det er det samme med Static Content Hosting: på grunn av mer praktiske teknologier (inkludert det faktum at det er detaljert dokumentasjon og en stor database med svar), har denne tilnærmingen blitt enda enklere.

Som et resultat har implementeringen av en rekke ganske komplekse arkitektoniske mønstre nå blitt mye enklere, noe som betyr at det er bedre å se nærmere på det på forhånd. Hvis i en ti år gammel applikasjon en av løsningene ovenfor ble forlatt på grunn av de høye kostnadene ved implementering og drift, kan du nå, i en ny applikasjon, eller etter refaktorisering, opprette en tjeneste som allerede vil være arkitektonisk både utvidbar ( når det gjelder ytelse) og klargjort til nye forespørsler fra kunder (for eksempel for å lokalisere personopplysninger).

Og viktigst av alt: ikke bruk disse metodene hvis du har en enkel applikasjon. Ja, de er vakre og interessante, men for et nettsted med toppbesøk på 100 personer kan du ofte klare deg med en klassisk monolitt (i hvert fall på utsiden kan alt inni deles inn i moduler osv.).

Kilde: www.habr.com

Legg til en kommentar