Ciao, Habr!
Alla luce degli attuali eventi legati al coronavirus, molti servizi online hanno registrato un aumento del carico di lavoro. Ad esempio, , poiché non c'erano sufficienti risorse. E non è sempre possibile aumentare la potenza del server semplicemente aggiungendo hardware più potente, tuttavia è necessario gestire le richieste dei clienti (o passeranno ai concorrenti).
In questo articolo parlerò brevemente delle pratiche popolari che consentono di creare un servizio veloce e resistente ai guasti. Tuttavia, tra le possibili schemi di sviluppo, ho selezionato solo quelli che attualmente sono facili da usare. Per ciascun punto, avete già a disposizione librerie pronte o avete la possibilità di risolvere il problema tramite una piattaforma cloud.
Scalabilità orizzontale
Il punto più semplice e noto a tutti. In generale, le due modalità di distribuzione del carico che si incontrano più frequentemente sono la scalabilità orizzontale e verticale. consentite ai servizi di lavorare in parallelo, distribuendo così il carico tra di essi. ordinate server più potenti o ottimizzate il codice.
Per esempio, prendo come riferimento un'astratta soluzione di archiviazione cloud, cioè un qualche equivalente di OwnCloud, OneDrive e così via.
L'immagine standard di tale schema è qui sotto, tuttavia essa dimostra solo la complessità del sistema. Dobbiamo infatti sincronizzare i servizi in qualche modo. Cosa succede se un utente ha salvato un file sul tablet e poi vuole visualizzarlo sul telefono?

La differenza tra gli approcci: nella scalabilità verticale siamo disposti ad aumentare la potenza dei nodi, mentre in quella orizzontale aggiungiamo nuovi nodi per distribuire il carico.
CQRS
è un pattern piuttosto importante, poiché consente a diversi clienti non solo di connettersi a diversi servizi, ma anche di ricevere flussi di eventi identici. I suoi vantaggi non sono così evidenti per una semplice applicazione, tuttavia è estremamente importante (e semplice) per un servizio carico. La sua essenza: i flussi di dati in entrata e in uscita non devono incrociarsi. Questo significa che non puoi inviare una richiesta e aspettarti una risposta; invece, invii una richiesta al servizio A, ma ricevi la risposta nel servizio B.
Il primo vantaggio di questo approccio è la possibilità di interrompere la connessione (in senso ampio) durante l'esecuzione di una lunga richiesta. Prendiamo come esempio una sequenza più o meno standard:
- Il client ha inviato una richiesta al server.
- Il server ha avviato un'elaborazione lunga.
- Il server ha risposto al client con il risultato.
Immaginiamo che nel punto 2 ci sia stata un'interruzione della connessione (o la rete si è riconnessa, o l'utente ha navigato su un'altra pagina, interrompendo la connessione). In questo caso, sarà difficile per il server inviare una risposta all'utente con le informazioni su cosa sia stato elaborato. Applicando CQRS, la sequenza sarà leggermente diversa:
- Il client si è iscritto per ricevere aggiornamenti.
- Il client ha inviato una richiesta al server.
- Il server ha risposto 'richiesta ricevuta'.
- Il server ha risposto con il risultato attraverso il canale del punto '1'.

