Chat distribuido en Node.JS y Redis

El resultado es una imagen de broma para lavar el "correo de paloma"


Una pequeña pregunta / respuesta:


¿Para quién es? Personas que tienen poca o ninguna experiencia con sistemas distribuidos, y que están interesadas en ver cómo se pueden construir, qué patrones y soluciones existen.


¿Por qué es esto? Él mismo se interesó en qué y cómo. Recogí información de varias fuentes, decidí publicarla de forma concentrada, porque en algún momento me gustaría ver un trabajo similar. De hecho, esta es una declaración textual de mi arrojo y pensamiento personales. Además, seguramente habrá muchas correcciones en los comentarios de personas conocedoras, y este es en parte el propósito de escribir todo esto en forma de artículo.


Declaración del problema.


¿Cómo hacer un chat? Esta debería ser una tarea trivial, probablemente cada segundo Beckender aserra la suya propia, al igual que los desarrolladores de juegos hacen sus tetris / serpientes, etc. usuarios activos y en general fue increíblemente genial. La clara necesidad de una arquitectura distribuida proviene de esto, porque no es realista tener la capacidad actual para adaptarse a la cantidad imaginaria de clientes en una máquina. En lugar de simplemente sentarme y esperar la aparición de las computadoras cuánticas, decidí estudiar el tema de los sistemas distribuidos.


Vale la pena señalar que una respuesta rápida es muy importante, la notoria en tiempo real, ¡es un chat ! No entrega de correo de paloma.


% broma aleatoria sobre la publicación rusa %


Usaremos Node.JS, es ideal para la creación de prototipos. Para enchufes, tome Socket.IO. Escribe en TypeScript.


Y entonces, ¿qué queremos?


  1. Para que los usuarios puedan enviarse mensajes entre ellos
  2. Sepa quién está conectado / desconectado

¿Cómo lo queremos?


Servidor único


No hay nada que decir especialmente, directamente al código. Declare la interfaz del mensaje:


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

En el servidor:


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

En el cliente, algo como:


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

Puede trabajar con un estado en línea como este:


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

Y en el cliente:


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

Nota: el código no se ejecutó, escribo de memoria solo por ejemplo

Al igual que la leña, hacemos girar la autorización real de syudy, la gestión de la sala (historial de mensajes, agregar / eliminar participantes) y las ganancias.


PERO! Pero vamos a tomar el control de la paz mundial, lo que significa que no es el momento de parar, estamos avanzando rápidamente:


Clúster Node.JS


Los ejemplos del uso de Socket.IO en muchos nodos están en el sitio web oficial . Incluyendo también hay un clúster Node.JS nativo, que me pareció inaplicable a mi tarea: nos permite expandir nuestra aplicación en toda la máquina, PERO no más allá de su alcance, por lo que definitivamente lo extrañamos. ¡Necesitamos finalmente ir más allá de los límites de una sola pieza de hierro!


Distribuir y andar en bicicleta


Como hacerlo Obviamente, debe conectar de alguna manera nuestras instancias, lanzadas no solo en casa en el sótano, sino también en el sótano vecino. Lo primero que viene a la mente: hacemos algún tipo de enlace intermedio que servirá como un bus entre todos nuestros nodos:


1549140775997


Cuando un nodo quiere enviar un mensaje a otro, realiza una solicitud al Bus y, a su vez, lo reenvía a donde sea necesario, todo es simple. ¡Nuestra red está lista!


FIN.


... pero no es tan simple?)


Con este enfoque, nos encontramos con el rendimiento de este enlace intermedio y, de hecho, nos gustaría contactar directamente con los nodos necesarios, porque ¿qué puede ser más rápido que la comunicación directa? ¡Entonces, avancemos en esta dirección!


¿Qué se necesita primero? En realidad, legitimar una instancia a otra. Pero, ¿cómo aprende el primero sobre la existencia del segundo? Pero queremos tener un número infinito de ellos, ¡aumentar / eliminar arbitrariamente! Necesitamos un servidor maestro cuya dirección sea conocida, todos se conectan a ella, por lo que conoce todos los nodos existentes en la red y comparte amablemente esta información con todos.


1549048945334


El nodo se eleva, le dice al maestro sobre su despertar, le da una lista de otros nodos activos, nos conectamos a ellos y eso es todo, la red está lista. El maestro puede ser cónsul o algo así, pero como estamos en bicicleta, el maestro debe ser hecho a sí mismo.


