Skip to content

🎯 Meta del capítulo: poder sentarte delante de una pizarra y diseñar un sistema que aguante carga real, explicando cada decisión. No para aprobar una entrevista: para entender por qué las arquitecturas se parecen tanto entre sí y qué problema resolvía cada pieza.

⚠️ Antes de empezar, la advertencia más importante del capítulo. Todo lo que viene aquí añade complejidad. Un solo servidor con PostgreSQL bien indexado sirve cómodamente a decenas de miles de usuarios. La mayoría de los proyectos que "necesitan escalar" en realidad necesitan un índice. Este capítulo es un catálogo de herramientas para cuando midas un problema, no una lista de tareas pendientes.


24.1 · Escala: los números que dan sentido a todo

Antes de diseñar nada, hay que saber de qué tamaño es el problema. Estos son los órdenes de magnitud que cambian la arquitectura:

EscalaPeticiones/sArquitectura suficiente
Proyecto personal< 101 servidor. Todo dentro.
Startup temprana10 – 1001 servidor + BD gestionada + backups
Producto establecido100 – 1.000Balanceador + 2-3 servidores + caché + réplica de lectura
Escala grande1.000 – 10.000Lo anterior + colas + CDN + particionado de las tablas grandes
Escala masiva> 10.000Sistemas distribuidos de verdad, equipos dedicados

🧠 Perspectiva que casi nadie tiene: 1.000 peticiones por segundo son 86 millones al día. Muy pocos productos del mundo llegan ahí. Si tu aplicación hace 20 peticiones por segundo en hora punta y va lenta, el problema no es la escala: es una consulta sin índice, un N+1 o una llamada externa síncrona. Diagnostica antes de distribuir.

Estimaciones de servilleta

Sirven para descartar diseños en dos minutos, sin construir nada. El método:

PROBLEMA: red social, 10 millones de usuarios activos al día,
          cada uno publica 2 veces y lee su feed 20 veces.

ESCRITURAS
  10M usuarios × 2 posts = 20M posts/día
  20.000.000 / 86.400 s  ≈ 230 escrituras/s   (media)
  Pico ≈ 3× la media      ≈ 700 escrituras/s  ← usa SIEMPRE ×3 para el pico

LECTURAS
  10M × 20 lecturas de feed = 200M/día ≈ 2.300/s (media), ~7.000/s (pico)
  Ratio lectura/escritura ≈ 10:1
  ⮕ CONCLUSIÓN 1: es un sistema dominado por LECTURAS
     → caché y réplicas de lectura son la palanca principal

ALMACENAMIENTO
  Un post ≈ 300 bytes de texto + metadatos ≈ 1 KB
  20M/día × 1 KB = 20 GB/día ≈ 7,3 TB/año
  ⮕ CONCLUSIÓN 2: cabe en una base de datos relacional bien particionada.
     NO hace falta nada exótico para el texto.
     Las IMÁGENES sí: 20M × 200 KB = 4 TB/día → object storage + CDN, no la BD.

ANCHO DE BANDA
  Pico de lectura: 7.000/s × 50 KB de feed ≈ 350 MB/s
  ⮕ CONCLUSIÓN 3: el ancho de banda de salida será una partida de coste
     importante → CDN delante, obligatorio.

💡 Los cuatro números que necesitas siempre: peticiones por segundo (media y pico), ratio lectura/escritura, tamaño de los datos y crecimiento anual. Con eso decides el 90% de la arquitectura. Regla del pico: multiplica la media por 3. El tráfico nunca es plano — se concentra en unas pocas horas.

Atajos mentales útiles:

1 día         ≈ 86.400 s ≈ 10⁵ s     (redondea: te ahorra la calculadora)
1 millón/día  ≈ 12 por segundo
1.000 millones/día ≈ 12.000 por segundo
1 KB × 1M     = 1 GB      ·      1 KB × 1.000M = 1 TB

24.2 · Escalar: vertical, horizontal y el orden correcto

VERTICAL (scale up)                 HORIZONTAL (scale out)
─────────────────────               ──────────────────────
   ┌────────────┐                   ┌────┐ ┌────┐ ┌────┐ ┌────┐
   │  Servidor  │                   │ S1 │ │ S2 │ │ S3 │ │ S4 │
   │  MÁS GRANDE│                   └────┘ └────┘ └────┘ └────┘
   │ 64 CPU     │                   Muchos servidores pequeños
   │ 512 GB RAM │
   └────────────┘

