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 mes | 100 millones |
| Lecturas por mes | 10 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 ID | Sin colisiones, determinista | Secuencial (predecible), punto único de fallo para generación de ID |
| Hash + Truncamiento | Sin estado, misma URL siempre obtiene mismo código | Necesita manejo de colisiones, búsquedas extra en BD |
| Claves Pre-generadas | Rápido, sin colisiones, sin coordinación | Requiere 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.