Come si può notare, lo schema è leggermente più complesso. Inoltre, l'approccio intuitivo di richiesta-risposta è assente qui. Tuttavia, come si può notare, l'interruzione della connessione durante l'elaborazione della richiesta non porterà a un errore. Inoltre, se l'utente è effettivamente connesso al servizio da più dispositivi (ad esempio, da un telefono cellulare e da un tablet), è possibile far sì che la risposta arrivi a entrambi i dispositivi.
Ciò che è interessante è che il codice per elaborare i messaggi in arrivo diventa simile (non al 100%) sia per gli eventi influenzati dal client stesso, sia per gli altri eventi, compresi quelli di altri clienti.
Tuttavia, nella realtà otteniamo vantaggi aggiuntivi poiché il flusso unidirezionale può essere elaborato in modo funzionale (utilizzando RX e analoghi). E questo è già un grande vantaggio, poiché in sostanza l'applicazione può diventare completamente reattiva, e ancor di più grazie all'approccio funzionale. Per le applicazioni
Se si combina questo approccio con la scalabilità orizzontale, il vantaggio è di poter inviare richieste a un server e ricevere risposte da un altro. In questo modo, il client può scegliere il servizio più conveniente per lui, mentre il sistema interno sarà comunque in grado di gestire correttamente gli eventi.
Event Sourcing
Come sapete, una delle caratteristiche principali di un sistema distribuito è l'assenza di tempo comune e di una sezione critica comune. Per un processo, è possibile effettuare la sincronizzazione (sugli stessi mutex), all'interno della quale si è certi che nessun altro stia eseguendo quel codice. Tuttavia, per un sistema distribuito, ciò è pericoloso, poiché richiederà delle sovraccarichi e si perderà tutto il fascino della scalabilità: comunque tutti i componenti dovranno aspettare uno.
Da qui otteniamo un fatto importante: un sistema distribuito veloce non può essere sincronizzato, poiché ciò ridurrebbe le prestazioni. D'altra parte, spesso abbiamo bisogno di una certa coerenza dei componenti. E per questo possiamo utilizzare l'approccio con , dove è garantito che, in assenza di modifiche ai dati, dopo un certo intervallo di tempo dall'ultimo aggiornamento («in definitiva») tutte le richieste restituiranno l'ultimo valore aggiornato.
È importante comprendere che per i database classici si applica abbastanza spesso , dove ogni nodo possiede la stessa informazione (ciò si ottiene spesso quando una transazione è considerata stabilita solo dopo la risposta di un secondo server). Qui ci sono alcune concessioni a causa dei livelli di isolamento, tuttavia il concetto generale rimane lo stesso: si può vivere in un mondo completamente coerente.
Tornando però all'obiettivo iniziale. Se una parte del sistema può essere costruita con , allora si può costruire il seguente schema.

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 compiti in altre code.
- Ogni evento in arrivo ha un identificatore (necessario per la deduplicazione).
- La coda funziona ideologicamente secondo lo schema "append only". Non è possibile rimuovere o riordinare gli elementi.
- La coda opera secondo lo schema FIFO (scusate per la tautologia). Se è necessario eseguire in parallelo, andrà trasferito in una delle fasi gli oggetti in diverse code.
Ricordo che stiamo considerando il caso di un archivio di file online. In questo caso, il sistema apparirà, all'incirca, in questo modo:

È importante notare che i servizi nel diagramma non devono necessariamente significare server separati. Anche un singolo processo può essere lo stesso. Ciò che conta è che, ideologicamente, queste cose sono separate in modo tale da poter facilmente applicare la scalabilità orizzontale.
E per due utenti, lo schema apparirà così (i servizi destinati a utenti diversi sono contrassegnati con colori diversi):

I vantaggi di una tale combinazione:
- I servizi di elaborazione delle informazioni sono separati. Anche le code sono separate. Se dovessimo aumentare la capacità del sistema, basterà avviare più servizi su un numero maggiore di server.
- Quando riceviamo informazioni dall'utente, non è necessario attendere il salvataggio completo dei dati. Al contrario, possiamo semplicemente rispondere "ok" e poi iniziare a lavorare gradualmente. Inoltre, la coda smussa i picchi, poiché l'aggiunta di un nuovo oggetto avviene rapidamente e l'utente non deve necessariamente attendere il completamento dell'intero ciclo.
- Per esempio, ho aggiunto un servizio di deduplicazione che cerca di unire file identici. Se funziona a lungo nel 1% dei casi, il cliente praticamente non se ne accorge (vedi sopra), il che è un grande vantaggio, poiché non ci è più richiesta una velocità e un'affidabilità al 100%.
Tuttavia, si notano subito anche gli svantaggi:
- La nostra sistema ha perso la stretta coerenza. Questo significa che, se ad esempio ci si iscrive a diversi servizi, teoricamente si può ottenere uno stato diverso (poiché uno dei servizi potrebbe non riuscire a ricevere la notifica dalla coda interna). Come ulteriore conseguenza, il sistema ora non dispone di un tempo comune. Cioè, non è possibile, ad esempio, ordinare tutti gli eventi semplicemente per orario di arrivo, poiché gli orologi tra i server potrebbero non essere sincronizzati (per di più, avere lo stesso orario su due server è un'utopia).
- Nessun evento può ora essere semplicemente annullato (come si potrebbe fare con un database). Invece, è necessario aggiungere un nuovo evento — , che cambierà l'ultimo stato in quello necessario. Come esempio da un'area simile: senza riscrivere la storia (che è negativa in alcuni casi), in git non è possibile annullare un commit, tuttavia è possibile fare un , che in sostanza ripristinerà semplicemente lo stato precedente. Tuttavia, nella cronologia rimarrà sia il commit errato sia il rollback.
- Lo schema dei dati può cambiare da una versione all'altra, ma ora non è possibile aggiornare i vecchi eventi al nuovo standard (poiché in linea di principio non è possibile modificare gli eventi).
Come si può vedere, l'Event Sourcing si integra perfettamente con il CQRS. Inoltre, implementare un sistema con code efficienti e convenienti, ma senza separazione dei flussi di dati, è già di per sé difficile, poiché sarà necessario aggiungere punti di sincronizzazione che annulleranno l'intero effetto positivo delle code. Applicando entrambi gli approcci contemporaneamente, è necessario apportare lievi modifiche al codice del programma. Nel nostro caso, quando si invia un file al server, nella risposta si riceve solo 'ok', il che significa che 'l'operazione di aggiunta del file è stata salvata'. Formalmente, ciò non implica che i dati siano già disponibili su altri dispositivi (ad esempio, il servizio di deduplicazione potrebbe ricostruire l'indice). Tuttavia, dopo un po', il cliente riceverà una notifica del tipo 'il file X è stato salvato'.
Come risultato:
- Il numero di stati di invio dei file aumenta: invece del classico 'file inviato' otteniamo due stati: 'file aggiunto in coda sul server' e 'file salvato nello storage'. L'ultimo significa che altri dispositivi possono già iniziare a ricevere il file (con la precisazione che le code funzionano a velocità diverse).
- Poiché le informazioni sull'invio ora arrivano attraverso canali diversi, dobbiamo pensare a soluzioni per ricevere lo stato di elaborazione del file. Di conseguenza, a differenza della classica richiesta-risposta, il client può essere riavviato durante il processo di elaborazione del file, ma lo stato di questa stessa elaborazione sarà corretto. Inoltre, questo punto funziona sostanzialmente 'out of the box'. Come conseguenza: siamo ora più tolleranti ai guasti.
Sharding
Come già descritto sopra, nei sistemi con event sourcing non c'è una stretta coerenza. Ciò significa che possiamo utilizzare più repository senza alcuna sincronizzazione tra di essi. Avvicinandoci al nostro compito, possiamo:
- Dividere i file in base ai tipi. Ad esempio, le immagini/video possono essere decodificati e scegliere un formato più efficiente.
- Separare gli account per paese. A causa di molte leggi, questo può essere necessario, tuttavia, questo schema architetturale offre tale possibilità automaticamente.

Se desideri trasferire dati da uno storage a un altro, qui le soluzioni standard non basteranno. Sfortunatamente, in questo caso è necessario fermare la coda, eseguire la migrazione e poi riavviarla. In generale, non è possibile trasferire i dati "al volo", tuttavia, se la coda degli eventi è completamente mantenuta e hai delle istantanee dei precedenti stati dello storage, possiamo riprodurre gli eventi nel modo seguente:
- In Event Source, ogni evento ha un proprio identificatore (ideale sarebbe che non diminuisca). Dunque, nello storage possiamo aggiungere un campo: id dell'ultimo elemento elaborato.
- Dupliciamo la coda affinché tutti gli eventi possano essere elaborati per più storage indipendenti (il primo è quello in cui i dati sono già memorizzati, mentre il secondo è nuovo, ma attualmente vuoto). La seconda coda, naturalmente, non è ancora elaborata.
- Avviamo la seconda coda (cioè iniziamo a riprodurre gli eventi).
- Quando la nuova coda sarà relativamente vuota (cioè la differenza media di tempo tra l'aggiunta di un elemento e il suo prelievo sarà accettabile), si può iniziare a passare i lettori allo nuovo storage.
Come si può vedere, nella nostra sistema non ci sono mai state né ci sono attualmente rigidità nella coerenza. Esiste solo una eventual constistency, cioè la garanzia che gli eventi vengano elaborati nello stesso ordine (tuttavia, potrebbe esserci un ritardo diverso). E, approfittando di questo, possiamo trasferire relativamente facilmente i dati senza fermare il sistema fino all'altro lato del mondo.
Dunque, continuando il nostro esempio di storage online per files, tale architettura ci offre già una serie di vantaggi:
- Possiamo spostare oggetti più vicino agli utenti, in modo dinamico. In questo modo possiamo migliorare la qualità del servizio.
- Possiamo conservare parte dei dati all'interno delle aziende. Ad esempio, gli utenti Enterprise di solito richiedono di salvare i loro dati in data center sotto controllo (per evitare perdite di dati). Grazie al sharding possiamo supportare facilmente questo. E il compito diventa ancora più semplice se il cliente ha un cloud compatibile (ad esempio, ).
- E la cosa più importante è che possiamo non farlo. Infatti, per iniziare, ci basterebbe uno spazio di archiviazione per tutti gli account (per cominciare a lavorare più rapidamente). E la caratteristica principale di questo sistema è che, sebbene sia espandibile, all'inizio è abbastanza semplice. Non bisogna subito scrivere codice che gestisca un milione di code indipendenti, ecc. Se necessario, questo può essere fatto in futuro.
Hosting di Contenuti Statici
Questo punto può sembrare abbastanza evidente, tuttavia è comunque necessario per un'applicazione standard e carica. La sua essenza è semplice: tutto il contenuto statico viene distribuito non dallo stesso server dove si trova l'applicazione, ma da server speciali, dedicati proprio a questo scopo. Di conseguenza, queste operazioni vengono eseguite più rapidamente (un ipotetico nginx restituisce i file in modo più veloce e meno costoso rispetto a un server Java). Inoltre, l'architettura CDN () consente di posizionare i nostri file più vicini agli utenti finali, migliorando così l'esperienza di utilizzo del servizio.
L'esempio più semplice e standard di contenuto statico è un insieme di script e immagini per un sito web. Con essi è tutto chiaro: sono noti in anticipo, poi l'archivio viene caricato sui server CDN, da dove viene distribuito agli utenti finali.
Tuttavia, in realtà è possibile applicare un approccio simile a un'architettura lambda per il contenuto statico. Torniamo al nostro compito (un archivio di file online), in cui dobbiamo distribuire file agli utenti. La soluzione più semplice è realizzare un servizio che, per ogni richiesta dell'utente, esegue tutte le verifiche necessarie (autenticazione, ecc.), e poi scarica il file direttamente dal nostro archivio. Il principale svantaggio di tale approccio è che il contenuto statico (e un file con una determinata revisione è, in effetti, contenuto statico) viene distribuito dallo stesso server che contiene la logica di business. In alternativa, possiamo mettere in atto il seguente schema:
- Il server fornisce un URL per il download. Può essere del tipo file_id + key, dove key è una mini-firma digitale che conferisce il diritto di accesso alla risorsa per le prossime 24 ore.
- La distribuzione del file è gestita da un semplice nginx con le seguenti opzioni:
- Caching dei contenuti. Poiché questo servizio potrebbe trovarsi su un server separato, abbiamo lasciato uno spazio per il futuro con la possibilità di memorizzare tutti gli ultimi file scaricati su disco.
- Controllo della chiave al momento della creazione della connessione
- Opzionale: elaborazione dei contenuti in streaming. Ad esempio, se stiamo comprimendo tutti i file nel servizio, è possibile eseguire la decompressione direttamente in questo modulo. Di conseguenza: le operazioni di I/O vengono eseguite dove è più appropriato. L'archiviatore in Java può all'occorrenza occupare molta memoria inutile, ma riscrivere il servizio con logica di business in linguaggi come Rust o C++ potrebbe risultare altrettanto inefficace. Nel nostro caso, vengono utilizzati processi diversi (o persino servizi), quindi possiamo separare in modo abbastanza efficace la logica di business dalle operazioni di I/O.

Uno schema simile non assomiglia molto alla distribuzione di contenuti statici (poiché non stiamo esportando l'intero pacchetto di statico in un altro luogo), tuttavia in realtà questo approccio si occupa proprio della distribuzione di dati immutabili. Inoltre, questo schema può essere generalizzato anche ad altri casi in cui il contenuto non è statico, ma può essere rappresentato come un insieme di blocchi immutabili e non eliminabili (anche se possono essere aggiunti).
Come ulteriore esempio (per consolidare): se hai lavorato con Jenkins o TeamCity, sai che entrambe le soluzioni sono scritte in Java. Entrambi sono processi Java che si occupano sia dell'orchestrazione delle build che della gestione dei contenuti. In particolare, entrambi hanno compiti del tipo "trasferire file/cartelle da un server". Ad esempio: la distribuzione degli artefatti, il trasferimento del codice sorgente (quando l'agente non scarica il codice direttamente dal repository, ma lo fa il server), accesso ai log. Tutti questi compiti differiscono per l'impatto sulle operazioni di I/O. Quindi, risulta che il server, responsabile della complessa logica di business, deve anche essere in grado di gestire in modo efficace grandi flussi di dati. E, cosa interessante, tale operazione può essere delegata allo stesso nginx seguendo esattamente lo stesso schema (salvo aggiungere in richiesta una chiave dei dati).
Tuttavia, se torniamo al nostro sistema, risulta uno schema simile:

Come si può vedere, il sistema è radicalmente complicato. Ora non si tratta solo di un mini-processo che memorizza i file localmente. Adesso è necessaria una gestione più complessa, il controllo delle versioni API, ecc. Pertanto, dopo aver disegnato tutti i diagrammi, è meglio valutare attentamente se la scalabilità giustifichi tali costi. Tuttavia, se desiderate avere la possibilità di espandere il sistema (incluso il lavoro con un numero maggiore di utenti), dovrete adottare tali soluzioni. D'altra parte, come risultato, il sistema è architettonicamente pronto per aumentare il carico (praticamente ogni componente può essere clonato per la scalabilità orizzontale). Il sistema può essere aggiornato senza fermarsi (alcune operazioni rallenteranno semplicemente leggermente).
Come ho già detto all'inizio, ora diversi servizi online stanno subendo un carico maggiore. E alcuni di essi hanno semplicemente smesso di funzionare correttamente. In sostanza, i sistemi hanno fallito proprio nel momento in cui il business avrebbe dovuto generare profitti. In altre parole, invece di una consegna posticipata, invece di proporre ai clienti "pianificate la fornitura per i prossimi mesi", il sistema ha semplicemente detto "andate dai concorrenti". Questa è, di fatto, la conseguenza di una bassa produttività: le perdite si verificheranno proprio quando il profitto sarebbe stato massimo.
Conclusione
Tutti questi approcci erano già noti in passato. Lo stesso VK utilizza da tempo l'idea di Static Content Hosting per la distribuzione delle immagini. Un sacco di giochi online adottano lo schema di Sharding per suddividere i giocatori per regione o per separare le aree di gioco (se il mondo è unico). L'approccio Event Sourcing è utilizzato attivamente nell'email. La maggior parte delle applicazioni per trader, dove i dati arrivano continuamente, sono in realtà costruite sull'approccio CQRS, per poter filtrare i dati ricevuti. Inoltre, la scalabilità orizzontale è già stata applicata in molti servizi da tempo.
Tuttavia, ciò che è più importante è che tutti questi pattern sono diventati molto facili da applicare nelle moderne applicazioni (se sono appropriati, ovviamente). Le nuvole offrono sharding e scalabilità orizzontale subito, il che è molto più semplice rispetto a dover ordinare diversi server dedicati in diversi data center da soli. CQRS è diventato molto più facile grazie allo sviluppo di librerie come RX. Dieci anni fa, pochi siti web sarebbero stati in grado di supportare una cosa del genere. Anche l'event sourcing è incredibilmente facile da configurare grazie a contenitori già pronti come Apache Kafka. Dieci anni fa sarebbe stata un'innovazione, ora è normalità. Lo stesso vale per l'hosting di contenuti statici: grazie a tecnologie più comode (anche per il fatto che c'è documentazione dettagliata e una grande base di risposte), questo approccio è diventato ancora più semplice.
In sintesi, l'implementazione di diversi piuttosto complessi pattern architetturali è diventata ora molto più semplice, e quindi vale la pena esaminarli in anticipo. Se in un'applicazione di dieci anni fa si era rinunciato a una delle soluzioni sopra per via dell'alto costo di implementazione e gestione, adesso, in una nuova applicazione, o dopo un refactoring, è possibile creare un servizio che architettonicamente sarà sia scalabile (dal punto di vista delle prestazioni), sia pronto a nuove richieste da parte dei clienti (ad esempio, per la localizzazione dei dati personali).
E la cosa più importante: per favore non utilizzate questi approcci se avete un'applicazione semplice. Sì, sono belli e interessanti, tuttavia per un sito con un picco di visitatori di 100 persone, di solito si può benissimo fare a meno di un classico monolite (almeno esternamente, internamente si può suddividere tutto in moduli, ecc.).
Fonte: habr.com
