En un sistema distribuït els errors no són l'excepció, sinó la norma: les xarxes es tallen, els serveis se saturen i les dependències es tornen lentes. La resiliència és la capacitat d'un sistema per continuar funcionant, encara que sigui de manera degradada, quan les seves dependències fallen. Sense patrons de resiliència, un únic servei lent pot desencadenar un error en cascada que tombi tota la plataforma.

En aquesta lliçó estudiarem els patrons fonamentals (timeouts, reintents amb backoff, circuit breaker i bulkhead) i veurem com implementar-los en Java amb la biblioteca Resilience4j.

Contingut

  1. Per què necessitem resiliència: l'error en cascada
  2. Timeouts
  3. Reintents amb backoff i jitter
  4. Circuit Breaker i els seus estats
  5. Bulkhead (mampares)
  6. Fallback (degradació elegant)
  7. Combinant patrons
  8. Errors comuns i consells
  9. Exercicis
  10. Conclusió

  1. Per què necessitem resiliència: l'error en cascada

Imagina que el servei A crida el servei B i B es torna lent. Si A no té timeout, els seus fils es queden esperant. Quan s'esgoten tots els fils, A deixa de respondre. Qui crida A també es penja, i així successivament. Un error local es converteix en una apagada global.

graph LR
    C[Client] --> A[Servei A]
    A --> B[Servei B lent]
    B -. bloqueja fils .-> A
    A -. es queda sense fils .-> C
    C -. error total .-> X[Caiguda en cascada]

Els patrons de resiliència trenquen aquesta cadena: limiten el temps d'espera, aïllen recursos i tallen el flux cap a dependències malaltes.

  1. Timeouts

Un timeout defineix quant de temps màxim esperem una resposta abans de rendir-nos. És la primera línia de defensa: sense timeout, qualsevol dependència lenta t'arrossega.

// Timeout explícit: si la crida triga més de 2 segons, falla ràpid
TimeLimiterConfig config = TimeLimiterConfig.custom()
        .timeoutDuration(Duration.ofSeconds(2))
        .build();
TimeLimiter timeLimiter = TimeLimiter.of(config);

La regla: tota crida de xarxa ha de tenir timeout. És millor fallar ràpid i de manera controlada que quedar-se penjat indefinidament. Un bon timeout se sol basar en el percentil 99 de latència observat, amb una mica de marge.

  1. Reintents amb backoff i jitter

Molts errors són transitoris (un paquet perdut, una microsaturació). En aquests casos, reintentar té sentit. Però reintentar malament empitjora les coses.

  • Reintent ingenu: reintentar immediatament i moltes vegades pot saturar encara més un servei que ja pateix.
  • Backoff exponencial: esperar cada vegada més entre reintents (1s, 2s, 4s...) dóna temps a recuperar-se.
  • Jitter: afegir aleatorietat a l'espera evita que tots els clients reintentin alhora (l'"efecte ramat").
// Reintents amb backoff exponencial i jitter usant Resilience4j
RetryConfig config = RetryConfig.custom()
        .maxAttempts(3)                        // 1 intent + 2 reintents
        .intervalFunction(
            IntervalFunction.ofExponentialRandomBackoff(
                Duration.ofMillis(500),        // espera inicial
                2.0,                           // factor: 500ms, 1s, 2s...
                0.5))                          // jitter del 50%
        .retryOnException(e -> e instanceof IOException) // només errors transitoris
        .build();
Retry retry = Retry.of("clientes", config);

Aspectes clau:

  • maxAttempts(3): com a molt 3 intents en total.
  • ofExponentialRandomBackoff: cada reintent espera més, amb aleatorietat.
  • retryOnException: només reintentem errors transitoris. Mai reintentis operacions no idempotents sense protecció, o podries duplicar efectes (com un cobrament).

  1. Circuit Breaker i els seus estats

