TechLead
Leccion 14 de 30
7 min de lectura
Diseño de Sistemas

Diseño de Sistemas: Acortador de URLs

Diseña un servicio acortador de URLs como Bitly cubriendo requisitos, esquema de base de datos, estrategias de codificación, optimización de lectura y seguimiento de analíticas

Enunciado del Problema

Diseña un servicio de acortamiento de URLs como Bitly o TinyURL. El servicio toma una URL larga y retorna un alias corto y único que redirige a la URL original. Esta es una pregunta clásica de entrevista de diseño de sistemas que prueba tu capacidad de pensar sobre hashing, almacenamiento, caché y escala.

Paso 1: Recopilación de Requisitos

Requisitos Funcionales

  • Dada una URL larga, generar un alias corto y único
  • Cuando los usuarios acceden a la URL corta, redirigirlos a la URL larga original
  • Los usuarios pueden opcionalmente elegir un alias personalizado
  • Los enlaces expiran después de un tiempo configurable (por defecto: sin expiración)
  • Rastrear analíticas: conteo de clics, referrer, geografía, dispositivo

Requisitos No Funcionales

  • El sistema debe ser altamente disponible (las redirecciones siempre deben funcionar)
  • La redirección de URL debe ser en tiempo real con latencia mínima (<100ms)
  • Las URLs cortas no deben ser predecibles (por seguridad)
  • El sistema tiene lecturas intensivas (ratio de lectura a escritura 100:1)

Estimaciones de Escala

Métrica Estimación
Nuevas URLs por mes100 millones
Lecturas por mes10 mil millones (ratio 100:1)
Lecturas por segundo~3,800 (10B / 30 / 24 / 3600)
Escrituras por segundo~38
Almacenamiento por URL~500 bytes (URL corta + URL larga + metadatos)
Almacenamiento para 5 años~3 TB (100M * 12 * 5 * 500B)

Paso 2: Diseño de Alto Nivel

El sistema consiste en varios componentes clave trabajando juntos:

  • API Gateway / Balanceador de Carga: Distribuye solicitudes entrantes entre servidores de aplicación
  • Servidores de Aplicación: Manejan la lógica de creación de URL y redirección
  • Base de Datos: Almacena el mapeo entre URLs cortas y URLs largas
  • Caché (Redis): Cachea mapeos de URL frecuentemente accedidos para redirección rápida
  • Servicio de Analíticas: Procesa eventos de clic asincrónicamente
// Diseño del API
interface URLShortenerAPI {
  // Crear una URL corta
  // POST /api/v1/shorten
  createShortURL(request: {
    longUrl: string;
    customAlias?: string;
    expiresAt?: Date;
  }): Promise<{
    shortUrl: string;
    longUrl: string;
    expiresAt: Date | null;
    createdAt: Date;
  }>;

  // Redirección (manejada a nivel HTTP)
  // GET /:shortCode -> redirección 301/302 a longUrl

  // Obtener analíticas
  // GET /api/v1/analytics/:shortCode
  getAnalytics(shortCode: string): Promise<{
    totalClicks: number;
    clicksByDay: Array<{ date: string; count: number }>;
    topReferrers: Array<{ referrer: string; count: number }>;
    topCountries: Array<{ country: string; count: number }>;
  }>;
}

Paso 3: Esquema de Base de Datos

// Esquema de base de datos
interface URLMapping {
  id: string;            // Clave primaria (auto-incremento o UUID)
  shortCode: string;     // Único, indexado - el código de URL corta (ej., "aB3x7K")
  longUrl: string;       // La URL original
  userId?: string;       // Opcional: quién la creó
  createdAt: Date;
  expiresAt?: Date;
  clickCount: number;    // Desnormalizado para acceso rápido
}

// Eventos de analíticas (tabla separada o cola de mensajes)
interface ClickEvent {
  id: string;
  shortCode: string;
  timestamp: Date;
  ipAddress: string;
  userAgent: string;
  referrer?: string;
  country?: string;
  device?: string;
}

