Verteilter Chat auf Node.JS und Redis

Das Ergebnis ist ein bisschen wie eine Dovemail


Kleine Frage / Antwort:


Für wen ist es? Menschen, die mit verteilten Systemen wenig oder gar nicht zu tun haben und interessiert sind, wie sie aufgebaut werden können, welche Muster und Lösungen vorhanden sind.


Warum ist das so? Es wurde für mich interessant, was und wie. Ich habe mich aus verschiedenen Quellen zusammengetan und beschloss, mich in konzentrierter Form darzustellen, da ich diese Arbeit zu gegebener Zeit gerne selbst sehen würde. Im Wesentlichen ist dies eine Textaussage meiner persönlichen Gedanken und Einwände. Es wird sicherlich auch viele Korrekturen in Kommentaren von sachkundigen Personen geben, zum Teil ist dies der Zweck, all dies in Form eines Artikels zu schreiben.


Problemstellung


Wie mache ich einen Chat? Dies sollte eine triviale Aufgabe sein, wahrscheinlich hat jeder zweite Backender seine eigenen gesägt, so wie Spielentwickler ihre Tetris / Schlangen usw. anfertigen aktive Benutzer und war im Allgemeinen unglaublich cool. Daraus ergibt sich ein klares Bedürfnis nach einer verteilten Architektur, da mit den aktuellen Kapazitäten noch nicht alle imaginären Clients auf einem Rechner untergebracht werden können. Anstatt nur zu sitzen und auf das Erscheinen von Quantencomputern zu warten, widmete ich mich entschlossen dem Thema verteilter Systeme.


Es ist erwähnenswert, dass die schnelle Antwort sehr wichtig ist, die notorische Echtzeit, denn dies ist ein Chat ! und nicht die Postzustellung durch Tauben.


% zufälliger Witz über russische Post %


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


Und so wollen wir im Allgemeinen:


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

Wie wir es wollen:


Einzelner Server


Es gibt nichts besonders zu sagen, sofort 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 den 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 lief nicht, ich schreibe zum Beispiel nur aus dem Speicher

Genau wie Brennholz, dokruchiva syudy echte Autorisierung, Verwaltung von Räumen (Geschichte der Nachrichten, Hinzufügen / Löschen von Teilnehmern) und Gewinn.


ABER! Wir werden den Weltfrieden ergreifen, und deshalb nicht die Zeit, um aufzuhören. Wir bewegen uns schnell:


Node.JS-Cluster


Beispiele für die Verwendung von Socket.IO auf einer Reihe von Knoten finden Sie direkt auf der offiziellen Website . Dazu gehört auch der native Node.JS-Cluster, der mir auf meine Aufgabe nicht zutreffend erschien: Er ermöglicht es uns, unsere Anwendung auf die gesamte Maschine auszudehnen, ABER nicht darüber hinaus, daher kommen wir definitiv vorbei. Wir müssen endlich über die Grenzen eines Eisenstücks hinausgehen!


Verteilen und Fahrrad fahren


Wie kann man das machen? Natürlich müssen wir unsere Instanzen irgendwie binden, die nicht nur zu Hause im Keller laufen, sondern auch im Keller des Nachbarn. Was zuerst einfällt: Wir machen eine Zwischenverbindung, die als Bus zwischen all unseren Knoten dient:


1549140775997


Wenn ein Knoten eine Nachricht an einen anderen senden möchte, sendet er eine Anforderung an den Bus und leitet sie bereits an die richtige Stelle weiter, alles ist einfach. Unser Netzwerk ist fertig!


FIN.


... aber ist nicht alles so einfach?)


Bei diesem Ansatz stoßen wir auf die Leistung dieser Zwischenverbindung, und im Allgemeinen möchten wir direkt auf die richtigen Knoten verweisen, denn was kann schneller sein, als direkt zu kommunizieren? Also lasst es uns in diese Richtung bewegen!


