Des modèles architecturaux pratiques

Hé Habr !

À la lumière des événements actuels liés au coronavirus, un certain nombre de services Internet ont commencé à recevoir une charge accrue. Par exemple, L’une des chaînes de vente au détail britanniques a tout simplement fermé son site de commande en ligne., parce qu'il n'y avait pas assez de capacité. Et il n’est pas toujours possible d’accélérer un serveur en ajoutant simplement du matériel plus puissant, mais les demandes des clients doivent être traitées (sinon elles iront aux concurrents).

Dans cet article, je parlerai brièvement des pratiques populaires qui vous permettront de créer un service rapide et tolérant aux pannes. Cependant, parmi les schémas de développement possibles, j'ai sélectionné uniquement ceux qui sont actuellement facile à utiliser. Pour chaque élément, soit vous disposez de bibliothèques prêtes à l'emploi, soit vous avez la possibilité de résoudre le problème à l'aide d'une plate-forme cloud.

Mise à l'échelle horizontale

Le point le plus simple et le plus connu. Classiquement, les deux schémas de répartition de charge les plus courants sont la mise à l'échelle horizontale et verticale. Dans le premier cas vous autorisez les services à s'exécuter en parallèle, répartissant ainsi la charge entre eux. Dans la seconde vous commandez des serveurs plus puissants ou optimisez le code.

Par exemple, je prendrai le stockage de fichiers cloud abstrait, c'est-à-dire un analogue de OwnCloud, OneDrive, etc.

Une image standard d'un tel circuit est présentée ci-dessous, mais elle ne fait que démontrer la complexité du système. Après tout, nous devons d'une manière ou d'une autre synchroniser les services. Que se passe-t-il si l'utilisateur enregistre un fichier depuis la tablette et souhaite ensuite le visualiser depuis le téléphone ?

Des modèles architecturaux pratiques
La différence entre les approches : en mise à l'échelle verticale, nous sommes prêts à augmenter la puissance des nœuds, et en mise à l'échelle horizontale, nous sommes prêts à ajouter de nouveaux nœuds pour répartir la charge.

CQRS

Ségrégation de responsabilité de requête de commande Un modèle assez important, puisqu'il permet à différents clients non seulement de se connecter à différents services, mais également de recevoir les mêmes flux d'événements. Ses avantages ne sont pas si évidents pour une application simple, mais ils sont extrêmement importants (et simples) pour un service très chargé. Son essence : les flux de données entrants et sortants ne doivent pas se croiser. Autrement dit, vous ne pouvez pas envoyer une requête et attendre une réponse ; à la place, vous envoyez une requête au service A, mais recevez une réponse du service B.

Le premier bonus de cette approche est la possibilité de rompre la connexion (au sens large du terme) tout en exécutant une requête longue. Par exemple, prenons une séquence plus ou moins standard :

  1. Le client a envoyé une requête au serveur.
  2. Le serveur a démarré un long temps de traitement.
  3. Le serveur a répondu au client avec le résultat.

Imaginons qu'au point 2 la connexion ait été interrompue (ou le réseau s'est reconnecté, ou l'utilisateur est allé sur une autre page, rompant la connexion). Dans ce cas, il sera difficile pour le serveur d'envoyer une réponse à l'utilisateur contenant des informations sur ce qui a été traité exactement. En utilisant CQRS, la séquence sera légèrement différente :

  1. Le client s'est abonné aux mises à jour.
  2. Le client a envoyé une requête au serveur.
  3. Le serveur a répondu « demande acceptée ».
  4. Le serveur a répondu avec le résultat via le canal du point « 1 ».

Des modèles architecturaux pratiques

Comme vous pouvez le constater, le schéma est un peu plus compliqué. De plus, l’approche intuitive demande-réponse fait défaut ici. Cependant, comme vous pouvez le constater, une rupture de connexion lors du traitement d’une requête n’entraînera pas d’erreur. De plus, si en fait l'utilisateur est connecté au service depuis plusieurs appareils (par exemple, depuis un téléphone mobile et depuis une tablette), vous pouvez vous assurer que la réponse arrive sur les deux appareils.