✅ Trivial: cambias el plan       ✅ Casi ilimitado
✅ Cero cambios en el código      ✅ Tolera fallos (si cae uno, siguen los otros)
❌ Tiene techo físico             ❌ Exige que la app sea SIN ESTADO
❌ Precio no lineal (×2 CPU ≠ ×2 €)❌ Complejidad: balanceo, sesiones, despliegues
❌ Punto único de fallo           ❌ Los datos compartidos se vuelven el cuello

💡 El orden correcto de escalar, y casi nadie lo respeta:

1. MEDIR              ← ¿dónde está el cuello de botella de verdad?
2. Optimizar consultas e índices     (gratis, suele dar ×10 a ×100)
3. Añadir caché                      (barato, suele dar ×10)
4. Escalar VERTICALMENTE             (una tarjeta de crédito, cero código)
5. Escalar HORIZONTALMENTE la app    (aquí empieza la complejidad de verdad)
6. Réplicas de lectura de la BD
7. Particionar (sharding) la BD      ← el último recurso, y es doloroso

Saltar directamente al 5 o al 7 porque "es lo que hace Netflix" es cómo los equipos pequeños se hunden en complejidad accidental (capítulo 23) mientras su problema real era un Seq Scan.

El requisito que hace posible todo lo demás: sin estado (stateless)

Para poner varios servidores detrás de un balanceador, ninguno puede guardar información que los demás necesiten.

❌ CON ESTADO                          ✅ SIN ESTADO
Servidor 1: sesión de Ana en RAM       Los 3 servidores consultan Redis
Servidor 2: no sabe quién es Ana       para la sesión → cualquiera responde
→ Ana pierde la sesión aleatoriamente     a cualquier petición

Lo que no puede vivir en el servidor de aplicación:

❌ En el servidor✅ Dónde va
Sesiones en memoria/archivoRedis, o un JWT firmado (cap. 20)
Archivos subidos por usuariosS3 / R2 / object storage
Caché local de datos compartidosRedis (apéndice B)
Tareas programadas en cada instanciaUn worker dedicado, o un leader lock
Contadores en variables globalesRedis o la base de datos

⚠️ El bug clásico al pasar de 1 a 2 servidores: todo "funciona pero raro". Los usuarios pierden la sesión al azar, las fotos subidas aparecen y desaparecen al recargar, y los emails programados se envían dos y tres veces. No es un fallo intermitente: es que cada petición cae en una máquina distinta y el estado no es compartido.


24.3 · Balanceo de carga

                     ┌─────────────────┐
    Usuarios ───────►│  BALANCEADOR    │  Nginx · HAProxy · ALB · Cloudflare
                     │  (cap. 13)      │
                     └────────┬────────┘
                     ┌────────┼────────┐
                     ▼        ▼        ▼
                 ┌──────┐ ┌──────┐ ┌──────┐
                 │ App1 │ │ App2 │ │ App3 │  ← idénticas y SIN ESTADO
                 └──────┘ └──────┘ └──────┘
                     └────────┼────────┘

                   ┌────────────────────┐
                   │ PostgreSQL + Redis │  ← el estado vive AQUÍ
                   └────────────────────┘
AlgoritmoCómo reparteCuándo usarlo
Round robinPor turnosPor defecto; servidores iguales
Least connectionsAl menos ocupadoPeticiones de duración muy variable
IP hashMisma IP → mismo servidorSolo si tienes estado local (evítalo)
WeightedSegún capacidadServidores de distinta potencia

Lo que de verdad importa: el health check. Un balanceador sin comprobación de salud reparte tráfico alegremente a un servidor muerto.

nginx
upstream backend {
    server 10.0.0.1:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.2:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.3:3000 backup;          # solo si los otros caen
}

💡 Diseña bien tu endpoint /health. Debe comprobar lo que la app necesita (¿responde la base de datos? ¿está Redis vivo?), pero no dependencias no críticas — si tu health check falla porque la API de un tercero está caída, el balanceador retirará servidores perfectamente sanos y te provocarás una caída total tú solo. Lo estándar es separar dos:

  • /health/live → "¿el proceso está vivo?" (trivial, sin dependencias)
  • /health/ready → "¿puedo atender tráfico?" (BD y caché sí; terceros no)

24.4 · Caché: la palanca más potente (y la más traicionera)

"Solo hay dos problemas difíciles en informática: la invalidación de caché y ponerle nombre a las cosas." — Phil Karlton

Dónde se puede cachear (de más cerca del usuario a más lejos):

