Fiabilidad del transporte de Websocket (pérdida de datos de Socket.io durante la reconexión)

81

Usado

NodeJS, Socket.io

Problema

Imagina que hay 2 usuarios U1 y U2 , conectados a una aplicación a través de Socket.io. El algoritmo es el siguiente:

  1. U1 pierde completamente la conexión a Internet (por ejemplo, apaga Internet)
  2. U2 envía un mensaje a U1 .
  3. U1 aún no recibe el mensaje, porque Internet no funciona
  4. El servidor detecta la desconexión de U1 por tiempo de espera de latido
  5. U1 se vuelve a conectar a socket.io
  6. U1 nunca recibe el mensaje de U2 ; se pierde en el paso 4, supongo.

Explicación posible

Creo que entiendo por qué sucede:

  • en el paso 4, el servidor mata la instancia de socket y la cola de mensajes a U1 también
  • Además, en el paso 5, U1 y el servidor crean una nueva conexión (no se reutiliza), por lo que incluso si el mensaje todavía está en cola, la conexión anterior se pierde de todos modos.

Necesitas ayuda

¿Cómo puedo evitar este tipo de pérdida de datos? Tengo que usar Hearbeats, porque la gente no cuelga la aplicación para siempre. También debo dar la posibilidad de volver a conectarme, porque cuando implemento una nueva versión de la aplicación, no quiero tiempo de inactividad.

PD: Lo que llamo "mensaje" no es sólo un mensaje de texto que puedo almacenar en la base de datos, sino un valioso mensaje del sistema, cuya entrega debe garantizarse, o la interfaz de usuario se estropea.

¡Gracias!


Adición 1

Ya tengo un sistema de cuentas de usuario. Además, mi aplicación ya es compleja. Agregar estados fuera de línea / en línea no ayudará, porque ya tengo este tipo de cosas. El problema es diferente.

Consulte el paso 2. En este paso, técnicamente no podemos decir si U1 se desconecta , simplemente pierde la conexión, digamos durante 2 segundos, probablemente debido a una mala conexión a Internet. Entonces U2 le envía un mensaje, pero U1 no lo recibe porque Internet todavía está inactivo para él (paso 3). El paso 4 es necesario para detectar usuarios sin conexión, digamos, el tiempo de espera es de 60 segundos. Finalmente, en otros 10 segundos, la conexión a Internet para U1 se activa y se vuelve a conectar a socket.io. Pero el mensaje de U2 se pierde en el espacio porque el servidor U1 se desconectó por tiempo de espera.

Ese es el problema, no quiero entregar al 100%.


Solución

  1. Recopile un emisor (nombre de emisión y datos) en {} usuario, identificado por un ID de emisor aleatorio. Enviar emitir
  2. Confirme la emisión en el lado del cliente (envíe la emisión de vuelta al servidor con emitID)
  3. Si se confirma, elimina el objeto de {} identificado por emitID
  4. Si el usuario se volvió a conectar, marque {} para este usuario y recorra el paso ejecutando el Paso 1 para cada objeto en {}
  5. Cuando se desconecta o se conecta al ras {} para el usuario si es necesario
// Server
const pendingEmits = {};

socket.on('reconnection', () => resendAllPendingLimits);
socket.on('confirm', (emitID) => { delete(pendingEmits[emitID]); });

// Client
socket.on('something', () => {
    socket.emit('confirm', emitID);
});

Solución 2 (un poco)

Añadido el 1 de febrero de 2020.

Si bien esta no es realmente una solución para Websockets, es posible que alguien la encuentre útil. Migramos de Websockets a SSE + Ajax. SSE le permite conectarse desde un cliente para mantener una conexión TCP persistente y recibir mensajes de un servidor en tiempo real. Para enviar mensajes de un cliente a un servidor, simplemente use Ajax. Hay desventajas como la latencia y la sobrecarga, pero SSE garantiza la confiabilidad porque es una conexión TCP.

Como usamos Express, usamos esta biblioteca para SSE https://github.com/dpskvn/express-sse , pero puede elegir la que más le convenga.

SSE no es compatible con IE y la mayoría de las versiones de Edge, por lo que necesitaría un polyfill: https://github.com/Yaffle/EventSource .