Was brauchst du zuerst? Eigentlich eine Instanz zur anderen. Aber wie kann der erste von der Existenz des zweiten erfahren? Wir wollen eine unendliche Anzahl von ihnen haben, willkürlich abholen / reinigen! Wir brauchen einen Master-Server, dessen Adresse offensichtlich bekannt ist, jeder verbindet sich mit ihm, wodurch er alle vorhandenen Knoten im Netzwerk kennt und diese Informationen freundlicherweise mit jedem teilt.


1549048945334


Der Knoten steigt auf, erzählt dem Master von seinem Erwachen, er gibt eine Liste der anderen aktiven Knoten, wir stellen eine Verbindung zu ihnen her und das ist es, das Netzwerk ist bereit. Konsul oder ähnliches kann als Meister fungieren, aber da wir Rad fahren, sollte der Meister selbst gemacht sein.


Toll, jetzt haben wir unser eigenes Skynet! Aber die aktuelle Implementierung des Chats darin ist nicht mehr geeignet. Lassen Sie uns tatsächlich Anforderungen erfinden:


  1. Wenn ein Benutzer eine Nachricht sendet, müssen wir wissen, an wen er sie sendet, dh, um Zugriff auf die Raummitglieder zu haben.
  2. Wenn wir Teilnehmer empfangen haben, müssen wir ihnen Nachrichten übermitteln.
  3. Wir müssen wissen, welcher Benutzer gerade online ist.
  4. Geben Sie den Benutzern die Möglichkeit, den Online-Status anderer Benutzer zu abonnieren, um in Echtzeit über die Änderungen zu erfahren.

Lass uns mit den Benutzern umgehen. Zum Beispiel können Sie dem Master mitteilen, mit welchem ​​Knoten der Benutzer 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 UserB autorisiert ist, benachrichtigt Node2 den Master, der sich daran erinnert, dass UserB an Node2 angehängt ist. Wenn UserA eine Nachricht an UserB senden möchte, erhalten Sie folgendes Bild:


1549140491881


Im Prinzip funktioniert alles, aber ich möchte eine Extrarunde in Form einer Abfrage des Masters vermeiden, es wäre wirtschaftlicher, sofort direkt zum erforderlichen Knoten zu gehen, da dafür alles gestartet wurde. Dies kann geschehen, wenn sie jedem in ihrer Umgebung sagen, welche Benutzer mit ihnen verbunden sind, jeder von ihnen zu einem autarken Analogon des Masters wird und der Master selbst unnötig wird, da die Liste des Verhältnisses "Benutzer => Knoten" für alle doppelt vorhanden ist. Wenn Sie den Knoten starten, genügt es, eine Verbindung zu einem bereits laufenden Knoten herzustellen, seine Liste für sich selbst und voila abzurufen, er ist auch für den Kampf bereit.


1549139768940


1549139882747


Aber als Kompromiss erhalten wir eine Verdoppelung der Liste, die zwar ein Verhältnis von "Benutzer-ID -> [Host-Verbindungen]" ist, aber bei einer ausreichenden Anzahl von Benutzern wird der Speicher ziemlich groß sein. Und im Allgemeinen, um es selbst zu schneiden - es riecht eindeutig nach der Fahrradbranche. Je mehr Code - desto mehr potenzielle Fehler. Vielleicht werden wir diese Option einfrieren und wir werden sehen, dass bereits alles fertig ist:


Message Brokers


Eine Entität, die die gleichen "Bus" - "Intermediate" wie oben erwähnt implementiert Seine Aufgabe ist es, Nachrichten zu empfangen und zuzustellen. Wir als Benutzer - wir können sie abonnieren und unsere senden. Es ist einfach


Es gibt bewährte RabbitMQ- und Kafka-Programme: Sie tun nur, was sie übermitteln - dies ist ihr Zweck, überfüllt mit allen erforderlichen Funktionen. In ihrer Welt muss die Nachricht übermittelt werden, egal was passiert.


