En un sistema distribuido los fallos no son la excepción, sino la norma: las redes se cortan, los servicios se saturan y las dependencias se vuelven lentas. La resiliencia es la capacidad de un sistema para seguir funcionando, aunque sea de forma degradada, cuando sus dependencias fallan. Sin patrones de resiliencia, un único servicio lento puede desencadenar un fallo en cascada que tumbe toda la plataforma.
En esta lección estudiaremos los patrones fundamentales (timeouts, reintentos con backoff, circuit breaker y bulkhead) y veremos cómo implementarlos en Java con la biblioteca Resilience4j.
Contenido
- Por qué necesitamos resiliencia: el fallo en cascada
- Timeouts
- Reintentos con backoff y jitter
- Circuit Breaker y sus estados
- Bulkhead (mamparos)
- Fallback (degradación elegante)
- Combinando patrones
- Errores comunes y consejos
- Ejercicios
- Conclusión
- Por qué necesitamos resiliencia: el fallo en cascada
Imagina que el servicio A llama al servicio B y B se vuelve lento. Si A no tiene timeout, sus hilos se quedan esperando. Cuando se agotan todos los hilos, A deja de responder. Quien llama a A también se cuelga, y así sucesivamente. Un fallo local se convierte en un apagón global.
graph LR
C[Cliente] --> A[Servicio A]
A --> B[Servicio B lento]
B -. bloquea hilos .-> A
A -. se queda sin hilos .-> C
C -. error total .-> X[Caída en cascada]Los patrones de resiliencia rompen esta cadena: limitan el tiempo de espera, aíslan recursos y cortan el flujo hacia dependencias enfermas.
- Timeouts
Un timeout define cuánto tiempo máximo esperamos una respuesta antes de rendirnos. Es la primera línea de defensa: sin timeout, cualquier dependencia lenta te arrastra.
// Timeout explícito: si la llamada tarda más de 2 segundos, falla rápido
TimeLimiterConfig config = TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofSeconds(2))
.build();
TimeLimiter timeLimiter = TimeLimiter.of(config);La regla: toda llamada de red debe tener timeout. Es mejor fallar rápido y de forma controlada que quedarse colgado indefinidamente. Un buen timeout suele basarse en el percentil 99 de latencia observado, con algo de margen.
- Reintentos con backoff y jitter
Muchos fallos son transitorios (un paquete perdido, una micro-saturación). En esos casos, reintentar tiene sentido. Pero reintentar mal empeora las cosas.
- Reintento ingenuo: reintentar inmediatamente y muchas veces puede saturar aún más un servicio que ya sufre.
- Backoff exponencial: esperar cada vez más entre reintentos (1s, 2s, 4s...) da tiempo a recuperarse.
- Jitter: añadir aleatoriedad a la espera evita que todos los clientes reintenten a la vez (el "efecto manada").
// Reintentos con backoff exponencial y jitter usando Resilience4j
RetryConfig config = RetryConfig.custom()
.maxAttempts(3) // 1 intento + 2 reintentos
.intervalFunction(
IntervalFunction.ofExponentialRandomBackoff(
Duration.ofMillis(500), // espera inicial
2.0, // factor: 500ms, 1s, 2s...
0.5)) // jitter del 50%
.retryOnException(e -> e instanceof IOException) // solo fallos transitorios
.build();
Retry retry = Retry.of("clientes", config);Aspectos clave:
maxAttempts(3): como mucho 3 intentos en total.ofExponentialRandomBackoff: cada reintento espera más, con aleatoriedad.retryOnException: solo reintentamos errores transitorios. Nunca reintentes operaciones no idempotentes sin protección, o podrías duplicar efectos (como un cobro).
- Circuit Breaker y sus estados
El circuit breaker (cortacircuitos) es el patrón estrella. Funciona como el cortacircuitos eléctrico de tu casa: si detecta demasiados fallos, "abre el circuito" y deja de enviar peticiones a la dependencia enferma durante un tiempo, dándole margen para recuperarse y evitando malgastar recursos en llamadas condenadas al fallo.
Tiene tres estados:
| Estado | Comportamiento | Transición |
|---|---|---|
| Cerrado (Closed) | Las llamadas pasan normalmente; se cuentan los fallos. | Si la tasa de fallos supera el umbral → Abierto. |
| Abierto (Open) | Las llamadas se rechazan al instante (fail-fast). | Tras un tiempo de espera → Semiabierto. |
| Semiabierto (Half-Open) | Deja pasar unas pocas llamadas de prueba. | Si tienen éxito → Cerrado; si fallan → Abierto. |
stateDiagram-v2
[*] --> Cerrado
Cerrado --> Abierto: tasa de fallos > umbral
Abierto --> Semiabierto: pasa el tiempo de espera
Semiabierto --> Cerrado: llamadas de prueba OK
Semiabierto --> Abierto: llamadas de prueba fallanImplementación con Resilience4j:
// Circuit breaker: abre si fallan >50% de las últimas 10 llamadas
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // umbral 50% de fallos
.slidingWindowSize(10) // ventana de 10 llamadas
.waitDurationInOpenState(Duration.ofSeconds(10))// 10s en abierto
.permittedNumberOfCallsInHalfOpenState(3) // 3 llamadas de prueba
.build();
CircuitBreaker breaker = CircuitBreaker.of("clientes", config);
// Decoramos la llamada con el breaker
Supplier<Cliente> llamada = CircuitBreaker
.decorateSupplier(breaker, () -> clienteClient.obtener(id));Cuando el circuito está abierto, las llamadas fallan instantáneamente sin tocar la red, lo que protege tanto al llamante (no agota hilos) como al servicio enfermo (no recibe más carga).
- Bulkhead (mamparos)
El nombre viene de los mamparos de un barco: compartimentos estancos que, si uno se inunda, no hunden todo el navío. En software, el bulkhead aísla recursos (hilos, conexiones) por dependencia, de modo que una dependencia saturada no consuma todos los recursos del servicio.
// Bulkhead: como mucho 10 llamadas concurrentes al servicio de clientes
BulkheadConfig config = BulkheadConfig.custom()
.maxConcurrentCalls(10) // máximo 10 en paralelo
.maxWaitDuration(Duration.ofMillis(100)) // espera máxima para entrar
.build();
Bulkhead bulkhead = Bulkhead.of("clientes", config);Sin bulkhead, si el servicio de clientes se ralentiza, podría acaparar los 200 hilos del servicio y dejar sin recursos a las llamadas a otros servicios sanos. Con bulkhead, solo 10 hilos pueden quedar atrapados; el resto sigue atendiendo otras dependencias.
| Tipo de bulkhead | Mecanismo |
|---|---|
| Semáforo | Limita el número de llamadas concurrentes (ligero). |
| Thread pool | Asigna un pool de hilos dedicado por dependencia. |
- Fallback (degradación elegante)
Cuando una llamada falla (por timeout, circuito abierto o bulkhead lleno), un fallback ofrece una respuesta alternativa en lugar de propagar el error. Es la diferencia entre "el sistema entero ha caído" y "esta parte funciona en modo degradado".
// Fallback: si no podemos obtener el cliente, devolvemos datos mínimos cacheados
public Cliente obtenerClienteResiliente(String id) {
try {
return decorarConBreakerYRetry(id);
} catch (Exception e) {
// Degradación elegante: respuesta parcial en vez de error total
return cacheLocal.getOrDefault(id, Cliente.desconocido(id));
}
}El fallback debe ofrecer algo útil: datos cacheados, un valor por defecto razonable o un mensaje claro. Lo que nunca debe hacer es ocultar un error crítico sin registrarlo.
- Combinando patrones
Los patrones se complementan y suelen aplicarse en conjunto, en un orden lógico:
// Composición típica: bulkhead -> timelimiter -> circuit breaker -> retry -> fallback
Supplier<Cliente> decorada = Decorators.ofSupplier(() -> clienteClient.obtener(id))
.withBulkhead(bulkhead) // 1. limita concurrencia
.withTimeLimiter(timeLimiter, scheduler) // 2. corta si tarda
.withCircuitBreaker(breaker) // 3. corta si la dependencia está enferma
.withRetry(retry) // 4. reintenta fallos transitorios
.withFallback(List.of(Exception.class),
e -> Cliente.desconocido(id)) // 5. degradación
.decorate();El orden importa: el bulkhead y el timeout protegen recursos; el circuit breaker corta el flujo; el retry recupera fallos transitorios; y el fallback garantiza siempre una respuesta. Resilience4j permite componerlos de forma declarativa.
Errores Comunes y Consejos
- No poner timeouts: es la causa número uno de fallos en cascada. Pon timeouts en todo.
- Reintentar operaciones no idempotentes: un reintento de un cobro puede duplicarlo. Asegura idempotencia antes de reintentar.
- Reintentos sin backoff ni jitter: saturan aún más al servicio enfermo y provocan el efecto manada.
- Umbrales del circuit breaker mal calibrados: demasiado sensible abre por nada; demasiado tolerante no protege. Ajústalos con datos reales.
- Fallbacks que ocultan errores: un fallback debe registrar (log y métrica) el fallo, no enterrarlo en silencio.
Ejercicios
- Describe los tres estados de un circuit breaker y las condiciones que provocan cada transición.
- Explica por qué los reintentos deben usar backoff exponencial con jitter en lugar de reintentar inmediatamente. ¿Qué problema evita el jitter?
- Diseña, en pseudocódigo Java con Resilience4j, una llamada al "servicio de pólizas" protegida con timeout de 2s, circuit breaker (umbral 50%) y un fallback que devuelva una lista vacía.
Soluciones
-
Cerrado: las llamadas pasan y se cuentan los fallos; si la tasa de fallos supera el umbral, pasa a Abierto. Abierto: rechaza llamadas al instante; tras el tiempo de espera, pasa a Semiabierto. Semiabierto: deja pasar unas pocas llamadas de prueba; si tienen éxito vuelve a Cerrado, si fallan vuelve a Abierto.
-
El backoff exponencial da tiempo al servicio enfermo a recuperarse en lugar de bombardearlo. El jitter (aleatoriedad) evita el efecto manada: que todos los clientes, que fallaron a la vez, reintenten exactamente al mismo tiempo y vuelvan a saturar el servicio en oleadas sincronizadas.
CircuitBreaker breaker = CircuitBreaker.of("polizas",
CircuitBreakerConfig.custom().failureRateThreshold(50).build());
TimeLimiter limiter = TimeLimiter.of(
TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(2)).build());
Supplier<List<Poliza>> decorada = Decorators
.ofSupplier(() -> polizaClient.listar(clienteId))
.withCircuitBreaker(breaker)
.withTimeLimiter(limiter, scheduler)
.withFallback(List.of(Exception.class), e -> Collections.emptyList())
.decorate();Conclusión
Hemos visto que la resiliencia se construye combinando patrones: los timeouts evitan esperas eternas, los reintentos con backoff y jitter superan fallos transitorios, el circuit breaker corta el flujo hacia dependencias enfermas, el bulkhead aísla recursos y el fallback garantiza una respuesta degradada. Juntos, rompen los fallos en cascada y mantienen el sistema en pie.
Estos patrones gestionan los fallos de disponibilidad, pero queda un reto más profundo: cuando los datos están repartidos y replicados, ¿podemos tener a la vez consistencia, disponibilidad y tolerancia a particiones? La siguiente lección, El Teorema CAP y la Consistencia de Datos, nos da el marco teórico para entender estas concesiones inevitables.
Curso de Arquitectura de Aplicaciones
Módulo 1: Fundamentos de la Arquitectura de Aplicaciones
- ¿Qué es la Arquitectura de Aplicaciones?
- El Rol del Arquitecto de Software
- Atributos de Calidad y Requisitos No Funcionales
- Decisiones Arquitectónicas y Compromisos (Trade-offs)
- Documentación de Arquitectura: Vistas y el Modelo C4
Módulo 2: Principios y Tácticas de Diseño
- Acoplamiento, Cohesión y Separación de Responsabilidades
- Principios SOLID Aplicados a la Arquitectura
- DRY, KISS, YAGNI y Otros Principios de Diseño
- Tácticas Arquitectónicas para los Atributos de Calidad
- Gestión de la Deuda Técnica
Módulo 3: Estilos y Patrones Arquitectónicos
- Arquitectura Monolítica
- Arquitectura en Capas (N-Tier)
- Arquitectura Cliente-Servidor
- Arquitectura Hexagonal (Puertos y Adaptadores)
- Arquitectura Limpia y Cebolla (Clean & Onion)
Módulo 4: Arquitecturas Distribuidas y Microservicios
- Introducción a los Sistemas Distribuidos
- Arquitectura de Microservicios
- Descomposición de Servicios y Bounded Contexts
- API Gateway, Service Discovery y Comunicación entre Servicios
- Patrones de Resiliencia: Circuit Breaker, Retry y Bulkhead
- El Teorema CAP y la Consistencia de Datos
Módulo 5: Arquitecturas Dirigidas por Eventos y Mensajería
- Fundamentos de la Arquitectura Orientada a Eventos
- Mensajería Asíncrona: Colas y Brokers
- Patrones de Eventos: Event Sourcing y CQRS
- Gestión de Transacciones Distribuidas: Patrón Saga
- Streaming de Datos en Tiempo Real
Módulo 6: Diseño Dirigido por el Dominio (DDD)
- Conceptos Fundamentales del DDD
- Diseño Estratégico: Bounded Contexts y Lenguaje Ubicuo
- Diseño Táctico: Entidades, Agregados y Repositorios
- Mapeo de Contextos (Context Mapping)
Módulo 7: Datos y Persistencia
- Estrategias de Persistencia: SQL vs NoSQL
- Patrones de Acceso a Datos: Repository, Unit of Work y DAO
- Base de Datos por Servicio y Gestión de Datos Distribuidos
- Caché y Estrategias de Invalidación
Módulo 8: Arquitectura en la Nube y Despliegue
- Fundamentos del Cloud Computing (IaaS, PaaS, SaaS)
- Contenedores y Orquestación con Docker y Kubernetes
- Arquitectura Serverless
- Patrones de Diseño Cloud-Native
- Infraestructura como Código (IaC)
Módulo 9: Calidad, Seguridad y Observabilidad
- Escalabilidad: Horizontal vs Vertical y Balanceo de Carga
- Alta Disponibilidad y Tolerancia a Fallos
- Seguridad por Diseño y Autenticación/Autorización
- Observabilidad: Logging, Métricas y Trazabilidad
- Rendimiento y Pruebas de Carga