¡Genial, ahora tenemos nuestra propia Skynet! Pero la implementación actual del chat ya no es adecuada. Vamos a presentar los requisitos:


  1. Cuando un usuario envía un mensaje, necesitamos saber a quién se lo envía, es decir, tener acceso a los participantes en la sala.
  2. Cuando recibimos a los participantes, debemos entregarles mensajes.
  3. Necesitamos saber qué usuario está en línea ahora.
  4. Para mayor comodidad: brinde a los usuarios la oportunidad de suscribirse al estado en línea de otros usuarios, para que en tiempo real se enteren de su cambio

Tratemos con los usuarios. Por ejemplo, puede hacer que el maestro sepa qué nodo está conectado a qué nodo. La situación es la siguiente:


1549237952673


Dos usuarios están conectados a diferentes nodos. El maestro sabe esto, los nodos saben lo que sabe el maestro. Cuando el usuario B inicia sesión, el nodo 2 notifica al maestro, que "recuerda" que el usuario B está conectado al nodo 2. Cuando el usuario A quiere enviar un mensaje de usuario B, obtiene la siguiente imagen:


1549140491881


En principio, todo funciona, pero me gustaría evitar una ronda adicional de viaje en forma de interrogar al maestro, sería más económico contactar inmediatamente al nodo correcto directamente, porque es por eso que todo comenzó. Esto se puede hacer si le dicen a todos los usuarios que están conectados a ellos, cada uno de ellos se convierte en un análogo autosuficiente del asistente y el asistente en sí mismo se vuelve innecesario, porque la lista de la proporción "Usuario => Nodo" está duplicada para todos. Al comienzo de un nodo, es suficiente para conectarse a cualquiera que ya se esté ejecutando, extraer su lista y listo, también está listo para la batalla.


1549139768940


1549139882747


Pero como compensación, obtenemos una duplicación de la lista, que, aunque es una relación de "ID de usuario -> [conexiones de host]", pero con un número suficiente de usuarios resultará ser bastante grande en la memoria. Y, en general, cortarlo usted mismo: claramente huele a la industria de la bicicleta. Cuanto más código, más errores potenciales. Quizás congelemos esta opción y echemos un vistazo a lo que ya está listo:


Corredores de mensajes


La entidad que implementa el mismo "Bus", el "enlace intermedio" mencionado anteriormente. Su tarea es recibir y entregar mensajes. Nosotros, como usuarios, podemos suscribirnos a ellos y enviar los nuestros. Todo es simple


Hay RabbitMQ y Kafka probados: simplemente hacen lo que entregan mensajes: este es su propósito, repleto de todas las funcionalidades necesarias para el cuello. En su mundo, un mensaje debe ser entregado pase lo que pase.


Al mismo tiempo, está Redis y su pub / sub, lo mismo que los chicos antes mencionados, pero más dudoso: simplemente recibe el mensaje estúpidamente y lo entrega al suscriptor, sin colas ni otros gastos generales. No le importan los mensajes en sí mismos, desaparecerán si el suscriptor cuelga; lo tirará y tomará uno nuevo, como si le arrojaran un póker candente en sus manos del que desea deshacerse más rápido. Además, si cae repentinamente, todos los mensajes también se hundirán junto con él. En otras palabras, no se trata de ninguna garantía de entrega.


... y esto es lo que necesitas!


Bueno, de verdad, solo chateamos. No es algún tipo de servicio de dinero crítico o centro de control de vuelo espacial, sino ... solo una charla. El riesgo es que Pete condicional una vez al año no reciba un mensaje de cada mil; puede ser descuidado si a cambio conseguimos un crecimiento de la productividad y establecemos con él el número de usuarios para los mismos días, intercambiamos en todo su esplendor. Además, al mismo tiempo, puede mantener un historial de mensajes en algún tipo de repositorio persistente, lo que significa que Petya aún verá ese mensaje perdido al volver a cargar la página / aplicación. Es por eso que nos centraremos en Redis pub / sub, o más bien: mire el adaptador existente para SocketIO, que se menciona en el artículo en la oficina. sitio .


Entonces que es esto?


Adaptador Redis


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