El circuit breaker (tallacircuits) és el patró estrella. Funciona com el tallacircuits elèctric de casa teva: si detecta massa errors, "obre el circuit" i deixa d'enviar peticions a la dependència malalta durant un temps, donant-li marge per recuperar-se i evitant malgastar recursos en crides condemnades a l'error.

Té tres estats:

Estat Comportament Transició
Tancat (Closed) Les crides passen normalment; es compten els errors. Si la taxa d'errors supera el llindar → Obert.
Obert (Open) Les crides es rebutgen a l'instant (fail-fast). Després d'un temps d'espera → Semiobert.
Semiobert (Half-Open) Deixa passar unes poques crides de prova. Si tenen èxit → Tancat; si fallen → Obert.
stateDiagram-v2
    [*] --> Cerrado
    Cerrado --> Abierto: taxa d'errors > llindar
    Abierto --> Semiabierto: passa el temps d'espera
    Semiabierto --> Cerrado: crides de prova OK
    Semiabierto --> Abierto: crides de prova fallen

Implementació amb Resilience4j:

// Circuit breaker: obre si fallen >50% de les últimes 10 crides
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
        .failureRateThreshold(50)                       // llindar 50% d'errors
        .slidingWindowSize(10)                          // finestra de 10 crides
        .waitDurationInOpenState(Duration.ofSeconds(10))// 10s en obert
        .permittedNumberOfCallsInHalfOpenState(3)       // 3 crides de prova
        .build();
CircuitBreaker breaker = CircuitBreaker.of("clientes", config);

// Decorem la crida amb el breaker
Supplier<Cliente> llamada = CircuitBreaker
        .decorateSupplier(breaker, () -> clienteClient.obtener(id));

Quan el circuit està obert, les crides fallen instantàniament sense tocar la xarxa, cosa que protegeix tant qui crida (no esgota fils) com el servei malalt (no rep més càrrega).

  1. Bulkhead (mampares)

El nom ve de les mampares d'un vaixell: compartiments estancs que, si un s'inunda, no enfonsen tota la nau. En programari, el bulkhead aïlla recursos (fils, connexions) per dependència, de manera que una dependència saturada no consumeixi tots els recursos del servei.

// Bulkhead: com a molt 10 crides concurrents al servei de clients
BulkheadConfig config = BulkheadConfig.custom()
        .maxConcurrentCalls(10)                  // màxim 10 en paral·lel
        .maxWaitDuration(Duration.ofMillis(100)) // espera màxima per entrar
        .build();
Bulkhead bulkhead = Bulkhead.of("clientes", config);

Sense bulkhead, si el servei de clients s'alenteix, podria acaparar els 200 fils del servei i deixar sense recursos les crides a altres serveis sans. Amb bulkhead, només 10 fils poden quedar atrapats; la resta continua atenent altres dependències.

Tipus de bulkhead Mecanisme
Semàfor Limita el nombre de crides concurrents (lleuger).
Thread pool Assigna un pool de fils dedicat per dependència.

  1. Fallback (degradació elegant)

Quan una crida falla (per timeout, circuit obert o bulkhead ple), un fallback ofereix una resposta alternativa en lloc de propagar l'error. És la diferència entre "el sistema sencer ha caigut" i "aquesta part funciona en mode degradat".

// Fallback: si no podem obtenir el client, retornem dades mínimes de la memòria cau
public Cliente obtenerClienteResiliente(String id) {
    try {
        return decorarConBreakerYRetry(id);
    } catch (Exception e) {
        // Degradació elegant: resposta parcial en lloc d'error total
        return cacheLocal.getOrDefault(id, Cliente.desconocido(id));
    }
}

El fallback ha d'oferir alguna cosa útil: dades de la memòria cau, un valor per defecte raonable o un missatge clar. El que mai no ha de fer és amagar un error crític sense registrar-lo.

  1. Combinant patrons

Els patrons es complementen i se solen aplicar en conjunt, en un ordre lògic:

// Composició típica: bulkhead -> timelimiter -> circuit breaker -> retry -> fallback
Supplier<Cliente> decorada = Decorators.ofSupplier(() -> clienteClient.obtener(id))
        .withBulkhead(bulkhead)        // 1. limita concurrència
        .withTimeLimiter(timeLimiter, scheduler) // 2. talla si triga
        .withCircuitBreaker(breaker)   // 3. talla si la dependència està malalta
        .withRetry(retry)              // 4. reintenta errors transitoris
        .withFallback(List.of(Exception.class),
                      e -> Cliente.desconocido(id)) // 5. degradació
        .decorate();

L'ordre importa: el bulkhead i el timeout protegeixen recursos; el circuit breaker talla el flux; el retry recupera errors transitoris; i el fallback garanteix sempre una resposta. Resilience4j permet compondre'ls de manera declarativa.

Errors Comuns i Consells

  • No posar timeouts: és la causa número u d'errors en cascada. Posa timeouts a tot.
  • Reintentar operacions no idempotents: un reintent d'un cobrament pot duplicar-lo. Assegura la idempotència abans de reintentar.
  • Reintents sense backoff ni jitter: saturen encara més el servei malalt i provoquen l'efecte ramat.
  • Llindars del circuit breaker mal calibrats: massa sensible obre per no res; massa tolerant no protegeix. Ajusta'ls amb dades reals.
  • Fallbacks que amaguen errors: un fallback ha de registrar (log i mètrica) l'error, no enterrar-lo en silenci.

Exercicis

  1. Descriu els tres estats d'un circuit breaker i les condicions que provoquen cada transició.
  2. Explica per què els reintents han d'usar backoff exponencial amb jitter en lloc de reintentar immediatament. Quin problema evita el jitter?
  3. Dissenya, en pseudocodi Java amb Resilience4j, una crida al "servei de pòlisses" protegida amb timeout de 2s, circuit breaker (llindar 50%) i un fallback que retorni una llista buida.

Solucions

  1. Tancat: les crides passen i es compten els errors; si la taxa d'errors supera el llindar, passa a Obert. Obert: rebutja crides a l'instant; després del temps d'espera, passa a Semiobert. Semiobert: deixa passar unes poques crides de prova; si tenen èxit torna a Tancat, si fallen torna a Obert.

  2. El backoff exponencial dóna temps al servei malalt a recuperar-se en lloc de bombardejar-lo. El jitter (aleatorietat) evita l'efecte ramat: que tots els clients, que van fallar alhora, reintentin exactament al mateix temps i tornin a saturar el servei en onades sincronitzades.

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ó

Hem vist que la resiliència es construeix combinant patrons: els timeouts eviten esperes eternes, els reintents amb backoff i jitter superen errors transitoris, el circuit breaker talla el flux cap a dependències malaltes, el bulkhead aïlla recursos i el fallback garanteix una resposta degradada. Junts, trenquen els errors en cascada i mantenen el sistema dret.

Aquests patrons gestionen els errors de disponibilitat, però queda un repte més profund: quan les dades estan repartides i replicades, podem tenir alhora consistència, disponibilitat i tolerància a particions? La lliçó següent, El Teorema CAP i la Consistència de Dades, ens dóna el marc teòric per entendre aquestes concessions inevitables.

Curs d'Arquitectura d'Aplicacions

Mòdul 1: Fonaments de l'Arquitectura d'Aplicacions

Mòdul 2: Principis i Tàctiques de Disseny

Mòdul 3: Estils i Patrons Arquitectònics

Mòdul 4: Arquitectures Distribuïdes i Microserveis

Mòdul 5: Arquitectures Dirigides per Esdeveniments i Missatgeria

Mòdul 6: Disseny Dirigit pel Domini (DDD)

Mòdul 7: Dades i Persistència

Mòdul 8: Arquitectura al Núvol i Desplegament

Mòdul 9: Qualitat, Seguretat i Observabilitat

Mòdul 10: Evolució, Governança i Casos Pràctics

© Copyright 2026. Tots els drets reservats