Modelli architettonici convenienti

Ehi Habr!

Alla luce degli attuali eventi legati al coronavirus, numerosi servizi Internet hanno iniziato a ricevere un carico maggiore. Per esempio, Una delle catene di vendita al dettaglio del Regno Unito ha semplicemente chiuso il suo sito di ordinazione online., perché non c'era abbastanza capacità. E non è sempre possibile velocizzare un server semplicemente aggiungendo apparecchiature più potenti, ma le richieste dei clienti devono essere elaborate (altrimenti andranno alla concorrenza).

In questo articolo parlerò brevemente delle pratiche più diffuse che ti permetteranno di creare un servizio veloce e tollerante ai guasti. Tuttavia, tra i possibili schemi di sviluppo, ho selezionato solo quelli attualmente esistenti facile da usare. Per ogni articolo hai librerie già pronte oppure hai l'opportunità di risolvere il problema utilizzando una piattaforma cloud.

Ridimensionamento orizzontale

Il punto più semplice e noto. Convenzionalmente, i due schemi di distribuzione del carico più comuni sono il ridimensionamento orizzontale e verticale. Nel primo caso si consente ai servizi di funzionare in parallelo, distribuendo così il carico tra di loro. Nel secondo ordini server più potenti o ottimizzi il codice.

Ad esempio, prenderò l'archiviazione astratta di file nel cloud, ovvero un analogo di OwnCloud, OneDrive e così via.

Di seguito è riportata un'immagine standard di tale circuito, ma dimostra solo la complessità del sistema. Dopotutto, dobbiamo in qualche modo sincronizzare i servizi. Cosa succede se l'utente salva un file dal tablet e poi desidera visualizzarlo dal telefono?

Modelli architettonici convenienti
La differenza tra gli approcci: nel ridimensionamento verticale, siamo pronti ad aumentare la potenza dei nodi, e nel ridimensionamento orizzontale, siamo pronti ad aggiungere nuovi nodi per distribuire il carico.

CQRS

Segregazione delle responsabilità delle query di comando Un modello piuttosto importante, poiché consente a client diversi non solo di connettersi a servizi diversi, ma anche di ricevere gli stessi flussi di eventi. I suoi vantaggi non sono così evidenti per una semplice applicazione, ma sono estremamente importanti (e semplici) per un servizio impegnativo. La sua essenza: i flussi di dati in entrata e in uscita non dovrebbero intersecarsi. Cioè, non puoi inviare una richiesta e aspettarti una risposta; invece, invii una richiesta al servizio A, ma ricevi una risposta dal servizio B.

Il primo vantaggio di questo approccio è la possibilità di interrompere la connessione (nel senso ampio del termine) durante l'esecuzione di una richiesta lunga. Prendiamo ad esempio una sequenza più o meno standard:

  1. Il client ha inviato una richiesta al server.
  2. Il server ha iniziato un tempo di elaborazione lungo.
  3. Il server ha risposto al client con il risultato.

Immaginiamo che al punto 2 la connessione sia stata interrotta (o la rete si sia ricollegata, oppure l'utente sia passato ad un'altra pagina, interrompendo la connessione). In questo caso sarà difficile per il server inviare una risposta all'utente con informazioni su cosa è stato esattamente elaborato. Utilizzando CQRS, la sequenza sarà leggermente diversa:

  1. Il client si è iscritto agli aggiornamenti.
  2. Il client ha inviato una richiesta al server.
  3. Il server ha risposto "richiesta accettata".
  4. Il server ha risposto con il risultato attraverso il canale dal punto “1”.

Modelli architettonici convenienti

Come puoi vedere, lo schema è un po’ più complesso. Inoltre qui manca l’approccio intuitivo richiesta-risposta. Tuttavia, come puoi vedere, un'interruzione della connessione durante l'elaborazione di una richiesta non porterà a un errore. Inoltre, se infatti l'utente è connesso al servizio da più dispositivi (ad esempio da un cellulare e da un tablet), puoi fare in modo che la risposta arrivi su entrambi i dispositivi.