Con su ayuda, una aplicación ordinaria a través de unas pocas líneas y un número mínimo de gestos se convierte en un chat distribuido real. Pero como? Si miras dentro , resulta que solo hay un archivo por medio centenar de líneas.


En el caso cuando emitimos un mensaje


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

se inserta en rábanos, se transmite a todas las demás instancias de nuestro chat, que a su vez ya lo emiten localmente en sockets


1549232309776


El mensaje se distribuirá en todos los nodos, incluso si lo emitimos a un usuario específico. Es decir, cada nodo acepta todos los mensajes y ya comprende si lo necesita.


Además, se implementó un simple rpc (llamada a procedimientos remotos), que permite no solo enviar sino también recibir respuestas. Por ejemplo, puede controlar los zócalos de forma remota, como "quién está en la sala especificada", "ordenar que el zócalo se una a la sala", etc.


¿Qué se puede hacer con esto? Por ejemplo, use la ID de usuario como el nombre de la sala (ID de usuario == ID de la sala). Al autorizar, para conectar el zócalo y cuando queremos enviar un mensaje al usuario, solo un casco en él. Además, podemos averiguar si el usuario está en línea, simplemente mirando si hay enchufes en la habitación especificada.


En principio, podemos parar aquí, pero como siempre, no es suficiente para nosotros:


  1. Cuello de botella en una sola instancia de rábano
  2. Redundancia, me gustaría que los nodos reciban solo los mensajes que necesitan

A expensas del párrafo uno, mire algo como:


Redis cluster


Conecta varias instancias de rábano, después de lo cual funcionan como un todo. ¿Pero cómo lo hace? Sí, así:


1549233023980


... y vemos que el mensaje está duplicado para todos los miembros del clúster. Es decir, no está destinado a aumentar la productividad, sino a aumentar la confiabilidad, que sin duda es buena y necesaria, pero para nuestro caso no tiene valor y no salva la situación con un cuello de botella de ninguna manera, además, en suma, es aún más desperdicio de recursos.


1549231953897


Soy un principiante, no sé mucho, a veces tengo que volver al pitchforking, lo que haremos. No, dejemos el rábano para que no se deslice en absoluto, pero debes pensar en algo con la arquitectura porque el actual no es bueno.


Gire por el camino equivocado


Que necesitamos Aumentar el rendimiento general. Por ejemplo, tratemos de generar estúpidamente otra instancia. Imagine que socket.io-redis puede conectarse a varios, al enviar un mensaje, selecciona al azar y se suscribe a todo. Resulta así:


1549239818663


Voila! En general, el problema está resuelto, los rábanos ya no son un cuello de botella, ¡puedes generar cualquier cantidad de copias! Pero se convirtieron en nodos. Sí, sí, nuestras instancias de chat aún digieren TODOS los mensajes, a los que no estaban destinados.


Puede viceversa: suscribirse a uno aleatorio, lo que reducirá la carga en los nodos y empujará todo:


1549239361416


Vemos que se ha vuelto al revés: los nodos se sienten más tranquilos, pero la carga en la instancia de rábano ha aumentado. Esto tampoco es bueno. Necesitas andar en bicicleta un poco.


Para bombear nuestro sistema, dejaremos solo el paquete socket.io-redis, aunque es genial, necesitamos más libertad. Y así, conectamos el rábano:


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

Configure nuestro sistema de mensajería:


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

Por el momento, resulta como en socket.io-redis: escuchamos todos los mensajes. Ahora lo arreglaremos.


Organizamos las suscripciones de la siguiente manera: recuerde el concepto con "id de usuario == id de sala", y cuando aparezca el usuario, nos suscribiremos al canal del mismo nombre en el rábano. Por lo tanto, nuestros nodos solo recibirán mensajes destinados a ellos y no escucharán la "transmisión completa".


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

Impresionante, ahora estamos seguros de que los nodos solo reciben mensajes destinados a ellos, ¡nada más! Sin embargo, debe tenerse en cuenta que las suscripciones en sí son ahora mucho, mucho más grandes, lo que significa que se comerán la memoria del año a año, + más operaciones de suscripción / cancelación de suscripción, que son relativamente caras. Pero, en cualquier caso, esto nos da cierta flexibilidad, incluso puede detenerse en este momento y volver a visitar todas las opciones anteriores, ya teniendo en cuenta nuestra nueva propiedad de nodos en forma de mensajes de recepción más selectivos y castas. Por ejemplo, los nodos pueden suscribirse a una de varias instancias de rábano y, al presionar, enviar un mensaje a todas las instancias:


1550174595491


... pero, digan lo que uno diga, todavía no ofrecen una extensibilidad infinita con una sobrecarga razonable, es necesario dar a luz a otras opciones. En un momento, me vino a la mente el siguiente esquema: qué pasa si las instancias de rábano se dividen en grupos, digamos A y B, dos instancias en cada una. Al suscribirse, los nodos son firmados por una instancia de cada grupo, y al enviar, envían un mensaje a todas las instancias de un solo grupo aleatorio.


1550174092066


1550174943313


Por lo tanto, obtenemos una estructura operativa con un potencial de capacidad de expansión infinito en tiempo real, la carga en un nodo individual en cualquier punto no depende del tamaño del sistema, porque:


  1. El ancho de banda total se divide entre grupos, es decir, con un aumento de usuarios / actividad, simplemente comparamos grupos adicionales.
  2. La gestión de usuarios (suscripciones) se divide dentro de los propios grupos, es decir, al aumentar los usuarios / suscripciones, simplemente aumentamos el número de instancias dentro de los grupos.

... y como siempre hay un "PERO": cuanto más se obtiene, más recursos se necesitan para la próxima ganancia, me parece una compensación exorbitante.


En general, si lo piensa, los enchufes antes mencionados provienen de no saber qué usuario está en qué nodo. Bueno, de hecho, si tuviéramos esta información, podríamos enviar los mensajes justo donde lo necesitan, sin duplicaciones innecesarias. ¿Qué hemos intentado hacer todo este tiempo? Intentaron hacer que el sistema fuera infinitamente escalable, sin tener un mecanismo de direccionamiento claro, que inevitablemente se topaba con un callejón sin salida o una redundancia injustificada. Por ejemplo, puede recordar que el asistente actúa como una "libreta de direcciones":


1550233610561


Algo similar le dice a este tipo:


Para obtener la ubicación del usuario, hacemos un viaje de ida y vuelta adicional, que en principio está bien, pero no en nuestro caso. Parece que estamos cavando en la dirección equivocada, necesitamos algo más ...


Fuerza de hash


Existe un hash. Tiene un rango finito de valores. Puede obtenerlo de cualquier dato. Pero, ¿qué pasa si divide este rango entre instancias de rábano? Bueno, tomamos la ID de usuario, producimos un hash y, dependiendo del rango en el que resultó suscribirse / empujar a una instancia específica. Es decir, no sabemos de antemano dónde existe qué usuario, pero después de haberlo recibido, podemos decir con confianza que es en n instancia, inf 100. Ahora lo mismo, pero con el código:


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

Voila! Ahora que no dependemos del número de instancias de la palabra en general, ¡podemos escalar todo lo que queramos sin gastos generales! Bueno, en serio, esta es una opción brillante, la única desventaja es la necesidad de reiniciar completamente el sistema al actualizar el número de instancias de rábano. Existe un anillo estándar y un anillo de partición que le permiten superar esto, pero no son aplicables en un sistema de mensajería. Bueno, puede hacer la lógica de migrar suscripciones entre instancias, pero esto todavía cuesta un código adicional de tamaño incomprensible y, como sabemos, cuanto más código, más errores, no necesitamos esto, gracias. Y en nuestro caso, el tiempo de inactividad es una compensación bastante aceptable.


También puede ver RabbitMQ con su complemento , que nos permite hacer lo mismo que nosotros, y + proporciona la migración de suscripciones (como dije anteriormente, está vinculado con la funcionalidad de la cabeza a los pies). En principio, puede tomarlo y dormir tranquilo, pero si alguien se equivoca en su afinación para llevar el modo a tiempo real, dejando solo una función con un anillo de hash.


Inundó el repositorio en github.


Implementa la versión final a la que hemos llegado. Además, existe una lógica adicional para trabajar con salas (diálogos).


En general, estoy satisfecho y se puede redondear.


Total


Puede hacer cualquier cosa, pero existen recursos, y son finitos, por lo que debe retorcerse.


Comenzamos con un completo desconocimiento de cómo los sistemas distribuidos pueden funcionar con patrones concretos más o menos tangibles, y eso es bueno.

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


All Articles