Chat distribué sur Node.JS et Redis

Le résultat est une image de plaisanterie pour laver "pigeon mail"


Une petite question / réponse:


À qui est-il destiné? Les gens qui ont peu ou pas d'expérience avec les systèmes distribués et qui souhaitent voir comment ils peuvent être construits, quels modèles et solutions existent.


Pourquoi ça? Lui-même s'est intéressé à quoi et comment. J'ai récupéré des informations de différentes sources, j'ai décidé de les publier sous une forme concentrée, car à un moment j'aimerais moi-même voir un tel travail. En fait, ceci est une déclaration textuelle de mes lancers et pensées personnels. En outre, il y aura certainement de nombreuses corrections dans les commentaires de personnes bien informées, et c'est en partie le but d'écrire tout cela sous la forme d'un article.


Énoncé du problème


Comment faire un chat? Cela devrait être une tâche insignifiante, probablement chaque deuxième beckender scie le sien, tout comme les développeurs de jeux fabriquent leurs tetris / serpents, etc. J'ai repris celui-ci, mais pour le rendre plus intéressant, il devrait être prêt à conquérir le monde, afin qu'il puisse supporter des centaines de milliards utilisateurs actifs et en général était incroyablement cool. Le besoin évident d'une architecture distribuée vient de cela, car il est irréaliste d'avoir la capacité actuelle de s'adapter à tout le nombre imaginaire de clients sur une seule machine. Au lieu de simplement m'asseoir et attendre l'apparition des ordinateurs quantiques, je me suis résolument mis à étudier le sujet des systèmes distribués.


Il est à noter qu'une réponse rapide est très importante, le fameux temps réel, c'est un chat ! pas la livraison de courrier de pigeon.


% blague aléatoire sur la poste russe %


Nous utiliserons Node.JS, il est idéal pour le prototypage. Pour les sockets, prenez Socket.IO. Écrivez sur TypeScript.


Et alors que voulons-nous:


  1. Pour que les utilisateurs puissent s’envoyer des messages
  2. Savoir qui est en ligne / hors ligne

Comment le voulons-nous:


Serveur unique


Il n'y a rien à dire surtout, juste au code. Déclarez l'interface de message:


interface Message{ roomId: string,//    message: string,//    } 

Sur le serveur:


 io.on('connection', sock=>{ //    sock.on('join', (roomId:number)=> sock.join(roomId)) //    //         sock.on('message', (data:Message)=> io.to(data.roomId).emit('message', data)) }) 

Sur le client, quelque chose comme:


 sock.on('connect', ()=> { const roomId = 'some room' //      sock.on('message', (data:Message)=> console.log(`Message ${data.message} from ${data.roomId}`)) //   sock.emit('join', roomId) //    sock.emit('message', <Message>{roomId: roomId, message: 'Halo!'}) }) 

Vous pouvez travailler avec un statut en ligne comme celui-ci:


 io.on('connection', sock=>{ //         // ,        - //      sock.on('auth', (uid:string)=> sock.join(uid)) //,     , //          //   sock.on('isOnline', (uid:string, resp)=> resp(io.sockets.clients(uid).length > 0)) }) 

Et sur le client:


 sock.on('connect', ()=> { const uid = 'im uid, rly' //  sock.emit('auth', uid) //     sock.emit('isOnline', uid, (isOnline:boolean)=> console.log(`User online status is ${isOnline}`)) }) 

Remarque: le code n'a pas fonctionné, j'écris de la mémoire juste par exemple

Tout comme le bois de chauffage, nous faisons tourner la véritable autorisation syudy, la gestion de la salle (historique des messages, ajout / suppression de participants) et le profit.


MAIS! Mais nous allons prendre le dessus sur la paix mondiale, ce qui signifie que ce n'est pas le moment de s'arrêter, nous allons rapidement de l'avant:


Cluster Node.JS


Des exemples d'utilisation de Socket.IO sur de nombreux nœuds sont disponibles sur le site officiel . Y compris il y a aussi un cluster Node.JS natif, qui m'a semblé inapplicable à ma tâche: il nous permet d'étendre notre application sur toute la machine, MAIS pas au-delà de sa portée, donc nous le manquons définitivement. Nous devons enfin dépasser les limites d'une seule pièce de fer!


Distribuer et faire du vélo


Comment faire Évidemment, vous devez en quelque sorte connecter nos instances, lancées non seulement à la maison dans le sous-sol, mais aussi dans le sous-sol voisin. Ce qui nous vient à l'esprit: nous créons une sorte de lien intermédiaire qui servira de bus entre tous nos nœuds:


1549140775997


Lorsqu'un nœud veut envoyer un message à un autre, il fait une demande à Bus, et déjà, à son tour, il le transmet là où il est nécessaire, tout est simple. Notre réseau est prêt!


FIN.


... mais ce n'est pas si simple?)


Avec cette approche, nous nous heurtons aux performances de cette liaison intermédiaire, et en effet nous aimerions contacter directement les nœuds nécessaires, car quoi de plus rapide qu'une communication directe? Avançons donc dans cette direction!


De quoi a-t-on besoin en premier? En fait, légitimez une instance à une autre. Mais comment le premier apprend-il l'existence du second? Mais nous voulons en avoir un nombre infini, augmenter / supprimer arbitrairement! Nous avons besoin d'un serveur maître dont l'adresse est connue pour être connue, tout le monde s'y connecte, grâce à quoi il connaît tous les nœuds existants du réseau et partage gentiment ces informations avec tout le monde.


1549048945334


Le nœud se lève, informe le maître de son réveil, il donne une liste des autres nœuds actifs, on s'y connecte et c'est tout, le réseau est prêt. Le maître peut être consul ou quelque chose comme ça, mais puisque nous faisons du vélo, le maître doit être autodidacte.


Super, nous avons maintenant notre propre skynet! Mais l'implémentation actuelle du chat n'est plus adaptée. Voyons en fait les exigences:


  1. Lorsqu'un utilisateur envoie un message, nous devons savoir à QUI il l'envoie, c'est-à-dire avoir accès aux participants dans la salle.
  2. Lorsque nous avons reçu les participants, nous devons leur transmettre des messages.
  3. Nous devons savoir quel utilisateur est en ligne maintenant.
  4. Pour plus de commodité - donnez aux utilisateurs la possibilité de s'abonner au statut en ligne des autres utilisateurs, afin qu'en temps réel, ils soient informés de son changement

Traitons les utilisateurs. Par exemple, vous pouvez faire savoir au maître quel nœud est connecté à quel nœud. La situation est la suivante:


1549237952673


Deux utilisateurs sont connectés à différents nœuds. Le maître le sait, les nœuds savent ce que le maître sait. Lorsque UserB se connecte, Node2 notifie le maître, qui "se souvient" que UserB est connecté à Node2. Lorsque UserA souhaite envoyer un message UserB, vous obtenez l'image suivante:


1549140491881


En principe, tout fonctionne, mais je voudrais éviter un aller-retour supplémentaire sous forme d'interrogation du maître, il serait plus économique de contacter directement le bon noeud directement, car c'est pourquoi tout a commencé. Cela peut être fait s'ils disent à tout le monde quels utilisateurs sont connectés à eux, chacun d'eux devient un analogue autonome de l'assistant, et l'assistant lui-même devient inutile, car la liste du rapport "Utilisateur => Noeud" est dupliquée pour tout le monde. Au début d'un nœud, il suffit de se connecter à n'importe lequel déjà en cours d'exécution, de tirer sa liste pour vous et le tour est joué, il est également prêt pour la bataille.


1549139768940


1549139882747


Mais comme compromis, nous obtenons une duplication de la liste, qui, bien que ce soit un rapport "id utilisateur -> [connexions hôtes]", mais avec un nombre suffisant d'utilisateurs, il s'avérera être assez volumineux en mémoire. Et en général, le couper vous-même - cela sent clairement l'industrie du vélo. Plus il y a de code, plus il y a d'erreurs potentielles. Peut-être que nous gelons cette option et examinons ce qui est déjà prêt:


Courtiers de messages


L'entité qui implémente le même "Bus", le "lien intermédiaire" mentionné ci-dessus. Sa tâche est de recevoir et de délivrer des messages. En tant qu'utilisateurs, nous pouvons nous y abonner et envoyer les nôtres. Tout est simple.


Il y a RabbitMQ et Kafka éprouvés: ils font juste ce qu'ils délivrent des messages - c'est leur but, bourré de toutes les fonctionnalités nécessaires au cou. Dans leur monde, un message doit être délivré quoi qu'il arrive.


En même temps, il y a Redis et son pub / sub - les mêmes que les gars susmentionnés, mais plus de chêne: il reçoit tout simplement bêtement le message et le livre à l'abonné, sans files d'attente et autres frais généraux. Il ne se soucie absolument pas des messages eux-mêmes, ils disparaîtront, si l'abonné se bloque - il le jettera et en prendra un nouveau, comme s'ils jetteraient un poker chauffé au rouge dans ses mains dont vous voulez vous débarrasser plus rapidement. De plus, s'il tombe soudainement - tous les messages couleront avec lui. En d'autres termes, il n'est pas question de garantie de livraison.


... et c'est ce dont vous avez besoin!


Eh bien, vraiment, nous ne faisons que discuter. Pas une sorte de service d'argent critique ou de centre de contrôle de vol spatial, mais ... juste une conversation. Le risque est que Pete conditionnel une fois par an ne reçoive pas un message sur mille - il peut être négligé si en retour nous obtenons une croissance de la productivité et en place avec lui le nombre d'utilisateurs pour les mêmes jours, un échange dans toute sa splendeur. De plus, en même temps, vous pouvez conserver un historique des messages dans une sorte de référentiel persistant, ce qui signifie que Petya verra toujours ce message manqué en rechargeant la page / l'application. C'est pourquoi nous nous concentrerons sur Redis pub / sub, ou plutôt: regardez l'adaptateur existant pour SocketIO, qui est mentionné dans l'article du bureau. site .


Alors qu'est-ce que c'est?


Adaptateur Redis


https://github.com/socketio/socket.io-redis


Avec son aide, une application ordinaire en quelques lignes et un minimum de gestes se transforme en un vrai chat distribué! Mais comment? Si vous regardez à l'intérieur - il s'avère qu'il n'y a qu'un seul fichier par demi-cent lignes.


Dans le cas où nous émettons un message


 io.emit("everyone", "hello") 

il est poussé dans des radis, transmis à toutes les autres instances de notre chat, qui à son tour le délivre déjà localement sur des sockets


1549232309776


Le message sera distribué sur tous les nœuds même si nous émettons à un utilisateur spécifique. C'est-à-dire que chaque nœud accepte tous les messages et comprend déjà s'il en a besoin.


De plus, il est implémenté un rpc simple (appelant des procédures distantes), qui permet non seulement d'envoyer mais aussi de recevoir des réponses. Par exemple, vous pouvez contrôler les prises à distance, telles que "qui se trouve dans la pièce spécifiée", "ordonner à la prise de rejoindre la pièce", etc.


Que peut-on faire avec ça? Par exemple, utilisez l'ID utilisateur comme nom de chambre (id utilisateur == id salle). Lors de l'autorisation, pour y connecter la prise, et quand nous voulons envoyer un message à l'utilisateur - juste un casque dedans. En outre, nous pouvons savoir si l'utilisateur est en ligne, en regardant simplement s'il y a des prises dans la pièce spécifiée.


En principe, nous pouvons nous arrêter ici, mais comme toujours, cela ne nous suffit pas:


  1. Col de bouteille dans une seule instance de radis
  2. Redondance, je souhaite que les nœuds ne reçoivent que les messages dont ils ont besoin

Au détriment du paragraphe un, regardez une chose telle que:


Cluster Redis


Il connecte plusieurs instances de radis, après quoi elles fonctionnent dans leur ensemble. Mais comment fait-il? Oui, comme ça:


1549233023980


... et nous voyons que le message est dupliqué sur tous les membres du cluster. Autrement dit, il n'est pas destiné à augmenter la productivité, mais à accroître la fiabilité, ce qui est certainement bon et nécessaire, mais dans notre cas, il n'a pas de valeur et ne sauve en rien la situation avec un goulot d'étranglement, et en somme, c'est encore plus un gaspillage de ressources.


1549231953897


Je suis débutant, je ne sais pas grand chose, parfois je dois retourner au pitchforking, ce que nous ferons. Non, laissons le radis pour qu'il ne glisse pas du tout, mais vous devez penser à quelque chose avec l'architecture car l'actuel n'est pas bon.


Tournez dans le mauvais sens


De quoi avons-nous besoin? Augmentez le débit global. Par exemple, essayons de générer stupidement une autre instance. Imaginez que socket.io-redis puisse se connecter à plusieurs, lors de l'envoi d'un message, il sélectionne au hasard et s'abonne à tout. Il se présente comme ceci:


1549239818663


Voila! En général, le problème est résolu, les radis ne sont plus un goulot d'étranglement, vous pouvez générer un nombre illimité de copies! Mais ils sont devenus des nœuds. Oui, oui, nos instances de chat digèrent toujours TOUS les messages auxquels ils n'étaient pas destinés.


Vous pouvez vice versa: souscrire à un aléatoire, ce qui réduira la charge sur les nœuds, et poussera tout:


1549239361416


Nous voyons que c'est devenu l'inverse: les nœuds sont plus calmes, mais la charge sur l'instance de radis a augmenté. Ce n'est pas bon non plus. Vous devez faire du vélo un peu.


Afin de pomper notre système, nous laisserons le paquet socket.io-redis seul, bien qu'il soit cool, nous avons besoin de plus de liberté. Et donc, nous connectons le radis:


 //  : const pub = new RedisClient({host: 'localhost', port: 6379})//  const sub = new RedisClient({host: 'localhost', port: 6379})//   //    interface Message{ roomId: string,//    message: string,//    } 

Configurez notre système de messagerie:


 //     sub.on('message', (channel:string, dataRaw:string)=> { const data = <Message>JSON.parse(dataRaw) io.to(data.roomId).emit('message', data)) }) //   sub.subscribe("messagesChannel") //    sock.on('join', (roomId:number)=> sock.join(roomId)) //   sock.on('message', (data:Message)=> { //   pub.publish("messagesChannel", JSON.stringify(data)) }) 

Pour le moment, cela se passe comme dans socket.io-redis: on écoute tous les messages. Maintenant, nous allons le réparer.


Nous organisons les abonnements comme suit: rappelez-vous le concept avec "id utilisateur == id salle", et lorsque l'utilisateur apparaît, nous nous abonnons à la chaîne du même nom dans le radis. Ainsi, nos nœuds ne recevront que des messages qui leur sont destinés, et n'écouteront pas "l'ensemble de la diffusion".


 //     sub.on('message', (channel:string, message:string)=> { io.to(channel).emit('message', message)) }) let UID:string|null = null; sock.on('auth', (uid:string)=> { UID = uid //   -   //  UID  sub.subscribe(UID) //   sock.join(UID) }) sock.on('writeYourself', (message:string)=> { //  ,        UID if (UID) pub.publish(UID, message) }) 

Génial, maintenant nous sommes sûrs que les nœuds ne reçoivent que des messages qui leur sont destinés, rien de plus! Il convient de noter, cependant, que les abonnements eux-mêmes sont désormais beaucoup, beaucoup plus importants, ce qui signifie qu'ils vont manger la mémoire de yoy yoy, + plus d'opérations d'abonnement / désabonnement, qui sont relativement coûteuses. Mais en tout cas, cela nous donne une certaine souplesse, vous pouvez même vous arrêter à ce moment et revoir toutes les options précédentes, en tenant déjà compte de notre nouvelle propriété de nœuds sous la forme de messages de réception plus sélectifs et chastes. Par exemple, les nœuds peuvent s'abonner à l'une des instances de radis et, en poussant, envoyer un message à toutes les instances:


1550174595491


... mais, quoi qu'on en dise, ils ne donnent toujours pas d'extensibilité infinie avec des frais généraux raisonnables, vous devez donner naissance à d'autres options. À un moment donné, le schéma suivant m'est venu à l'esprit: que faire si les instances de radis sont divisées en groupes, disons A et B, deux instances dans chacun. Lors de l'abonnement, les nœuds sont signés par une instance de chaque groupe et, lors de l'envoi, ils envoient un message à toutes les instances d'un même groupe aléatoire.


1550174092066


1550174943313


Ainsi, nous obtenons une structure d'exploitation avec un potentiel d'extensibilité infini en temps réel, la charge sur un nœud individuel à tout moment ne dépend pas de la taille du système, car:


  1. La bande passante totale est divisée entre les groupes, c'est-à-dire qu'avec une augmentation du nombre d'utilisateurs / activité, nous comparons simplement des groupes supplémentaires.
  2. La gestion des utilisateurs (abonnements) est divisée au sein des groupes eux-mêmes, c'est-à-dire qu'en augmentant les utilisateurs / abonnements, nous augmentons simplement le nombre d'instances au sein des groupes.

... et comme toujours, il y a un "MAIS": plus tout cela devient, plus les ressources sont nécessaires pour le gain suivant, il me semble un compromis exorbitant.


En général, si vous y réfléchissez, les bouchons mentionnés ci-dessus viennent de ne pas savoir quel utilisateur est sur quel nœud. Eh bien, en effet, si nous avions ces informations, nous pourrions pousser les messages là où ils en avaient besoin, sans répétition inutile. Qu'avons-nous essayé de faire tout ce temps? Ils ont essayé de rendre le système évolutif à l'infini, sans disposer d'un mécanisme d'adressage clair, qui s'est inévitablement heurté à une impasse ou à une redondance injustifiée. Par exemple, vous pouvez rappeler l'assistant agissant comme un «carnet d'adresses»:


1550233610561


Quelque chose de similaire raconte ce mec:


Pour obtenir la localisation de l'utilisateur, nous faisons un aller-retour supplémentaire, ce qui est en principe OK, mais pas dans notre cas. Il semble que nous creusons dans la mauvaise direction, nous avons besoin d'autre chose ...


Force de hachage


Il existe une chose comme un hachage. Il a une plage finie de valeurs. Vous pouvez l'obtenir à partir de toutes les données. Mais que se passe-t-il si vous divisez cette plage entre les instances de radis? Eh bien, nous prenons l'ID utilisateur, produisons un hachage, et en fonction de la plage dans laquelle il s'est avéré être abonné / poussé vers une instance spécifique. Autrement dit, nous ne savons pas à l'avance où se trouve l'utilisateur, mais après l'avoir reçu, nous pouvons affirmer avec certitude qu'il s'agit de l'instance n, inf 100. Maintenant, la même chose, mais avec le code:


 function hash(val:string):number{/**/}// -,   const clients:RedisClient[] = []//   const uid = "some uid"//  //,            //      const selectedClient = clients[hash(uid) % clients.length] 

Voila! Maintenant, nous ne dépendons pas du nombre d'instances du mot en général, nous pouvons évoluer autant que nous le voulons sans frais généraux! Eh bien, sérieusement, c'est une option brillante, dont le seul inconvénient est la nécessité de redémarrer complètement le système lors de la mise à jour du nombre d'instances de radis. Il existe un anneau standard et un anneau de partition qui vous permettent de surmonter cela, mais ils ne sont pas applicables dans un système de messagerie. Eh bien, vous pouvez faire la logique de la migration des abonnements entre les instances, mais cela coûte toujours un morceau de code supplémentaire de taille incompréhensible, et comme nous le savons - plus de code, plus de bugs, nous n'en avons pas besoin, merci. Et dans notre cas, le temps d'arrêt est un compromis tout à fait acceptable.


Vous pouvez également regarder RabbitMQ avec son plugin , qui vous permet de faire la même chose que nous, et + fournit la migration des abonnements (comme je l'ai dit ci-dessus - il est lié avec des fonctionnalités de la tête aux pieds). En principe, vous pouvez le prendre et dormir paisiblement, mais si quelqu'un tâtonne dans son réglage afin d'amener le mode en temps réel, ne laissant qu'une fonctionnalité avec un anneau de hachage.


Inondation du référentiel sur github.


Il implémente la version finale à laquelle nous sommes arrivés. De plus, il existe une logique supplémentaire pour travailler avec des pièces (dialogues).


En général, je suis satisfait et peut être arrondi.


Total


Vous pouvez tout faire, mais les ressources existent, et elles sont limitées, vous devez donc vous tortiller.


Nous avons commencé par ignorer complètement comment les systèmes distribués peuvent fonctionner selon des modèles concrets plus ou moins tangibles, et c'est bien.

Source: https://habr.com/ru/post/fr440546/


All Articles