Zur gleichen Zeit gibt es Redis und sein Pub / Sub - das gleiche wie die zuvor genannten, aber eichelhafter: Er erhält einfach nur die Nachricht und übermittelt sie dem Abonnenten, ohne Warteschlangen und andere Gemeinkosten. Er interessiert sich absolut nicht für die Nachrichten selbst, wenn sie verschwinden, wenn der Abonnent auflegt - er wird sie wegwerfen und etwas Neues annehmen, als würde er einen roten, heißen Poker in die Hand werfen, den er schneller loswerden möchte. Wenn er plötzlich fällt, gehen auch alle Nachrichten mit ihm verloren. Mit anderen Worten, es gibt keine Garantie für die Sprachausgabe.


... und das brauchen Sie!


Wir unterhalten uns einfach. Kein kritischer Geldservice oder Kontrollzentrum für Weltraummissionen, aber ... nur ein Chat. Das Risiko, dass der bedingte Petya einmal im Jahr keine Nachricht aus tausend Nachrichten erhält, kann vernachlässigt werden, wenn wir im Gegenzug eine Leistungssteigerung erzielen und an dessen Stelle die Anzahl der Benutzer an denselben Tagen in vollem Umfang abhängt. Darüber hinaus können Sie gleichzeitig einen Verlauf der Nachrichten in einem permanenten Speicher aufbewahren. Das bedeutet, dass Petya die gleiche fehlende Nachricht sieht, indem Sie die Seite / Anwendung erneut laden. Um genau zu sein, werden wir uns deshalb auf Redis pub / sub konzentrieren: Schauen wir uns den vorhandenen Adapter für SocketIO an, der im Artikel im Büro erwähnt wird. Website .


Also was ist das?


Redis Adapter


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


Die gewohnte Anwendung mit wenigen Zeilen und einer minimalen Anzahl von Gesten wird so zu einem echten verteilten Chat! Aber wie? Wenn Sie nach innen schauen - es gibt nur eine Datei auf dem Boden, hunderte Zeilen.


In dem Fall, wenn wir eine Nachricht ausgeben


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

es schiebt sich in einen Rettich, wird an alle anderen Instanzen unseres Chats übertragen, die ihn dann lokal auf ihren Steckdosen abgeben


1549232309776


Die Nachricht wird über alle Knoten verteilt, auch wenn wir sie an einen bestimmten Benutzer ausgeben. Jeder Knoten akzeptiert alle Nachrichten und weiß bereits, ob er sie benötigt.


Außerdem ist ein einfacher RPC (Remote Procedure Call) implementiert, mit dem nicht nur Antworten gesendet, sondern auch empfangen werden können. Sie können z. B. Sockets fernsteuern, z. B. "Wer ist im angegebenen Raum", "Socket anordnen, um den Raum zu betreten" usw.


Was kann man damit machen? Verwenden Sie beispielsweise die Benutzer-ID als Namen des Raums (Benutzer-ID = Raum-ID ==). Wenn Sie autorisieren, einer Steckdose beizutreten, und wenn Sie dem Benutzer eine Nachricht senden möchten - einfach einen Helm hinein. Wir können auch herausfinden, ob der Benutzer online ist, indem Sie einfach prüfen, ob Steckdosen im angegebenen Raum vorhanden sind.


Im Prinzip kann dies gestoppt werden, aber wie immer haben wir wenig:


  1. Flaschenhals als einzige Rettichinstanz
  2. Redundanz, ich möchte, dass die Knoten nur die Nachrichten erhalten, die sie benötigen

Auf Kosten des ersten Artikels betrachten wir Folgendes:


Redis-Cluster


Verbindet mehrere Rettichinstanzen und arbeitet dann als Einheit. Aber wie macht er das? Ja so:


1549233023980


... und sehen Sie, dass die Nachricht an alle Mitglieder des Clusters dupliziert wird. Es ist nicht beabsichtigt, die Leistung zu steigern, sondern die Zuverlässigkeit zu verbessern, was schön und notwendig ist, aber für unseren Fall hat es keinen Wert und rettet die Situation nicht mit einem Engpass, und insgesamt ist es sogar noch mehr Ressourcenverbrauch.