// Esquema SQL
// CREATE TABLE url_mappings (
//   id BIGSERIAL PRIMARY KEY,
//   short_code VARCHAR(10) UNIQUE NOT NULL,
//   long_url TEXT NOT NULL,
//   user_id VARCHAR(36),
//   created_at TIMESTAMP DEFAULT NOW(),
//   expires_at TIMESTAMP,
//   click_count BIGINT DEFAULT 0
// );
// CREATE INDEX idx_short_code ON url_mappings(short_code);
// CREATE INDEX idx_expires_at ON url_mappings(expires_at) WHERE expires_at IS NOT NULL;

Paso 4: Estrategia de Codificación / Hashing de URL

El desafío central es generar un código corto y único para cada URL. Hay varios enfoques, cada uno con compensaciones.

Enfoque 1: Codificación Base62 de ID Auto-Incremental

Usa un ID auto-incremental de base de datos y conviértelo a una cadena base62 (a-z, A-Z, 0-9). Una cadena base62 de 7 caracteres puede representar 62^7 = 3.5 billones de URLs únicas.

const BASE62_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

function encodeBase62(num: number): string {
  if (num === 0) return BASE62_CHARS[0];

  let result = "";
  while (num > 0) {
    result = BASE62_CHARS[num % 62] + result;
    num = Math.floor(num / 62);
  }

  return result;
}

function decodeBase62(str: string): number {
  let result = 0;
  for (const char of str) {
    result = result * 62 + BASE62_CHARS.indexOf(char);
  }
  return result;
}

// Ejemplo: ID 1000000 -> base62 "4C92" (4 caracteres)
// Ejemplo: ID 3500000000000 -> base62 "Zzzzzz" (7 caracteres)

Enfoque 2: Basado en Hash (MD5/SHA256 + Truncamiento)

Hashea la URL larga y toma los primeros 7 caracteres. Maneja colisiones verificando la base de datos y re-hasheando si es necesario.

import { createHash } from "crypto";

function generateShortCode(longUrl: string, attempt = 0): string {
  const input = attempt === 0 ? longUrl : `${longUrl}${attempt}`;
  const hash = createHash("md5").update(input).digest("base64url");
  return hash.substring(0, 7); // Tomar los primeros 7 caracteres
}

async function createShortURL(longUrl: string, db: Database): Promise<string> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const shortCode = generateShortCode(longUrl, attempt);

    // Verificar colisión
    const existing = await db.findByShortCode(shortCode);
    if (!existing) {
      await db.insert({ shortCode, longUrl });
      return shortCode;
    }

    // Si la misma URL ya fue acortada, retornar la existente
    if (existing.longUrl === longUrl) {
      return existing.shortCode;
    }

    // Colisión con URL diferente, intentar de nuevo
  }

  throw new Error("No se pudo generar un código corto único");
}

Enfoque 3: Servicio de Claves Pre-generadas

Genera claves únicas aleatorias por adelantado y almacénalas en un pool. Cuando una nueva URL se acorta, toma una clave del pool. Esto evita verificaciones de colisión en tiempo de escritura y es el enfoque más escalable.

Comparación de Estrategias de Codificación

Estrategia Pros Contras
Base62 de IDSin colisiones, deterministaSecuencial (predecible), punto único de fallo para generación de ID
Hash + TruncamientoSin estado, misma URL siempre obtiene mismo códigoNecesita manejo de colisiones, búsquedas extra en BD
Claves Pre-generadasRápido, sin colisiones, sin coordinaciónRequiere servicio de gestión de claves, claves desperdiciadas en fallos

Paso 5: Optimización para Lecturas Intensivas

Con un ratio de lectura a escritura de 100:1, optimizar la ruta de redirección es crítico. Cada milisegundo cuenta cuando miles de millones de redirecciones ocurren por mes.

class URLRedirectService {
  private cache: RedisClient;
  private db: Database;