igorpavlov
fuente
Eso es cierto. Pero socket.io es en realidad solo un protocolo de transporte. Por sí solo, no puede garantizar una entrega de mensajes consistente y confiable. Debe examinar (y leer) las arquitecturas pub-sub (publicar-suscribir) y las colas de mensajes. En la práctica, utilizará una base de datos persistente como redis para almacenar mensajes.
user568109
Entonces, ¿pubsub resolverá este problema? Si escribe una respuesta completa y la solución funciona, será recompensado con una recompensa (50 puntos).
igorpavlov
8
una pregunta tan bellamente organizada
Katie
1
Gracias. Debo decir que la respuesta aceptada funciona para mí. Actualmente utilizo el esquema sugerido y no tengo problemas.
igorpavlov
¡Hola Igor! Soy nuevo en Node.js y Socket.io. Si es posible, ¿podría mostrar su código :)
Eazy

Respuestas:

102

Otros han insinuado esto en otras respuestas y comentarios, pero el problema principal es que Socket.IO es solo un mecanismo de entrega, y no puede depender solo de él para una entrega confiable. La única persona que sabe con certeza que un mensaje se ha entregado correctamente al cliente es el propio cliente . Para este tipo de sistema, recomendaría hacer las siguientes afirmaciones:

  1. Los mensajes no se envían directamente a los clientes; en cambio, se envían al servidor y se almacenan en algún tipo de almacén de datos.
  2. Los clientes son responsables de preguntar "qué me perdí" cuando se vuelven a conectar, y consultarán los mensajes almacenados en el almacén de datos para actualizar su estado.
  3. Si se envía un mensaje al servidor mientras el cliente destinatario está conectado, ese mensaje se enviará en tiempo real al cliente.

Por supuesto, dependiendo de las necesidades de su aplicación, puede ajustar partes de esto; por ejemplo, puede usar, digamos, una lista de Redis o un conjunto ordenado para los mensajes, y borrarlos si sabe con certeza que un cliente está activo. hasta la fecha.


Aquí hay un par de ejemplos:

Camino feliz :

  • U1 y U2 están conectados al sistema.
  • U2 envía un mensaje al servidor que debería recibir U1.
  • El servidor almacena el mensaje en algún tipo de almacenamiento persistente, marcándolo para U1 con algún tipo de marca de tiempo o ID secuencial.
  • El servidor envía el mensaje a U1 a través de Socket.IO.
  • El cliente de U1 confirma (quizás a través de una devolución de llamada de Socket.IO) que recibió el mensaje.
  • El servidor elimina el mensaje persistente del almacén de datos.

Ruta sin conexión :

  • U1 pierde la conectividad a Internet.
  • U2 envía un mensaje al servidor que debería recibir U1.
  • El servidor almacena el mensaje en algún tipo de almacenamiento persistente, marcándolo para U1 con algún tipo de marca de tiempo o ID secuencial.
  • El servidor envía el mensaje a U1 a través de Socket.IO.
  • El cliente de U1 no confirma la recepción porque está desconectado.
  • Quizás U2 envíe a U1 algunos mensajes más; todos se almacenan en el almacén de datos de la misma manera.
  • Cuando U1 se vuelve a conectar, le pregunta al servidor "El último mensaje que vi fue X / Tengo el estado X, ¿qué me perdí?"
  • El servidor envía a U1 todos los mensajes que perdió del almacén de datos según la solicitud de U1
  • El cliente de U1 confirma la recepción y el servidor elimina esos mensajes del almacén de datos.

Si desea absolutamente una entrega garantizada, entonces es importante diseñar su sistema de tal manera que estar conectado realmente no importe, y que la entrega en tiempo real sea simplemente una ventaja ; esto casi siempre implica un almacén de datos de algún tipo. Como mencionó el usuario568109 en un comentario, existen sistemas de mensajería que abstraen el almacenamiento y la entrega de dichos mensajes, y puede valer la pena buscar una solución prediseñada de este tipo. (Es probable que aún tenga que escribir la integración de Socket.IO usted mismo).

Si no está interesado en almacenar los mensajes en la base de datos, es posible que pueda guardarlos en una matriz local; el servidor intenta enviar el mensaje a U1 y lo almacena en una lista de "mensajes pendientes" hasta que el cliente de U1 confirma que lo recibió. Si el cliente está desconectado, cuando regrese, puede decirle al servidor "Oye, me desconectaron, por favor envíame todo lo que me perdí" y el servidor puede iterar a través de esos mensajes.

