Taller de Arquitectura: Diseña un Acortador de URLs
Bienvenido a este taller práctico donde diseñarás la arquitectura de un sistema real.
No vamos a solo leer teoría. Vamos a pensar, decidir y construir.
El Problema
Tu cliente quiere un servicio como bit.ly:
- Usuarios ingresan una URL larga
- Reciben una URL corta (ej:
luxia.us/abc123) - Al visitar la URL corta, redirige a la original
Suena simple, ¿verdad? Veamos qué tan profundo llega el agujero del conejo.
Paso 1: Requerimientos
Antes de escribir código, necesitamos entender qué estamos construyendo.
Requerimientos Funcionales
Piensa primero: ¿Qué debería poder hacer el usuario?
<details> <summary>Ver respuesta</summary>- Crear una URL corta a partir de una larga
- Redirigir automáticamente al visitar la URL corta
- (Opcional) Ver estadísticas de clics
- (Opcional) URLs personalizadas (ej:
luxia.us/mi-link) - (Opcional) Expiración de URLs
Requerimientos No Funcionales
Piensa primero: ¿Qué características de rendimiento necesitamos?
<details> <summary>Ver respuesta</summary>- Alta disponibilidad: El servicio debe estar siempre activo
- Baja latencia: Redirecciones en < 100ms
- Escalable: Millones de URLs sin degradación
- Durabilidad: Las URLs no deben perderse
Paso 2: Estimación de Capacidad
Esta es la parte que separa a los ingenieros junior de los senior.
Supongamos:
- 100 millones de URLs creadas por mes
- Ratio lectura:escritura de 100:1 (100 lecturas por cada URL creada)
Cálculos
URLs por segundo (escritura):
100M / 30 días / 24 horas / 3600 segundos ≈ 40 URLs/segundo
Lecturas por segundo:
40 × 100 = 4,000 lecturas/segundo
Almacenamiento (5 años)
Piensa primero: ¿Cuánto espacio necesitamos?
<details> <summary>Ver cálculo</summary>- URLs por mes: 100M
- URLs en 5 años: 100M × 12 × 5 = 6 mil millones
- Tamaño promedio por registro: ~500 bytes (URL + metadata)
- Total: 6B × 500B = 3TB
No es tanto. Un disco moderno lo maneja.
</details>Paso 3: Diseño de la API
Mantengámoslo simple con REST.
Endpoints
POST /api/shorten
Body: { "url": "https://ejemplo.com/pagina-muy-larga" }
Response: { "shortUrl": "https://luxia.us/abc123", "code": "abc123" }
GET /:code
Response: 301 Redirect to original URL
¿Por qué 301 y no 302?
Piensa primero: ¿Cuál es la diferencia?
<details> <summary>Ver respuesta</summary>- 301 (Moved Permanently): El navegador cachea la redirección
- 302 (Found): Cada visita pasa por nuestro servidor
Para un acortador de URLs, 302 es mejor si queremos:
- Contar cada visita
- Poder cambiar la URL destino
- Agregar expiración
Para máximo rendimiento (menos carga): 301
</details>Paso 4: Esquema de Base de Datos
Opción 1: SQL (PostgreSQL)
CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(10) UNIQUE NOT NULL,
original_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
click_count BIGINT DEFAULT 0
);
CREATE INDEX idx_urls_code ON urls(code);
Opción 2: NoSQL (MongoDB)
{
_id: ObjectId,
code: "abc123",
originalUrl: "https://...",
createdAt: ISODate,
expiresAt: ISODate,
clicks: 0
}
¿Cuál elegirías?
Piensa primero: ¿Qué factores considerarías?
<details> <summary>Ver análisis</summary>Para este caso, SQL es probablemente mejor:
- Esquema simple y fijo
- Necesitamos transacciones para evitar colisiones
- Las consultas son simples (solo por código)
- PostgreSQL escala muy bien hasta millones de registros
NoSQL sería mejor si:
- Esquema variable por URL
- Necesitas sharding horizontal desde el inicio
- Tienes experiencia con MongoDB
Paso 5: Algoritmo de Codificación
El corazón del sistema: ¿cómo generamos el código corto?
Opción A: Base62 con ID
const CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
function toBase62(num) {
if (num === 0) return CHARS[0]
let result = ''
while (num > 0) {
result = CHARS[num % 62] + result
num = Math.floor(num / 62)
}
return result
}
// Ejemplo:
// toBase62(1) = "1"
// toBase62(62) = "10"
// toBase62(1000000) = "4c92"
Con 6 caracteres: 62^6 = 56 mil millones de URLs posibles
Opción B: Hash + Truncado
const crypto = require('crypto')
function generateCode(url) {
const hash = crypto.createHash('md5').update(url).digest('base64')
return hash.substring(0, 7).replace(/[+/=]/g, 'x')
}
¿Cuál tiene menos colisiones?
Piensa primero: ¿Cuál elegirías y por qué?
<details> <summary>Ver análisis</summary>Base62 con ID auto-incremental:
- ✅ Cero colisiones (cada ID es único)
- ✅ Predecible y eficiente
- ❌ Revela cuántas URLs existen (ID 1000 = mil URLs)
- ❌ Fácil de enumerar URLs
Hash truncado:
- ✅ No revela información del sistema
- ✅ Mismo input = mismo output (útil para deduplicación)
- ❌ Posibles colisiones (hay que verificar)
- ❌ Más procesamiento
Solución híbrida: Base62 con ID + offset aleatorio
const OFFSET = 100000000 // Número aleatorio grande
const code = toBase62(id + OFFSET)
</details>
Paso 6: Manejo de Colisiones
Si usamos hash, necesitamos manejar colisiones.
async function createShortUrl(originalUrl) {
let code = generateCode(originalUrl)
let attempts = 0
while (attempts < 5) {
const existing = await db.findByCode(code)
if (!existing) {
// Código disponible
await db.insert({ code, originalUrl })
return code
}
if (existing.originalUrl === originalUrl) {
// Misma URL, reutilizar código
return code
}
// Colisión: agregar sufijo y reintentar
code = generateCode(originalUrl + Date.now() + attempts)
attempts++
}
throw new Error('No se pudo generar código único')
}
Paso 7: Estrategia de Caché
Con 4,000 lecturas/segundo, necesitamos caché.
Redis al Rescate
const Redis = require('ioredis')
const redis = new Redis()
async function getOriginalUrl(code) {
// Primero buscar en caché
const cached = await redis.get(`url:${code}`)
if (cached) {
return cached
}
// Si no está, buscar en DB
const url = await db.findByCode(code)
if (url) {
// Guardar en caché por 1 hora
await redis.setex(`url:${code}`, 3600, url.originalUrl)
return url.originalUrl
}
return null
}
¿Qué URLs cachear?
Piensa primero: ¿Cacheamos todo?
<details> <summary>Ver estrategia</summary>No todo merece caché:
- URLs populares (> 10 clics/hora): Caché largo (1 hora)
- URLs nuevas: Caché corto (5 minutos)
- URLs expiradas: No cachear
Implementación con LRU:
// Redis con límite de memoria
// maxmemory 1gb
// maxmemory-policy allkeys-lru
Esto mantiene las URLs más accedidas en caché automáticamente.
</details>Paso 8: Rate Limiting
Protégete de abusos.
const rateLimit = require('express-rate-limit')
// Limitar creación de URLs
const createLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
max: 100, // 100 URLs por ventana
message: { error: 'Demasiadas URLs creadas. Intenta más tarde.' }
})
// Limitar redirecciones (anti-bot)
const redirectLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minuto
max: 1000, // 1000 redirecciones
message: 'Rate limit exceeded'
})
app.post('/api/shorten', createLimiter, shortenHandler)
app.get('/:code', redirectLimiter, redirectHandler)
Paso 9: Discusión de Escalabilidad
Escenario: 1,000 requests/día
¿Qué necesitas?
- Un servidor básico (1 CPU, 1GB RAM)
- PostgreSQL local
- Sin caché necesario
Escenario: 100,000 requests/día
¿Qué cambia?
<details> <summary>Ver respuesta</summary>- Agregar Redis para caché
- Servidor con más recursos (2 CPU, 4GB RAM)
- Considerar réplica de lectura para DB
Escenario: 1,000,000 requests/día
¿Qué cambia?
<details> <summary>Ver respuesta</summary>- Load Balancer (Nginx o AWS ALB)
- Múltiples servidores de aplicación
- Redis Cluster para caché distribuido
- Réplicas de lectura para PostgreSQL
- Considerar CDN para redirecciones estáticas
Escenario: 100,000,000 requests/día
Arquitectura empresarial:
<details> <summary>Ver respuesta</summary>- Sharding de base de datos por rango de código
- Múltiples regiones geográficas
- CDN edge workers para redirecciones
- Kafka/RabbitMQ para analytics async
- Kubernetes para orquestación
Diagrama de Arquitectura
┌─────────────────┐
│ CDN │
│ (CloudFlare) │
└────────┬────────┘
│
┌────────▼────────┐
│ Load Balancer │
│ (Nginx) │
└────────┬────────┘
│
┌────────────────────────┼────────────────────────┐
│ │ │
┌────────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
│ App Server │ │ App Server │ │ App Server │
│ (Node.js) │ │ (Node.js) │ │ (Node.js) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└────────────────────────┼────────────────────────┘
│
┌────────────────────────┼────────────────────────┐
│ │ │
┌────────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
│ Redis │ │ PostgreSQL │ │ PostgreSQL │
│ (Caché) │ │ (Primary) │ │ (Replica) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Implementación: Código Inicial
Estructura del Proyecto
url-shortener/
├── docker-compose.yml
├── package.json
├── src/
│ ├── index.js
│ ├── routes/
│ │ └── urls.js
│ ├── services/
│ │ ├── urlService.js
│ │ └── cacheService.js
│ └── db/
│ └── postgres.js
└── .env.example
docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/urlshortener
- REDIS_URL=redis://cache:6379
depends_on:
- db
- cache
db:
image: postgres:15
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: urlshortener
volumes:
- postgres_data:/var/lib/postgresql/data
cache:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
src/index.js
const express = require('express')
const { Pool } = require('pg')
const Redis = require('ioredis')
const app = express()
app.use(express.json())
// Conexiones
const db = new Pool({ connectionString: process.env.DATABASE_URL })
const redis = new Redis(process.env.REDIS_URL)
// Base62 encoding
const CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
const toBase62 = (num) => {
if (num === 0) return CHARS[0]
let result = ''
while (num > 0) {
result = CHARS[num % 62] + result
num = Math.floor(num / 62)
}
return result
}
// POST /api/shorten - Crear URL corta
app.post('/api/shorten', async (req, res) => {
try {
const { url } = req.body
if (!url || !url.startsWith('http')) {
return res.status(400).json({ error: 'URL inválida' })
}
// Insertar y obtener ID
const result = await db.query(
'INSERT INTO urls (original_url) VALUES ($1) RETURNING id',
[url]
)
const code = toBase62(result.rows[0].id + 10000000)
// Actualizar con el código
await db.query('UPDATE urls SET code = $1 WHERE id = $2', [code, result.rows[0].id])
// Cachear
await redis.setex(`url:${code}`, 3600, url)
res.json({
shortUrl: `${process.env.BASE_URL || 'http://localhost:3000'}/${code}`,
code
})
} catch (error) {
console.error(error)
res.status(500).json({ error: 'Error interno' })
}
})
// GET /:code - Redireccionar
app.get('/:code', async (req, res) => {
try {
const { code } = req.params
// Buscar en caché
let originalUrl = await redis.get(`url:${code}`)
if (!originalUrl) {
// Buscar en DB
const result = await db.query(
'SELECT original_url FROM urls WHERE code = $1',
[code]
)
if (result.rows.length === 0) {
return res.status(404).json({ error: 'URL no encontrada' })
}
originalUrl = result.rows[0].original_url
// Cachear para próximas visitas
await redis.setex(`url:${code}`, 3600, originalUrl)
}
// Incrementar contador (async, no bloquea)
db.query('UPDATE urls SET click_count = click_count + 1 WHERE code = $1', [code])
res.redirect(302, originalUrl)
} catch (error) {
console.error(error)
res.status(500).json({ error: 'Error interno' })
}
})
// Iniciar servidor
const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
console.log(`Servidor corriendo en puerto ${PORT}`)
})
Inicializar Base de Datos
-- init.sql
CREATE TABLE IF NOT EXISTS urls (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(10) UNIQUE,
original_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
click_count BIGINT DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_urls_code ON urls(code);
Ejercicios para Ti
- Agrega expiración: Modifica el esquema y código para que las URLs expiren
- Analytics: Guarda información del visitante (IP, User-Agent, referrer)
- URLs personalizadas: Permite que el usuario elija su propio código
- API Key: Implementa autenticación para crear URLs
- Dashboard: Crea una interfaz para ver estadísticas
Reflexión Final
Diseñar arquitectura es sobre trade-offs:
- Simplicidad vs Escalabilidad: Empieza simple, escala cuando necesites
- Consistencia vs Disponibilidad: Para URLs, disponibilidad importa más
- Costo vs Rendimiento: Redis cuesta, pero ahorra en servidores
La mejor arquitectura es la que resuelve el problema de hoy sin bloquear las soluciones del mañana.
¿Listo para construir el tuyo?
Clona este código, modifícalo, y despliégalo. La mejor forma de aprender arquitectura es haciéndola.