Aplicaciones Real-Time de Alta Escala

Crear aplicaciones Real-Time es complicado, bastante complicado, pero a su vez sirve para mejorar la experience de usuario al ofrecer actualizaciones inmediatas del contenido.

Hay múltiples formas de implementar real-time, ya sea con WebSockets, Server Sent Events o Long Polling. En este caso vamos a hablar de como usar WebSockets en una aplicación de gran demanda sin matar nuestros servidores.

Nota: Todo lo que viene a continuación esta basado en mi experiencia trabajando con WebSockets (WS) en proyectos como Platzi Live.

Stack Tecnológico

Es importante entender que esto se puede hacer con prácticamente cualquier tecnología, no solo Node.js o Go, Ruby, PHP, Python, etc. tienen formas de usar WS ya que al final del día es solo un protocolo, como lo es HTTP.

Dicho eso, la naturaleza asíncrona de Node.js y su simplicidad lo hacen una buena elección para implementar un servidor de WS, combinado con librerías como socket.io se puede tener un servidor funcionando en muy poco tiempo. Es probable que eventualmente sea necesario migrar a otras tecnologías como Go o Rust para soportar mayor cantidad de usuarios por instancia de nuestro servidor, pero esto normalmente ocurre al tener una demanda extremadamente alta.

De todas formas es importante usar una tecnología que conocemos y nos sintamos cómodos programando, si usan Ruby pueden usar ActionCable para implementarlo sin problemas y eventualmente ver si vale la pena migrar, capaz en este caso a Elixir debido a la similaridad.

Otra parte importante de un servidor de WS es un sistema de colas o Pub/Sub que nos permita escalar horizontalmente nuestro servidor sin perder mensajes, de esta forma nuestro servidor simplemente recibiría mensajes desde estos servicios y los enviaría a sus usuarios conectados. Para esto podemos usar Redis como Pub/Sub simple o un sistema más especializado como RabbitMQ o Apache Kafka o usando bases de datos que permitan suscribirse a cambios como RethinkDB.

Evitar Mutaciones desde WebSocket

Una vez empezamos a usar WS es común pensar en enviar todo por este canal, de esta forma si necesitamos hacer consultas lo hacemos por WS, si vamos a crear un nuevo recurso, modificarlo o borrarlo vamos a enviar la información necesaria para esto usando WS. Aunque esto funciona a pequeña escala, cuando empezamos a tener muchos usuarios realizando este tipo de mutaciones vamos a empezar a saturar nuestro canal de WS necesitando levantar más instancias para menor cantidad de personas.

Para evitar esto lo mejor es mantener un API que, usando HTTP, permita a los clientes de nuestra aplicaciones realizar consultas y mutaciones, al ser un protocolo sin estado es posible escalar este API para recibir más peticiones de forma mucho más fácil que WS.

Luego de que una mutación ocurra el API debería enviar a nuestro sistema de colas una notificación del cambio, nuestro servidor de WS procesaría entonces el mensaje de la cola y enviaría a todos los clientes subscriptos la notificación de que cambió algo, dicha notificación puede ser tanto los datos que cambiaron como un simple aviso de que cambió y el ID para poder luego consultar el cambio mediante HTTP.

Deltas de Información

Un error muy común es enviar mucha información por nuestro canal de WS, esto causa el mismo problema que enviar consultas y mutaciones por WS, saturamos nuestro canal de WS de información no deseada. Mi recomendación acá es enviar deltas de información ¿Qué significa esto? Enviar solo lo que cambió y una forma de indentificar el recurso, en vez de enviar el recurso entero.

Si tenemos un sistema de comentarios podríamos tener un objeto similar a este en nuestro cliente:

{
  "id": 123,
  "author": 456,
  "message": "Hello, world!",
  "likes": [6546, 123213, 4678234, 12, 567, 98] // lista de IDs de usuario
}

Supongamos que un nuevo usuario, el 678, le da like al comentario, podríamos enviar todo el comentario de nuevo actualizado con el nuevo array de likes, ó, simplemente enviar un pequeño mensaje con el delta.

{
  "action": "like",
  "comment": 123,
  "user": 678
}

Y nuestro cliente entonces se encargaría de aplicar este like al comentario que ya tiene y actualizar la UI. Hacer esto reduce la cantidad de información enviada por nuestro canal a solo lo esencial.

Es importante tener en cuenta que esto puede tener un problema, si el cliente todavía no tiene el comentario 123 en su estado interno entonces el delta no se va a aplicar y puede que quede desactualizado.

De igual forma es posible que el usuario pierda información durante una desconexión, esto se puede mitigar haciendo consultas cada cierto tiempo o enviando información de cual fue el último delta obtenido, si el usuario está desactualizado el API HTTP puede enviar los nuevos deltas hasta que recupere todos los deltas perdidos.

Otra opción es que si el cliente recibe una actualización para el comentarion 123 y no lo posee en su estado puede pedirlo por HTTP y guardarlo de forma que recupere mensajes perdidos. Todo depende del nivel de consistencia de datos que deseamos en nuestro sistema.

Flujo de datos

Vamos entonces a ejemplificar el flujo de datos según lo dicho anteriormente.

  • Cliente consulta datos al API HTTP
  • API HTTP responde con la información al cliente
  • Cliente se suscribe al API WS
  • Cliente envia una mutación al API HTTP para crear un nuevo recurso
  • API HTTP actualiza la base de datos para insertar el nuevo recurso
  • API HTTP responde al cliente para confirmar la mutación
  • API HTTP encola la notificación del nuevo recurso
  • API WS procesa la cola para obtener la notificación
  • API WS envía a los clientes suscriptos la notificación
  • Cliente actualiza su estado al recibir el mensaje