1549231953897


Ich bin ein Anfänger, ich weiß nicht viel, manchmal muss ich zu Valesopedostroenie zurückkehren, was wir tun werden. Nein, lassen wir den Rettich, um überhaupt nicht auszurutschen, aber etwas muss mit der Architektur erfunden werden, denn die jetzige ist nicht gut.


Die falsche Wende


Was brauchen wir? Erhöhen Sie den Gesamtdurchsatz. Zum Beispiel werden wir versuchen, eine weitere Instanz dumm zu schlafen. Stellen Sie sich vor, dass socket.io-redis eine Verbindung zu mehreren herstellen kann, wenn Sie eine Nachricht pushen, eine zufällige auswählen und alles abonnieren. Es stellt sich so dar:


1549239818663


Voila! Im Allgemeinen ist das Problem gelöst, Rettich ist kein Engpass mehr, Sie können eine beliebige Anzahl von Instanzen erzeugen! Aber sie wurden zu Knoten. Ja, unsere Chatik-Instanzen verarbeiten ALLE Nachrichten, unabhängig davon, was sie beabsichtigen.


Es kann auch andersherum sein: Abonnieren Sie eine zufällige, was die Belastung der Knoten reduziert, und schieben Sie in alles:


1549239361416


Wir sehen, dass es das Gegenteil geworden ist: Die Knoten fühlen sich ruhiger an, aber die Last für die Rettich-Instanz hat zugenommen. Auch das ist nicht gut. Brauchen Sie ein bisschen sensibel.


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


//Отдельные каналы для:
const pub = new RedisClient({host: 'localhost', port: 6379})//Пуша сообщений
const sub = new RedisClient({host: 'localhost', port: 6379})//Подписок на них
//Также вспоминаем этот интерфейс
interface Message{
    roomId: string,//В какую комнату пишем
    message: string,//Что мы туда пишем
}

Passen Sie unser Nachrichtensystem an:


//Отлавливаем все приходящие сообщения тут
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 reparieren wir es.


Wir organisieren Abonnements auf folgende Weise: Wir rufen das Konzept mit einer "user id == room id" ab, und wenn ein Benutzer erscheint, abonnieren wir den gleichnamigen Kanal in Rettich. Daher empfangen unsere Knoten nur Nachrichten, die für sie bestimmt sind, und nicht, um "die gesamte Sendung" zu hören.


//Отлавливаем все приходящие сообщения тут
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)
})

Toll, jetzt sind wir sicher, dass die Knoten nur für sie bestimmte Nachrichten erhalten, mehr nicht! Es sollte jedoch beachtet werden, dass die Abonnements selbst jetzt viel, viel mehr sind, was bedeutet, dass sie die Erinnerung an den zweiten Jahrgang nutzen werden, + mehr Abonnements / Abbestellungen, die relativ teuer sind. Dies gibt uns jedoch in jedem Fall ein gewisses Maß an Flexibilität. Es ist an dieser Stelle sogar möglich, alle vorherigen Optionen anzuhalten und neu zu überdenken, wobei unsere neue Eigenschaft der Knoten in Form von selektiveren, keuschen Empfangsnachrichten berücksichtigt wird. Beispielsweise können Knoten eine von mehreren Rettichinstanzen abonnieren. Wenn Sie Push ausführen, senden Sie eine Nachricht an alle Instanzen:


1550174595491


... aber sie bieten immer noch keine unendliche Erweiterbarkeit mit einem angemessenen Overhead, Sie müssen andere Optionen gebären. An einem Punkt kam folgendes Schema in Frage: Was wäre, wenn die Rettich-Instanzen in Gruppen unterteilt würden, sagen wir A und B, jeweils zwei Instanzen. Beim Abonnieren abonnieren Knoten eine Instanz aus jeder Gruppe, und beim Push senden sie eine Nachricht an alle Instanzen einer zufälligen Gruppe.