Navegador ──► CDN ──► Proxy inverso ──► Caché de app ──► Caché de BD
(0 ms)       (20 ms)   (1 ms)           (Redis, 1 ms)    (interna)
   │            │          │                 │
   └── el más barato y rápido; también el más difícil de invalidar
CapaQué se cacheaTTL típico
NavegadorJS, CSS, imágenes con hash en el nombre1 año (inmutable)
CDNFicheros estáticos, páginas públicashoras – días
RedisConsultas caras, sesiones, contadoressegundos – horas
En memoria del procesoConfiguración, catálogos que casi no cambianminutos

Los patrones

python
# 1. CACHE-ASIDE (el 90% de los casos) — la app gestiona la caché
def obtener_usuario(id):
    clave = f"usuario:{id}"
    if (cacheado := redis.get(clave)):
        return json.loads(cacheado)              # HIT
    usuario = db.query("SELECT * FROM usuarios WHERE id = %s", id)   # MISS
    redis.setex(clave, 300, json.dumps(usuario))  # TTL de 5 minutos
    return usuario

# 2. WRITE-THROUGH — escribir actualiza BD y caché a la vez
def actualizar_usuario(id, datos):
    usuario = db.update(id, datos)
    redis.setex(f"usuario:{id}", 300, json.dumps(usuario))
    return usuario

# 3. WRITE-BEHIND — escribe en caché y persiste en diferido
#    Rapidísimo, pero si Redis cae pierdes datos. Solo para lo prescindible
#    (contadores de visitas, "último acceso"), NUNCA para datos de negocio.

Invalidar: las tres estrategias

python
# A. TTL — que caduque solo. El más simple y el que deberías usar por defecto.
redis.setex(clave, 300, valor)

# B. Invalidación explícita — al modificar, borra
def actualizar_producto(id, datos):
    db.update(id, datos)
    redis.delete(f"producto:{id}")
    redis.delete("productos:destacados")   # ⚠️ ¿te acordarás de TODAS las claves derivadas?

# C. Versionado de clave — no invalidas: cambias el nombre. Elegante y sin fugas.
version = redis.get("productos:version") or 1
clave = f"productos:lista:v{version}"
# al modificar cualquier producto: redis.incr("productos:version")
# → todas las claves viejas dejan de consultarse y caducan solas por TTL

⚠️ Los tres fallos clásicos de caché, con nombre propio:

  • Cache stampede (estampida). Caduca una clave muy solicitada y 5.000 peticiones simultáneas van a la vez a la base de datos, que se cae. Solución: que solo una la recalcule (un lock corto en Redis) mientras las demás sirven el valor viejo; o TTL con jitter aleatorio (300 + random(0,60)) para que miles de claves no caduquen en el mismo instante.
  • Cache penetration. Alguien pide en bucle IDs que no existen; cada petición es un MISS que va a la BD. Solución: cachear también el resultado negativo, con un TTL corto.
  • Datos obsoletos. El usuario cambia su nombre y sigue viendo el viejo. Solución: decide y documenta qué TTL es aceptable para cada dato. El precio de un producto no puede estar desactualizado 10 minutos; el número de "me gusta", sí.

🧠 Regla de oro: cachea solo lo que has medido que es caro. Cada caché es una copia de la verdad que puede desincronizarse, y añade una clase entera de bugs que no existían: los que solo aparecen en producción, después de un rato, y no se reproducen en local.


24.5 · Escalar la base de datos

Casi siempre es la base de datos el cuello de botella, porque es la única pieza que no puedes duplicar sin más: tiene estado.

1. Índices y consultas (haz esto primero, siempre)

sql
EXPLAIN ANALYZE SELECT * FROM pedidos WHERE usuario_id = 42 ORDER BY creado_en DESC LIMIT 20;
-- Seq Scan  → falta un índice
-- Index Scan → vas bien
CREATE INDEX idx_pedidos_usuario_fecha ON pedidos (usuario_id, creado_en DESC);

Un índice bien puesto puede ser una mejora de ×1000. Ninguna arquitectura distribuida compite con eso, y cuesta una línea. (Capítulos 1 y 2, ampliados.)

2. Réplicas de lectura

              ESCRITURAS                    LECTURAS
                   │                     ┌─────┴─────┐
                   ▼                     ▼           ▼
             ┌──────────┐  replicación ┌─────────┐ ┌─────────┐
             │ PRIMARIA │ ───────────► │ RÉPLICA │ │ RÉPLICA │
             │ (escribe)│              │ (lee)   │ │ (lee)   │
             └──────────┘              └─────────┘ └─────────┘

