Verteilter Chat auf Node.JS und Redis

Das Ergebnis ist ein Scherzbild zum Waschen von "Taubenpost"


Eine kleine Frage / Antwort:


Für wen ist es? Menschen, die wenig oder keine Erfahrung mit verteilten Systemen haben und daran interessiert sind, wie sie aufgebaut werden können, welche Muster und Lösungen existieren.


Warum ist das so? Er selbst interessierte sich für was und wie. Ich habe Informationen aus verschiedenen Quellen gesammelt und mich entschlossen, sie in konzentrierter Form zu veröffentlichen, weil ich selbst einmal gerne eine ähnliche Arbeit sehen würde. Tatsächlich ist dies eine Textaussage meines persönlichen Werfens und Denkens. Es wird sicherlich auch viele Korrekturen in den Kommentaren von sachkundigen Personen geben, und dies ist teilweise der Zweck, all dies in Form eines Artikels zu schreiben.


Erklärung des Problems


Wie mache ich einen Chat? Dies sollte eine triviale Aufgabe sein, wahrscheinlich hat jeder zweite Beckender seine eigene gesägt, genau wie Spieleentwickler ihre Tetris / Schlangen usw. herstellen. Ich habe diese Aufgabe aufgegriffen, aber um sie interessanter zu machen, sollte sie bereit sein, die Welt zu übernehmen, damit sie Hunderten von Milliarden standhalten kann aktive Benutzer und im Allgemeinen war unglaublich cool. Die eindeutige Notwendigkeit einer verteilten Architektur ergibt sich daraus, da es unrealistisch ist, über die derzeitige Kapazität zu verfügen, um die gesamte imaginäre Anzahl von Kunden auf einer Maschine unterzubringen. Anstatt nur zu sitzen und auf das Erscheinen von Quantencomputern zu warten, begann ich entschlossen, das Thema verteilte Systeme zu studieren.


Es ist erwähnenswert, dass eine schnelle Antwort sehr wichtig ist, die berüchtigte Echtzeit, es ist ein Chat ! keine Taubenpostzustellung.


% zufälliger Witz über russische Post %


Wir werden Node.JS verwenden, es ist ideal für das Prototyping. Nehmen Sie für Steckdosen Socket.IO. Schreiben Sie auf TypeScript.


Und was wollen wir:


  1. Damit Benutzer sich gegenseitig Nachrichten senden können
  2. Wissen, wer online / offline ist

Wie wollen wir es:


Einzelner Server


Es gibt nichts Besonderes zu sagen, direkt zum Code. Deklarieren Sie die Nachrichtenschnittstelle:


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

Auf dem Server:


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

Auf dem Client so etwas wie:


 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!'}) }) 

Sie können mit dem Online-Status wie folgt arbeiten:


 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)) }) 

Und auf dem Kunden:


 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}`)) }) 

Hinweis: Der Code wurde nicht ausgeführt, ich schreibe nur zum Beispiel aus dem Speicher

Genau wie bei Brennholz spinnen wir syudy echte Autorisierung, Raumverwaltung (Nachrichtenverlauf, Hinzufügen / Entfernen von Teilnehmern) und Gewinn.


ABER! Aber wir werden den Weltfrieden übernehmen, was bedeutet, dass es nicht an der Zeit ist aufzuhören, wir bewegen uns schnell weiter:


Node.JS-Cluster


Beispiele für die Verwendung von Socket.IO auf vielen Knoten finden Sie direkt auf der offiziellen Website . Dazu gehört auch ein nativer Node.JS-Cluster, der mir für meine Aufgabe nicht zutreffend erschien: Er ermöglicht es uns, unsere Anwendung auf der gesamten Maschine zu erweitern, ABER nicht über den Rahmen hinaus, sodass wir ihn definitiv vermissen. Wir müssen endlich die Grenzen eines Stücks Eisen überschreiten!


Verteilen und Fahrrad fahren


Wie kann man das machen? Natürlich müssen Sie unsere Instanzen irgendwie verbinden, die nicht nur zu Hause im Keller, sondern auch im benachbarten Keller gestartet werden. Was zuerst in den Sinn kommt: Wir stellen eine Art Zwischenverbindung her, die als Bus zwischen all unseren Knoten dient:


1549140775997


Wenn ein Knoten eine Nachricht an einen anderen senden möchte, sendet er eine Anfrage an Bus und leitet sie bereits an den Ort weiter, an dem sie benötigt wird. Alles ist einfach. Unser Netzwerk ist fertig!


FIN.


... aber es ist nicht so einfach?)


Mit diesem Ansatz stoßen wir auf die Leistung dieser Zwischenverbindung, und tatsächlich möchten wir die erforderlichen Knoten direkt kontaktieren, denn was kann schneller sein als direkte Kommunikation? Bewegen wir uns also in diese Richtung!


Was wird zuerst benötigt? Legitimieren Sie tatsächlich eine Instanz zu einer anderen. Aber wie erfährt der erste von der Existenz des zweiten? Aber wir wollen eine unendliche Anzahl von ihnen haben, willkürlich erhöhen / entfernen! Wir brauchen einen Master-Server, dessen Adresse bekanntermaßen bekannt ist, jeder stellt eine Verbindung zu ihm her, aufgrund dessen er alle vorhandenen Knoten im Netzwerk kennt und diese Informationen freundlicherweise an alle weitergibt.


1549048945334


Der Knoten steigt an, teilt dem Master sein Erwachen mit, gibt eine Liste anderer aktiver Knoten an, wir stellen eine Verbindung zu ihnen her und das Netzwerk ist bereit. Der Meister mag Konsul oder so etwas sein, aber da wir Fahrrad fahren, muss der Meister selbst gemacht sein.


Großartig, jetzt haben wir unser eigenes Skynet! Die aktuelle Implementierung des darin enthaltenen Chats ist jedoch nicht mehr geeignet. Lassen Sie uns tatsächlich die Anforderungen ausarbeiten:


  1. Wenn ein Benutzer eine Nachricht sendet, müssen wir wissen, an wen er sie sendet, dh Zugriff auf die Teilnehmer im Raum haben.
  2. Wenn wir die Teilnehmer erhalten haben, müssen wir ihnen Nachrichten übermitteln.
  3. Wir müssen wissen, welcher Benutzer jetzt online ist.
  4. Zur Vereinfachung: Geben Sie den Benutzern die Möglichkeit, den Online-Status anderer Benutzer zu abonnieren, damit sie in Echtzeit über deren Änderungen informiert werden

Beschäftigen wir uns mit Benutzern. Sie können dem Master beispielsweise mitteilen, welcher Knoten mit welchem ​​Knoten verbunden ist. Die Situation ist wie folgt:


1549237952673


Zwei Benutzer sind mit verschiedenen Knoten verbunden. Der Master weiß das, die Knoten wissen, was der Master weiß. Wenn sich UserB anmeldet, benachrichtigt Node2 den Master, der sich "erinnert", dass UserB mit Node2 verbunden ist. Wenn UserA eine UserB-Nachricht senden möchte, erhalten Sie das folgende Bild:


1549140491881


Im Prinzip funktioniert alles, aber ich möchte eine zusätzliche Runde in Form einer Befragung des Masters vermeiden. Es wäre wirtschaftlicher, sofort direkt zum gewünschten Knoten zu gehen, denn darum ging es. Dies kann geschehen, wenn sie allen mitteilen, mit welchen Benutzern sie verbunden sind, jeder von ihnen zu einem autarken Analogon des Assistenten wird und der Assistent selbst unnötig wird, da die Liste des Verhältnisses "Benutzer => Knoten" für alle dupliziert wird. Zu Beginn eines Knotens reicht es aus, eine Verbindung zu einem bereits laufenden Knoten herzustellen, seine Liste an sich selbst und voila zu ziehen. Außerdem ist er kampfbereit.


1549139768940


1549139882747


Als Kompromiss erhalten wir jedoch eine Verdoppelung der Liste, die zwar ein Verhältnis von "Benutzer-ID -> [Host-Verbindungen]" darstellt, sich jedoch bei einer ausreichenden Anzahl von Benutzern als ziemlich groß im Speicher herausstellt. Und im Allgemeinen, wenn Sie es selbst schneiden - es riecht eindeutig nach Fahrradindustrie. Je mehr Code, desto mehr potenzielle Fehler. Vielleicht frieren wir diese Option ein und schauen uns an, was bereits fertig ist:


Nachrichtenbroker


Die Entität, die denselben "Bus" implementiert, die oben erwähnte "Zwischenverbindung". Ihre Aufgabe ist es, Nachrichten zu empfangen und zuzustellen. Wir als Benutzer können sie abonnieren und unsere eigenen senden. Alles ist einfach.


Es gibt bewährte RabbitMQ und Kafka: Sie tun genau das, was sie übermitteln - dies ist ihr Zweck, vollgepackt mit allen notwendigen Funktionen für den Hals. In ihrer Welt muss eine Nachricht übermittelt werden, egal was passiert.


Zur gleichen Zeit gibt es Redis und sein Pub / Sub - genau wie die oben genannten Leute, aber mehr Eiche: Es empfängt die Nachricht nur dumm und liefert sie an den Abonnenten, ohne Warteschlangen und andere Gemeinkosten. Er kümmert sich absolut nicht um die Nachrichten selbst, sie werden verschwinden, wenn der Abonnent hängt - er wird sie wegwerfen und eine neue aufnehmen, als würden sie einen glühenden Poker in seine Hände werfen, den Sie schneller loswerden möchten. Auch wenn er plötzlich fällt - alle Nachrichten werden auch mit ihm sinken. Von einer Liefergarantie ist also keine Rede.


... und das brauchen Sie!


Nun, wirklich, wir unterhalten uns nur. Keine Art von kritischem Geldservice oder Raumfahrt-Kontrollzentrum, sondern ... nur ein Gespräch. Das Risiko, dass der bedingte Pete einmal im Jahr nicht eine von tausend Nachrichten erhält - es kann vernachlässigt werden, wenn wir im Gegenzug ein Produktivitätswachstum erzielen und damit die Anzahl der Benutzer für die gleichen Tage in seiner ganzen Pracht abwägen. Darüber hinaus können Sie gleichzeitig einen Nachrichtenverlauf in einem dauerhaften Repository aufbewahren. Dies bedeutet, dass Petya diese verpasste Nachricht weiterhin sieht, indem Sie die Seite / Anwendung neu laden. Aus diesem Grund konzentrieren wir uns auf Redis Pub / Sub oder besser gesagt: Sehen Sie sich den vorhandenen Adapter für SocketIO an, der im Artikel im Büro erwähnt wird. Website .


Was ist das?


Redis Adapter


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


Mit seiner Hilfe verwandelt sich eine gewöhnliche Anwendung mit wenigen Zeilen und einer minimalen Anzahl von Gesten in einen echten verteilten Chat! Aber wie? Wenn Sie nach innen schauen , stellt sich heraus, dass es nur eine Datei pro halbe hundert Zeilen gibt.


In dem Fall, wenn wir eine Nachricht ausgeben


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

Es wird in Radieschen geschoben und an alle anderen Instanzen unseres Chats übertragen, die es wiederum bereits lokal an Sockets ausgeben


1549232309776


Die Nachricht wird auf alle Knoten verteilt, auch wenn sie an einen bestimmten Benutzer gesendet wird. Das heißt, jeder Knoten akzeptiert alle Nachrichten und versteht bereits, ob er sie benötigt.


Außerdem ist ein einfacher RPC (Aufruf von Remoteprozeduren) implementiert, mit dem nicht nur Antworten gesendet, sondern auch empfangen werden können. Sie können beispielsweise Steckdosen fernsteuern, z. B. "Wer befindet sich in dem angegebenen Raum?", "Die Steckdose soll dem Raum beitreten" usw.


Was kann man damit machen? Verwenden Sie beispielsweise die Benutzer-ID als Raumnamen (Benutzer-ID == Raum-ID). Wenn Sie autorisieren, den Socket daran anzuschließen und wenn wir eine Nachricht an den Benutzer senden möchten - nur einen Helm hinein. Außerdem können wir herausfinden, ob der Benutzer online ist, indem wir einfach prüfen, ob sich im angegebenen Raum Steckdosen befinden.


Im Prinzip können wir hier aufhören, aber wie immer reicht es uns nicht:


  1. Flaschenhals in einem einzigen Rettich
  2. Redundanz, ich möchte, dass die Knoten nur die Nachrichten empfangen, die sie benötigen

Betrachten Sie auf Kosten von Absatz 1 Folgendes:


Redis-Cluster


Es verbindet mehrere Rettichinstanzen, nach denen sie als Ganzes arbeiten. Aber wie macht er das? Ja, so:


1549233023980


... und wir sehen, dass die Nachricht an alle Clustermitglieder dupliziert wird. Das heißt, es ist nicht beabsichtigt, die Produktivität zu steigern, sondern die Zuverlässigkeit zu erhöhen, was sicherlich gut und notwendig ist, aber für unseren Fall hat es keinen Wert und rettet die Situation in keiner Weise mit einem Engpass, und in der Summe ist es noch mehr Verschwendung von Ressourcen.


1549231953897


Ich bin ein Anfänger, ich weiß nicht viel, manchmal muss ich zum Pitchforking zurückkehren, was wir tun werden. Nein, lassen wir den Rettich so, dass er überhaupt nicht verrutscht, aber Sie müssen an etwas mit Architektur denken, weil der aktuelle nicht gut ist.


Biegen Sie in die falsche Richtung ab


Was brauchen wir Erhöhen Sie den Gesamtdurchsatz. Versuchen wir zum Beispiel, dumm eine andere Instanz zu erzeugen. Stellen Sie sich vor, socket.io-redis kann eine Verbindung zu mehreren herstellen. Wenn Sie eine Nachricht senden, wird eine zufällige Nachricht ausgewählt und alles abonniert. Es stellt sich so heraus:


1549239818663


Voila! Im Allgemeinen ist das Problem gelöst, Radieschen sind kein Engpass mehr, Sie können beliebig viele Kopien erzeugen! Aber sie wurden zu Knoten. Ja, ja, unsere Chat-Instanzen verarbeiten immer noch ALLE Nachrichten, für die sie nicht bestimmt waren.


Sie können umgekehrt: Abonnieren Sie eine zufällige, wodurch die Belastung der Knoten verringert wird, und drücken Sie alles:


1549239361416


Wir sehen, dass es umgekehrt geworden ist: Die Knoten fühlen sich ruhiger an, aber die Belastung der Rettichinstanz hat zugenommen. Das ist auch nicht gut. Sie müssen ein bisschen Fahrrad fahren.


Um unser System zu pumpen, lassen wir das Paket socket.io-redis in Ruhe, obwohl es cool ist, brauchen wir mehr Freiheit. Und so verbinden wir den Rettich:


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

Richten Sie unser Messaging-System ein:


 //     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)) }) 

Im Moment stellt sich heraus, wie in socket.io-redis: Wir hören alle Nachrichten ab. Jetzt werden wir es beheben.


Wir organisieren Abonnements wie folgt: Denken Sie an das Konzept mit "Benutzer-ID == Raum-ID", und wenn der Benutzer angezeigt wird, abonnieren wir den gleichnamigen Kanal im Rettich. Daher empfangen unsere Knoten nur Nachrichten, die für sie bestimmt sind, und hören nicht die "gesamte Sendung" ab.


 //     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) }) 

Genial, jetzt sind wir sicher, dass Knoten nur Nachrichten empfangen, die für sie bestimmt sind, mehr nicht! Es sollte jedoch beachtet werden, dass die Abonnements selbst jetzt viel, viel größer sind, was bedeutet, dass sie den Speicher des Yoy Yoy + mehr Abonnement- / Abmeldevorgänge verschlingen, die relativ teuer sind. Auf jeden Fall gibt uns dies eine gewisse Flexibilität. Sie können sogar in diesem Moment anhalten und alle vorherigen Optionen erneut prüfen, wobei Sie bereits unsere neue Eigenschaft von Knoten in Form selektiverer, keusch empfangender Nachrichten berücksichtigen. Beispielsweise können Knoten eine von mehreren Rettichinstanzen abonnieren und beim Drücken eine Nachricht an alle Instanzen senden:


1550174595491


... aber was auch immer man sagen mag, sie bieten immer noch keine unendliche Erweiterbarkeit mit angemessenem Overhead, Sie müssen andere Optionen hervorbringen. An einem Punkt kam mir das folgende Schema in den Sinn: Was ist, wenn Rettichinstanzen in Gruppen unterteilt werden, z. B. A und B, jeweils zwei Instanzen. Beim Abonnieren werden Knoten von einer Instanz aus jeder Gruppe signiert, und beim Push senden sie eine Nachricht an alle Instanzen einer einzelnen zufälligen Gruppe.


1550174092066


1550174943313


So erhalten wir in Echtzeit eine Betriebsstruktur mit unendlichem Erweiterungspotential. Die Belastung eines einzelnen Knotens hängt zu keinem Zeitpunkt von der Größe des Systems ab, weil:


  1. Die Gesamtbandbreite wird zwischen Gruppen aufgeteilt, d. H. Mit einer Zunahme der Benutzer / Aktivität vergleichen wir einfach zusätzliche Gruppen.
  2. Die Benutzerverwaltung (Abonnements) ist innerhalb der Gruppen selbst aufgeteilt, d. H. Wenn Benutzer / Abonnements erhöht werden, erhöhen wir einfach die Anzahl der Instanzen innerhalb der Gruppen.

... und wie immer gibt es ein "ABER": Je mehr es alles bekommt, desto mehr Ressourcen werden für den nächsten Gewinn benötigt, es scheint mir ein exorbitanter Kompromiss.


Wenn Sie darüber nachdenken, sind die oben genannten Stecker im Allgemeinen darauf zurückzuführen, dass Sie nicht wissen, welcher Benutzer sich auf welchem ​​Knoten befindet. Wenn wir diese Informationen hätten, könnten wir Nachrichten genau dort pushen, wo sie benötigt werden, ohne unnötige Doppelarbeit. Was haben wir die ganze Zeit versucht? Sie versuchten, das System unendlich skalierbar zu machen, ohne über einen eindeutigen Adressierungsmechanismus zu verfügen, der unweigerlich entweder in eine Sackgasse oder in eine ungerechtfertigte Redundanz geriet. Sie können beispielsweise den Assistenten aufrufen, der als „Adressbuch“ fungiert:


1550233610561


Ähnliches sagt diesem Kerl:


Um den Standort des Benutzers zu ermitteln, führen wir eine zusätzliche Rundreise durch, die im Prinzip in Ordnung ist, in unserem Fall jedoch nicht. Es scheint, wir graben in die falsche Richtung, wir brauchen etwas anderes ...


Hash Stärke


Es gibt so etwas wie einen Hash. Es hat einen endlichen Wertebereich. Sie können es aus beliebigen Daten erhalten. Aber was ist, wenn Sie diesen Bereich auf Rettichinstanzen aufteilen? Nun, wir nehmen die Benutzer-ID, erstellen einen Hash und abhängig von dem Bereich, in dem sich herausstellte, dass sie eine bestimmte Instanz abonniert / pusht. Das heißt, wir wissen nicht im Voraus, wo welcher Benutzer existiert, aber nachdem wir ihn erhalten haben, können wir sicher sagen, dass es sich um die n-Instanz inf 100 handelt. Nun das Gleiche, aber mit dem Code:


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

Voila! Jetzt sind wir nicht mehr auf die Anzahl der Instanzen des Wortes im Allgemeinen angewiesen, sondern können ohne Overhead so viel skalieren, wie wir möchten! Im Ernst, dies ist eine brillante Option, deren einziges Minus die Notwendigkeit ist, das System vollständig neu zu starten, wenn die Anzahl der Rettichinstanzen aktualisiert wird. Es gibt so etwas wie einen Standardring und einen Partitionsring , mit denen Sie dies überwinden können, die jedoch in einem Nachrichtensystem nicht anwendbar sind. Sie können die Logik der Migration von Abonnements zwischen Instanzen erstellen, dies kostet jedoch immer noch einen zusätzlichen Code von unverständlicher Größe. Wie wir wissen, benötigen wir diesen Code nicht, je mehr Code, desto mehr Fehler. In unserem Fall sind Ausfallzeiten ein akzeptabler Kompromiss.


Sie können sich auch RabbitMQ mit seinem Plugin ansehen, mit dem wir das Gleiche tun können wie wir und + die Migration von Abonnements ermöglicht (wie oben erwähnt - es ist an die Funktionalität von Kopf bis Fuß gebunden). Im Prinzip können Sie es nehmen und ruhig schlafen, aber wenn jemand an seiner Abstimmung herumfummelt, um den Modus in Echtzeit zu bringen, bleibt nur eine Funktion mit einem Hash-Ring übrig.


Überflutete das Repository auf Github.


Es implementiert die endgültige Version, zu der wir gekommen sind. Zusätzlich gibt es eine zusätzliche Logik für die Arbeit mit Räumen (Dialoge).


Generell bin ich zufrieden und kann abgerundet werden.


Insgesamt


Sie können alles tun, aber es gibt so etwas wie Ressourcen, und sie sind endlich, also müssen Sie sich winden.


Wir haben mit völliger Unkenntnis darüber begonnen, wie verteilte Systeme zu mehr oder weniger greifbaren konkreten Mustern funktionieren können, und das ist gut so.

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


All Articles