Enunciado del Problema
Diseña una aplicación de chat en tiempo real como WhatsApp, Slack o Facebook Messenger. El sistema debe soportar mensajería uno a uno, chats grupales, estado en línea/desconectado y garantías de entrega de mensajes. Este problema prueba tu comprensión de la comunicación en tiempo real, conexiones persistentes y mensajería distribuida.
Paso 1: Requisitos
Requisitos Funcionales
- Mensajería uno a uno entre usuarios
- Chat grupal (hasta 500 miembros)
- Estado en línea/desconectado/última vez visto (presencia)
- Estado de entrega del mensaje: enviado, entregado, leído
- Notificaciones push para usuarios desconectados
- Compartir media (imágenes, archivos)
- Historial de mensajes y búsqueda
Requisitos No Funcionales
- Entrega en tiempo real (<100ms para usuarios en línea)
- Orden de mensajes garantizado dentro de una conversación
- Sin pérdida de mensajes (entrega al menos una vez)
- Soporte para 50 millones de usuarios activos diarios
- Alta disponibilidad
Paso 2: Protocolo de Comunicación
La elección del protocolo de comunicación es la decisión más fundamental para un sistema de chat. HTTP request-response no es adecuado para comunicación bidireccional en tiempo real.
Comparación de Protocolos
| Protocolo | Cómo Funciona | Latencia | Caso de Uso |
|---|---|---|---|
| HTTP Polling | El cliente consulta al servidor periódicamente | Alta (intervalo de polling) | No adecuado para chat |
| Long Polling | El servidor mantiene la solicitud hasta que haya datos | Media | Opción de respaldo |
| WebSocket | Conexión persistente full-duplex | Muy baja | Elección principal para chat |
| Server-Sent Events | El servidor empuja al cliente (unidireccional) | Baja | Solo notificaciones |
WebSocket es la elección clara para aplicaciones de chat. Proporciona una conexión persistente y bidireccional entre el cliente y el servidor, permitiendo entrega de mensajes en tiempo real en ambas direcciones con overhead mínimo.
// Gestión de conexiones WebSocket
import { WebSocketServer, WebSocket } from "ws";
interface ConnectedUser {
userId: string;
socket: WebSocket;
serverId: string; // A qué servidor de chat está conectado este usuario
}
class ChatWebSocketServer {
private connections = new Map<string, WebSocket>();
private wss: WebSocketServer;
constructor(port: number) {
this.wss = new WebSocketServer({ port });
this.wss.on("connection", this.handleConnection.bind(this));
}
private handleConnection(socket: WebSocket, request: any) {
const userId = this.authenticateUser(request);
if (!userId) {
socket.close(4001, "Unauthorized");
return;
}
// Registrar conexión
this.connections.set(userId, socket);
this.updatePresence(userId, "online");
socket.on("message", (data) => this.handleMessage(userId, data));
socket.on("close", () => {
this.connections.delete(userId);
this.updatePresence(userId, "offline");
});
// Heartbeat para detectar conexiones obsoletas
socket.on("pong", () => { /* la conexión está viva */ });
}
async sendToUser(targetUserId: string, message: any): Promise<boolean> {
const socket = this.connections.get(targetUserId);
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
return true; // Entregado
}
return false; // Usuario no está en este servidor
}
}
Paso 3: Arquitectura del Sistema
Un sistema de chat debe manejar millones de conexiones WebSocket concurrentes distribuidas a través de múltiples servidores. La arquitectura necesita una forma de enrutar mensajes entre usuarios que pueden estar conectados a diferentes servidores.
Componentes Clave
- Servidores WebSocket (Servidores de Chat): Mantienen conexiones persistentes con los clientes. Cada servidor maneja 50K-100K conexiones concurrentes.
- Registro de Conexiones (Redis): Mapea userId al servidor de chat al que están conectados.
- Cola de Mensajes (Kafka): Desacopla el procesamiento de mensajes de la entrega. Asegura durabilidad y ordenamiento.
- Almacenamiento de Mensajes (Cassandra): Almacena el historial de mensajes con consultas eficientes por rango de tiempo.
- Servicio de Presencia: Rastrear estado en línea/desconectado de usuarios.
- Servicio de Notificaciones Push: Entrega notificaciones a usuarios desconectados via APNs/FCM.
// Flujo de mensajes: Usuario A envía un mensaje al Usuario B
interface ChatMessage {
id: string; // ID globalmente único (Snowflake)
conversationId: string;
senderId: string;
content: string;
contentType: "text" | "image" | "file";
timestamp: number;
status: "sent" | "delivered" | "read";
}
class MessageRouter {
private redis: RedisClient;
private kafka: KafkaProducer;
private pushService: PushNotificationService;
async routeMessage(message: ChatMessage, recipientId: string): Promise<void> {
// Paso 1: Persistir el mensaje
await this.kafka.publish("messages", {
key: message.conversationId, // Particionar por conversación para ordenamiento
value: message,
});
// Paso 2: Encontrar en qué servidor está conectado el destinatario
const serverInfo = await this.redis.get(`conn:${recipientId}`);
if (serverInfo) {
// Usuario en línea - enrutar a su servidor de chat
const { serverId } = JSON.parse(serverInfo);
await this.forwardToServer(serverId, recipientId, message);
} else {
// Usuario desconectado - enviar notificación push
await this.pushService.send(recipientId, {
title: `Nuevo mensaje de ${message.senderId}`,
body: message.content.substring(0, 100),
data: { conversationId: message.conversationId },
});
}
}
private async forwardToServer(
serverId: string,
recipientId: string,
message: ChatMessage
): Promise<void> {
// Usar RPC interno o pub/sub para alcanzar el servidor correcto
await this.redis.publish(`server:${serverId}`, JSON.stringify({
type: "deliver",
recipientId,
message,
}));
}
}
Paso 4: Almacenamiento y Recuperación de Mensajes
El almacenamiento de mensajes de chat necesita manejar un rendimiento de escritura extremadamente alto y soportar recuperación eficiente del historial de mensajes dentro de una conversación.
// Esquema de almacenamiento de mensajes (Cassandra)
// Clave de partición: conversation_id
// Clave de clustering: message_id (Snowflake, así está ordenado por tiempo)
// CREATE TABLE messages (
// conversation_id TEXT,
// message_id BIGINT,
// sender_id TEXT,
// content TEXT,
// content_type TEXT,
// created_at TIMESTAMP,
// PRIMARY KEY (conversation_id, message_id)
// ) WITH CLUSTERING ORDER BY (message_id DESC);
class MessageStore {
// Obtener historial de mensajes para una conversación (paginado)
async getMessages(
conversationId: string,
beforeMessageId?: string,
limit = 50
): Promise<ChatMessage[]> {
let query = "SELECT * FROM messages WHERE conversation_id = ?";
const params: any[] = [conversationId];
if (beforeMessageId) {
query += " AND message_id < ?";
params.push(beforeMessageId);
}
query += " ORDER BY message_id DESC LIMIT ?";
params.push(limit);
return this.cassandra.execute(query, params);
}
// Almacenar un nuevo mensaje
async saveMessage(message: ChatMessage): Promise<void> {
await this.cassandra.execute(
"INSERT INTO messages (conversation_id, message_id, sender_id, content, content_type, created_at) VALUES (?, ?, ?, ?, ?, ?)",
[message.conversationId, message.id, message.senderId, message.content, message.contentType, message.timestamp]
);
}
}
Paso 5: Diseño de Chat Grupal
Los chats grupales agregan complejidad porque un solo mensaje debe entregarse a múltiples destinatarios. El enfoque depende del tamaño del grupo.
interface GroupChat {
id: string;
name: string;
memberIds: string[];
adminIds: string[];
createdAt: Date;
}
class GroupMessageHandler {
async sendGroupMessage(
groupId: string,
message: ChatMessage
): Promise<void> {
// Persistir el mensaje una vez (no por miembro)
await this.messageStore.saveMessage(message);
// Obtener miembros del grupo
const members = await this.getGroupMembers(groupId);
// Entregar a cada miembro (excepto el remitente)
const deliveryPromises = members
.filter((memberId) => memberId !== message.senderId)
.map((memberId) => this.router.routeMessage(message, memberId));
// Fan-out de entrega en paralelo
await Promise.allSettled(deliveryPromises);
}
}
// Para grupos grandes (>100 miembros), considerar:
// 1. Entrega por lotes para reducir overhead por mensaje
// 2. Usar un canal pub/sub por grupo en lugar de entrega individual
// 3. Limitar la tasa de mensajes para prevenir spam
Paso 6: Estado En Línea/Desconectado (Presencia)
El seguimiento de presencia indica a los usuarios cuáles de sus contactos están actualmente en línea. Parece simple pero es desafiante a escala porque los cambios de estado son frecuentes y deben propagarse a muchos usuarios interesados.
class PresenceService {
private redis: RedisClient;
// Llamado cuando el usuario se conecta
async setOnline(userId: string): Promise<void> {
await this.redis.set(`presence:${userId}`, "online");
// Notificar a amigos/contactos sobre cambio de estado
await this.broadcastStatusChange(userId, "online");
}
// Llamado cuando el usuario se desconecta
async setOffline(userId: string): Promise<void> {
// No marcar desconectado inmediatamente (maneja desconexiones breves)
await this.redis.set(`presence:${userId}`, "offline");
await this.redis.set(`last_seen:${userId}`, Date.now().toString());
await this.broadcastStatusChange(userId, "offline");
}
// Enfoque heartbeat: los clientes envían heartbeats cada 30 segundos
// Si no se recibe heartbeat por 60 segundos, marcar como desconectado
async heartbeat(userId: string): Promise<void> {
await this.redis.setex(`heartbeat:${userId}`, 60, "alive");
await this.redis.set(`presence:${userId}`, "online");
}
async isOnline(userId: string): Promise<boolean> {
return (await this.redis.get(`presence:${userId}`)) === "online";
}
// Solo transmitir a usuarios que tienen conversación con este usuario
// No transmitir a TODOS los usuarios (demasiado costoso)
private async broadcastStatusChange(
userId: string,
status: string
): Promise<void> {
const recentContacts = await this.getRecentContacts(userId);
for (const contactId of recentContacts) {
await this.router.sendToUser(contactId, {
type: "presence_update",
userId,
status,
lastSeen: status === "offline" ? Date.now() : undefined,
});
}
}
}
Paso 7: Notificaciones Push
Cuando un usuario está desconectado, los mensajes deben entregarse via notificaciones push a través de servicios específicos de plataforma (APNs para iOS, FCM para Android).
- Almacenar tokens de dispositivo por usuario (los usuarios pueden tener múltiples dispositivos)
- Respetar preferencias de notificación del usuario (conversaciones silenciadas, no molestar)
- Agrupar notificaciones para chats grupales para evitar inundación de notificaciones
- Incluir suficiente contexto para que la notificación sea útil sin revelar el contenido completo del mensaje
Paso 8: Fundamentos de Cifrado de Extremo a Extremo
El cifrado de extremo a extremo (E2EE) asegura que solo el remitente y el destinatario puedan leer los mensajes. El servidor solo ve texto cifrado y no puede descifrarlo.
Protocolo Signal (Usado por WhatsApp)
- Intercambio de Claves: Cada usuario genera un par de claves pública/privada. Las claves públicas se intercambian a través del servidor.
- Algoritmo Double Ratchet: Genera una nueva clave de cifrado por cada mensaje, proporcionando forward secrecy.
- Forward Secrecy: Incluso si una clave se compromete, los mensajes pasados no pueden descifrarse.
- Rol del Servidor: El servidor solo almacena y reenvía mensajes cifrados. Nunca tiene acceso al texto plano.
Compensaciones de E2EE
- La búsqueda del lado del servidor es imposible: El servidor no puede indexar o buscar mensajes cifrados
- Multi-dispositivo es complejo: Cada dispositivo necesita su propio par de claves y los mensajes deben cifrarse por dispositivo
- Gestión de claves en chat grupal: Agregar/eliminar miembros requiere regenerar las claves del grupo
- Cifrado de respaldo: Los respaldos en la nube también deben cifrarse para mantener las garantías de E2EE
Resumen de Arquitectura
- Protocolo: WebSocket para mensajería bidireccional en tiempo real
- Enrutamiento de Mensajes: Redis para registro de conexiones, pub/sub para entrega entre servidores
- Almacenamiento: Cassandra para historial de mensajes (alto rendimiento de escritura, ordenado por tiempo)
- Presencia: Redis con detección basada en heartbeat, transmitir solo a contactos recientes
- Ordenamiento: IDs Snowflake garantizan ordenamiento dentro de una partición de conversación
- Garantías de Entrega: Al menos una vez via reintento + deduplicación con IDs de mensaje