Como el 90% del tráfico suele ser lectura, esto multiplica la capacidad casi sin cambiar el código. Pero introduce un problema nuevo:

⚠️ El replication lag y el bug que provoca. La réplica va unos milisegundos (a veces segundos) por detrás. El resultado: el usuario guarda su perfil (va a la primaria), la página recarga y lee (de una réplica)… y ve los datos viejos. Piensa que no se guardó y lo hace otra vez.

Solución estándar: lee de la primaria durante unos segundos después de que ese usuario haya escrito (patrón "read-your-own-writes"), y de las réplicas todo lo demás. No intentes que las réplicas vayan al día: no es su naturaleza.

3. Particionar (sharding) — el último recurso

Repartir las filas de una tabla entre varias bases de datos según una clave:

usuarios con id 1-1M    → shard 1
usuarios con id 1M-2M   → shard 2
usuarios con id 2M-3M   → shard 3

⚠️ Sharding rompe cosas que dabas por sentadas: los JOIN entre shards dejan de existir, las transacciones ACID solo funcionan dentro de un shard, COUNT(*) global exige consultar todos, los ids autoincrementales chocan (necesitas UUID o Snowflake) y reparticionar más adelante es un proyecto de meses.

Antes de particionar, agota: índices, caché, réplicas, escalado vertical (una máquina de 512 GB de RAM es sorprendentemente barata comparada con el coste humano del sharding), archivado de datos históricos y particionado nativo de PostgreSQL por rango de fechas (PARTITION BY RANGE), que resuelve el caso más común — tablas de eventos y logs enormes — sin distribuir nada.


24.6 · Colas y procesamiento asíncrono

La pregunta clave: ¿el usuario necesita el resultado AHORA? Si no, sácalo del request.

❌ SÍNCRONO — el usuario espera todo
POST /pedidos → validar (10ms) → cobrar (800ms) → email (2000ms)
              → factura PDF (1500ms) → actualizar stock (50ms) → 200 OK
Total: 4,4 s de espera. Y si el email falla, ¿el pedido se anula? 😱

✅ ASÍNCRONO — el usuario espera lo imprescindible
POST /pedidos → validar (10ms) → cobrar (800ms) → guardar (20ms)
              → encolar [email, factura, stock] → 201 Created
Total: 830 ms. Lo demás ocurre en segundo plano, con reintentos.
mermaid
graph LR
    A[API] -->|publica| B[(Cola<br/>Redis / RabbitMQ)]
    B --> C[Worker 1]
    B --> D[Worker 2]
    B --> E[Worker 3]
    C --> F[Email]
    D --> G[PDF]
    E --> H[Almacén]
    C -.falla 3 veces.-> I[(Dead letter<br/>queue)]

Qué va a una cola: emails, PDFs, procesado de imágenes y vídeo, informes, sincronización con sistemas externos, webhooks salientes, indexación de búsqueda, notificaciones push.

Qué NO: cobrar un pago (el usuario debe saber si funcionó), validar datos, autenticar, cualquier cosa cuyo resultado se muestre inmediatamente.

Las dos reglas que hacen que las colas funcionen

Regla 1 — Las tareas deben ser idempotentes. Una cola garantiza al menos una entrega, no exactamente una. Tu tarea se ejecutará dos veces antes o después.

python
# ❌ NO idempotente: si se reintenta, cobras dos veces
def procesar_pago(pedido_id):
    stripe.cobrar(pedido.total)

# ✅ Idempotente: la segunda ejecución no hace nada
def procesar_pago(pedido_id):
    pedido = db.get(pedido_id)
    if pedido.estado == "pagado":          # ya se hizo → salir
        return
    stripe.cobrar(pedido.total, idempotency_key=f"pedido-{pedido_id}")
    pedido.estado = "pagado"
    db.save(pedido)

🧠 La clave de idempotencia es el concepto más importante de los sistemas distribuidos. La idea: cada operación lleva un identificador único; si el servidor ve el mismo identificador dos veces, devuelve el resultado de la primera sin volver a ejecutar nada. Stripe, las pasarelas de pago y cualquier API seria lo implementan (apéndice P). Cuando diseñes una API que cobre dinero, envíe mensajes o modifique stock, exige una clave de idempotencia.

Regla 2 — Reintentos con retroceso exponencial y una cola de fallos.