Fait intéressant, le code de traitement des messages entrants devient le même (pas à 100 %) à la fois pour les événements influencés par le client lui-même et pour d'autres événements, y compris ceux d'autres clients.

Cependant, en réalité, nous obtenons un bonus supplémentaire dû au fait que le flux unidirectionnel peut être géré de manière fonctionnelle (en utilisant RX et similaire). Et c'est déjà un sérieux plus, puisqu'en substance l'application peut être rendue totalement réactive, et également en utilisant une approche fonctionnelle. Pour les gros programmes, cela peut économiser considérablement les ressources de développement et de support.

Si nous combinons cette approche avec une mise à l'échelle horizontale, nous obtenons en prime la possibilité d'envoyer des requêtes à un serveur et de recevoir des réponses d'un autre. Ainsi, le client peut choisir le service qui lui convient et le système interne sera toujours capable de traiter correctement les événements.

Sourcing événementiel

Comme vous le savez, l'une des principales caractéristiques d'un système distribué est l'absence d'heure commune, de section critique commune. Pour un processus, vous pouvez effectuer une synchronisation (sur les mêmes mutex), au sein de laquelle vous êtes sûr que personne d'autre n'exécute ce code. Cependant, cela est dangereux pour un système distribué, car cela nécessitera une surcharge et tuera également toute la beauté de la mise à l'échelle - tous les composants en attendront toujours une.

De là, nous obtenons un fait important : un système distribué rapide ne peut pas être synchronisé, car nous réduirions alors les performances. En revanche, on a souvent besoin d’une certaine cohérence entre les composants. Et pour cela, vous pouvez utiliser l'approche avec cohérence éventuelle, où il est garanti que s'il n'y a pas de modification des données pendant un certain temps après la dernière mise à jour (« éventuellement »), toutes les requêtes renverront la dernière valeur mise à jour.

Il est important de comprendre que pour les bases de données classiques, il est assez souvent utilisé forte cohérence, où chaque nœud a les mêmes informations (ceci est souvent réalisé dans le cas où la transaction est considérée comme établie seulement après la réponse du deuxième serveur). Il y a quelques assouplissements ici en raison des niveaux d'isolement, mais l'idée générale reste la même : vous pouvez vivre dans un monde complètement harmonisé.

Cependant, revenons à la tâche initiale. Si une partie du système peut être construite avec cohérence éventuelle, alors nous pouvons construire le diagramme suivant.

Des modèles architecturaux pratiques

Caractéristiques importantes de cette approche :

  • Chaque demande entrante est placée dans une file d'attente.
  • Lors du traitement d'une demande, le service peut également placer des tâches dans d'autres files d'attente.
  • Chaque événement entrant possède un identifiant (nécessaire à la déduplication).
  • La file d'attente fonctionne idéologiquement selon le schéma « ajouter uniquement ». Vous ne pouvez pas en supprimer des éléments ni les réorganiser.
  • La file d'attente fonctionne selon le schéma FIFO (désolé pour la tautologie). Si vous devez effectuer une exécution parallèle, vous devez à un moment donné déplacer les objets vers différentes files d'attente.

Je vous rappelle que nous étudions le cas du stockage de fichiers en ligne. Dans ce cas, le système ressemblera à ceci :

Des modèles architecturaux pratiques

Il est important que les services présentés dans le diagramme ne désignent pas nécessairement un serveur distinct. Même le processus peut être le même. Une autre chose est importante : idéologiquement, ces éléments sont séparés de telle manière qu’une échelle horizontale peut être facilement appliquée.

Et pour deux utilisateurs le schéma ressemblera à ceci (les services destinés à différents utilisateurs sont indiqués dans des couleurs différentes) :

Des modèles architecturaux pratiques

