TechLead
Leccion 16 de 30
8 min de lectura
Diseño de Sistemas

Diseño de Sistemas: Aplicación de Chat

Diseña una aplicación de chat en tiempo real cubriendo conexiones WebSocket, almacenamiento de mensajes, chats grupales, sistemas de presencia y cifrado de extremo a extremo

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 PollingEl cliente consulta al servidor periódicamenteAlta (intervalo de polling)No adecuado para chat
Long PollingEl servidor mantiene la solicitud hasta que haya datosMediaOpción de respaldo
WebSocketConexión persistente full-duplexMuy bajaElección principal para chat
Server-Sent EventsEl servidor empuja al cliente (unidireccional)BajaSolo 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

Continuar Aprendiendo