Afortunadamente, Socket.IO proporciona un mecanismo que permite a un cliente "responder" a un mensaje que se parece a las devoluciones de llamada nativas de JS. Aquí hay un pseudocódigo:

// server
pendingMessagesForSocket = [];

function sendMessage(message) {
  pendingMessagesForSocket.push(message);
  socket.emit('message', message, function() {
    pendingMessagesForSocket.remove(message);
  }
};

socket.on('reconnection', function(lastKnownMessage) {
  // you may want to make sure you resend them in order, or one at a time, etc.
  for (message in pendingMessagesForSocket since lastKnownMessage) {
    socket.emit('message', message, function() {
      pendingMessagesForSocket.remove(message);
    }
  }
});

// client
socket.on('connection', function() {
  if (previouslyConnected) {
    socket.emit('reconnection', lastKnownMessage);
  } else {
    // first connection; any further connections means we disconnected
    previouslyConnected = true;
  }
});

socket.on('message', function(data, callback) {
  // Do something with `data`
  lastKnownMessage = data;
  callback(); // confirm we received the message
});

Esto es bastante similar a la última sugerencia, simplemente sin un almacén de datos persistente.


También puede interesarle el concepto de abastecimiento de eventos .

Michelle Tilley
fuente
2
He esperado la respuesta final completa con una declaración, que los clientes DEBEN confirmar la entrega. Parece que realmente no hay otra manera.
igorpavlov
¡Feliz de haber ayudado! Dame un ping si tienes alguna pregunta.
Michelle Tilley
Esto funcionará en un escenario de chat uno a uno. lo que sucede en las salas de ejemplo donde el mensaje se envía a varios usuarios. broadcast / socket.in no admite devolución de llamada. entonces, ¿cómo manejamos esa situación? mi pregunta sobre esto. ( stackoverflow.com/questions/43186636/… )
jit
2

La respuesta de Michelle es bastante acertada, pero hay algunas otras cosas importantes a considerar. La pregunta principal que debe hacerse es: "¿Hay alguna diferencia entre un usuario y un socket en mi aplicación?" Otra forma de preguntar es "¿Puede cada usuario que haya iniciado sesión tener más de 1 conexión de socket a la vez?"

En el mundo web, es probable que siempre exista la posibilidad de que un solo usuario tenga múltiples conexiones de socket, a menos que haya establecido específicamente algo que lo evite. El ejemplo más simple de esto es si un usuario tiene abiertas dos pestañas de la misma página. En estos casos, no le importa enviar un mensaje / evento al usuario humano solo una vez ... debe enviarlo a cada instancia de socket para ese usuario para que cada pestaña pueda ejecutar sus devoluciones de llamada para actualizar el estado de la interfaz de usuario. Tal vez esto no sea una preocupación para ciertas aplicaciones, pero mi instinto dice que lo sería para la mayoría. Si esto le preocupa, siga leyendo ...

Para resolver esto (asumiendo que está utilizando una base de datos como su almacenamiento persistente) necesitaría 3 tablas.

  1. usuarios, que es un 1 a 1 con personas reales
  2. clientes - que representa una "pestaña" que podría tener una sola conexión a un servidor de socket. (cualquier 'usuario' puede tener múltiples)
  3. mensajes: un mensaje que debe enviarse a un cliente (no un mensaje que debe enviarse a un usuario oa un socket)

La tabla de usuarios es opcional si su aplicación no la requiere, pero el OP dijo que tienen una.

La otra cosa que debe definirse correctamente es "¿qué es una conexión de socket?", "¿Cuándo se crea una conexión de socket?", "¿Cuándo se reutiliza una conexión de socket?". El psudocódigo de Michelle hace que parezca que una conexión de socket se puede reutilizar. Con Socket.IO, NO PUEDEN reutilizarse. He visto ser la fuente de mucha confusión. Hay escenarios de la vida real en los que el ejemplo de Michelle tiene sentido. Pero tengo que imaginarme que esos escenarios son raros. Lo que realmente sucede es que cuando se pierde una conexión de socket, esa conexión, ID, etc. nunca se reutilizarán. Por lo tanto, cualquier mensaje marcado específicamente para ese socket nunca se entregará a nadie porque cuando el cliente que se había conectado originalmente, se vuelve a conectar, obtiene una conexión completamente nueva y una nueva ID. Esto significa que '

Entonces, para un ejemplo basado en la web, aquí estaría el conjunto de pasos que recomendaría:

  • Cuando un usuario carga un cliente (por lo general, una sola página web) que tiene el potencial de crear una conexión de socket, agregue una fila a la base de datos de clientes que está vinculada a su ID de usuario.
  • Cuando el usuario realmente se conecte al servidor de socket, pase el ID de cliente al servidor con la solicitud de conexión.
  • El servidor debe validar que el usuario tiene permiso para conectarse y que la fila del cliente en la tabla de clientes está disponible para la conexión y permitir / denegar en consecuencia.
  • Actualice la fila del cliente con el ID de socket generado por Socket.IO.
  • Envíe todos los elementos de la tabla de mensajes relacionados con la identificación del cliente. No habría ninguna en la conexión inicial, pero si se trataba de un cliente que intentaba reconectarse, puede haber alguna.
  • Siempre que sea necesario enviar un mensaje a ese socket, agregue una fila en la tabla de mensajes que esté vinculada al ID de cliente que generó (no al ID del socket).
  • Intente emitir el mensaje y escuche al cliente con el reconocimiento.
  • Cuando obtenga el reconocimiento, elimine ese elemento de la tabla de mensajes.
  • Es posible que desee crear alguna lógica en el lado del cliente que descarte los mensajes duplicados enviados desde el servidor, ya que técnicamente es una posibilidad, como algunos han señalado.
  • Luego, cuando un cliente se desconecta del servidor de socket (intencionalmente o por error), NO elimine la fila del cliente, simplemente borre el ID del socket como máximo. Esto se debe a que ese mismo cliente podría intentar volver a conectarse.
  • Cuando un cliente intente volver a conectarse, envíe el mismo ID de cliente que envió con el intento de conexión original. El servidor verá esto como una conexión inicial.
  • Cuando se destruye el cliente (el usuario cierra la pestaña o se aleja), es cuando elimina la fila del cliente y todos los mensajes para este cliente. Este paso puede resultar un poco complicado.

Porque el último paso es complicado (al menos solía serlo, no he hecho nada de eso en mucho tiempo), y porque hay casos como pérdida de energía en los que el cliente se desconecta sin limpiar la fila del cliente y nunca lo intenta para volver a conectarse con esa misma fila de cliente, probablemente desee tener algo que se ejecute periódicamente para limpiar cualquier cliente obsoleto y filas de mensajes. O puede almacenar permanentemente todos los clientes y mensajes para siempre y marcar su estado de manera adecuada.

Entonces, para que quede claro, en los casos en que un usuario tiene dos pestañas abiertas, agregará dos mensajes idénticos a la tabla de mensajes, cada uno marcado para un cliente diferente porque su servidor necesita saber si cada cliente los recibió, no solo cada usuario.

Ryan
fuente
1

Parece que ya tienes un sistema de cuentas de usuario. Usted sabe qué cuenta está en línea / fuera de línea, puede manejar el evento de conexión / desconexión:

Entonces, la solución es agregar mensajes en línea / fuera de línea y fuera de línea en la base de datos para cada usuario:

chatApp.onLogin(function (user) {
   user.readOfflineMessage(function (msgs) {
       user.sendOfflineMessage(msgs, function (err) {
           if (!err) user.clearOfflineMessage();
       });
   })
});

chatApp.onMessage(function (fromUser, toUser, msg) {
   if (user.isOnline()) {
      toUser.sendMessage(msg, function (err) {
          // alert CAN NOT SEND, RETRY?
      });
   } else {
      toUser.addToOfflineQueue(msg);
   }
})
sombrero húmedo
fuente
Por favor, lea la sección "Adición 1" de mi pregunta. No creo que tu respuesta sea una solución.
igorpavlov
Eso es interesante, empiezo mi propio proyecto de chat ahora, tal vez con web RTC: ->
damphat
El mío también en WebRTC. Pero en este contexto no importa. Ah ... Si todas las personas tienen Internet estable ... Estoy tan frustrado cuando los usuarios tienen 100 Mbps en Speedtest, pero en realidad si intentan hacer ping, tienen un 20% de pérdida de paquetes. ¿Quién necesita tal Internet? =)
igorpavlov
0

Mire aquí: Manejar la recarga del navegador socket.io .

Creo que podrías usar la solución que se me ocurrió. Si lo modifica correctamente, debería funcionar como desee.

Son
fuente
Eso es interesante, no pude encontrar esta pregunta, pero busqué en Google durante varias horas. ¡Echaremos un vistazo!
igorpavlov
Parece que ya utilizo este tipo de arquitectura. No resuelve el problema exacto que describí.
igorpavlov
0

Lo que creo que quieres es tener un socket reutilizable para cada usuario, algo como:

Cliente:

socket.on("msg", function(){
    socket.send("msg-conf");
});

Servidor:

// Add this socket property to all users, with your existing user system
user.socket = {
    messages:[],
    io:null
}
user.send = function(msg){ // Call this method to send a message
    if(this.socket.io){ // this.io will be set to null when dissconnected
        // Wait For Confirmation that message was sent.
        var hasconf = false;
        this.socket.io.on("msg-conf", function(data){
            // Expect the client to emit "msg-conf"
            hasconf = true;
        });
        // send the message
        this.socket.io.send("msg", msg); // if connected, call socket.io's send method
        setTimeout(function(){
            if(!hasconf){
                this.socket = null; // If the client did not respond, mark them as offline.
                this.socket.messages.push(msg); // Add it to the queue
            }
        }, 60 * 1000); // Make sure this is the same as your timeout.

    } else {
        this.socket.messages.push(msg); // Otherwise, it's offline. Add it to the message queue
    }
}
user.flush = function(){ // Call this when user comes back online
    for(var msg in this.socket.messages){ // For every message in the queue, send it.
        this.send(msg);
    }
}
// Make Sure this runs whenever the user gets logged in/comes online
user.onconnect = function(socket){
    this.socket.io = socket; // Set the socket.io socket
    this.flush(); // Send all messages that are waiting
}
// Make sure this is called when the user disconnects/logs out
user.disconnect = function(){
    self.socket.io = null; // Set the socket to null, so any messages are queued not send.
}

Luego, la cola de sockets se conserva entre desconexiones.

Asegúrese de que guarde la socketpropiedad de cada usuario en la base de datos y haga que los métodos formen parte de su prototipo de usuario. La base de datos no importa, simplemente guárdela sin embargo ha estado guardando a sus usuarios.

Esto evitará el problema mencionado en el complemento 1 al requerir una confirmación del cliente antes de marcar el mensaje como enviado. Si realmente lo desea, puede darle a cada mensaje una identificación y hacer que el cliente envíe la identificación del mensaje a msg-conf, luego verifíquelo.

En este ejemplo, useres el usuario de la plantilla del que se copian todos los usuarios, o como el prototipo de usuario.

Nota: esto no ha sido probado.

Ari Porad
fuente
¿Puede decirme cuál es realmente la variable "usuario"?
igorpavlov
De hecho, creo que acertó mi pregunta. Pero, ¿puede proporcionar también algunos comentarios para cada fragmento de código? Todavía no entiendo cómo puedo integrarlo en mi código. Además, ¿dónde debería guardarlo en la base de datos y a qué tipo de base de datos se refiere? Redis o podría ser Mongo o no importa?
igorpavlov
Todavía no resuelve el problema. Cuando se envía el mensaje, ambos usuarios (remitente y receptor) están EN LÍNEA para el servidor. Lea atentamente la adición 1 de mi pregunta. En este caso, "this.socket.io" siempre será "verdadero", por lo que el mensaje se envía, pero no se recibe. Intentas resolver el problema cuando SENDER se desconecta, pero no RECEIVER. ¿O no tengo razón?
igorpavlov
@igorpavlov, lo siento, pero me estás entendiendo mal. Imagínese esto: U1 quiere enviar un mensaje, "Hola" a U2: users.getUserByName("U2").send("Hi"). Entonces, si U2 está en línea, socket.io de U2 no será nulo, por lo que se enviará el mensaje. Si el socket de U2 es nulo, se pondrá en cola hasta que U2 se conecte.
Ari Porad
1
Creo que @igorpavlov tiene razón. Habrá un período de tiempo en el que el cliente se desconectará realmente, pero el servidor no lo sabe porque el latido aún no ha ocurrido. En este periodo de tiempo, this.socket.ioserá no ser null, y el servidor intentará entregar los mensajes.
Michelle Tilley
0

Como ya se escribió en otra respuesta, también creo que debería considerar el tiempo real como una ventaja: el sistema debería poder funcionar también sin tiempo real.

Estoy desarrollando un chat empresarial para una gran empresa (ios, android, web frontend y .net core + postGres backend) y después de haber desarrollado una forma para que websocket restablezca la conexión (a través de un uuid de socket) y reciba mensajes no entregados (almacenado en una cola) Entendí que había una solución mejor: resincronizar a través de la API de descanso.

Básicamente terminé usando websocket solo en tiempo real, con una etiqueta entera en cada mensaje en tiempo real (usuario en línea, mecanografiado, mensaje de chat, etc.) para monitorear los mensajes perdidos.

Cuando el cliente obtiene una identificación que no es monolítica (+1), entonces entiende que no está sincronizado, por lo que elimina todos los mensajes de socket y solicita una resincronización de todos sus observadores a través de la API REST.

De esta manera, podemos manejar muchas variaciones en el estado de la aplicación durante el período fuera de línea sin tener que analizar toneladas de mensajes websocket seguidos en la reconexión y estamos seguros de que estaremos sincronizados (porque la última fecha de sincronización se establece solo con la API REST , no del enchufe).

La única parte complicada es monitorear los mensajes en tiempo real desde el momento en que llama a la API de REST hasta el momento en que el servidor responde porque lo que se lee en la base de datos tarda en volver al cliente y, mientras tanto, pueden ocurrir variaciones, por lo que deben almacenarse en caché. y tuvo en cuenta.

Entraremos en producción en un par de meses, espero volver a dormir para entonces :)