Bonus d'une telle combinaison :

  • Les services de traitement de l'information sont séparés. Les files d'attente sont également séparées. Si nous devons augmenter le débit du système, il nous suffit alors de lancer davantage de services sur davantage de serveurs.
  • Lorsque nous recevons des informations d'un utilisateur, nous n'avons pas besoin d'attendre que les données soient complètement enregistrées. Au contraire, il suffit de répondre « ok » et de commencer progressivement à travailler. Dans le même temps, la file d'attente atténue les pics, car l'ajout d'un nouvel objet se produit rapidement et l'utilisateur n'a pas à attendre un parcours complet de tout le cycle.
  • A titre d'exemple, j'ai ajouté un service de déduplication qui tente de fusionner des fichiers identiques. Si cela fonctionne longtemps dans 1% des cas, le client ne s'en apercevra pratiquement pas (voir ci-dessus), ce qui est un gros plus, puisqu'on n'est plus obligé d'être rapide et fiable à XNUMX%.

Cependant, les inconvénients sont immédiatement visibles :

  • Notre système a perdu sa stricte cohérence. Cela signifie que si, par exemple, vous vous abonnez à différents services, vous pouvez théoriquement obtenir un état différent (puisque l'un des services peut ne pas avoir le temps de recevoir une notification de la file d'attente interne). Autre conséquence : le système n'a plus d'heure commune. C'est-à-dire qu'il est impossible, par exemple, de trier tous les événements simplement par heure d'arrivée, puisque les horloges entre serveurs peuvent ne pas être synchrones (d'ailleurs, la même heure sur deux serveurs est une utopie).
  • Aucun événement ne peut désormais être simplement annulé (comme cela pourrait être le cas avec une base de données). Au lieu de cela, vous devez ajouter un nouvel événement - événement de compensation, ce qui changera le dernier état en celui requis. A titre d'exemple dans un domaine similaire : sans réécrire l'historique (ce qui est mauvais dans certains cas), vous ne pouvez pas annuler un commit dans git, mais vous pouvez créer un commit spécial. validation de restauration, qui renvoie essentiellement l'ancien état. Cependant, la validation erronée et la restauration resteront dans l’historique.
  • Le schéma des données peut changer d'une version à l'autre, mais les anciens événements ne pourront plus être mis à jour vers le nouveau standard (puisque les événements ne peuvent en principe pas être modifiés).

Comme vous pouvez le constater, Event Sourcing fonctionne bien avec CQRS. De plus, mettre en œuvre un système avec des files d'attente efficaces et pratiques, mais sans séparer les flux de données, est déjà difficile en soi, car il faudra ajouter des points de synchronisation qui neutraliseront tout l'effet positif des files d'attente. En appliquant les deux approches à la fois, il est nécessaire d'ajuster légèrement le code du programme. Dans notre cas, lors de l'envoi d'un fichier au serveur, la réponse arrive uniquement « ok », ce qui signifie uniquement que « l'opération d'ajout du fichier a été enregistrée ». Formellement, cela ne signifie pas que les données sont déjà disponibles sur d'autres appareils (par exemple, le service de déduplication peut reconstruire l'index). Cependant, après un certain temps, le client recevra une notification du style « le fichier X a été enregistré ».

Par conséquent:

  • Le nombre de statuts d'envoi de fichiers augmente : au lieu du classique « fichier envoyé », nous en obtenons deux : « le fichier a été ajouté à la file d'attente sur le serveur » et « le fichier a été enregistré en stockage ». Ce dernier signifie que d'autres appareils peuvent déjà commencer à recevoir le fichier (en tenant compte du fait que les files d'attente fonctionnent à des vitesses différentes).
  • Étant donné que les informations de soumission transitent désormais par différents canaux, nous devons trouver des solutions pour connaître l'état de traitement du dossier. Conséquence de ceci : contrairement à la requête-réponse classique, le client peut être redémarré pendant le traitement du fichier, mais le statut de ce traitement lui-même sera correct. De plus, cet article fonctionne essentiellement immédiatement. Conséquence : nous sommes désormais plus tolérants à l’égard des échecs.