python
# Reintentar inmediatamente satura el servicio que ya estaba en apuros.
# Espera 1s, 2s, 4s, 8s… + un poco de aleatoriedad (jitter) para que
# 1000 workers no reintenten todos en el mismo milisegundo.
espera = min(2 ** intento, 300) + random.uniform(0, 1)

Tras N intentos fallidos, el mensaje va a una dead letter queue: no se pierde, no bloquea la cola y un humano puede revisarlo. Una cola sin DLQ o pierde datos o se atasca para siempre.


24.7 · Consistencia: CAP y lo que significa en la práctica

Teorema CAP: en un sistema distribuido, ante una partición de red (P: los nodos no se ven entre sí), tienes que elegir entre consistencia (C: todos ven el mismo dato) y disponibilidad (A: el sistema sigue respondiendo).

        ┌─────────────────────────────────────────────┐
        │  La red SE VA a partir. No es opcional.     │
        │  Así que la elección real es siempre C o A. │
        └─────────────────────────────────────────────┘

   CP — prefiere ser correcto            AP — prefiere seguir respondiendo
   "mejor error que dato erróneo"        "mejor dato viejo que un error"
   PostgreSQL, MongoDB, etcd, ZooKeeper  Cassandra, DynamoDB, DNS, CDNs
   → saldos bancarios, stock, pagos      → likes, feeds, catálogos, métricas

⚠️ CAP se cita mal constantemente. No dice "elige dos de tres": la P no se elige, se sufre. Y no es una etiqueta permanente del sistema, sino una decisión por operación. La misma aplicación debe ser CP al cobrar (si hay duda, falla) y AP al mostrar el número de comentarios (si hay duda, muestra el último que sepas).

Consistencia eventual, en cristiano: si dejas de escribir, en algún momento todas las copias coincidirán. Es lo que usas sin darte cuenta a diario:

  • Publicas un tuit y un amigo tarda unos segundos en verlo.
  • Cambias tu DNS y tarda horas en propagarse.
  • Subes un producto y la búsqueda lo indexa 30 segundos después.

💡 La pregunta de diseño correcta no es "¿quiero consistencia?" — todos la queremos. Es: "¿cuántos segundos de desfase puede tolerar ESTE dato, y qué pasa exactamente si se toleran?". Para el stock de un producto único: cero (vendes dos veces lo mismo). Para el contador de visitas: minutos, y no pasa nada. Esa respuesta, dato por dato, es tu diseño.


24.8 · Diseñar para el fallo

"Todo falla, siempre." — Werner Vogels (CTO de Amazon)

En un sistema con muchas piezas, algo está roto en todo momento. La pregunta no es cómo evitar los fallos, sino cómo hacer que no se propaguen.

Timeouts: sin ellos, un fallo se convierte en una caída total

python
# ❌ Sin timeout: si el servicio externo se cuelga, TU hilo se cuelga.
#    Con suficientes peticiones, tu pool se agota y tu app cae entera
#    aunque tu código sea perfecto.
respuesta = requests.get("https://api-externa.com/datos")

# ✅ Con timeout
respuesta = requests.get("https://api-externa.com/datos", timeout=(3, 5))
#                                                 conexión ┘   └ lectura

⚠️ La causa nº1 de caídas en cascada es una llamada sin timeout. Es lo primero que se revisa en un post-mortem. Toda llamada de red — HTTP, base de datos, Redis, gRPC — lleva timeout. Sin excepciones.

Circuit breaker: dejar de llamar a lo que está roto

    CERRADO ──── 5 fallos seguidos ────► ABIERTO
   (pasa todo)                        (falla al instante, sin llamar)
        ▲                                    │
        │                            tras 30 s
        │                                    ▼
        └──── éxito ──── SEMIABIERTO ◄───────┘
                      (deja pasar 1 de prueba)

Si un servicio está caído, seguir llamándolo cada vez (y esperar el timeout de 5 s en cada petición) es la forma más rápida de tumbarte a ti también. El circuit breaker corta: falla inmediatamente, libera tus recursos y le da margen al otro servicio para recuperarse.

Degradación elegante

python
def obtener_recomendaciones(usuario_id):
    try:
        return servicio_ml.recomendar(usuario_id, timeout=1)
    except (Timeout, CircuitoAbierto):
        return productos_mas_vendidos()    # peor, pero la página FUNCIONA

🧠 Pregunta de diseño para cada dependencia: "si esto se cae, ¿qué debería ver el usuario?" Si la respuesta es "un error 500 en toda la página", casi siempre es la respuesta equivocada. Un feed sin recomendaciones personalizadas, una ficha de producto sin reseñas o una web sin buscador siguen siendo útiles. Una página en blanco, no.

Rate limiting: protegerte de tus propios usuarios

python
# Token bucket: N peticiones por ventana, con capacidad de ráfaga
clave = f"rate:{usuario_id}"
actual = redis.incr(clave)
if actual == 1:
    redis.expire(clave, 60)
if actual > 100:
    raise HTTPException(429, headers={"Retry-After": "60"})

Protege contra abusos, bots, bucles infinitos de un cliente mal programado y picos accidentales. Y el 429 con Retry-After le dice al cliente qué hacer en vez de dejarlo reintentando a ciegas.


24.9 · Poniéndolo todo junto: la evolución de un sistema real

mermaid
graph TD
    subgraph E1["Etapa 1 · 0-1.000 usuarios"]
        A1["Un servidor:<br/>app + PostgreSQL + Nginx"]
    end
    subgraph E2["Etapa 2 · 1k-10k"]
        A2["App"] --> B2[("BD gestionada<br/>+ backups")]
        A2 --> C2[("Redis:<br/>sesiones y caché")]
        A2 --> D2["CDN: estáticos"]
    end
    subgraph E3["Etapa 3 · 10k-100k"]
        LB3["Balanceador"] --> A3a["App 1"] & A3b["App 2"] & A3c["App 3"]
        A3a --> P3[("Primaria")]
        A3a --> R3[("Réplicas de lectura")]
        A3a --> Q3[("Cola")] --> W3["Workers"]
    end
    subgraph E4["Etapa 4 · 100k+"]
        S4["Servicios separados por dominio<br/>+ particionado de tablas grandes<br/>+ buscador dedicado + observabilidad"]
    end
    E1 --> E2 --> E3 --> E4

⚠️ Empieza siempre en la etapa 1. El error más caro que puede cometer un equipo pequeño es arrancar en la etapa 4 "porque vamos a crecer mucho": acabas con la complejidad operativa de una empresa de 200 personas, siendo tres, sin haber validado siquiera que alguien quiere el producto. Cada etapa se gana con métricas que demuestran que la anterior se quedó corta.


✅ Ejercicio del capítulo

Diseña el backend de un acortador de URLs (tipo bit.ly). Es el ejercicio canónico porque parece trivial y toca casi todo el capítulo.

Requisitos

  • POST /acortar recibe una URL larga y devuelve una corta (https://ac.me/aB3xK9).
  • GET /aB3xK9 redirige (301/302) a la URL original.
  • 100 millones de URLs creadas al año.
  • Ratio lectura/escritura 100:1.
  • La redirección debe responder en menos de 50 ms en el percentil 99.
  • Estadísticas de clics por URL (no necesitan ser exactas al instante).

Parte 1 — Estimaciones

  1. Escrituras por segundo (media y pico).
  2. Lecturas por segundo (media y pico).
  3. Almacenamiento a 5 años (razona el tamaño de una fila).
  4. ¿Cuánta RAM haría falta para cachear el 20% más popular?

Parte 2 — Diseño 5. Esquema de la tabla, con los índices exactos que necesitas y por qué. 6. Cómo generas el código corto. Compara al menos tres opciones (contador en base62, hash truncado, aleatorio con comprobación de colisión) e indica el problema de cada una. 7. ¿Dónde pones caché y con qué TTL? ¿Qué haces con las URLs que no existen? 8. ¿301 o 302? La respuesta tiene consecuencias directas sobre el requisito de estadísticas. 9. ¿Cómo registras los clics sin añadir latencia a la redirección? 10. Dibuja el diagrama de la arquitectura (mermaid).

Parte 3 — Fallos y abusos 11. ¿Qué pasa si Redis se cae por completo? ¿Y si cae la base de datos? 12. ¿Cómo evitas que se use tu servicio para phishing y distribución de malware? 13. ¿Cómo limitas la creación de URLs por usuario e IP? 14. Un enlace se hace viral: 50.000 peticiones por segundo sobre el mismo código. ¿Aguanta tu diseño? ¿Qué se rompe primero?

Parte 4 — Evolución 15. Escribe cómo sería el sistema en la etapa 1 (lanzamiento, un servidor) y cuál sería la métrica concreta que te haría pasar a la siguiente etapa.

💡 Pistas de la solución (abre solo si te atascas)

Estimaciones. 100M/año ÷ 3,15×10⁷ s ≈ 3 escrituras/s de media (~10/s en pico). Las lecturas son ×100: 300/s de media, ~1.000/s en pico. Fíjate en lo que esto significa: son números pequeñísimos. Un solo PostgreSQL bien indexado los sirve sin despeinarse. Descubrir eso en dos minutos, antes de diseñar nada, es exactamente el objetivo del ejercicio.

Almacenamiento: ~500 bytes por fila (URL larga + código + metadatos) × 500M filas ≈ 250 GB a cinco años. Cabe de sobra en un disco. El 20% más popular en caché ≈ 50 GB… pero en realidad la distribución es de cola larga extrema: el 1% de las URLs se lleva casi todos los clics, así que unos pocos GB de Redis dan un ratio de acierto altísimo.

Punto 6 — La decisión central. Contador global en base62 ([0-9a-zA-Z], 62⁷ ≈ 3,5 billones de combinaciones con 7 caracteres) es simple y sin colisiones, pero los códigos son secuenciales y adivinables: cualquiera puede recorrer todas las URLs creadas, lo que es un problema de privacidad serio. Hash truncado (MD5/SHA de la URL, primeros 7 caracteres) es determinista — la misma URL da el mismo código, lo que ahorra espacio — pero hay colisiones y debes gestionarlas. Aleatorio + comprobación de existencia es impredecible y simple, a costa de una lectura extra en cada creación (despreciable a 3/s). Con el espacio tan poco ocupado, la probabilidad de colisión es mínima. Para este caso, aleatorio gana: el requisito de no-enumerabilidad pesa más que la eficiencia.

Punto 8 — El trade-off bonito del ejercicio. 301 Moved Permanently deja que el navegador y los intermediarios cacheen la redirección: rapidísimo y casi gratis para ti… pero las siguientes visitas ya no pasan por tu servidor, así que pierdes las estadísticas y no puedes desactivar un enlace malicioso. 302 Found obliga a pasar siempre por ti: mides todo y mantienes el control, a cambio de servir cada clic. Como las estadísticas y la capacidad de bloquear enlaces son requisitos, la respuesta es 302 (o 307). Este es el tipo de decisión donde un detalle del protocolo HTTP determina si puedes cumplir un requisito de producto.

Punto 9 — Nunca escribas en la base de datos dentro del request de redirección. Responde el 302 primero y publica el evento de clic en una cola (o incrementa un contador en Redis que un worker vuelca a la BD cada minuto). Las estadísticas son explícitamente "eventualmente consistentes" según el enunciado: aprovéchalo.

Punto 11 — Si Redis cae, todo debe seguir funcionando más lento leyendo de PostgreSQL: degradación elegante (24.8). Si cae la base de datos, las lecturas cacheadas siguen sirviéndose pero la creación falla → devuelve 503 con Retry-After, no un 500 genérico.

Punto 14 — Un solo código viral es el caso perfecto para caché: es una clave en Redis con un ratio de acierto del ~100%. Redis aguanta 50k/s sin problema. Lo que se rompe primero no es la base de datos: es tu ancho de banda y el número de conexiones concurrentes del balanceador. Ojo también con el registro de clics: 50k eventos/s a una cola es mucho más que 1.000/s — ahí sí conviene agregar en memoria y volcar cada N segundos en vez de un evento por clic.


🧠 Autoevaluación

Porque en la inmensa mayoría de los casos el problema no es la capacidad, sino la eficiencia. Una consulta sin índice puede tardar 2 segundos donde debería tardar 2 milisegundos: añadir tres servidores multiplica por tres la capacidad, pero un índice la multiplica por mil, cuesta una línea y no añade complejidad operativa.

El orden correcto es medir → optimizar consultas → cachear → escalar vertical → escalar horizontal. Los tres primeros pasos son baratos y reversibles; el escalado horizontal introduce balanceo, sesiones compartidas, despliegues coordinados y una clase entera de bugs nuevos. Añadir servidores a un sistema ineficiente es pagar más por seguir siendo ineficiente.

Significa que el servidor no guarda entre peticiones ninguna información que las siguientes vayan a necesitar: ni sesiones en memoria, ni archivos subidos en disco local, ni cachés locales de datos compartidos, ni contadores en variables globales. Todo ese estado vive en un almacén compartido (Redis, PostgreSQL, object storage) o viaja con la petición (un JWT firmado).

Es condición previa porque un balanceador reparte cada petición al servidor que esté libre: si el estado vive en la máquina, un usuario cuya segunda petición cae en otro servidor pierde la sesión, no encuentra su archivo o ve un contador distinto. El síntoma característico es "funciona pero falla de forma aleatoria e irreproducible", y es exactamente lo que ocurre al pasar de uno a dos servidores sin haber externalizado el estado.

Ocurre cuando caduca una clave de caché muy solicitada y todas las peticiones concurrentes fallan a la vez, disparando cientos o miles de consultas idénticas contra la base de datos en el mismo instante. La ironía es que la caché estaba funcionando perfectamente: el problema aparece justo en el momento en que expira.

Dos soluciones:

  1. Bloqueo de recálculo: solo la primera petición que detecta el MISS adquiere un lock corto en Redis y recalcula; las demás sirven el valor antiguo (o esperan brevemente) hasta que esté listo.
  2. TTL con jitter: en vez de un TTL fijo de 300 s, usar 300 + aleatorio(0, 60). Así miles de claves creadas a la vez no expiran simultáneamente, y la carga se reparte.

Una tercera opción es el refresco proactivo: un proceso en segundo plano renueva las claves más calientes antes de que caduquen.

Es replication lag. La escritura fue a la base de datos primaria, pero la lectura inmediata posterior fue a una réplica que todavía no había recibido el cambio (el retraso suele ser de milisegundos, pero puede llegar a segundos bajo carga). El usuario ve su dato anterior y concluye que no se guardó — muchas veces vuelve a enviar el formulario, con lo que puede duplicar datos.

La solución habitual es read-your-own-writes: durante unos segundos después de que un usuario haya escrito, sus lecturas se dirigen a la primaria; el resto del tráfico sigue yendo a réplicas. Se implementa marcando la sesión con una fecha de última escritura. La alternativa —esperar a que las réplicas estén siempre al día— contradice el propósito mismo de la replicación asíncrona.

Porque las colas garantizan entrega al menos una vez, no exactamente una vez. Un worker puede procesar un mensaje correctamente y morir justo antes de confirmarlo; la cola, al no recibir el acuse, lo reentrega. También hay reintentos por timeout de red, redespliegues a mitad de proceso y reequilibrados de particiones. Con volumen suficiente, la doble ejecución no es un caso hipotético: es una certeza estadística.

Si la tarea es idempotente (comprobar el estado antes de actuar, usar claves de idempotencia contra las APIs externas, INSERT ... ON CONFLICT DO NOTHING), la segunda ejecución es inofensiva. Si no lo es, cobras dos veces la misma tarjeta, envías el email duplicado o descuentas el stock dos veces. "Exactamente una vez" no existe de forma barata en sistemas distribuidos: lo que existe es al menos una vez + idempotencia, que es equivalente y mucho más simple.

Porque cada petición entrante ocupa un recurso finito: un hilo, un worker o una conexión del pool. Si una llamada a un servicio externo se queda esperando indefinidamente, ese recurso no se libera nunca. Con un flujo constante de peticiones, todos los workers acaban bloqueados esperando al mismo servicio lento, y tu aplicación deja de responder incluso a las peticiones que no tienen nada que ver con esa integración.

Es el mecanismo clásico de fallo en cascada: un servicio secundario degradado tumba un sistema entero que, por lo demás, funcionaba perfectamente. La defensa tiene tres capas: timeouts en toda llamada de red (agresivos: el usuario no va a esperar 30 s de todos modos), un circuit breaker que deje de llamar al servicio caído y falle al instante, y degradación elegante que devuelva una respuesta útil aunque sea peor.

Se elige consistencia eventual cuando un desfase temporal de segundos es aceptable para el negocio y a cambio se gana disponibilidad, latencia o capacidad de escalar. Se elige consistencia fuerte cuando leer un dato obsoleto produce una decisión incorrecta e irreversible.

Eventual: el contador de "me gusta" de una publicación (si durante tres segundos marca 41 en vez de 42, nadie se ve perjudicado) y el índice del buscador (un producto nuevo puede tardar medio minuto en aparecer en las búsquedas sin que eso rompa nada).

Fuerte: el saldo de una cuenta bancaria (leer un saldo antiguo permite gastar dinero dos veces) y el stock de un producto con unidades limitadas (dos clientes comprando la última unidad genera una venta que no puedes cumplir).

La clave es que no es una propiedad global del sistema sino una decisión por dato: la misma aplicación de comercio electrónico usa consistencia fuerte para el stock y el pago, y eventual para las reseñas, las recomendaciones y las estadísticas.


Siguiente: 25-decisiones-y-adr.md — cómo documentar, defender y revisar las decisiones que acabas de aprender a tomar.