È interessante notare che il codice per l'elaborazione dei messaggi in arrivo diventa lo stesso (non al 100%) sia per gli eventi influenzati dal client stesso, sia per altri eventi, compresi quelli di altri client.

Tuttavia, in realtà otteniamo un ulteriore vantaggio dovuto al fatto che il flusso unidirezionale può essere gestito in modo funzionale (utilizzando RX e simili). E questo è già un vantaggio serio, poiché in sostanza l'applicazione può essere resa completamente reattiva, oltre che utilizzando un approccio funzionale. Per i programmi fat, ciò può far risparmiare in modo significativo le risorse di sviluppo e supporto.

Se combiniamo questo approccio con il ridimensionamento orizzontale, come bonus otteniamo la possibilità di inviare richieste a un server e ricevere risposte da un altro. Pertanto, il cliente può scegliere il servizio che gli è conveniente e il sistema interno sarà comunque in grado di elaborare correttamente gli eventi.

Approvvigionamento di eventi

Come sapete, una delle caratteristiche principali di un sistema distribuito è l'assenza di un tempo comune, di una sezione critica comune. Per un processo, puoi eseguire una sincronizzazione (sugli stessi mutex), all'interno della quale sei sicuro che nessun altro stia eseguendo questo codice. Tuttavia, questo è pericoloso per un sistema distribuito, poiché richiederà un sovraccarico e ucciderà anche tutta la bellezza del ridimensionamento: tutti i componenti ne aspetteranno comunque uno.

Da qui otteniamo un fatto importante: un sistema distribuito veloce non può essere sincronizzato, perché in tal caso ridurremo le prestazioni. D’altro canto, spesso abbiamo bisogno di una certa coerenza tra le componenti. E per questo puoi usare l'approccio con eventuale consistenza, dove è garantito che se non vengono apportate modifiche ai dati per un certo periodo di tempo dopo l'ultimo aggiornamento ("eventualmente"), tutte le query restituiranno l'ultimo valore aggiornato.

È importante capire che per i database classici viene utilizzato abbastanza spesso consistenza forte, dove ogni nodo ha le stesse informazioni (questo si ottiene spesso nel caso in cui la transazione si considera stabilita solo dopo la risposta del secondo server). Qui c'è un po' di relax a causa dei livelli di isolamento, ma l'idea generale rimane la stessa: puoi vivere in un mondo completamente armonizzato.

Tuttavia, torniamo al compito originale. Se parte del sistema può essere costruita con eventuale consistenza, allora possiamo costruire il seguente diagramma.

Modelli architettonici convenienti

Caratteristiche importanti di questo approccio:

  • Ogni richiesta in entrata viene inserita in una coda.
  • Durante l'elaborazione di una richiesta, il servizio può anche inserire attività in altre code.
  • Ogni evento in entrata ha un identificatore (necessario per la deduplicazione).
  • La coda funziona ideologicamente secondo lo schema "append only". Non è possibile rimuovere elementi da esso o riorganizzarli.
  • La coda funziona secondo lo schema FIFO (scusate la tautologia). Se è necessario eseguire l'esecuzione parallela, a un certo punto dovresti spostare gli oggetti su code diverse.

Permettetemi di ricordarvi che stiamo considerando il caso dell'archiviazione di file online. In questo caso, il sistema sarà simile a questo:

Modelli architettonici convenienti

È importante che i servizi nel diagramma non indichino necessariamente un server separato. Anche il processo potrebbe essere lo stesso. Un’altra cosa è importante: ideologicamente queste cose sono separate in modo tale che il ridimensionamento orizzontale può essere facilmente applicato.

E per due utenti il ​​diagramma sarà simile a questo (i servizi destinati a utenti diversi sono indicati in colori diversi):

Modelli architettonici convenienti

Bonus da tale combinazione:

  • I servizi di elaborazione delle informazioni sono separati. Anche le code sono separate. Se abbiamo bisogno di aumentare il throughput del sistema, dobbiamo solo lanciare più servizi su più server.
  • Quando riceviamo informazioni da un utente, non dobbiamo attendere che i dati siano completamente salvati. Al contrario, basta rispondere “ok” e poi iniziare gradualmente a lavorare. Allo stesso tempo, la coda attenua i picchi, poiché l'aggiunta di un nuovo oggetto avviene rapidamente e l'utente non deve attendere il passaggio completo dell'intero ciclo.
  • Ad esempio, ho aggiunto un servizio di deduplicazione che tenta di unire file identici. Se funziona a lungo nell'1% dei casi, il cliente difficilmente se ne accorgerà (vedi sopra), il che è un grande vantaggio, poiché non siamo più tenuti a essere veloci e affidabili al XNUMX%.

Tuttavia gli svantaggi sono immediatamente visibili:

  • Il nostro sistema ha perso la sua rigorosa coerenza. Ciò significa che se, ad esempio, ti abboni a servizi diversi, in teoria potresti ottenere uno stato diverso (poiché uno dei servizi potrebbe non avere il tempo di ricevere una notifica dalla coda interna). Come altra conseguenza, il sistema ora non ha un orario comune. Cioè, è impossibile, ad esempio, ordinare tutti gli eventi semplicemente in base all'ora di arrivo, poiché gli orologi tra i server potrebbero non essere sincroni (inoltre, la stessa ora su due server è un'utopia).
  • Nessun evento può ora essere semplicemente ripristinato (come potrebbe essere fatto con un database). Invece, devi aggiungere un nuovo evento − evento di compensazione, che cambierà l'ultimo stato in quello richiesto. Come esempio da un'area simile: senza riscrivere la cronologia (il che in alcuni casi è negativo), non puoi ripristinare un commit in git, ma puoi creare uno speciale commit di ripristino, che essenzialmente restituisce semplicemente il vecchio stato. Tuttavia, sia il commit errato che il rollback rimarranno nella storia.
  • Lo schema dei dati può cambiare da una versione all'altra, ma i vecchi eventi non potranno più essere aggiornati al nuovo standard (poiché in linea di principio gli eventi non possono essere modificati).

Come puoi vedere, Event Sourcing funziona bene con CQRS. Inoltre, implementare un sistema con code efficiente e conveniente, ma senza separare i flussi di dati, è già di per sé difficile, perché bisognerà aggiungere punti di sincronizzazione che neutralizzeranno tutto l'effetto positivo delle code. Applicando entrambi gli approcci contemporaneamente, è necessario modificare leggermente il codice del programma. Nel nostro caso, quando si invia un file al server, la risposta arriva solo “ok”, il che significa solo che “l’operazione di aggiunta del file è stata salvata”. Formalmente ciò non significa che i dati siano già disponibili su altri dispositivi (ad esempio, il servizio di deduplicazione può ricostruire l'indice). Tuttavia, dopo un po’ di tempo, il client riceverà una notifica del tipo “il file X è stato salvato”.

Di conseguenza:

  • Il numero degli stati di invio dei file è in aumento: al posto del classico “file inviato”, ne otteniamo due: “il file è stato aggiunto alla coda sul server” e “il file è stato salvato in archivio”. Quest'ultimo significa che altri dispositivi possono già iniziare a ricevere il file (adattato al fatto che le code funzionano a velocità diverse).
  • Dato che le informazioni sull'invio ora arrivano attraverso canali diversi, dobbiamo trovare soluzioni per ricevere lo stato di elaborazione del file. Di conseguenza: a differenza della classica richiesta-risposta, il client può essere riavviato durante l'elaborazione del file, ma lo stato di questa elaborazione stessa sarà corretto. Inoltre, questo articolo funziona, essenzialmente, fuori dagli schemi. Di conseguenza: ora siamo più tolleranti nei confronti dei fallimenti.

sharding

Come descritto sopra, i sistemi di event sourcing mancano di una rigorosa coerenza. Ciò significa che possiamo utilizzare più archivi senza alcuna sincronizzazione tra loro. Affrontando il nostro problema, possiamo:

  • File separati per tipo. Ad esempio, è possibile decodificare immagini/video e selezionare un formato più efficiente.
  • Conti separati per paese. A causa di molte leggi, ciò potrebbe essere richiesto, ma questo schema di architettura fornisce automaticamente tale opportunità

Modelli architettonici convenienti

Se desideri trasferire dati da un archivio all'altro, i mezzi standard non sono più sufficienti. Sfortunatamente, in questo caso, devi fermare la coda, fare la migrazione e poi avviarla. Nel caso generale, i dati non possono essere trasferiti “al volo”, tuttavia, se la coda degli eventi viene archiviata completamente e si dispone di istantanee degli stati di archiviazione precedenti, è possibile riprodurre gli eventi come segue:

  • In Event Source, ogni evento ha il proprio identificatore (idealmente, non decrescente). Ciò significa che possiamo aggiungere un campo all'archivio: l'id dell'ultimo elemento elaborato.
  • Duplichiamo la coda in modo che tutti gli eventi possano essere elaborati per più archivi indipendenti (il primo è quello in cui i dati sono già archiviati e il secondo è nuovo, ma ancora vuoto). La seconda coda, ovviamente, non è ancora in fase di elaborazione.
  • Lanciamo la seconda coda (ovvero iniziamo a riprodurre gli eventi).
  • Quando la nuova coda è relativamente vuota (ovvero, la differenza di tempo media tra l'aggiunta di un elemento e il suo recupero è accettabile), è possibile iniziare a spostare i lettori nel nuovo archivio.

Come potete vedere, non avevamo, e non abbiamo ancora, una rigorosa coerenza nel nostro sistema. Esiste solo una coerenza finale, ovvero una garanzia che gli eventi vengano elaborati nello stesso ordine (ma possibilmente con ritardi diversi). E, utilizzando questo, possiamo trasferire i dati in modo relativamente semplice senza fermare il sistema dall’altra parte del globo.

Pertanto, continuando il nostro esempio sull'archiviazione online dei file, tale architettura ci offre già una serie di vantaggi:

  • Possiamo avvicinare gli oggetti agli utenti in modo dinamico. In questo modo potrai migliorare la qualità del servizio.
  • Potremmo archiviare alcuni dati all'interno delle aziende. Ad esempio, gli utenti aziendali spesso richiedono che i propri dati vengano archiviati in data center controllati (per evitare fughe di dati). Attraverso lo sharding possiamo facilmente supportarlo. E il compito è ancora più semplice se il cliente dispone di un cloud compatibile (ad esempio Azure ospitato autonomamente).
  • E la cosa più importante è che non dobbiamo farlo. Dopotutto, per cominciare, saremmo abbastanza contenti di avere uno spazio di archiviazione per tutti gli account (per iniziare a lavorare rapidamente). E la caratteristica principale di questo sistema è che, sebbene sia espandibile, nella fase iniziale è abbastanza semplice. Semplicemente non devi scrivere immediatamente codice che funzioni con un milione di code indipendenti separate, ecc. Se necessario, ciò potrà essere fatto in futuro.

Hosting di contenuti statici

Questo punto può sembrare abbastanza ovvio, ma è comunque necessario per un'applicazione caricata più o meno standard. La sua essenza è semplice: tutto il contenuto statico è distribuito non dallo stesso server su cui si trova l'applicazione, ma da quelli speciali dedicati specificamente a questo compito. Di conseguenza, queste operazioni vengono eseguite più velocemente (nginx condizionale serve i file più rapidamente e in modo meno costoso rispetto a un server Java). Inoltre architettura CDN (Content Delivery Network) ci consente di localizzare i nostri file più vicino agli utenti finali, il che ha un effetto positivo sulla comodità di lavorare con il servizio.

L'esempio più semplice e standard di contenuto statico è un insieme di script e immagini per un sito web. Con loro tutto è semplice: vengono conosciuti in anticipo, quindi l'archivio viene caricato sui server CDN, da dove vengono distribuiti agli utenti finali.

Tuttavia, in realtà, per i contenuti statici è possibile utilizzare un approccio in qualche modo simile all'architettura lambda. Torniamo al nostro compito (archiviazione di file online), in cui dobbiamo distribuire file agli utenti. La soluzione più semplice è realizzare un servizio che, per ogni richiesta dell'utente, effettui tutti i controlli necessari (autorizzazioni, ecc.), per poi scaricare il file direttamente dal nostro storage. Lo svantaggio principale di questo approccio è che il contenuto statico (e un file con una determinata revisione è, di fatto, contenuto statico) viene distribuito dallo stesso server che contiene la logica aziendale. Invece, puoi creare il seguente diagramma:

  • Il server fornisce un URL di download. Può avere la forma file_id + chiave, dove chiave è una mini-firma digitale che dà il diritto di accedere alla risorsa per le successive XNUMX ore.
  • Il file è distribuito da semplice nginx con le seguenti opzioni:
    • Caching dei contenuti. Poiché questo servizio può trovarsi su un server separato, ci siamo lasciati una riserva per il futuro con la possibilità di archiviare su disco tutti gli ultimi file scaricati.
    • Controllo della chiave al momento della creazione della connessione
  • Facoltativo: elaborazione del contenuto in streaming. Ad esempio, se comprimiamo tutti i file nel servizio, possiamo decomprimerli direttamente in questo modulo. Di conseguenza: le operazioni di I/O vengono eseguite dove appartengono. Un archiviatore in Java allocherà facilmente molta memoria aggiuntiva, ma anche riscrivere un servizio con logica aziendale in condizionali Rust/C++ potrebbe essere inefficace. Nel nostro caso vengono utilizzati processi (o anche servizi) diversi e quindi possiamo separare in modo abbastanza efficace la logica aziendale e le operazioni IO.

Modelli architettonici convenienti

Questo schema non è molto simile alla distribuzione di contenuto statico (poiché non carichiamo l'intero pacchetto statico da qualche parte), ma in realtà questo approccio riguarda proprio la distribuzione di dati immutabili. Inoltre, questo schema può essere generalizzato ad altri casi in cui il contenuto non è semplicemente statico, ma può essere rappresentato come un insieme di blocchi immutabili e non cancellabili (anche se possono essere aggiunti).

Come altro esempio (per rinforzo): se hai lavorato con Jenkins/TeamCity, allora sai che entrambe le soluzioni sono scritte in Java. Entrambi sono un processo Java che gestisce sia l'orchestrazione della build che la gestione dei contenuti. In particolare, entrambi hanno compiti come “trasferire un file/cartella dal server”. Ad esempio: emissione di artefatti, trasferimento del codice sorgente (quando l'agente non scarica il codice direttamente dal repository, ma il server lo fa per lui), accesso ai log. Tutte queste attività differiscono nel carico di I/O. Si scopre cioè che il server responsabile della complessa logica aziendale deve allo stesso tempo essere in grado di spingere efficacemente grandi flussi di dati attraverso se stesso. E la cosa più interessante è che tale operazione può essere delegata allo stesso nginx esattamente secondo lo stesso schema (tranne che la chiave dati dovrebbe essere aggiunta alla richiesta).

Tuttavia, se torniamo al nostro sistema, otteniamo un diagramma simile:

Modelli architettonici convenienti

Come potete vedere, il sistema è diventato radicalmente più complesso. Ora non è solo un mini-processo che archivia i file localmente. Ora ciò che è richiesto non è il supporto più semplice, il controllo della versione API, ecc. Pertanto, dopo aver tracciato tutti gli schemi, è meglio valutare nel dettaglio se l'estensibilità vale il costo. Tuttavia, se vuoi essere in grado di espandere il sistema (incluso lavorare con un numero ancora maggiore di utenti), allora dovrai optare per tali soluzioni. Ma, di conseguenza, il sistema è architetturalmente pronto per un carico maggiore (quasi ogni componente può essere clonato per il ridimensionamento orizzontale). Il sistema potrà essere aggiornato senza fermarlo (semplicemente alcune operazioni verranno leggermente rallentate).

Come ho detto all'inizio, ora numerosi servizi Internet hanno iniziato a ricevere un carico maggiore. E alcuni di loro hanno semplicemente iniziato a smettere di funzionare correttamente. In effetti, i sistemi hanno fallito proprio nel momento in cui si supponeva che l’azienda guadagnasse. Cioè, invece di differire la consegna, invece di suggerire ai clienti di “pianificare la consegna per i prossimi mesi”, il sistema diceva semplicemente “vai ai tuoi concorrenti”. In realtà, questo è il prezzo della bassa produttività: le perdite si verificheranno proprio quando i profitti sarebbero più alti.

conclusione

Tutti questi approcci erano conosciuti prima. Lo stesso VK utilizza da tempo l'idea dell'hosting di contenuti statici per visualizzare le immagini. Molti giochi online utilizzano lo schema Sharding per dividere i giocatori in regioni o per separare le posizioni di gioco (se il mondo stesso è uno). L'approccio Event Sourcing viene utilizzato attivamente nella posta elettronica. La maggior parte delle applicazioni di trading in cui i dati vengono ricevuti costantemente sono in realtà basate su un approccio CQRS per poter filtrare i dati ricevuti. Bene, il ridimensionamento orizzontale è stato utilizzato in molti servizi per molto tempo.

Tuttavia, cosa più importante, tutti questi modelli sono diventati molto facili da applicare nelle applicazioni moderne (se appropriati, ovviamente). I cloud offrono subito lo Sharding e il ridimensionamento orizzontale, il che è molto più semplice che ordinare da soli diversi server dedicati in diversi data center. CQRS è diventato molto più semplice, se non altro grazie allo sviluppo di librerie come RX. Circa 10 anni fa, un raro sito web poteva supportarlo. Event Sourcing è anche incredibilmente facile da configurare grazie ai contenitori già pronti con Apache Kafka. 10 anni fa questa sarebbe stata un’innovazione, ora è un luogo comune. Lo stesso vale per l’hosting di contenuti statici: grazie a tecnologie più convenienti (incluso il fatto che sia disponibile una documentazione dettagliata e un ampio database di risposte), questo approccio è diventato ancora più semplice.

Di conseguenza, l'implementazione di una serie di modelli architettonici piuttosto complessi è ora diventata molto più semplice, il che significa che è meglio esaminarli più da vicino in anticipo. Se in un'applicazione vecchia di dieci anni una delle soluzioni sopra indicate veniva abbandonata a causa degli alti costi di implementazione e di funzionamento, ora, in una nuova applicazione, o dopo il refactoring, è possibile creare un servizio che sarà già architetturalmente sia estensibile ( in termini di prestazioni) e pronti a nuove richieste dei clienti (ad esempio, per localizzare i dati personali).

E, cosa più importante: non utilizzare questi approcci se la tua applicazione è semplice. Sì, sono belli e interessanti, ma per un sito con un picco di 100 persone, spesso ci si può accontentare di un classico monolite (almeno all'esterno, tutto all'interno è divisibile in moduli, ecc.).

Fonte: habr.com

Aggiungi un commento