  async redirect(shortCode: string): Promise<string | null> {
    // Capa 1: Verificar caché Redis
    const cached = await this.cache.get(`url:${shortCode}`);
    if (cached) {
      this.trackClickAsync(shortCode); // Fire and forget
      return cached;
    }

    // Capa 2: Verificar base de datos
    const mapping = await this.db.findByShortCode(shortCode);
    if (!mapping) return null;

    // Verificar expiración
    if (mapping.expiresAt && mapping.expiresAt < new Date()) {
      return null;
    }

    // Popular caché para futuras solicitudes (TTL 24 horas)
    await this.cache.setex(`url:${shortCode}`, 86400, mapping.longUrl);

    this.trackClickAsync(shortCode); // Fire and forget
    return mapping.longUrl;
  }

  // Decisión de redirección 301 vs 302
  // 301 (Permanente): El navegador lo cachea, menos solicitudes a nuestro servidor
  //   - Pro: Reduce carga del servidor
  //   - Contra: Pierde analíticas (navegador va directo)
  // 302 (Temporal): El navegador siempre consulta nuestro servidor primero
  //   - Pro: Analíticas precisas
  //   - Contra: Más carga del servidor
  // Recomendación: Usar 302 si las analíticas importan, 301 para máximo rendimiento

  private trackClickAsync(shortCode: string): void {
    // Enviar a cola de mensajes para procesamiento asíncrono
    messageQueue.publish("clicks", {
      shortCode,
      timestamp: Date.now(),
      // ... otros metadatos
    });
  }
}

Paso 6: Analíticas y Seguimiento

Las analíticas deben procesarse asincrónicamente para evitar ralentizar las redirecciones. Los eventos de clic se publican en una cola de mensajes (Kafka o SQS) y son procesados por un servicio de analíticas separado.

// Pipeline de analíticas
class AnalyticsProcessor {
  async processClickEvent(event: ClickEvent): Promise<void> {
    // Parsear user agent para info del dispositivo
    const deviceInfo = parseUserAgent(event.userAgent);

    // Geo-localizar desde IP
    const location = await geolocate(event.ipAddress);

    // Almacenar en base de datos de analíticas (optimizada para series de tiempo)
    await this.analyticsDB.insert({
      shortCode: event.shortCode,
      timestamp: event.timestamp,
      country: location.country,
      city: location.city,
      device: deviceInfo.device,
      browser: deviceInfo.browser,
      os: deviceInfo.os,
      referrer: event.referrer,
    });

    // Incrementar contadores (desnormalizados para lecturas rápidas)
    await this.cache.incr(`clicks:${event.shortCode}`);
    await this.cache.incr(`clicks:${event.shortCode}:${todayKey()}`);
  }
}

Resumen Completo de Arquitectura

Decisiones Clave de Diseño

  • Codificación: Base62 con un generador de IDs distribuido (como Twitter Snowflake) para códigos no predecibles y sin colisiones
  • Almacenamiento: PostgreSQL para mapeos de URL (esquema simple, consistencia fuerte para escrituras)
  • Caché: Redis con TTL de 24 horas para URLs frecuentes (la mayoría de redirecciones servidas desde caché)
  • Analíticas: Kafka para streaming de eventos de clic, Cassandra o ClickHouse para almacenamiento de analíticas
  • Redirección: 302 para precisión de analíticas, considerar 301 para URLs de alto tráfico donde las analíticas no son necesarias
  • Escalado: Servidores de aplicación sin estado detrás de un balanceador de carga, réplicas de lectura de base de datos para consultas de analíticas

Preguntas de Seguimiento Comunes

  • ¿Cómo manejas URLs expiradas? Un trabajo en segundo plano busca URLs expiradas y las elimina del caché. Los registros de base de datos pueden ser soft-deleted para retención de analíticas.
  • ¿Cómo previenes el abuso? Limitación de tasa por usuario/IP, detección de URLs spam usando listas de bloqueo, y CAPTCHA para usuarios anónimos.
  • ¿Cómo manejas URLs populares? Las URLs populares se cachean en Redis con TTLs más largos. Múltiples réplicas de caché distribuyen la carga de lectura.

Continuar Aprendiendo