Fundamentos de System Design Medio¶
Los bloques de construcción que aparecen en casi cualquier sistema a escala. Para cada uno: qué problema resuelve, cómo funciona, sus variantes y qué coste tiene.
1. Escalado¶
Cuando un sistema no da abasto, hay dos formas de darle más capacidad:
| Vertical (scale up) | Horizontal (scale out) | |
|---|---|---|
| Qué | Máquina más grande (más CPU, memoria) | Más máquinas iguales trabajando en paralelo |
| Ventajas | Simple: sin cambios en el código | Casi ilimitado; si cae una máquina, las demás siguen |
| Inconvenientes | Hay un máximo físico y económico; una sola máquina es un punto único de fallo | Exige que las instancias sean intercambiables, balanceo y datos distribuidos |
| Cuándo | Primeros pasos, bases de datos (hasta cierto punto) | Servicios web, workers, cualquier cosa que deba crecer mucho |
Requisito para escalar horizontalmente: servidores sin estado
Una instancia sin estado (stateless) no guarda en su memoria nada que otra no pueda reconstruir: la sesión del usuario, carritos, cachés locales imprescindibles… van a un almacén compartido (Redis, base de datos) o al propio cliente (token JWT). Así cualquier instancia puede atender cualquier petición y se pueden añadir o quitar libremente.
2. Balanceo de carga¶
Un balanceador de carga reparte las peticiones entre varias instancias y deja de enviar a las que no están sanas.
| Tipo | Qué ve | Puede hacer | Ejemplos |
|---|---|---|---|
| L4 (transporte) | IPs y puertos (TCP/UDP) | Repartir conexiones, muy rápido | AWS NLB, Azure Load Balancer, IPVS |
| L7 (aplicación) | HTTP: ruta, cabeceras, cookies | Enrutar /api a un servicio y /web a otro, reintentos, TLS, rate limiting |
ALB, NGINX, Envoy, Ingress |
| Algoritmo | Cómo reparte | Cuándo |
|---|---|---|
| Round robin | Por turnos | Peticiones homogéneas |
| Least connections | A la instancia con menos conexiones activas | Peticiones de duración variable |
| Ponderado | Proporcional a la capacidad de cada instancia | Instancias de distinto tamaño |
| Hash de IP o clave / consistent hashing | Siempre la misma instancia para la misma clave | Afinidad (cachés locales, sesiones) |
- Health checks: el balanceador comprueba periódicamente un endpoint (
/health) y saca de la rotación las instancias que fallan. - El propio balanceador no puede ser un punto único de fallo: se despliega redundante (varias zonas, IP anycast, DNS).
3. Caché¶
Una caché guarda una copia de datos costosos de obtener (consulta lenta, llamada externa, cálculo) en un almacén más rápido (memoria). Reduce latencia y carga sobre el origen. El precio: los datos pueden estar desactualizados.
Dónde se cachea¶
| Nivel | Ejemplo |
|---|---|
| Cliente | Caché del navegador (Cache-Control), caché del SDK |
| CDN | Contenido estático o público cerca del usuario |
| Aplicación (local) | Caffeine en la memoria de cada instancia: rapidísima, pero cada instancia tiene su copia |
| Distribuida | Redis / Memcached compartido por todas las instancias |
| Base de datos | Buffer pool, vistas materializadas |
Estrategias de lectura y escritura¶
| Estrategia | Cómo funciona | Cuándo |
|---|---|---|
| Cache-aside (lazy loading) | La app busca en caché; si no está (fallo), lee de la BBDD y guarda en caché | La más común; lecturas frecuentes |
| Read-through | La caché se encarga de leer de la BBDD en un fallo | Librerías o servicios que lo soportan |
| Write-through | Cada escritura va a la caché y a la BBDD a la vez | Se leen justo después de escribir |
| Write-behind (write-back) | Se escribe en caché y se persiste después, en lote | Escrituras muy intensivas; riesgo de perder datos |
public Product find(ProductId id) { // cache-aside
String key = "product:" + id;
Product cached = redis.get(key);
if (cached != null) return cached; // acierto
Product p = repository.findById(id).orElseThrow(); // fallo: ir al origen
redis.setex(key, Duration.ofMinutes(10), p); // TTL: acota cuánto tiempo puede estar desactualizado
return p;
}
public void update(Product p) {
repository.save(p);
redis.del("product:" + p.id()); // invalidar: la próxima lectura traerá la versión nueva
}
Problemas típicos¶
| Problema | Qué pasa | Soluciones |
|---|---|---|
| Datos obsoletos | La caché devuelve un valor antiguo tras cambiar el origen | TTL adecuado, invalidar al escribir, versionar claves |
| Cache stampede | Una clave popular expira y miles de peticiones van a la vez a la BBDD | Bloqueo (solo una recalcula), TTL con variación aleatoria (jitter), refrescar antes de expirar |
| Hot key | Una sola clave recibe tanto tráfico que satura un nodo de la caché | Replicar la clave, caché local delante |
| Caché fría | Tras reiniciar, todo son fallos y el origen se satura | Precalentar, arrancar instancias poco a poco |
Políticas de expulsión cuando la caché se llena: LRU (la usada hace más tiempo, la más común), LFU (la menos usada), FIFO.
4. Bases de datos¶
SQL vs. NoSQL¶
| Relacional (PostgreSQL, MySQL) | NoSQL | |
|---|---|---|
| Modelo | Tablas con esquema, relaciones y joins | Clave-valor (Redis, DynamoDB), documento (MongoDB), columnar ancha (Cassandra), grafo (Neo4j) |
| Garantías | Transacciones ACID | A menudo consistencia eventual o configurable |
| Escalado | Vertical + réplicas de lectura; particionar es manual y complejo | Horizontal desde el diseño |
| Consultas | Cualquier consulta (SQL ad hoc) | Rápidas para los patrones de acceso previstos; el resto, difícil |
| Cuándo | Datos relacionados, transacciones, consultas variadas — la opción por defecto | Escala masiva, esquema variable, acceso simple por clave |
Replicación¶
Copiar los datos en varios servidores para disponibilidad (si cae uno, otro sigue) y escalar lecturas.
| Modelo | Cómo | Problema principal |
|---|---|---|
| Líder-seguidores | Las escrituras van al líder; se copian a los seguidores; las lecturas pueden ir a cualquiera | Replication lag: un seguidor puede ir por detrás → lees un dato antiguo |
| Multi-líder | Varios nodos (p. ej. uno por región) aceptan escrituras | Conflictos si dos modifican lo mismo a la vez |
| Sin líder | Se escribe y lee en varias réplicas; se usa un quórum | Complejidad; consistencia eventual |
- Replicación síncrona: el líder espera a que el seguidor confirme → sin pérdida de datos, más latencia.
- Asíncrona: no espera → rápida, pero si el líder cae se pueden perder las últimas escrituras.
- Read-your-writes: tras escribir, el usuario debe ver su cambio. Solución: leer del líder durante unos segundos tras escribir, o leer de réplicas que ya tengan esa versión.
Particionado (sharding)¶
Dividir los datos entre varios servidores cuando no caben o no dan abasto en uno.
| Estrategia | Cómo | Pros | Contras |
|---|---|---|---|
| Por rango | A-M en un servidor, N-Z en otro (o por fechas) |
Consultas por rango eficientes | Hotspots: si todos escriben "hoy", un servidor recibe todo |
| Por hash | hash(clave) % N o consistent hashing |
Reparto uniforme | Consultas por rango van a todos los servidores |
| Por directorio | Una tabla dice dónde está cada clave | Flexible | La tabla es un componente crítico más |
Problemas a resolver: reequilibrar al añadir servidores, consultas entre particiones (joins distribuidos), y claves calientes (el famoso con millones de seguidores concentra la carga en una partición).
Índices¶
Estructuras (normalmente B-tree) que aceleran las lecturas a cambio de escrituras más lentas y más espacio. Ver Bases de datos.
5. Teorema CAP y PACELC¶
En un sistema distribuido, cuando hay una partición de red (P: algunos nodos no pueden comunicarse con otros), el sistema tiene que elegir:
- Consistencia (C): toda lectura devuelve la última escritura (o un error). Los nodos que no pueden sincronizarse rechazan peticiones.
- Disponibilidad (A): toda petición recibe una respuesta, aunque pueda estar desactualizada.
| Elección | Comportamiento | Ejemplos |
|---|---|---|
| CP | Prefiere devolver error antes que un dato antiguo | etcd, ZooKeeper, bases de datos con consenso (Spanner) |
| AP | Prefiere responder siempre, aunque sea con datos antiguos | Cassandra, DynamoDB (por defecto), DNS |
PACELC completa CAP: si hay Partición → elegir A o C; En otro caso (funcionamiento normal) → elegir entre Latencia y Consistencia (sincronizar réplicas antes de responder cuesta tiempo).
Para explicarlo bien
"CAP no es elegir 2 de 3: las particiones de red ocurren sí o sí en un sistema distribuido. La decisión real es qué hacer cuando ocurren, y en condiciones normales, cuánta latencia estoy dispuesto a pagar por consistencia."
Ejemplo cercano: etcd es CP. Si el plano de control de Kubernetes pierde la mayoría de sus nodos de etcd, deja de aceptar cambios (no puedes desplegar), pero lo que ya corre sigue funcionando.
6. Consistent hashing¶
Problema: con servidor = hash(clave) % N, si N pasa de 4 a 5, el resultado cambia para casi todas las claves:
en una caché distribuida eso vacía la caché de golpe; en un almacén, obliga a mover casi todos los datos.
Solución: colocar servidores y claves en un anillo de valores de hash. Cada clave pertenece al primer servidor que encuentra avanzando en el sentido de las agujas del reloj.
flowchart LR
subgraph Anillo["Anillo de hash"]
direction LR
S0((Servidor A)) --> S1((Servidor B)) --> S2((Servidor C)) --> S0
end
K1[clave 1] -. sentido horario .-> S1
K2[clave 2] -. sentido horario .-> S2
- Al añadir un servidor, solo se mueven las claves entre él y su predecesor en el anillo (~1/N del total).
- Al quitar uno, sus claves pasan al siguiente; las demás no se mueven.
- Nodos virtuales: cada servidor aparece en muchas posiciones del anillo (p. ej. 100). Así el reparto es uniforme y un servidor más potente puede tener más posiciones.
- Se usa en: DynamoDB y Cassandra (particionado), cachés distribuidas, balanceadores con afinidad, CDNs.
7. Mensajería y asincronía¶
Una cola o un log de eventos entre servicios permite que el emisor no espere al receptor.
| Beneficio | Por qué |
|---|---|
| Desacoplamiento | El emisor no conoce a los receptores ni depende de que estén disponibles |
| Absorber picos | Si llegan 10 000 trabajos de golpe, se encolan y se procesan al ritmo posible |
| Reintentos | Un mensaje que falla se vuelve a intentar sin que el emisor lo sepa |
| Escalado independiente | Más consumidores si la cola crece |
| Cola (RabbitMQ, SQS) | Log distribuido (Kafka) | |
|---|---|---|
| Modelo | Cada mensaje lo consume un consumidor y desaparece | Log inmutable; cada grupo de consumidores lleva su posición |
| Releer | No | Sí (replay) |
| Orden | Limitado | Garantizado por partición |
| Uso típico | Trabajos y tareas | Eventos entre dominios, streaming, CDC |
Hay que diseñar para duplicados (consumidores idempotentes) y para mensajes que nunca podrán procesarse (dead letter queue tras N intentos). Ver Kafka.
8. Fiabilidad¶
Técnicas para que un fallo en una pieza no derribe el sistema:
| Técnica | Qué hace | Detalle |
|---|---|---|
| Timeouts | Limitar cuánto se espera a una dependencia | Siempre, en toda llamada remota; sin timeout, un servicio lento bloquea hilos hasta agotarlos |
| Reintentos con backoff y jitter | Repetir fallos transitorios esperando cada vez más, con aleatoriedad | Sin jitter, todos los clientes reintentan a la vez y rematan al servicio |
| Idempotencia | Repetir una operación da el mismo resultado | Imprescindible para reintentar operaciones de escritura |
| Circuit breaker | Tras muchos fallos, dejar de llamar un tiempo y fallar rápido | Estados: cerrado (normal) → abierto (corta) → semiabierto (prueba) |
| Bulkhead (mamparo) | Aislar recursos: pools separados por dependencia | Una dependencia lenta no agota los hilos de todo el servicio |
| Rate limiting / *load shedding* | Rechazar exceso de carga para proteger lo esencial | Ver rate limiter |
| Degradación elegante | Ofrecer una versión reducida si falla algo no esencial | Mostrar el pedido sin recomendaciones si ese servicio cae |
| Redundancia | Varias instancias, zonas y regiones | Activo-pasivo o activo-activo; definir RPO (datos que se pueden perder) y RTO (tiempo de recuperación) |
9. Generación de IDs únicos (Snowflake)¶
Problema: en un sistema distribuido, una secuencia autoincremental de base de datos es un cuello de botella y un punto único de fallo; los UUID aleatorios no son ordenables y fragmentan los índices.
Snowflake (Twitter) genera IDs de 64 bits sin coordinación:
| Bits | Contenido | Para qué |
|---|---|---|
| 1 | Signo (0) | Número positivo |
| 41 | Milisegundos desde una época propia | ~69 años de rango; los IDs se ordenan por tiempo |
| 10 | ID de máquina | Hasta 1 024 generadores sin coordinarse |
| 12 | Secuencia dentro del milisegundo | 4 096 IDs por milisegundo y máquina |
Alternativas modernas: UUIDv7 (estándar, 128 bits, ordenable por tiempo) y ULID.
10. Comunicación entre servicios¶
| REST | gRPC | GraphQL | WebSocket | |
|---|---|---|---|---|
| Transporte | HTTP/1.1-2, JSON | HTTP/2, Protobuf binario | HTTP, JSON | Conexión TCP persistente |
| Fuerte en | Simplicidad, universal, cacheable | Rendimiento, contratos tipados, streaming | El cliente pide exactamente lo que necesita | Tiempo real en ambos sentidos |
| Uso típico | APIs públicas | Servicio a servicio | BFF, aplicaciones móviles | Chat, notificaciones, dashboards en vivo |
Ver Diseño de APIs.
Preguntas de repaso¶
¿Cómo evitas un cache stampede?
Que solo una petición recalcule (bloqueo), TTL con variación aleatoria para que no expire todo a la vez, refrescar antes de expirar (refresh-ahead), o servir el valor caducado mientras se refresca (stale-while-revalidate).
¿Qué significa W + R > N?
En sistemas con quórum, si el número de réplicas que confirman una escritura (W) más las que se consultan en una lectura (R) supera el total de réplicas (N), al menos una réplica leída tiene la última escritura. Ejemplo: N=3, W=2, R=2.
¿Por qué etcd es CP y qué implica para Kubernetes?
etcd usa el algoritmo de consenso Raft y necesita mayoría para escribir. Si se pierde la mayoría, deja de aceptar escrituras antes que arriesgar inconsistencias. Por eso el plano de control se despliega con 3 o 5 nodos.
¿Qué ventaja tiene consistent hashing frente a hash % N?
Al cambiar el número de servidores solo se remapea una fracción pequeña (~1/N) de las claves, en lugar de casi todas.
¿Cuándo Kafka en vez de RabbitMQ?
Cuando se necesita alto volumen, retención y releer eventos, varios consumidores independientes del mismo flujo u orden por clave. RabbitMQ encaja mejor para colas de trabajos con enrutado flexible.
Ejercicios¶
Ejercicio 1 · Básico — Hacer un servicio stateless
Un servicio guarda en un HashMap en memoria el progreso de cada rollout que el usuario está configurando en un
asistente de 4 pasos. Al escalar a 3 réplicas, los usuarios pierden su progreso aleatoriamente. ¿Por qué y cómo lo arreglas?
Solución
Cada petición puede llegar a una réplica distinta, que no tiene ese progreso en su memoria. Opciones: (1) guardar el
borrador en un almacén compartido (Redis con TTL, o una tabla rollout_draft); (2) mantener el estado en el cliente
y enviarlo completo en cada paso. Evitar la "sesión pegajosa" (sticky session) como solución: se pierde el estado si
cae la instancia y reparte peor la carga.
Ejercicio 2 · Básico — Elegir estrategia de caché
Elige estrategia y TTL: (a) catálogo de versiones publicadas (cambia 2 veces al día, se lee miles de veces por minuto); (b) estado en vivo de un nodo (cambia cada pocos segundos); (c) precio de una suscripción al cobrar.
Solución
(a) Cache-aside con TTL largo (p. ej. 1 h) + invalidación al publicar una versión. (b) Caché corta (1-5 s) o ninguna: el dato es volátil y un valor viejo engaña al operador. (c) Sin caché en la operación de cobro (o validar contra el origen): un precio desactualizado en un cobro es un error de negocio; se puede cachear para mostrarlo.
Ejercicio 3 · Medio — Circuit breaker
El servicio de despliegues llama al servicio de inventario, que a veces tarda 30 s en responder. Durante esos episodios, el servicio de despliegues se queda sin hilos y deja de responder a todo. Propón las protecciones con valores concretos.
Solución
- Timeout de 2 s en la llamada (más que el p99 normal del inventario).
- Circuit breaker: si más del 50 % de las llamadas fallan en una ventana de 20, abrir 30 s (responder rápido con error o con el último dato conocido); luego semiabierto con unas pocas llamadas de prueba.
- Bulkhead: limitar a 20 las llamadas concurrentes al inventario (semáforo o pool propio), para que nunca consuman todos los hilos.
- Degradación: si el inventario no responde, mostrar los despliegues sin los datos de hardware. Con Resilience4j, todo esto son anotaciones y configuración.
Ejercicio 4 · Medio — Replication lag
Tras crear un sitio (escritura al líder), el usuario es redirigido a la ficha del sitio (lectura en una réplica) y ve "sitio no encontrado". Explica el porqué y da dos soluciones.
Solución
La réplica asíncrona aún no ha recibido la escritura (replication lag, normalmente milisegundos, a veces segundos).
Soluciones: (1) read-your-writes: durante unos segundos tras una escritura, las lecturas de ese usuario van al
líder (se marca con una cookie o en la sesión); (2) devolver el recurso creado en la propia respuesta del POST y
pintarlo sin volver a leer; (3) leer de réplicas indicando la posición mínima del log requerida (algunas bases de datos lo permiten).
Ejercicio 5 · Avanzado — Consistent hashing en código
Implementa un anillo de consistent hashing con nodos virtuales: addNode, removeNode y nodeFor(key).
Solución
public class HashRing {
private final TreeMap<Long, String> ring = new TreeMap<>();
private final int virtualNodes;
public HashRing(int virtualNodes) { this.virtualNodes = virtualNodes; }
public void addNode(String node) {
for (int i = 0; i < virtualNodes; i++) ring.put(hash(node + "#" + i), node);
}
public void removeNode(String node) {
for (int i = 0; i < virtualNodes; i++) ring.remove(hash(node + "#" + i));
}
public String nodeFor(String key) {
if (ring.isEmpty()) throw new IllegalStateException("Anillo vacío");
Map.Entry<Long, String> e = ring.ceilingEntry(hash(key)); // primer nodo en sentido horario
return (e != null ? e : ring.firstEntry()).getValue(); // si no hay, se da la vuelta al anillo
}
private static long hash(String s) {
// cualquier hash bien distribuido; aquí, los 8 primeros bytes de MD5
try {
byte[] d = MessageDigest.getInstance("MD5").digest(s.getBytes(StandardCharsets.UTF_8));
return ByteBuffer.wrap(d).getLong();
} catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
}
TreeMap.ceilingEntry encuentra en O(log n) el primer punto del anillo ≥ al hash de la clave. Prueba: con 4 nodos
y 100 nodos virtuales, reparte 100 000 claves, añade un quinto nodo y comprueba que solo cambian ~20 % de las asignaciones.