Sharding

Comme décrit ci-dessus, les systèmes de sourcing d’événements manquent de cohérence stricte. Cela signifie que nous pouvons utiliser plusieurs stockages sans aucune synchronisation entre eux. En abordant notre problème, nous pouvons :

  • Séparez les fichiers par type. Par exemple, les images/vidéos peuvent être décodées et un format plus efficace peut être sélectionné.
  • Comptes séparés par pays. En raison de nombreuses lois, cela peut être requis, mais ce schéma d'architecture offre automatiquement une telle opportunité.

Des modèles architecturaux pratiques

Si vous souhaitez transférer des données d’un stockage à un autre, alors les moyens standards ne suffisent plus. Malheureusement, dans ce cas, vous devez arrêter la file d'attente, effectuer la migration, puis la démarrer. Dans le cas général, les données ne peuvent pas être transférées « à la volée », cependant, si la file d'attente des événements est entièrement stockée et que vous disposez d'instantanés des états de stockage précédents, nous pouvons alors rejouer les événements comme suit :

  • Dans Event Source, chaque événement possède son propre identifiant (idéalement, non décroissant). Cela signifie que nous pouvons ajouter un champ au stockage - l'identifiant du dernier élément traité.
  • Nous dupliquons la file d'attente afin que tous les événements puissent être traités pour plusieurs stockages indépendants (le premier est celui dans lequel les données sont déjà stockées, et le second est nouveau, mais toujours vide). Bien entendu, la deuxième file d’attente n’est pas encore traitée.
  • Nous lançons la deuxième file d'attente (c'est-à-dire que nous commençons à rejouer les événements).
  • Lorsque la nouvelle file d'attente est relativement vide (c'est-à-dire que le délai moyen entre l'ajout d'un élément et sa récupération est acceptable), vous pouvez commencer à faire basculer les lecteurs vers le nouveau stockage.

Comme vous pouvez le constater, nous n’avions pas, et n’avons toujours pas, une cohérence stricte dans notre système. Il n'y a qu'une cohérence éventuelle, c'est-à-dire une garantie que les événements sont traités dans le même ordre (mais éventuellement avec des délais différents). Et grâce à cela, nous pouvons transférer des données relativement facilement sans arrêter le système à l’autre bout du monde.

Ainsi, poursuivant notre exemple sur le stockage de fichiers en ligne, une telle architecture nous apporte déjà un certain nombre de bonus :

  • Nous pouvons rapprocher les objets des utilisateurs de manière dynamique. De cette façon, vous pouvez améliorer la qualité du service.
  • Nous pouvons stocker certaines données au sein des entreprises. Par exemple, les utilisateurs d'entreprise exigent souvent que leurs données soient stockées dans des centres de données contrôlés (pour éviter les fuites de données). Grâce au partitionnement, nous pouvons facilement prendre en charge cela. Et la tâche est encore plus facile si le client dispose d'un cloud compatible (par exemple, Azure auto-hébergé).
  • Et le plus important c’est que nous n’ayons pas à faire ça. Après tout, pour commencer, nous serions très satisfaits d'un seul stockage pour tous les comptes (pour commencer à travailler rapidement). Et la principale caractéristique de ce système est que, bien qu’il soit extensible, il est assez simple au début. Vous n’avez tout simplement pas besoin d’écrire immédiatement du code qui fonctionne avec un million de files d’attente indépendantes distinctes, etc. Si nécessaire, cela pourra être fait à l'avenir.

Hébergement de contenu statique

Ce point peut paraître assez évident, mais il reste néanmoins nécessaire pour une application chargée plus ou moins standard. Son essence est simple : tout le contenu statique n'est pas distribué à partir du même serveur sur lequel se trouve l'application, mais à partir de serveurs spéciaux dédiés spécifiquement à cette tâche. En conséquence, ces opérations sont effectuées plus rapidement (nginx conditionnel sert les fichiers plus rapidement et moins cher qu'un serveur Java). Plus l'architecture CDN (Content Delivery Network) nous permet de localiser nos fichiers plus près des utilisateurs finaux, ce qui a un effet positif sur la commodité de travailler avec le service.

L’exemple le plus simple et le plus standard de contenu statique est un ensemble de scripts et d’images pour un site Web. Tout est simple avec eux : ils sont connus à l'avance, puis les archives sont téléchargées sur des serveurs CDN, d'où elles sont distribuées aux utilisateurs finaux.

Cependant, en réalité, pour le contenu statique, vous pouvez utiliser une approche quelque peu similaire à l'architecture lambda. Revenons à notre tâche (stockage de fichiers en ligne), dans laquelle nous devons distribuer des fichiers aux utilisateurs. La solution la plus simple est de créer un service qui, pour chaque demande de l'utilisateur, effectue toutes les vérifications nécessaires (autorisation, etc.), puis télécharge le fichier directement depuis notre stockage. Le principal inconvénient de cette approche est que le contenu statique (et un fichier avec une certaine révision est, en fait, un contenu statique) est distribué par le même serveur qui contient la logique métier. A la place, vous pouvez réaliser le schéma suivant :

  • Le serveur fournit une URL de téléchargement. Il peut être de la forme file_id + key, où key est une mini-signature numérique qui donne le droit d'accéder à la ressource pour les prochaines XNUMX heures.
  • Le fichier est distribué par simple nginx avec les options suivantes :
    • Mise en cache du contenu. Ce service pouvant être localisé sur un serveur séparé, nous nous sommes laissé une réserve pour l'avenir avec la possibilité de stocker sur disque tous les derniers fichiers téléchargés.
    • Vérification de la clé au moment de la création de la connexion
  • Facultatif : traitement du contenu en streaming. Par exemple, si nous compressons tous les fichiers du service, nous pouvons alors effectuer la décompression directement dans ce module. En conséquence : les opérations d’E/S sont effectuées là où elles doivent être. Un archiveur en Java allouera facilement beaucoup de mémoire supplémentaire, mais réécrire un service avec une logique métier dans des conditions Rust/C++ peut également s'avérer inefficace. Dans notre cas, différents processus (voire services) sont utilisés, et nous pouvons donc séparer assez efficacement la logique métier et les opérations d'E/S.

Des modèles architecturaux pratiques

Ce schéma n'est pas très similaire à la distribution de contenu statique (puisque nous ne téléchargeons pas l'intégralité du package statique quelque part), mais en réalité, cette approche concerne précisément la distribution de données immuables. De plus, ce schéma peut être généralisé à d'autres cas où le contenu n'est pas simplement statique, mais peut être représenté comme un ensemble de blocs immuables et non supprimables (bien qu'ils puissent être ajoutés).

Comme autre exemple (à titre de renforcement) : si vous avez travaillé avec Jenkins/TeamCity, alors vous savez que les deux solutions sont écrites en Java. Les deux sont un processus Java qui gère à la fois l’orchestration des builds et la gestion du contenu. En particulier, ils ont tous deux des tâches telles que « transférer un fichier/dossier depuis le serveur ». A titre d'exemple : émission d'artefacts, transfert de code source (lorsque l'agent ne télécharge pas le code directement depuis le référentiel, mais que le serveur le fait pour lui), accès aux logs. Toutes ces tâches diffèrent par leur charge d'E/S. Autrement dit, il s'avère que le serveur responsable d'une logique métier complexe doit en même temps être capable de transmettre efficacement de gros flux de données à travers lui-même. Et ce qui est le plus intéressant, c'est qu'une telle opération peut être déléguée au même nginx selon exactement le même schéma (sauf que la clé de données doit être ajoutée à la requête).

Cependant, si nous revenons à notre système, nous obtenons un diagramme similaire :

Des modèles architecturaux pratiques

Comme vous pouvez le constater, le système est devenu radicalement plus complexe. Désormais, il ne s'agit plus simplement d'un mini-processus qui stocke les fichiers localement. Désormais, ce qui est requis n'est pas le support le plus simple, le contrôle de version de l'API, etc. Par conséquent, une fois tous les diagrammes dessinés, il est préférable d’évaluer en détail si l’extensibilité en vaut le coût. Cependant, si vous souhaitez pouvoir étendre le système (y compris pour travailler avec un nombre encore plus grand d'utilisateurs), vous devrez alors opter pour des solutions similaires. Mais, par conséquent, le système est architecturalement prêt pour une charge accrue (presque tous les composants peuvent être clonés pour une mise à l'échelle horizontale). Le système peut être mis à jour sans l'arrêter (simplement certaines opérations seront légèrement ralenties).

Comme je l'ai dit au tout début, un certain nombre de services Internet ont commencé à recevoir une charge accrue. Et certains d’entre eux ont simplement commencé à ne plus fonctionner correctement. En fait, les systèmes ont échoué précisément au moment où l’entreprise était censée gagner de l’argent. Autrement dit, au lieu de différer la livraison, au lieu de suggérer aux clients de « planifier votre livraison pour les mois à venir », le système leur disait simplement « allez chez vos concurrents ». En fait, c’est le prix d’une faible productivité : les pertes se produiront précisément au moment où les profits seraient les plus élevés.

Conclusion

Toutes ces approches étaient connues auparavant. Le même VK utilise depuis longtemps l'idée de l'hébergement de contenu statique pour afficher des images. De nombreux jeux en ligne utilisent le système Sharding pour diviser les joueurs en régions ou pour séparer les emplacements de jeu (si le monde lui-même en est un). L’approche Event Sourcing est activement utilisée dans le courrier électronique. La plupart des applications de trading où les données sont reçues en permanence sont en fait construites sur une approche CQRS afin de pouvoir filtrer les données reçues. Eh bien, la mise à l'échelle horizontale est utilisée dans de nombreux services depuis assez longtemps.

Mais surtout, tous ces modèles sont devenus très faciles à appliquer dans les applications modernes (s’ils sont appropriés, bien sûr). Les cloud offrent immédiatement le partage et la mise à l'échelle horizontale, ce qui est beaucoup plus simple que de commander vous-même différents serveurs dédiés dans différents centres de données. CQRS est devenu beaucoup plus simple, ne serait-ce que grâce au développement de bibliothèques telles que RX. Il y a environ 10 ans, un site Web rare pouvait prendre en charge cela. Event Sourcing est également incroyablement simple à mettre en place grâce aux conteneurs prêts à l'emploi avec Apache Kafka. Il y a 10 ans, cela aurait été une innovation, maintenant c'est monnaie courante. C'est la même chose avec l'hébergement de contenu statique : grâce à des technologies plus pratiques (y compris le fait qu'il existe une documentation détaillée et une grande base de données de réponses), cette approche est devenue encore plus simple.

En conséquence, la mise en œuvre d'un certain nombre de modèles architecturaux plutôt complexes est désormais devenue beaucoup plus simple, ce qui signifie qu'il est préférable de l'examiner de plus près à l'avance. Si dans une application vieille de dix ans l'une des solutions ci-dessus a été abandonnée en raison du coût élevé de mise en œuvre et d'exploitation, maintenant, dans une nouvelle application, ou après refactoring, vous pouvez créer un service qui sera déjà architecturalement à la fois extensible ( en termes de performances) et prêt à répondre aux nouvelles demandes des clients (par exemple pour localiser des données personnelles).

Et surtout : n’utilisez pas ces approches si vous avez une application simple. Oui, ils sont beaux et intéressants, mais pour un site avec une fréquentation maximale de 100 personnes, on peut souvent se contenter d'un monolithe classique (au moins à l'extérieur, tout à l'intérieur peut être divisé en modules, etc.).

Source: habr.com

Ajouter un commentaire