🏗️

Workshop de Arquitectura

👨‍🍳 Chef⏱️ 90 minutos

📋 Prerequisitos sugeridos

  • Conocimientos básicos de backend
  • Entender APIs REST
  • Familiaridad con bases de datos

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>
  1. Crear una URL corta a partir de una larga
  2. Redirigir automáticamente al visitar la URL corta
  3. (Opcional) Ver estadísticas de clics
  4. (Opcional) URLs personalizadas (ej: luxia.us/mi-link)
  5. (Opcional) Expiración de URLs
</details>

Requerimientos No Funcionales

Piensa primero: ¿Qué características de rendimiento necesitamos?

<details> <summary>Ver respuesta</summary>
  1. Alta disponibilidad: El servicio debe estar siempre activo
  2. Baja latencia: Redirecciones en < 100ms
  3. Escalable: Millones de URLs sin degradación
  4. Durabilidad: Las URLs no deben perderse
</details>

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:

  1. Esquema simple y fijo
  2. Necesitamos transacciones para evitar colisiones
  3. Las consultas son simples (solo por código)
  4. 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
</details>

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é:

  1. URLs populares (> 10 clics/hora): Caché largo (1 hora)
  2. URLs nuevas: Caché corto (5 minutos)
  3. 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
</details>

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
</details>

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
</details>

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

  1. Agrega expiración: Modifica el esquema y código para que las URLs expiren
  2. Analytics: Guarda información del visitante (IP, User-Agent, referrer)
  3. URLs personalizadas: Permite que el usuario elija su propio código
  4. API Key: Implementa autenticación para crear URLs
  5. 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.