Una app de chat es la pregunta de diseño de sistemas que más estado esconde. Cada usuario mantiene una conexión activa con un servidor concreto, cada mensaje tiene que llegar exactamente una vez y en orden, y el mismo mensaje puede tener que llegar a una persona o a diez mil. Un diagrama con «cliente, servidor, base de datos» no responde a nada de esto.
Este artículo recorre un backend de chat decisión a decisión: cómo se conectan los clientes, cómo obtiene un mensaje su lugar en una conversación, cómo llega a todos los que deben verlo y cómo se almacena. Termina con un diagrama de arquitectura que puedes usar como punto de partida.
Requisitos
- Conversaciones individuales y de grupo, con texto y archivos multimedia.
- Los mensajes llegan en el mismo orden a cada miembro de una conversación.
- Sin duplicados cuando un cliente reintenta un envío.
- Los usuarios conectados reciben los mensajes en menos de un segundo; los desconectados reciben una notificación push y se ponen al día al reconectarse.
- Confirmaciones de entrega y de lectura, y un indicador de conexión.
Conexiones: por qué WebSockets y por qué gateways
Consultar periódicamente si hay mensajes nuevos desperdicia peticiones y añade retraso. Una conexión WebSocket permanece abierta, así que el servidor puede enviar un mensaje en cuanto existe. Cada dispositivo mantiene una conexión con un gateway de chat.
Los gateways son la parte con estado del sistema. Un gateway sabe qué usuarios están conectados a él y nada más. Mantenlos ligeros: autenticar la conexión una vez, recibir mensajes y pasarlos a un servicio sin estado, y empujar los mensajes salientes por el socket correcto. Cuando despliegas una versión nueva de un gateway, las conexiones se cortan y los clientes se reconectan, así que los clientes deben reconectarse con backoff y jitter y retomar desde el último mensaje que vieron.
Pon delante un balanceador de carga de capa 4, o uno que entienda las actualizaciones a WebSocket, y cuenta con que las conexiones de larga duración desequilibren la carga. Las conexiones nuevas deberían preferir el gateway menos cargado.
Enviar un mensaje: idempotencia y orden
El cliente genera un client_msg_id (un UUID) antes de enviar. Si la conexión se corta antes de que llegue la confirmación, el cliente reenvía con el mismo ID y el servicio de mensajes descarta el duplicado. Sin él, las redes móviles inestables producen mensajes dobles.
El orden es por conversación, no global. El servicio de mensajes asigna a cada mensaje el siguiente número de secuencia de su conversación y lo guarda. Los clientes ordenan por ese número, nunca por su propio reloj. Dos formas prácticas de asignarlo:
- Un contador por conversación, incrementado en la misma escritura que guarda el mensaje.
- Un ID ordenable por tiempo, como un ID al estilo Snowflake generado por el servidor, cuando puedes aceptar que «orden» signifique «el orden en que lo vio el servidor».
El número de secuencia también abarata ponerse al día. Un cliente que se reconecta dice «tengo la conversación 42 hasta el 1871» y recibe todo lo posterior.
Fan-out: conectados, desconectados y grupos grandes
Después de guardar el mensaje, el servicio publica un evento. Los workers de fan-out se encargan a partir de ahí:
- Buscar los miembros de la conversación.
- Buscar qué miembros están conectados y qué gateway mantiene su conexión, en un registro de presencia y sesiones como Redis.
- Enviar el mensaje a esos gateways, que lo empujan por los sockets.
- Enviar notificaciones push mediante APNs y FCM a los miembros desconectados.
Escribir el mensaje una vez y repartir las lecturas mantiene el almacenamiento lineal en el número de mensajes, no de miembros. Los grupos grandes requieren cuidado: un canal con 100 000 miembros no debería disparar 100 000 notificaciones push por mensaje. Notifica solo a los miembros que lo han activado, o agrupa y resume.
Presencia
La presencia es un latido con TTL. El gateway renueva «el usuario 7 está conectado al gateway 3» cada 30 segundos más o menos; cuando la clave caduca, el usuario está desconectado. No difundas cada cambio de presencia a cada contacto en tiempo real. Es una de las funciones más caras de un chat para lo poco que los usuarios notan sus retrasos. Obtén la presencia al abrir una conversación y refréscala periódicamente. Las claves que caducan funcionan igual que las entradas de caché; el artículo sobre estrategias de caché trata los TTL y lo que pasa cuando caducan todas a la vez.
Confirmaciones y cifrado de extremo a extremo
Las confirmaciones de entrega y de lectura también son mensajes. Cuando un dispositivo recibe el mensaje 1871, envía un pequeño evento «entregado hasta el 1871»; cuando el usuario abre la conversación, envía «leído hasta el 1871». Guarda el número más alto por miembro y conversación en lugar de una confirmación por mensaje, y el coste se mantiene plano en los grupos con mucha actividad.
El cifrado de extremo a extremo cambia lo que el servidor puede ver. Con un protocolo como el de Signal, el servidor guarda y reenvía texto cifrado, así que la búsqueda, la moderación y las vistas previas de enlaces pasan a los clientes. Decídelo pronto, porque cambia tanto el almacenamiento como el fan-out.
Almacenamiento
Los mensajes se escriben una vez, se leen sobre todo cuando son recientes y casi siempre se leen por conversación. Eso apunta a un almacén particionado por conversación y ordenado por número de secuencia. Discord, por ejemplo, particiona los mensajes por canal más un tramo de tiempo fijo para que un canal muy activo no se convierta en una partición enorme; su artículo sobre cómo almacena billones de mensajes describe el paso de ese modelo de Cassandra a ScyllaDB.
Mantén los archivos multimedia fuera del almacén de mensajes. Los clientes suben imágenes y vídeos directamente a un almacenamiento de objetos con una URL prefirmada, y el mensaje lleva una referencia. El servicio de mensajes nunca maneja los bytes.
El diagrama de arquitectura
Contenido del diagrama
- Users: mobile and web
- Load balancer
- Chat gateway
- Message service
- Message store
- Message events
- Fan-out workers
- Presence and sessions
- APNs and FCM
- Media bucket
- wss
- send
- append, next seq
- event
- who is online, which gateway
- deliver
- offline
- heartbeat
- pre-signed upload
Cómo leerlo:
- Izquierda: los usuarios se conectan por WebSocket (wss), a través del balanceador de carga, a un gateway de chat. Las subidas de archivos van directamente al bucket.
- Centro: el gateway pasa cada envío al servicio de mensajes, que descarta duplicados por
client_msg_id, asigna el siguiente número de secuencia y añade el mensaje al almacén. - Derecha: el servicio publica un evento. Los workers de fan-out comprueban la presencia y las sesiones, entregan por el gateway correcto y envían una notificación push a los miembros desconectados.
- El latido del gateway a la presencia es punteado porque funciona con un temporizador, no por mensaje.
- El bloque Markdown muestra el sobre del mensaje, para que todos estén de acuerdo en los campos.
Qué etiquetar
- Qué componentes guardan estado. Los gateways guardan conexiones; el servicio de mensajes no guarda nada. Eso decide cómo despliegas y escalas cada uno.
- La regla de orden. «Secuencia por conversación, asignada por el servidor» tiene que estar en el diagrama.
- La clave de deduplicación. Escribe
client_msg_iden la ruta de envío. - Las rutas de conectados y desconectados. Dibuja las dos; la mayoría de los errores vive en la segunda.
El artículo sobre arquitectura orientada a eventos trata con más detalle el lado de eventos y consumidores, y el ejemplo de diagrama de secuencia es una buena forma de documentar paso a paso el intercambio de envío y confirmación.
Constrúyelo en Archboard
- Inicia sesión con Google o GitHub y crea un diagrama.
- Añade una Persona del pack Modelo C4 para los usuarios y un Balanceador de carga en la nube del pack Nube.
- Añade tres nodos Contenedor del pack Modelo C4: el gateway de chat, el servicio de mensajes y los workers de fan-out. Anota en cada uno si tiene estado.
- Añade una Base de datos del pack Modelo C4 para el almacén de mensajes, Redis y Kafka del pack Datos para la presencia y los eventos, un nodo Almacenamiento de objetos del pack Nube para los archivos y un Sistema externo para APNs y FCM.
- Conéctalos, dibuja el latido y la subida de archivos como aristas discontinuas o punteadas, y añade un bloque Markdown del pack Básico con el sobre del mensaje.
El ejemplo usa 11 nodos de diagrama, dentro del plan Free.
Preguntas frecuentes
¿Necesito WebSockets para una app de chat? Para la entrega en tiempo real, sí, o un canal push parecido como server-sent events más HTTP para los envíos. El long polling funciona en apps pequeñas, pero gasta más peticiones y añade retraso.
¿Cómo mantengo los mensajes en orden? Deja que el servidor asigne un número de secuencia por conversación al guardar el mensaje y haz que los clientes ordenen por él. Los relojes de los clientes no coinciden y no son fiables para ordenar.
¿Cómo evito los mensajes duplicados? El cliente genera un ID de mensaje antes de enviar y lo reutiliza en los reintentos. El servidor guarda el ID e ignora un segundo mensaje con el mismo.
¿Qué base de datos debería guardar los mensajes de chat? Una que lea rápido por conversación y ordene por secuencia, como un almacén de columnas anchas particionado por conversación y tramo de tiempo. Para apps más pequeñas, Postgres funciona bien con un índice sobre conversación y secuencia.
¿Cómo se entregan los mensajes a los usuarios desconectados? Se guardan como cualquier otro mensaje. El paso de fan-out envía una notificación push y, cuando la app se reconecta, pide todo lo posterior al último número de secuencia que tiene.