Il Pisanello
fuente
-2

He estado mirando estas cosas últimamente y creo que un camino diferente podría ser mejor.

Intente buscar en Azure Service bus, las preguntas y el tema para cuidar los estados fuera de línea. El mensaje espera a que el usuario regrese y luego recibe el mensaje.

Es un costo ejecutar una cola, pero es como $ 0.05 por millón de operaciones para una cola básica, por lo que el costo de desarrollo sería mayor por las horas de trabajo que necesita para escribir un sistema de cola. https://azure.microsoft.com/en-us/pricing/details/service-bus/

Y azure bus tiene bibliotecas y ejemplos para PHP, C #, Xarmin, Anjular, Java Script, etc.

Entonces, el servidor envía un mensaje y no necesita preocuparse por rastrearlos. El cliente puede usar el mensaje para enviar de vuelta también, ya que los medios pueden manejar el equilibrio de carga si es necesario.

Soluciones verdaderas
fuente
A mí me parece una colocación de productos. Alguien puede encontrar esto útil, pero esto ni siquiera es una tecnología, sino un servicio completo, también pagado.
igorpavlov
-2

Prueba esta lista de chat de emisión

io.on('connect', onConnect);

function onConnect(socket){

  // sending to the client
  socket.emit('hello', 'can you hear me?', 1, 2, 'abc');

  // sending to all clients except sender
  socket.broadcast.emit('broadcast', 'hello friends!');

  // sending to all clients in 'game' room except sender
  socket.to('game').emit('nice game', "let's play a game");

  // sending to all clients in 'game1' and/or in 'game2' room, except sender
  socket.to('game1').to('game2').emit('nice game', "let's play a game (too)");

  // sending to all clients in 'game' room, including sender
  io.in('game').emit('big-announcement', 'the game will start soon');

  // sending to all clients in namespace 'myNamespace', including sender
  io.of('myNamespace').emit('bigger-announcement', 'the tournament will start soon');

  // sending to individual socketid (private message)
  socket.to(<socketid>).emit('hey', 'I just met you');

  // sending with acknowledgement
  socket.emit('question', 'do you think so?', function (answer) {});

  // sending without compression
  socket.compress(false).emit('uncompressed', "that's rough");

  // sending a message that might be dropped if the client is not ready to receive messages
  socket.volatile.emit('maybe', 'do you really need it?');

  // sending to all clients on this node (when using multiple nodes)
  io.local.emit('hi', 'my lovely babies');

};

Oladimeji Ajeniya
fuente