Bekväma arkitektoniska mönster

Hej Habr!

Mot bakgrund av aktuella händelser på grund av coronaviruset har ett antal internettjänster börjat få ökad belastning. Till exempel, En av de brittiska detaljhandelskedjorna stoppade helt enkelt sin beställningssajt online., eftersom det inte fanns tillräckligt med kapacitet. Och det är inte alltid möjligt att snabba upp en server genom att helt enkelt lägga till mer kraftfull utrustning, men klientförfrågningar måste behandlas (eller kommer de att gå till konkurrenter).

I den här artikeln kommer jag kort att prata om populära metoder som gör att du kan skapa en snabb och feltolerant tjänst. Men från de möjliga utvecklingsscheman valde jag bara de som är för närvarande lätt att använda. För varje objekt har du antingen färdiga bibliotek, eller så har du möjlighet att lösa problemet med hjälp av en molnplattform.

Horisontell skalning

Den enklaste och mest välkända punkten. Konventionellt är de vanligaste två lastfördelningsschemana horisontell och vertikal skalning. I det första fallet du låter tjänster köras parallellt och fördelar därmed belastningen mellan dem. I den andra du beställer kraftfullare servrar eller optimerar koden.

Till exempel tar jag abstrakt molnfillagring, det vill säga någon analog av OwnCloud, OneDrive och så vidare.

En standardbild av en sådan krets finns nedan, men den visar bara systemets komplexitet. När allt kommer omkring måste vi på något sätt synkronisera tjänsterna. Vad händer om användaren sparar en fil från surfplattan och sedan vill se den från telefonen?

Bekväma arkitektoniska mönster
Skillnaden mellan tillvägagångssätten: i vertikal skalning är vi redo att öka kraften hos noder, och i horisontell skalning är vi redo att lägga till nya noder för att fördela belastningen.

CQRS

Kommandoförfrågan Ansvarssegregering Ett ganska viktigt mönster, eftersom det tillåter olika klienter att inte bara ansluta till olika tjänster utan också att ta emot samma händelseströmmar. Dess fördelar är inte så uppenbara för en enkel applikation, men den är extremt viktig (och enkel) för en hektisk tjänst. Dess väsen: inkommande och utgående dataflöden bör inte korsa varandra. Det vill säga, du kan inte skicka en förfrågan och förvänta dig ett svar, istället skickar du en förfrågan till tjänst A, men får ett svar från tjänst B.

Den första bonusen med detta tillvägagångssätt är förmågan att bryta anslutningen (i ordets vida bemärkelse) samtidigt som en lång begäran utförs. Låt oss till exempel ta en mer eller mindre standardsekvens:

  1. Klienten skickade en begäran till servern.
  2. Servern startade en lång bearbetningstid.
  3. Servern svarade klienten med resultatet.

Låt oss föreställa oss att i punkt 2 bröts anslutningen (eller nätverket ansluts igen, eller så gick användaren till en annan sida och bröt anslutningen). I det här fallet blir det svårt för servern att skicka ett svar till användaren med information om exakt vad som bearbetades. Med CQRS kommer sekvensen att vara något annorlunda:

  1. Kunden har prenumererat på uppdateringar.
  2. Klienten skickade en begäran till servern.
  3. Servern svarade "förfrågan accepterad."
  4. Servern svarade med resultatet genom kanalen från punkt "1".

Bekväma arkitektoniska mönster

Som du kan se är schemat lite mer komplicerat. Dessutom saknas det intuitiva förfrågningssvar-metoden här. Men som du kan se kommer ett anslutningsavbrott under behandlingen av en begäran inte att leda till ett fel. Dessutom, om användaren faktiskt är ansluten till tjänsten från flera enheter (till exempel från en mobiltelefon och från en surfplatta), kan du se till att svaret kommer till båda enheterna.

Intressant nog blir koden för att behandla inkommande meddelanden densamma (inte 100%) både för händelser som påverkades av klienten själv och för andra händelser, inklusive de från andra klienter.

Men i verkligheten får vi en extra bonus på grund av det faktum att enkelriktat flöde kan hanteras i en funktionell stil (med RX och liknande). Och detta är redan ett allvarligt plus, eftersom applikationen i huvudsak kan göras helt reaktiv och även med ett funktionellt tillvägagångssätt. För fettprogram kan detta avsevärt spara utvecklings- och stödresurser.

Om vi ​​kombinerar detta tillvägagångssätt med horisontell skalning, så får vi som en bonus möjligheten att skicka förfrågningar till en server och ta emot svar från en annan. Således kan kunden välja den tjänst som är bekväm för honom, och systemet inuti kommer fortfarande att kunna bearbeta händelser korrekt.

Event Sourcing

Som ni vet är en av huvuddragen i ett distribuerat system frånvaron av en gemensam tid, en gemensam kritisk sektion. För en process kan du göra en synkronisering (på samma mutexes), inom vilken du är säker på att ingen annan exekverar den här koden. Detta är dock farligt för ett distribuerat system, eftersom det kommer att kräva overhead och kommer också att döda all skönhet med skalning - alla komponenter väntar fortfarande på en.

Härifrån får vi ett viktigt faktum - ett snabbt distribuerat system kan inte synkroniseras, för då kommer vi att minska prestandan. Å andra sidan behöver vi ofta en viss överensstämmelse mellan komponenter. Och för detta kan du använda tillvägagångssättet med eventuell konsistens, där det är garanterat att om det inte sker några dataändringar under en viss tid efter den senaste uppdateringen ("så småningom"), kommer alla frågor att returnera det senast uppdaterade värdet.

Det är viktigt att förstå att för klassiska databaser används det ganska ofta stark konsistens, där varje nod har samma information (detta uppnås ofta i de fall då transaktionen anses vara etablerad först efter att den andra servern svarar). Det finns vissa avslappningar här på grund av isoleringsnivåerna, men den allmänna idén förblir densamma - du kan leva i en helt harmoniserad värld.

Men låt oss återgå till den ursprungliga uppgiften. Om en del av systemet kan byggas med eventuell konsistens, då kan vi konstruera följande diagram.

Bekväma arkitektoniska mönster

Viktiga egenskaper hos detta tillvägagångssätt:

  • Varje inkommande förfrågan placeras i en kö.
  • Under behandlingen av en förfrågan kan tjänsten även placera uppgifter i andra köer.
  • Varje inkommande händelse har en identifierare (som är nödvändig för deduplicering).
  • Kön fungerar ideologiskt enligt schemat "endast lägga till". Du kan inte ta bort element från den eller ordna om dem.
  • Kön fungerar enligt FIFO-schemat (ursäkta tautologin). Om du behöver göra parallell exekvering, bör du i ett skede flytta objekt till olika köer.

Låt mig påminna dig om att vi överväger fallet med fillagring online. I det här fallet kommer systemet att se ut ungefär så här:

Bekväma arkitektoniska mönster

Det är viktigt att tjänsterna i diagrammet inte nödvändigtvis betyder en separat server. Även processen kan vara densamma. En annan sak är viktig: ideologiskt är dessa saker åtskilda på ett sådant sätt att horisontell skalning lätt kan tillämpas.

Och för två användare kommer diagrammet att se ut så här (tjänster avsedda för olika användare anges i olika färger):

Bekväma arkitektoniska mönster

Bonusar från en sådan kombination:

  • Informationsbehandlingstjänster är separerade. Köerna är också separerade. Om vi ​​behöver öka systemgenomströmningen behöver vi bara lansera fler tjänster på fler servrar.
  • När vi får information från en användare behöver vi inte vänta tills uppgifterna är helt sparade. Tvärtom behöver vi bara svara "ok" och sedan gradvis börja arbeta. Samtidigt jämnar kön ut toppar, eftersom det går snabbt att lägga till ett nytt objekt, och användaren behöver inte vänta på en fullständig passage genom hela cykeln.
  • Som ett exempel lade jag till en dedupliceringstjänst som försöker slå samman identiska filer. Om det fungerar länge i 1% av fallen kommer klienten knappt att märka det (se ovan), vilket är ett stort plus, eftersom vi inte längre behöver vara XNUMX% snabba och pålitliga.

Men nackdelarna är omedelbart synliga:

  • Vårt system har förlorat sin strikta konsekvens. Det betyder att om du till exempel prenumererar på olika tjänster, så kan du teoretiskt sett få ett annat tillstånd (eftersom någon av tjänsterna kanske inte hinner få ett meddelande från den interna kön). Som en annan konsekvens har systemet nu ingen gemensam tid. Det vill säga, det är till exempel omöjligt att sortera alla händelser helt enkelt efter ankomsttid, eftersom klockorna mellan servrar kanske inte är synkrona (desutom är samma tid på två servrar en utopi).
  • Inga händelser kan nu helt enkelt återställas (som skulle kunna göras med en databas). Istället måste du lägga till en ny händelse − ersättningshändelse, vilket kommer att ändra det sista tillståndet till det önskade. Som ett exempel från ett liknande område: utan att skriva om historien (vilket är dåligt i vissa fall), kan du inte rulla tillbaka en commit i git, men du kan göra en speciell rollback commit, som i huvudsak bara återställer det gamla tillståndet. Men både den felaktiga commit och återställningen kommer att finnas kvar i historien.
  • Dataschemat kan ändras från release till release, men gamla händelser kommer inte längre att kunna uppdateras till den nya standarden (eftersom händelser i princip inte kan ändras).

Som du kan se fungerar Event Sourcing bra med CQRS. Att implementera ett system med effektiva och bekväma köer, men utan att separera dataflöden, är dessutom redan svårt i sig, eftersom du måste lägga till synkroniseringspunkter som kommer att neutralisera hela den positiva effekten av köerna. Genom att tillämpa båda metoderna samtidigt, är det nödvändigt att justera programkoden något. I vårt fall, när du skickar en fil till servern, kommer svaret bara "ok", vilket bara betyder att "operationen att lägga till filen sparades." Formellt betyder detta inte att data redan är tillgänglig på andra enheter (till exempel kan dedupliceringstjänsten bygga om indexet). Men efter en tid kommer klienten att få ett meddelande i stil med "fil X har sparats."

Som ett resultat:

  • Antalet filsändningsstatusar ökar: istället för den klassiska "filen skickad" får vi två: "filen har lagts till i kön på servern" och "filen har sparats i lagring." Det senare innebär att andra enheter redan kan börja ta emot filen (justerat för att köerna går i olika hastigheter).
  • På grund av att inlämningsinformationen nu kommer via olika kanaler behöver vi komma på lösningar för att få bearbetningsstatus för filen. Som en konsekvens av detta: till skillnad från det klassiska förfrågningssvaret, kan klienten startas om under bearbetning av filen, men statusen för själva bearbetningen kommer att vara korrekt. Dessutom fungerar den här artikeln i princip ur lådan. Som en konsekvens: vi är nu mer toleranta mot misslyckanden.

Sharding

Som beskrivits ovan saknar system för händelseförsörjning strikt konsistens. Det betyder att vi kan använda flera lagringar utan någon synkronisering mellan dem. När vi närmar oss vårt problem kan vi:

  • Separera filer efter typ. Till exempel kan bilder/videor avkodas och ett mer effektivt format kan väljas.
  • Separera konton efter land. På grund av många lagar kan detta krävas, men detta arkitekturschema ger en sådan möjlighet automatiskt

Bekväma arkitektoniska mönster

Om du vill överföra data från en lagring till en annan räcker det inte längre med standardmedel. Tyvärr måste du i det här fallet stoppa kön, göra migreringen och sedan starta den. I det allmänna fallet kan data inte överföras "i farten", men om händelsekön är helt lagrad och du har ögonblicksbilder av tidigare lagringstillstånd, kan vi spela upp händelserna enligt följande:

  • I Event Source har varje händelse sin egen identifierare (helst icke-minskande). Det betyder att vi kan lägga till ett fält till lagringen - ID för det senast bearbetade elementet.
  • Vi duplicerar kön så att alla händelser kan behandlas för flera oberoende lagringar (den första är den där data redan är lagrad och den andra är ny, men fortfarande tom). Den andra kön behandlas förstås inte ännu.
  • Vi startar den andra kön (det vill säga vi börjar spela om händelser).
  • När den nya kön är relativt tom (det vill säga den genomsnittliga tidsskillnaden mellan att lägga till ett element och hämta det är acceptabelt) kan du börja byta läsare till den nya lagringen.

Som du kan se hade vi inte, och har fortfarande inte, strikt konsistens i vårt system. Det finns endast eventuell konsistens, det vill säga en garanti för att händelser behandlas i samma ordning (men eventuellt med olika förseningar). Och med detta kan vi relativt enkelt överföra data utan att stoppa systemet till andra sidan jordklotet.

För att fortsätta vårt exempel om onlinelagring för filer ger en sådan arkitektur oss redan ett antal bonusar:

  • Vi kan flytta objekt närmare användarna på ett dynamiskt sätt. På så sätt kan du förbättra kvaliteten på tjänsten.
  • Vi kan lagra vissa uppgifter inom företag. Till exempel kräver Enterprise-användare ofta att deras data lagras i kontrollerade datacenter (för att undvika dataläckor). Genom skärning kan vi enkelt stödja detta. Och uppgiften är ännu enklare om kunden har ett kompatibelt moln (t.ex. Azure är självvärd).
  • Och det viktigaste är att vi inte behöver göra detta. Till att börja med skulle vi trots allt vara ganska nöjda med ett lager för alla konton (för att snabbt komma igång med arbetet). Och nyckelfunktionen i detta system är att även om det är utbyggbart, är det i det inledande skedet ganska enkelt. Du behöver bara inte omedelbart skriva kod som fungerar med en miljon separata oberoende köer, etc. Vid behov kan detta göras i framtiden.

Hosting för statiskt innehåll

Denna punkt kan tyckas ganska uppenbar, men den är fortfarande nödvändig för en mer eller mindre standardladdad applikation. Dess kärna är enkel: allt statiskt innehåll distribueras inte från samma server där applikationen finns, utan från speciella dedikerade specifikt till denna uppgift. Som ett resultat utförs dessa operationer snabbare (villkorlig nginx betjänar filer snabbare och billigare än en Java-server). Plus CDN-arkitektur (Content Delivery Network) tillåter oss att lokalisera våra filer närmare slutanvändarna, vilket har en positiv effekt på bekvämligheten med att arbeta med tjänsten.

Det enklaste och vanligaste exemplet på statiskt innehåll är en uppsättning skript och bilder för en webbplats. Allt är enkelt med dem - de är kända i förväg, sedan laddas arkivet upp till CDN-servrar, varifrån de distribueras till slutanvändare.

Men i verkligheten, för statiskt innehåll, kan du använda ett tillvägagångssätt som liknar lambda-arkitektur. Låt oss återgå till vår uppgift (online-fillagring), där vi måste distribuera filer till användare. Den enklaste lösningen är att skapa en tjänst som för varje användarförfrågan gör alla nödvändiga kontroller (auktorisering etc.), och sedan laddar ner filen direkt från vårt lager. Den största nackdelen med detta tillvägagångssätt är att statiskt innehåll (och en fil med en viss version är i själva verket statiskt innehåll) distribueras av samma server som innehåller affärslogiken. Istället kan du göra följande diagram:

  • Servern tillhandahåller en nedladdnings-URL. Det kan vara av formen file_id + key, där nyckel är en minidigital signatur som ger rätt att komma åt resursen under de kommande XNUMX timmarna.
  • Filen distribueras av enkel nginx med följande alternativ:
    • Innehållscache. Eftersom denna tjänst kan placeras på en separat server har vi lämnat oss en reserv för framtiden med möjligheten att lagra alla de senaste nedladdade filerna på disk.
    • Kontrollerar nyckeln när anslutningen skapas
  • Valfritt: bearbetning av strömmande innehåll. Om vi ​​till exempel komprimerar alla filer i tjänsten kan vi packa upp direkt i den här modulen. Som en konsekvens: IO-operationer görs där de hör hemma. En arkiverare i Java kommer lätt att allokera mycket extra minne, men att skriva om en tjänst med affärslogik till Rust/C++-villkor kan också vara ineffektivt. I vårt fall används olika processer (eller till och med tjänster), och därför kan vi ganska effektivt separera affärslogik och IO-operationer.

Bekväma arkitektoniska mönster

Det här schemat är inte särskilt likt att distribuera statiskt innehåll (eftersom vi inte laddar upp hela det statiska paketet någonstans), men i verkligheten handlar detta tillvägagångssätt just om att distribuera oföränderlig data. Dessutom kan detta schema generaliseras till andra fall där innehållet inte bara är statiskt, utan kan representeras som en uppsättning oföränderliga och icke-raderbara block (även om de kan läggas till).

Som ett annat exempel (för förstärkning): om du har arbetat med Jenkins/TeamCity, då vet du att båda lösningarna är skrivna i Java. Båda är en Java-process som hanterar både build-orkestrering och innehållshantering. I synnerhet har de båda uppgifter som "överföra en fil/mapp från servern." Som ett exempel: utfärdande av artefakter, överföring av källkod (när agenten inte laddar ner koden direkt från förvaret, men servern gör det åt honom), tillgång till loggar. Alla dessa uppgifter skiljer sig åt i sin IO-belastning. Det vill säga, det visar sig att servern som ansvarar för komplex affärslogik samtidigt måste kunna driva igenom stora dataflöden effektivt. Och det som är mest intressant är att en sådan operation kan delegeras till samma nginx enligt exakt samma schema (förutom att datanyckeln ska läggas till förfrågan).

Men om vi återvänder till vårt system får vi ett liknande diagram:

Bekväma arkitektoniska mönster

Som ni ser har systemet blivit radikalt mer komplext. Nu är det inte bara en miniprocess som lagrar filer lokalt. Nu krävs inte det enklaste stödet, API-versionskontroll osv. Därför, efter att alla diagram har ritats, är det bäst att utvärdera i detalj om töjbarhet är värt kostnaden. Men om du vill kunna utöka systemet (inklusive för att arbeta med ett ännu större antal användare) så måste du gå efter liknande lösningar. Men som ett resultat är systemet arkitektoniskt redo för ökad belastning (nästan varje komponent kan klonas för horisontell skalning). Systemet kan uppdateras utan att stoppa det (helt enkelt vissa operationer kommer att sakta ner något).

Som jag sa i början, nu har ett antal internettjänster börjat få ökad belastning. Och några av dem började helt enkelt sluta fungera korrekt. Faktum är att systemen misslyckades just i det ögonblick då verksamheten skulle tjäna pengar. Det vill säga, istället för uppskjuten leverans, istället för att föreslå kunderna "planera din leverans för de kommande månaderna", sa systemet helt enkelt "gå till dina konkurrenter." I själva verket är detta priset för låg produktivitet: förluster kommer att inträffa just när vinsterna skulle vara som störst.

Slutsats

Alla dessa tillvägagångssätt var kända tidigare. Samma VK har länge använt idén om Static Content Hosting för att visa bilder. Många onlinespel använder Sharding-schemat för att dela in spelare i regioner eller för att separera spelplatser (om världen själv är en sådan). Event Sourcing-metoden används aktivt i e-post. De flesta handelsapplikationer där data ständigt tas emot är faktiskt byggda på en CQRS-metod för att kunna filtrera mottagen data. Tja, horisontell skalning har använts i många tjänster under ganska lång tid.

Men viktigast av allt, alla dessa mönster har blivit mycket enkla att applicera i moderna applikationer (om de är lämpliga, förstås). Moln erbjuder Sharding och horisontell skalning direkt, vilket är mycket enklare än att själv beställa olika dedikerade servrar i olika datacenter. CQRS har blivit mycket enklare, om så bara på grund av utvecklingen av bibliotek som RX. För ungefär 10 år sedan kunde en sällsynt webbplats stödja detta. Event Sourcing är också otroligt lätt att sätta upp tack vare färdiga behållare med Apache Kafka. För 10 år sedan skulle detta ha varit en innovation, nu är det vanligt. Det är samma sak med Static Content Hosting: tack vare mer bekväm teknik (inklusive det faktum att det finns detaljerad dokumentation och en stor databas med svar) har detta tillvägagångssätt blivit ännu enklare.

Som ett resultat har implementeringen av ett antal ganska komplexa arkitektoniska mönster nu blivit mycket enklare, vilket innebär att det är bättre att titta närmare på det i förväg. Om i en tio år gammal applikation en av lösningarna ovan övergavs på grund av de höga kostnaderna för implementering och drift, kan du nu, i en ny applikation, eller efter omfaktorisering, skapa en tjänst som redan kommer att vara arkitektoniskt både utbyggbar ( när det gäller prestanda) och färdiga till nya förfrågningar från kunder (till exempel för att lokalisera personuppgifter).

Och viktigast av allt: använd inte dessa metoder om du har en enkel applikation. Ja, de är vackra och intressanta, men för en sida med ett toppbesök på 100 personer kan du ofta klara dig med en klassisk monolit (åtminstone på utsidan kan allt inuti delas upp i moduler osv.).

Källa: will.com

Lägg en kommentar