De esta forma nuestro servidor de WS se mantiene lo más simple posible para reducir su carga de trabajo, permitiéndonos soportar mayor cantidad de usuarios con menos recursos.

Manejar desconexiones

Las conexiones a internet no son perfectas y fallan, de hecho fallan constantemente, es muy común perder paquetes de datos y muchos protocolos manejan esto encargándose de volver a pedir estos paquetes. Si vamos al mundo de las redes móviles esto es aún más común, ya sea que el usuario se aleja de su router y perdió conexión mientras pasaba a su plan datos, estaba en un vehículo y cambió de celda de telefonía o simplemente perdió señal.

Lo más probable es que el usuario se desconecte y al pasar esto va a perder conexión con su servidor de WS. Por eso es muy importante manejar las desconexiones del lado del cliente detectándolas e intentando conectarse nuevamente. Comunmente esto se intenta de a intervalos incrementales, de forma que el primer intento es inmediato, el segundo tarda unos segundos y así va creciendo pudiendo esperar minutos antes de volver a intentar.

Recuperar información

Tras una desconexión es posible que el usuario haya perdido deltas de información, ya sean nuevo recursos o actualizaciones de estos, dependiendo del tipo de consistencia que deseamos podemos manejar esto de diferentes formas.

Si la consistencia no es una prioridad (Eventual Consistency), por ejemplo en un chat masivo no importa perder algunos mensajes, podemos como mencionaba anteriormente traerse la información faltante al recibir un delta de un recurso que no esté en nuestro estado. Para el caso de que faltan actualizaciones podemos volver a traernos antes el primer delta el recurso ya sea que esté o no en el estado de la aplicación, de esta forma nos aseguramos de tener lo más actualizado posible.

En caso de que si sea necesario mantener una consistencia (Strong Consistency) podemos hacer que el cliente al recuperar conexión envié alguna especie de marca de tiempo (timestamp) ya sea el API de WS o de preferencia al API HTTP para indicar en que momento se quedó, el API que contactemos puede entonces enviar los deltas de información faltantes al cliente para que este los procese en el mismo orden en que ocurrieron y se actualice el estado.

En caso de elegir una consistencia fuerte va a ser necesario almacenar todos las notificaciones con su fecha en alguna base de datos para poder luego enviarlos al cliente, para esto podemos usar bases de datos basadas en documentos como MongoDB que nos van a permitir crear una colección de deltas y guardarlos todos junto a toda la información de cada uno, sin importar el esquema de datos.

Escalamiento horizontal

Hasta ahora hablamos de optimizar para usar una sola instancia, lo cual aunque ideal no es realista si pensamos en aplicaciones que tengan muchos usuarios. Y si bien podemos intentar escalar verticalmente añadiendo recursos al servidor de nuestro API WS para que tenga más memoria y CPU esto eventualmente es caro y no beneficia tanto, es entonces donde vamos a tener que escalar horizontalmente.

¿Qué significa esto? Que en vez de mejorar nuestro servidor vamos a agregar más servidores corriendo nuestro API WS, para que esto funcione vamos a necesitar distribuir la carga de trabajo entre estos servidores, en el caso de un API HTTP esto es relativamente sencillo, en el caso de WS debido a tener un estado que mantener (los clientes conectados) necesitamos usar un balanceador de cargas configurado para usar sticky sessions, esto quiere decir que después de la primer petición (la conexión) toda la comunicación entre el cliente y el API WS va a ocurrir con el mismo servidor o instancia de nuestro API.

En el caso de que el usuario se desconecte podemos volver a conectarlo a cualquiera de nuestro servidores. En el caso de que un servidor se apague, ya sea porque decidimos hacerlo o se cayó debido a un error, y todos los clientes de este se desconecten vamos a tener que distribuir la carga.

Lo que nos lleva a que nuestro balanceador de carga debe saber cuantos clientes conectados tiene un servidor o instancia para asegurarse de distribuir los nuevos usuarios sin sobrecargar ninguno de estos con más clientes de los que puede soportar, es posible que incluso tengamos que rechazar intentos de conexión hasta que se levante una nueva instancia del API WS o algún cliente se desconecte, esto debería ser un último recurso.

Una vez configurado nuestro balanceador es posible incluso automatizar el levantamiento de instancias si las actuales están llegando al límite de usuarios, algo común en horarios pico de uso o si nuestra aplicación tiene algún estallido de popularidad, hacer esto dejaría que la nueva instancia reciba a todos los clientes nuevos hasta que se empareje con las instancia anteriores, momento en el cual se podría levantar una nueva instancia de forma automática.

Cuando este aumento de uso desaparezca es posible empezar a apagar instancias, incluso instancias con poco uso para forzar a los clientes a reconectarse a las que queden corriendo. De esta forma podemos ahorrar en gastos pagando solo por los servidores que necesitemos cuando los necesitamos.

Palabras finales

Por último, mi mayor recomendación es que se intente evitar el uso de WebSockets lo más posible, long-polling o SSE son buenas alternativas más fáciles de escalar y baratas que funcionan igual de bien en muchos casos, lo mejor es intentar considerar WS como una alternativa final si deverdad necesitamos este tipos de conexiones constantes entre el cliente y el servidor.