1550174092066


1550174943313


So erhalten wir eine reale Struktur mit einem unendlichen Potenzial der Erweiterbarkeit in Echtzeit. Die Belastung eines einzelnen Knotens an einem beliebigen Punkt hängt nicht 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 Verwaltung durch Benutzer (Abonnements) ist innerhalb der Gruppen selbst aufgeteilt, dh mit einer Zunahme der Benutzer / Abonnements erhöhen wir einfach die Anzahl der Instanzen innerhalb der Gruppen.

... und wie immer gibt es ein "ABER": Je mehr es wird, desto mehr Ressourcen werden für die nächste Erhöhung benötigt, es scheint mir ein exorbitanter Kompromiss.


Im Allgemeinen, wenn Sie darüber nachdenken - die oben genannten Gags kommen von der Unwissenheit darüber, welcher Knoten welcher Benutzer ist. Nun ja, nachdem wir diese Informationen hatten, hätten wir die Nachrichten gegebenenfalls an den richtigen Ort bringen können, ohne unnötige Duplizierungen. Was haben wir die ganze Zeit versucht? Sie versuchten, das System unendlich skalierbar zu machen, ohne einen klaren Adressierungsmechanismus zu haben, aus dem sie unweigerlich entweder in eine Sackgasse oder in eine ungerechtfertigte Redundanz gerieten. Sie können beispielsweise den Master abrufen, der die Rolle "Adressbuch" übernimmt:


1550233610561


Etwas Ähnliches sagt diesem Kerl:


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


Hash-Stärke


Es gibt so etwas wie ein Hash. Es hat einen begrenzten Wertebereich. Sie können es aus beliebigen Daten erhalten. Und was ist, wenn dieser Bereich zwischen Rettich-Instanzen aufgeteilt ist? Nun, wir nehmen die Benutzer-ID, erzeugen einen Hash und abhängig von dem Bereich, in dem sich herausstellte, abonnieren wir eine bestimmte Instanz. Wir wissen nicht im Voraus, wo sich ein Benutzer befindet, aber nach Erhalt seiner ID können wir mit Sicherheit sagen, dass er sich in der n-Instanz infa 100 befindet. Jetzt dasselbe, 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 von der Anzahl der Instanzen abhängig, sondern können beliebig skalieren, ohne dass ein Overhead erforderlich ist. Nun, im Ernst, dies ist eine brillante Option. Der einzige Nachteil ist der vollständige Neustart des Systems, wenn die Anzahl der Rettich-Instanzen aktualisiert wird. Es gibt so etwas wie den Standardring und den Partitionsring , der dies überwinden kann, sie sind jedoch nicht auf die Bedingungen des Nachrichtensystems anwendbar. Nun, Sie können die Logik der Migration von Subskriptionen zwischen Instanzen ermöglichen, aber es kostet immer noch einen zusätzlichen Code von unverständlicher Größe, und wie wir wissen - je mehr Code, desto mehr Fehler brauchen wir nicht, danke. In unserem Fall sind Ausfallzeiten durchaus akzeptable Kompromisse.


Sie können sich auch RabbitMQ mit seinem Plugin ansehen , mit dem Sie dasselbe tun können, wie wir und + für die Migration von Abonnements vorsehen (wie ich oben sagte - es ist mit Funktionalität von Kopf bis Fuß verbunden). Im Prinzip kannst du es nehmen und ruhig schlafen, aber wenn jemand in seiner Stimmung fummelt, um den Modus in Echtzeit zu bringen, bleibt nur das Feature mit dem Hash-Ring.


Gefülltes Repository auf Githab.


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


Im Allgemeinen bin ich zufrieden und kann gerundet werden.


Gesamt


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


Wir haben mit völliger Unwissenheit darüber begonnen, wie verteilte Systeme mit weniger greifbaren spezifischen Mustern arbeiten können, und das ist gut so.


Jetzt auch beliebt: