← Blog
·12 blog.minutes

Fundamentos de Diseño de Sistemas que Todo Desarrollador Debería Saber

Balanceo de carga, caching, fragmentación, CDNs, colas de mensajes, teorema CAP, limitación de tasa: explicados con ejemplos del mundo real y configuraciones que realmente puedes usar.

Todo desarrollador backend eventualmente se topa con un muro. La aplicación funciona bien en tu laptop. Funciona bien en staging con tres usuarios concurrentes. Luego despliegas a producción, llegan mil personas a la vez y la base de datos se cae. La API empieza a devolver errores 5xx, el frontend se congela, y en algún lugar de Slack alguien publica la captura de pantalla que hace que tu teléfono vibre a las 2 AM.

El diseño de sistemas es lo que vive al otro lado de ese muro. Es el conjunto de patrones, compensaciones y decisiones de infraestructura que separan una aplicación de juguete de un sistema de producción que puede manejar tráfico real sin colapsar. La buena noticia es que no necesitas ser un ingeniero de infraestructura senior para entender los fundamentos. Necesitas conocer unos ocho conceptos, cómo interactúan y cuándo recurrir a cada uno.

Este artículo cubre los patrones de diseño de sistemas que más importan para los desarrolladores backend en activo. Cada sección explica el concepto, muestra un ejemplo práctico y describe las compensaciones que necesitas evaluar antes de adoptarlo. Estos no son conceptos abstractos de libro de texto, son herramientas a las que recurrirás cada vez que construyas algo que necesite escalar.

Balanceo de Carga — Manteniendo Cada Servidor Ocupado, Pero No Demasiado

El balanceo de carga es el patrón de diseño de sistemas más simple e impactante que puedes implementar. La idea es sencilla: en lugar de enviar todo el tráfico a un solo servidor, distribuyes las solicitudes entrantes entre un grupo de servidores. Esto te da dos cosas a la vez: mayor disponibilidad (si un servidor muere, los otros siguen sirviendo) y mayor rendimiento (varios servidores comparten el trabajo).

Los algoritmos de balanceo de carga más comunes son round-robin, mínimas conexiones y hash de IP. Round-robin recorre la lista de servidores en orden: simple, predecible, pero no sabe qué tan ocupado está cada servidor. Mínimas conexiones envía cada solicitud al servidor con menos conexiones activas, lo que maneja mejor las cargas de solicitudes desiguales. Hash de IP usa la dirección IP del cliente para elegir un servidor de forma determinista, lo que importa cuando necesitas sesiones persistentes, asegurando que el mismo cliente siempre llegue al mismo servidor.

En la práctica, la mayoría de las configuraciones de producción usan una combinación. Un balanceador de carga de capa 4 (que opera a nivel TCP) distribuye conexiones brutas a través de un grupo de proxies inversos o API gateways, que luego realizan balanceo de carga de capa 7 (que opera a nivel HTTP) para enrutar solicitudes a instancias de aplicación específicas. Este enfoque en capas mantiene el plano de datos rápido y la lógica de enrutamiento flexible.

# nginx.conf — simple round-robin load balancing across three app servers
upstream app_cluster {
    round-robin;
    server app1.internal:3000 max_fails=3 fail_timeout=30s;
    server app2.internal:3000 max_fails=3 fail_timeout=30s;
    server app3.internal:3000 max_fails=3 fail_timeout=30s;
}

server {
    listen 443 ssl;
    location / {
        proxy_pass http://app_cluster;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

El detalle crítico que la mayoría de los desarrolladores pasan por alto es la verificación de salud. Un balanceador de carga solo es útil si sabe qué servidores están sanos. Si app2 se cae pero el balanceador de carga sigue enviándole tráfico, tu tasa de errores aumenta aproximadamente un tercio. Configura siempre comprobaciones de salud activas: el balanceador de carga hace ping periódicamente a cada servidor y elimina automáticamente los que no responden del grupo.

Caching — Velocidad, ¿pero a qué Costo?

El caching es la optimización de rendimiento de mayor apalancamiento disponible para cualquier desarrollador. Un solo acierto de caché puede convertir una consulta de base de datos de 200 milisegundos en una búsqueda en memoria de 2 milisegundos. Dos órdenes de magnitud. Aplicado a millones de solicitudes, esa diferencia es la diferencia entre una factura mensual de base de datos de $10,000 y una de $500.

La pila de caching canónica tiene tres capas, cada una con diferentes características. El caching a nivel de aplicación (cachés en memoria como Redis o Memcached) almacena los resultados de cálculos costosos o consultas de base de datos. El caching de CDN almacena activos estáticos y semi-estáticos en ubicaciones periféricas cercanas a los usuarios. El caching HTTP utiliza cabeceras Cache-Control y ETag para permitir que los navegadores y proxies almacenen en caché las respuestas sin involucrar tus servidores.

La parte más difícil del caching no es configurarlo, sino invalidar la caché cuando los datos subyacentes cambian. La industria se ha establecido en algunos patrones fiables. Cache-aside (también llamado lazy loading) significa que la aplicación verifica primero la caché, recurre a la base de datos en caso de fallo y puebla la caché con el resultado. Write-through cache significa que cada escritura va tanto a la caché como a la base de datos simultáneamente. Write-behind cache significa que las escrituras van primero a la caché y se vacían asíncronamente a la base de datos.

Solo hay dos cosas difíciles en ciencias de la computación: la invalidación de caché y poner nombres a las cosas. La invalidación de caché es más difícil porque no puedes simplemente renombrarla y esperar que desaparezca.

Para la mayoría de las aplicaciones, cache-aside con un tiempo de vida corto es la opción predeterminada correcta. Establece un TTL que coincida con tu tolerancia a datos obsoletos: 60 segundos para perfiles de usuario, 5 minutos para listados de productos, 24 horas para datos de referencia. Cuando necesites una consistencia más fuerte, usa write-through cache pero acepta la mayor latencia de escritura. Cuando necesites el máximo rendimiento de lectura y puedas tolerar consistencia eventual, usa cache-aside con un TTL generoso.

import redis.asyncio as aioredis
import json

cache = aioredis.Redis.from_url("redis://cache:6379")

CACHE_TTL = 300  # 5 minutes

async def get_user_profile(user_id: str) -> dict:
    key = f"profile:{user_id}"
    cached = await cache.get(key)
    if cached:
        return json.loads(cached)
    profile = await db.fetch_user(user_id)
    if profile:
        await cache.setex(key, CACHE_TTL, json.dumps(profile))
    return profile

Una palabra de advertencia: el caching puede enmascarar problemas en lugar de resolverlos. Si tus consultas de base de datos son lentas porque te falta un índice, añadir una caché oculta el síntoma, pero la consulta subyacente sigue siendo lenta. La caché se desaloja, la consulta lenta se ejecuta, el usuario espera. Siempre perfila y optimiza primero tus caminos lentos, luego añade caching sobre la versión optimizada.

Fragmentación de Base de Datos — Dividiendo el Trabajo para que Ninguna Base de Datos se Ahogue

La fragmentación (particionado horizontal) es lo que usas cuando una sola instancia de base de datos no puede manejar tu rendimiento de escritura o el tamaño de tu conjunto de datos. La idea es dividir tus datos entre múltiples instancias de base de datos, donde cada instancia (shard) contiene un subconjunto de los datos. La aplicación determina qué shard consultar basándose en una clave de shard, típicamente un hash del ID de usuario, una región geográfica o un rango de tiempo.

La clave de shard es la decisión más importante en cualquier sistema fragmentado. Una buena clave de shard distribuye los datos uniformemente entre los shards y se alinea con tus patrones de consulta. Una mala clave de shard crea shards calientes: unos pocos shards que manejan la mayor parte del tráfico mientras el resto permanece inactivo. Por ejemplo, fragmentar por marca de tiempo de creación parece razonable hasta que te das cuenta de que el shard de hoy maneja todas las escrituras mientras que los shards del año pasado no manejan ninguna.

El hashing consistente resuelve el problema de rebalanceo que afecta a la fragmentación ingenua. En un esquema de fragmentación simple basado en módulo (shard = hash(key) % N), añadir un nuevo shard requiere reorganizar casi todos los datos. El hashing consistente mapea tanto las claves como los shards en un anillo hash; cuando añades un shard, solo las claves en la vecindad inmediata del nuevo shard necesitan moverse. Esto hace que escalar hacia arriba y hacia abajo sea mucho menos doloroso.

  • Fragmentación basada en hash: distribuye hasheando la clave de shard; simple y uniforme pero el resharden es costoso
  • Fragmentación basada en rangos: divide por rangos de valores (ej., usuarios con IDs 1–10000 en el shard A, 10001–20000 en el shard B); eficiente para consultas de rango pero propensa a puntos calientes
  • Fragmentación basada en directorio: mantiene una tabla de búsqueda que mapea claves a shards; flexible pero añade un salto de búsqueda y un punto único de fallo si el directorio se cae
  • Fragmentación geográfica: divide por región de usuario; excelente para latencia pero incómoda si los usuarios viajan o si los datos necesitan ser globales

La compensación que aceptas con la fragmentación es que las consultas entre shards se vuelven costosas o imposibles. Si fragmentas tu tabla de usuarios por user_id y tu tabla de pedidos por user_id, una consulta para todos los pedidos de los últimos 30 días debe golpear todos los shards. Las aplicaciones que necesitan analítica global o uniones entre shards a menudo usan una réplica de lectura secundaria (o una base de datos de analítica dedicada) que agrega datos de todos los shards de forma asíncrona.

El Teorema CAP — Solo Puedes Elegir Dos

El teorema CAP establece que un almacén de datos distribuido no puede proporcionar simultáneamente más de dos de tres garantías: Consistencia (toda lectura recibe la escritura más reciente), Disponibilidad (toda solicitud recibe una respuesta, incluso si no son los datos más recientes) y Tolerancia a Particiones (el sistema continúa operando a pesar de fallos de red entre nodos).

En la práctica, la tolerancia a particiones no es opcional. Las redes fallan. Los paquetes se pierden, las conexiones expiran, los centros de datos pierden energía. Así que la elección real es entre CP (consistencia + tolerancia a particiones) y AP (disponibilidad + tolerancia a particiones). Un sistema CP como etcd o Zookeeper se negará a servir lecturas si no puede garantizar consistencia entre nodos. Un sistema AP como Cassandra o DynamoDB servirá lecturas desde cualquier nodo alcanzable, incluso si ese nodo tiene datos obsoletos.

Esta no es una distinción académica. Cuando diseñas un sistema que abarca múltiples centros de datos, debes decidir qué sucede cuando el enlace de red entre ellos se cae. ¿Sigues sirviendo solicitudes con datos potencialmente obsoletos (AP)? ¿O dejas de servir hasta que la red se recupere (CP)? La respuesta depende de tu aplicación. Una red de entrega de contenido debería ser AP: contenido obsoleto es mejor que ningún contenido. Un sistema de procesamiento de pagos debería ser CP: nunca quieres cobrar dos veces a un cliente porque dos nodos aceptaron el mismo pago mientras estaban particionados.

Colas de Mensajes — Convirtiendo el Dolor Síncrono en Gracia Asíncrona

Las colas de mensajes son la columna vertebral del procesamiento asíncrono en sistemas distribuidos. Permiten que un servicio (el productor) envíe un mensaje a una cola sin esperar a que el consumidor lo procese. El consumidor recoge el mensaje cuando está listo, lo procesa y confirma su finalización. Esto desacopla al productor del consumidor tanto en tiempo como en espacio: no necesitan ejecutarse a la misma velocidad, ni siquiera al mismo tiempo.

Todo sistema backend no trivial debería usar una cola de mensajes en algún lugar. El ejemplo canónico es el envío de correos electrónicos. Cuando un usuario se registra en tu plataforma, no quieres que la respuesta HTTP espere a que el servicio de entrega de correos renderice una plantilla, se conecte a SendGrid y entregue el mensaje. En su lugar, tu API empuja un evento send_email a una cola y devuelve una respuesta 201 Created inmediatamente. Un trabajador separado recoge el evento, envía el correo y marca la tarea como completada.

Los dos modelos dominantes de colas de mensajes son publicación-suscripción (pub/sub) y colas de trabajo. En pub/sub, cada mensaje se transmite a todos los suscriptores. Esto es útil para arquitecturas impulsadas por eventos donde múltiples servicios necesitan reaccionar al mismo evento: un nuevo registro de usuario podría desencadenar un correo de bienvenida, una actualización de CRM y un evento de analítica simultáneamente. En una cola de trabajo, cada mensaje se entrega exactamente a un consumidor. Esto es útil para distribuir trabajo entre un grupo de trabajadores: cada carga de imagen va exactamente a un generador de miniaturas.

# docker-compose.yml — minimal RabbitMQ setup for local development
version: "3.8"
services:
  rabbitmq:
    image: rabbitmq:3-management-alpine
    ports:
      - "5672:5672"   # AMQP port for producers/consumers
      - "15672:15672" # Management UI
    environment:
      RABBITMQ_DEFAULT_USER: app
      RABBITMQ_DEFAULT_PASS: dev-only-password
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq

volumes:
  rabbitmq_data:

La parte complicada de las colas de mensajes es manejar los fallos con elegancia. ¿Qué pasa si el consumidor se cae a medio procesar un mensaje? RabbitMQ y Amazon SQS manejan esto con acuses de recibo de entrega: el consumidor debe confirmar explícitamente que un mensaje fue procesado con éxito. Si el consumidor se desconecta sin confirmar, el mensaje se vuelve a encolar y se entrega a otro consumidor. Esta garantía de entrega al menos una vez significa que tus consumidores deben ser idempotentes: procesar el mismo mensaje dos veces debe producir el mismo resultado que procesarlo una vez.

Las colas de mensajes fallidos (dead letter queues) son otro patrón esencial. Cuando un mensaje no puede procesarse después de varios reintentos (el servicio descendente está caído, los datos están malformados, la regla de negocio cambió), el mensaje se mueve a una cola de mensajes fallidos en lugar de reintentarse para siempre. Un operador monitorea la cola de mensajes fallidos, investiga la causa raíz y, o bien corrige el mensaje y lo reencola, o lo descarta después de confirmar que es seguro ignorarlo.

CDNs y Limitación de Tasa — Las Defensas de Primera Línea

Las redes de entrega de contenido y los limitadores de tasa sirven diferentes propósitos, pero comparten un rasgo común: son la primera línea de defensa entre tus usuarios y tus servidores. Una CDN mantiene los activos estáticos y las respuestas en caché cerca del usuario, reduciendo la latencia y descargando tráfico de tus servidores de origen. Un limitador de tasa evita que un solo usuario o cliente abrume tu sistema con solicitudes.

Las CDNs funcionan distribuyendo tu contenido a través de una red global de servidores periféricos. Cuando un usuario en Tokio solicita un activo, la CDN lo sirve desde la ubicación periférica más cercana en lugar de enrutar la solicitud hasta tu servidor de origen en Virginia. Esto reduce la latencia de 200 milisegundos a 10 milisegundos para activos estáticos. Las CDNs modernas van más allá: pueden almacenar en caché respuestas de API, terminar conexiones TLS e incluso ejecutar funciones serverless en el borde.

La limitación de tasa protege tu sistema en múltiples niveles. La limitación de tasa global limita el total de solicitudes que todo tu sistema puede manejar por segundo, protegiendo contra picos de tráfico y ataques DDoS. La limitación de tasa por usuario asegura que un inquilino abusivo no pueda privar de recursos a otros usuarios. La limitación de tasa a nivel de endpoint aplica diferentes límites a diferentes rutas: un endpoint de inicio de sesión podría permitir 5 solicitudes por minuto mientras que un endpoint de búsqueda de solo lectura permite 100 solicitudes por minuto.

El algoritmo de ventana deslizante es el estándar de la industria para la limitación de tasa porque es preciso y eficiente. En lugar de restablecer contadores a intervalos fijos (lo que permite ráfagas en el límite), una ventana deslizante considera las solicitudes durante un período de tiempo móvil. Redis es la opción natural para implementar esto: usa un conjunto ordenado con marcas de tiempo como puntuaciones, recorta las entradas fuera de la ventana y cuenta las entradas restantes. El costo de memoria es mínimo (unos pocos bytes por solicitud) y la complejidad temporal es logarítmica.

// Redis-backed sliding window rate limiter (TypeScript)
import { createClient } from "redis";

const redis = createClient({ url: "redis://ratelimit:6379" });

async function checkRateLimit(
  key: string,
  limit: number,
  windowMs: number
): Promise<{ allowed: boolean; remaining: number }> {
  const now = Date.now();
  const windowStart = now - windowMs;

  const multi = redis.multi();
  multi.zRemRangeByScore(key, 0, windowStart);
  multi.zAdd(key, { score: now, value: `${now}` });
  multi.zCard(key);
  multi.expire(key, Math.ceil(windowMs / 1000));

  const [, , count] = await multi.exec() as [any, any, number];
  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
  };
}

Tanto las CDNs como los limitadores de tasa comparten un principio operativo importante: ¿fallar abierto o fallar cerrado? Si el borde de la CDN no puede alcanzar el origen, ¿debería servir una respuesta en caché obsoleta (fallo abierto) o devolver un error (fallo cerrado)? Si el clúster de Redis del limitador de tasa se cae, ¿deberían pasar todas las solicitudes (fallo abierto, riesgo de sobrecarga) o deberían rechazarse todas las solicitudes (fallo cerrado, tiempo de inactividad garantizado)?

No hay una respuesta universal, pero un buen valor predeterminado para la mayoría de los sistemas es: fallo abierto para lecturas, fallo cerrado para escrituras. Una página de listado de productos obsoleta es aceptable. Una orden de compra perdida no lo es. Documenta esta decisión explícitamente en tu runbook para que el ingeniero de guardia sepa qué comportamiento esperar cuando la infraestructura se doblega.

Poniéndolo Todo Junto — Pensando en Compensaciones

El diseño de sistemas no se trata de memorizar patrones. Se trata de entender las compensaciones y reconocer qué patrón se ajusta a tus restricciones. Cada decisión implica una compensación entre consistencia y disponibilidad, entre rendimiento de lectura y latencia de escritura, entre complejidad operativa y rendimiento bruto. Los mejores ingenieros no son los que conocen más patrones, son los que pueden mirar un problema e identificar qué restricciones son fijas y cuáles son negociables.

Aquí hay un marco de decisión rápido para cuando te enfrentes a un nuevo problema de diseño de sistemas. Empieza listando tus requisitos no funcionales: volumen de tráfico esperado, objetivos de latencia, requisitos de consistencia, presupuesto para costos de infraestructura y familiaridad del equipo con la tecnología. Luego trabaja a través de los patrones de este artículo y pregúntate si cada uno te acerca o te aleja de tus requisitos.

Si la latencia es tu principal preocupación, empieza con caching y una CDN: dan la mayor mejora con la menor complejidad. Si la disponibilidad es crítica, usa balanceo de carga con comprobaciones de salud, diseña tus servicios para que sean stateless y elige AP sobre CP donde el negocio lo permita. Si estás lidiando con cargas de trabajo intensivas en escritura, evalúa la fragmentación y las colas de mensajes temprano: son mucho más fáciles de introducir antes de tener millones de filas de datos. Si estás lidiando con una API pública que consumirán desarrolladores externos, implementa la limitación de tasa desde el día uno. Añadirla más tarde significa versionar tu API o romper clientes existentes.

La habilidad más importante en el diseño de sistemas es saber lo que no necesitas. La mayoría de las aplicaciones no necesitan fragmentación. La mayoría de las aplicaciones no necesitan una cola de mensajes. La distribución prematura es la raíz de todos los males en el diseño de sistemas: cada sistema distribuido introduce modos de fallo que un sistema de un solo servidor simplemente no tiene. Añade complejidad solo cuando las métricas te lo indiquen, no porque el patrón suene impresionante en una entrevista de trabajo.

Empieza simple. Mide todo. Añade un patrón a la vez. Verifica la mejora antes de continuar. Los sistemas que sobreviven no son los que tienen la arquitectura más sofisticada, son los que son fáciles de entender, fáciles de operar y fáciles de cambiar cuando aparece el próximo cuello de botella.