Be Software Architecture Wise
Be Wise Apps

Read in English · Lire en français · Auf Deutsch lesen

Sección 9.4 de 9.8 · Avanzado · 32 min de lectura

Timeouts, reintentos, circuit breakers, bulkheads y degradación elegante

Videolección

5 min · narrado en inglés, con subtítulos
Solo escuchar (audio)
Leer la transcripción

Te damos la bienvenida a la Sección nueve punto cuatro. En un sistema distribuido siempre hay alguna dependencia lenta o fallando. La pregunta no es cómo evitar el fallo, sino cómo impedir que un fallo local se convierta en uno global.

Imagina que llamas por teléfono a un proveedor que nunca contesta. Si te quedas al teléfono todo el día, no haces nada más. Si todos tus compañeros hacen lo mismo, toda la oficina se para. Las oficinas sensatas cuelgan tras unos cuantos tonos. Eso es un timeout. Vuelven a intentarlo un poco más tarde, pero no sin fin. Eso es un reintento con pausa. Dejan de llamar a un proveedor que claramente está cerrado, y vuelven a probar al cabo de una hora. Eso es un circuit breaker. Se aseguran de que un solo proveedor no pueda ocupar todos los teléfonos. Eso es un bulkhead. Y tienen un plan B, como la lista de precios de ayer. Eso es una alternativa, un fallback. Los servicios de software necesitan exactamente los mismos hábitos.

Veamos por qué esto importa. El servicio de pedidos de la plataforma de reparto de comida tiene mil doscientos hilos repartidos entre seis instancias. A la hora del almuerzo, según la ley de Little, solo cincuenta están ocupados: treinta y cinco para los pagos, que llaman al proveedor de pagos, y quince para todo lo demás. A las doce y treinta y uno, el proveedor de pagos deja de responder, pero mantiene abiertas las conexiones. El cliente de pagos no tiene timeout, así que cada solicitud de pago retiene su hilo indefinidamente. Cada segundo se quedan atascados cien hilos más. En unos doce segundos, todos los hilos están esperando al proveedor de pagos. Ahora el historial de pedidos, el carrito y el seguimiento también fallan, aunque nunca tocan los pagos. Un fallo en el veinticinco por ciento del tráfico ha tumbado el cien por cien. Con un timeout de dos segundos, los pagos usan como máximo doscientos hilos, y quedan unos novecientos ochenta y cinco para todo lo demás. Solo fallan los pagos, y fallan con claridad. Un bulkhead va más allá. Limitar las llamadas de pago a cuarenta hilos por instancia garantiza al menos novecientos sesenta hilos para todo lo demás, sean cuales sean los timeouts.

Los timeouts deben acortarse a medida que se baja por una cadena de llamadas. Un plazo lo hace automático. La app móvil fija un plazo de mil milisegundos para toda la solicitud. El gateway gasta veinte milisegundos y pasa el tiempo restante: novecientos ochenta. El servicio de pedidos gasta cincuenta, luego trescientos veinte en otras llamadas, y pasa seiscientos diez al de precios. Precios necesita unos ochocientos milisegundos, así que falla rápido de inmediato, en lugar de hacer un trabajo que nadie va a esperar.

Los reintentos convierten los fallos breves en éxitos. Pero los reintentos también son carga. Reintenta solo los errores transitorios, como los timeouts o un quinientos tres, y solo las operaciones idempotentes, porque una solicitud que agotó su timeout puede haber tenido éxito. Reintenta dos o tres veces, esperando más cada vez: backoff exponencial, hasta un tope. Añade jitter, una espera aleatoria. Si no, mil clientes que fallaron juntos reintentan juntos, en oleadas que mantienen caída la dependencia. Y los reintentos se multiplican entre capas. Tres intentos en tres capas son veintisiete llamadas. Así que reintenta en una sola capa, y limita los reintentos a alrededor del diez por ciento de las solicitudes.

Un circuit breaker envuelve las llamadas a una dependencia. Mientras está cerrado, las llamadas pasan y se cuentan los fallos. Cuando los fallos superan un umbral, por ejemplo la mitad de las veinte últimas llamadas, el breaker se abre. Ahora todas las llamadas fallan rápido, sin tocar la red. Eso protege los hilos de quien llama y da margen a la dependencia para recuperarse. Tras una espera, por ejemplo de treinta segundos, pasa a semiabierto y deja pasar unas pocas llamadas de prueba. Si tienen éxito, vuelve a cerrarse. Si una llamada de prueba falla, se abre durante otro periodo.

Un bulkhead toma su nombre de los compartimentos estancos de un barco. Aquí, cada dependencia tiene su propio pool de hilos acotado. El proveedor de pagos es lento, así que su pool se llena, y las siguientes llamadas de pago se rechazan de inmediato. Pero las llamadas a restaurantes y repartidores continúan con normalidad. Un timeout limita cuánto tiempo puede retener recursos una llamada. Un bulkhead limita cuántas pueden retenerlos a la vez.

Cuando una llamada falla, quien llama debe responder de todos modos. Una alternativa puede ser la última carta conocida, un tiempo de entrega estimado, platos populares en lugar de recomendaciones personales, o un error claro y rápido. Eso es la degradación elegante: el recorrido principal sigue funcionando mientras los extras se hacen a un lado. Una alternativa debe ser sencilla, independiente de la parte que ha fallado y probarse con regularidad. La seguridad es la excepción. Si el servicio de autorización no está accesible, la alternativa segura es denegar. Por último, comprueba que una operación es idempotente antes de reintentarla. Una política de reintentos sobre una llamada de pago sin clave de idempotencia no es un patrón de resiliencia. Es un generador de cobros dobles.

La sección completa añade las solicitudes de cobertura y una lista de comprobación para cada llamada remota. A continuación, en la Sección nueve punto cinco, veremos qué nivel de calidad es suficiente. Nos vemos allí.

Lo que necesitas saber

El fallo es normal; propagarlo es opcional

En un sistema distribuido siempre hay alguna dependencia lenta o fallando: un proveedor de pagos tiene un mal minuto, una base de datos hace failover, un enlace de red pierde paquetes, un despliegue sale mal. La Sección 6.1 mostró por qué estos fallos parciales no se pueden evitar y por qué a menudo quien llama ni siquiera puede saber qué ha ocurrido. Por tanto, la pregunta arquitectónica no es cómo evitar el fallo, sino cómo impedir que un fallo local se convierta en uno global. Un fallo en cascada es la forma típica en que mueren los sistemas: una dependencia lenta vuelve lentos a quienes la llaman, sus recursos se llenan de solicitudes en espera, ellos a su vez se vuelven lentos para quienes los llaman, y en cuestión de segundos un problema en un componente menor ha tumbado toda la plataforma.

Michael Nygard catalogó los patrones que interrumpen estas cadenas en Release It! (2007; segunda edición, 2018), a partir de las caídas que había investigado. Su observación central es que los puntos de integración, todos los lugares en que un sistema llama a otro, son la principal fuente de inestabilidad, y que cada uno necesita una protección deliberada. Los patrones de esta sección (timeouts, plazos, reintentos con backoff, circuit breakers (disyuntores), bulkheads (mamparos), alternativas, idempotencia y solicitudes de cobertura) son el conjunto de herramientas estándar. Son tácticas en el sentido de la Sección 2.5: cada una es una única decisión orientada a la disponibilidad, y cada una tiene un costo.

Timeouts: el primer patrón y el más importante

Un timeout limita cuánto tiempo espera quien llama. Sin él, una llamada a una dependencia que nunca responde retiene un hilo, una conexión y memoria mientras el sistema operativo mantenga abierta la conexión, lo que pueden ser muchos minutos. Muchas bibliotecas cliente no tienen timeout por defecto (la popular biblioteca requests de Python espera indefinidamente salvo que se le indique lo contrario), así que la ausencia de un timeout es una decisión de diseño tomada por omisión. El ejemplo práctico de más abajo muestra la consecuencia.

Normalmente importan dos timeouts. El timeout de conexión limita el establecimiento de una conexión y puede ser corto, unos cientos de milisegundos dentro de un centro de datos, porque un servidor sano acepta conexiones casi de inmediato. El timeout de solicitud limita todo el intercambio y debe salir de la medición: un poco por encima de la latencia de percentil alto de la dependencia (p99 o p99.9) con carga normal, para que corte las esperas patológicas sin hacer fallar las solicitudes lentas corrientes. También debe caber dentro del presupuesto de quien llama. Si el gateway se rinde a los 2 segundos pero el servicio de pedidos espera 10 segundos al de precios, el servicio de pedidos sigue trabajando durante 8 segundos en una solicitud que nadie está esperando. Por tanto, los timeouts deben acortarse a medida que se baja por una cadena de llamadas, que es la idea en la que se basan los plazos.

Los plazos y su propagación

Un plazo (deadline) es un momento absoluto en el tiempo para el que debe haber terminado toda la solicitud. La propagación de plazos (deadline propagation) pasa el tiempo restante a cada llamada posterior, para que cada servicio sepa cuánto presupuesto queda en lugar de aplicar un timeout fijo propio. gRPC lo incorpora: un cliente fija un plazo, el tiempo restante viaja con la llamada, y un servidor puede comprobar si el plazo ha vencido y detener el trabajo. Sobre HTTP, los equipos propagan una cabecera con los milisegundos restantes o con el plazo absoluto, idealmente mediante el mismo middleware que propaga el contexto de trazado (Sección 9.6).

La propagación aporta dos beneficios. Un servicio puede fallar rápido cuando el tiempo que queda es claramente demasiado corto para el trabajo, en lugar de iniciar una consulta a la base de datos que no puede terminar a tiempo. Y cuando el usuario se rinde, el trabajo de los niveles inferiores también se detiene, en lugar de seguir consumiendo capacidad justo durante la sobrecarga que causó el retraso. Esto es descarte de carga de solicitudes muertas, una de las protecciones más eficaces que puede tener un sistema sometido a estrés.

Un plazo propagado a lo largo de una cadena de llamadas

Una solicitud empieza con un plazo de 1000 ms; cada servicio pasa el tiempo restante, y un servicio que no puede terminar en el tiempo que queda falla rápido en lugar de empezar a trabajarApp móvilplazo 1000 msAPI gatewayquedan 980Serv. Pedidosquedan 930Preciosquedan 610envíaenvíaenvíagasta 20 msgasta 50 msluego 320 ms enmás llamadasnecesita unos 800 ms:falla de inmediatoCada salto envía el tiempo restante, no un timeout nuevo propio
Cada salto pasa el tiempo restante; un servicio que no puede terminar en el tiempo que queda falla de inmediato en lugar de hacer un trabajo que nadie va a esperar.

Ejemplo práctico

Ejemplo práctico: cómo un solo timeout que falta se convierte en una caída total

El servicio de pedidos de la plataforma de reparto ejecuta 6 instancias con 200 hilos de trabajo cada una: 1200 hilos. A la hora del almuerzo recibe 400 solicitudes por segundo: 100 de pago, que llaman al proveedor de pagos (normalmente 300 ms, así que una solicitud de pago retiene su hilo unos 350 ms en total), y 300 solicitudes de otro tipo (historial de pedidos, carrito, seguimiento) que tardan 50 ms. El cliente de pagos no tiene ningún timeout configurado. A las 12:31 el proveedor de pagos deja de responder, pero mantiene abiertas las conexiones.

Hilos ocupados en condiciones normales (ley de Little)Pago 100 × 0.35 s = 35; resto del tráfico 300 × 0.05 s = 15; total 50 de 1200 (alrededor del 4%)
Después de las 12:31Cada solicitud de pago retiene ahora su hilo indefinidamente: cada segundo se quedan atascados 100 hilos más
Tiempo hasta el agotamiento(1200 − 15 − 35) ÷ 100 ≈ 11.5 segundos: en unos 12 segundos todos los hilos están esperando al proveedor de pagos
ConsecuenciaEl historial de pedidos, el carrito y el seguimiento, que nunca llaman a pagos, ahora hacen cola para conseguir hilos y agotan su timeout en el gateway. Un fallo en el 25% del tráfico ha tumbado el 100%
Y aguas arribaLas conexiones del gateway al servicio de pedidos se acumulan de la misma manera; las comprobaciones de estado agotan su timeout, las instancias se marcan como no sanas y, si la sonda de actividad (liveness probe) se ve afectada, se reinician, en frío, en las mismas condiciones
Con un timeout de 2 segundosHilos de pago en uso: 100 × 2 s = 200 en toda la flota (ley de Little). Quedan unos 985 hilos para el resto del tráfico, así que solo falla el pago, y falla con un error claro al cabo de 2 s
Añadir un circuit breakerEn cuanto, por ejemplo, la mitad de las 20 últimas llamadas han fallado, el breaker se abre: los pagos fallan en milisegundos y casi no usan hilos; una llamada de prueba cada 30 s detecta la recuperación
Añadir un bulkheadLimitar las llamadas de pago a 40 hilos por instancia (240 en total) garantiza al menos 960 hilos para todo lo demás, sean cuales sean los timeouts configurados
Añadir una alternativaEl proceso de pago ofrece al cliente otro método de pago, o explica que los pagos con tarjeta se están retrasando, en lugar de un error genérico

La aritmética es de nuevo la ley de Little: hilos en uso = tasa de solicitudes × tiempo que cada solicitud retiene un hilo. Sin timeout, el tiempo de retención no tiene límite, así que los hilos necesarios tampoco lo tienen.

Los timeouts limitan cuánto tiempo; los bulkheads limitan cuántos; los circuit breakers dejan de llamar por completo. Cada uno cierra una brecha distinta, y por eso se usan juntos.

Release It!, de Nygard, describe caídas reales que siguieron exactamente este patrón, a menudo desencadenadas por un punto de integración que nadie consideraba crítico.

Agotamiento del pool de hilos

Un proveedor de pagos lento retiene todos los hilos del servicio de pedidos, así que también fallan las solicitudes sin pagos y las llamadas a una base de datos sanaCompraHistorial,rastreoServ. pedidoslos 200 hilos esperandollamadas de pagoProveedor de pagosdeja de responder;lo normal: 0.3 sBDsana, pero no hayhilo libre para llamarlaambas fallansin hilos libres
Sin timeout, un proveedor de pagos lento retiene todos los hilos del servicio de pedidos, así que fallan incluso las solicitudes que nunca tocan los pagos.

Reintentos con backoff exponencial y jitter

Muchos fallos son transitorios: un paquete perdido, una conexión reiniciada durante un failover, un breve 503 de una instancia sobrecargada. Reintentar los convierte en éxitos que el usuario nunca ve. Pero los reintentos también son carga, y los reintentos mal diseñados están entre las causas más comunes de las caídas. Cuatro reglas los hacen seguros. Reintenta solo los errores transitorios (timeouts, fallos de conexión, 502, 503, 504, y 429 después del retraso indicado), nunca errores que se repetirán, como 400 o 404. Reintenta solo operaciones idempotentes o que lleven una clave de idempotencia, porque una solicitud que agotó su timeout puede haber tenido éxito. Reintenta un número pequeño de veces, normalmente dos o tres. Y espera entre intentos, con backoff exponencial: un retraso que se duplica en cada intento, hasta un tope.

El backoff por sí solo no basta. Si mil clientes fallan en el mismo momento, como ocurre cuando una dependencia tiene un fallo momentáneo, todos reintentan al cabo de 100 ms, después todos al cabo de 200 ms, y llegan en oleadas sincronizadas que pueden mantener caída la dependencia. El artículo de Marc Brooker Exponential Backoff And Jitter (AWS Architecture Blog, 2015) lo simuló y mostró que añadir aleatoriedad, jitter (fluctuación aleatoria), reduce mucho tanto el trabajo total como el tiempo hasta completarlo. Su jitter completo (full jitter) recomendado espera un tiempo aleatorio entre cero y la cota exponencial: sleep = random(0, min(cap, base × 2attempt)). El jitter debe aplicarse a todo lo que muchos clientes hacen a la vez: reintentos, reconexiones, tiempos de caducidad de la caché y trabajos programados.

import random, time

class TransientError(Exception):
    """Timeouts, connection resets, 502, 503 and 504 responses."""

def call_with_retries(fn, attempts=3, base=0.1, cap=2.0):
    for attempt in range(attempts):
        try:
            return fn()
        except TransientError:
            if attempt == attempts - 1:
                raise                      # give up: let the caller decide
            ceiling = min(cap, base * 2 ** attempt)
            time.sleep(random.uniform(0, ceiling))   # full jitter
Reintentar un fallo transitorio con backoff exponencial limitado y jitter completo (Brooker, 2015). Solo deben reintentarse las operaciones idempotentes o las que llevan una clave de idempotencia.

Tormentas de reintentos y presupuestos de reintentos

Los reintentos se multiplican entre capas. Supón que un toque en la app móvil pasa por el API gateway, el servicio de pedidos y el cliente de pagos, y que cada uno de los tres hace hasta tres intentos. Si el proveedor de pagos está caído, una sola acción del usuario produce 3 × 3 × 3 = 27 llamadas a él, y una caída que debería haber supuesto una tasa de errores constante se convierte en una tormenta de reintentos de 27 veces la carga normal, que mantiene caído al proveedor incluso después de corregida la causa original. Los usuarios que vuelven a tocar añaden una cuarta capa.

Tres defensas funcionan juntas. Primera, reintenta en una sola capa, normalmente la más cercana a la dependencia que falla, y haz que las demás capas pasen el error hacia arriba; el libro de SRE de Google recomienda que un servidor sobrecargado lo diga explícitamente, para que los llamantes de niveles superiores no reintenten. Segunda, usa un presupuesto de reintentos: el libro de SRE describe permitir reintentos solo mientras estén por debajo del 10% de las solicitudes de un cliente, lo que limita la carga adicional de los reintentos a unas 1.1 veces la normal en lugar de hasta tres veces. Los SDK de AWS implementan una cuota similar de tokens de reintento, y la política de reintentos de gRPC incluye una limitación (throttling) con el mismo fin. Tercera, combina los reintentos con un circuit breaker, para que no se reintente en absoluto una dependencia de la que se sabe que está caída.

Circuit breakers

Un circuit breaker, llamado así por Nygard en referencia al dispositivo eléctrico (el disyuntor), envuelve las llamadas a una dependencia y vigila sus resultados. Mientras la dependencia está sana, el breaker está cerrado (closed) y las llamadas pasan, mientras se cuentan los fallos y los timeouts. Cuando los fallos superan un umbral, por ejemplo la mitad de las 20 últimas llamadas, el breaker se abre (open): durante un periodo determinado todas las llamadas fallan de inmediato sin tocar la red, lo que protege los hilos de quien llama y da margen a la dependencia para recuperarse. Cuando vence el periodo, el breaker pasa a semiabierto (half-open) y deja pasar unas pocas llamadas de prueba. Si tienen éxito, se cierra; si una falla, vuelve a abrirse durante otro periodo. La descripción de Martin Fowler de 2014 dio a conocer ampliamente el patrón, y la biblioteca Hystrix de Netflix (ahora en modo de mantenimiento) lo popularizó en los microservicios; Resilience4j para Java y Polly para .NET son hoy las bibliotecas habituales, y las service meshes ofrecen protecciones relacionadas, como límites de concurrencia y detección de valores atípicos (outlier detection) (en Envoy se llaman circuit breaking, aunque funcionan de otra manera).

La máquina de estados del circuit breaker

Estados del circuit breaker: cerrado deja pasar llamadas y cuenta fallos; demasiados fallos lo abren y las llamadas fallan rápido; tras una espera pasa a semiabierto y deja pasar llamadas de prueba; se cierra si salen bien o se reabre si fallanClosedlas llamadas pasan;se cuentan fallosOpenfallan enseguida;sin ir a la redHalf-openalgunas llamadasde prueba pasantasa de fallos sobre el umbral(p. ej., 50% de las 20 últimas)tras la espera(p. ej. 30 s)falla una pruebapruebascon éxito
Cerrado cuenta los fallos, abierto falla rápido y semiabierto comprueba la recuperación con unas pocas llamadas de prueba.

Los ajustes merecen reflexión. ¿Qué cuenta como fallo? Los timeouts y las respuestas 5xx, sí; las respuestas 4xx normalmente no, porque reflejan la solicitud de quien llama y no la salud de la dependencia. Los éxitos lentos también pueden contar, para que una dependencia que responde en 5 segundos se trate como fallida. La ventana debe ser lo bastante grande para no dispararse por un par de llamadas desafortunadas (las bibliotecas exigen un número mínimo de llamadas), y el periodo abierto lo bastante largo para la recuperación pero lo bastante corto para no prolongar una caída. Los breakers deben ser por dependencia, y a veces por endpoint, para que un endpoint de informes que falla no corte los pedidos. Los reintentos deben tratar un breaker abierto como una respuesta definitiva y no como algo que reintentar. Y cada cambio de estado debe registrarse y representarse en gráficos, porque un breaker abierto es un síntoma que alguien necesita ver.

import time

class CircuitOpen(Exception):
    pass

class CircuitBreaker:
    def __init__(self, failure_threshold=5, reset_after=30.0):
        self.failure_threshold = failure_threshold  # failures in a row that trip it
        self.reset_after = reset_after              # seconds to stay open
        self.failures, self.state, self.opened_at = 0, "closed", 0.0

    def call(self, fn, *args, **kwargs):
        if self.state == "open":
            if time.monotonic() - self.opened_at < self.reset_after:
                raise CircuitOpen("failing fast")   # no remote call at all
            self.state = "half-open"                # let a trial call through
        try:
            result = fn(*args, **kwargs)
        except Exception:
            self.failures += 1
            if self.state == "half-open" or self.failures >= self.failure_threshold:
                self.state, self.opened_at = "open", time.monotonic()
            raise
        self.failures, self.state = 0, "closed"    # success closes the circuit
        return result

payments = CircuitBreaker()
# payments.call(provider.authorise, order_id, amount, timeout=2.0)
Un circuit breaker mínimo. Las bibliotecas de producción como Resilience4j (Java) y Polly (.NET) añaden ventanas de tasa de fallos, detección de llamadas lentas, límites de llamadas de prueba concurrentes, seguridad entre hilos y métricas.

Bulkheads

El casco de un barco está dividido por mamparos (bulkheads) en compartimentos estancos, para que una vía de agua inunde un compartimento en lugar de hundir el barco. En software, un bulkhead reparte los recursos para que una parte que falla o está sobrecargada no pueda consumirlo todo. La forma más común da a cada dependencia su propio pool de hilos, pool de conexiones o semáforo acotado: los pagos pueden usar como máximo 40 llamadas concurrentes por instancia, las consultas de restaurantes 40 y la asignación de repartidores 40. Cuando los pagos se ralentizan, su pool se llena y las siguientes llamadas de pago se rechazan de inmediato, pero las llamadas de restaurantes y repartidores continúan. Un timeout limita cuánto tiempo puede retener recursos cada llamada; un bulkhead limita cuántas pueden retenerlos a la vez, así que protege incluso cuando los timeouts son demasiado largos.

Los bulkheads también funcionan a mayor escala. Despliegues separados del mismo servicio para distintos llamantes (un grupo de instancias del servicio de pedidos para la app de clientes y otro para la API de restaurantes asociados) impiden que una integración de socio que se comporta mal deje sin recursos a los clientes. Las arquitecturas basadas en células, el eje Z del cubo de escalado, convierten cada célula en un bulkhead para una parte de los clientes, y el shuffle sharding, descrito por ingenieros de AWS, asigna a cada cliente una pequeña combinación aleatoria de instancias para que el tráfico venenoso de un cliente afecte a muy pocos otros. El costo de todo bulkhead es la utilización: la capacidad reservada para un compartimento no se puede prestar a otro, así que los pools deben dimensionarse a partir de la demanda medida.

Bulkheads dentro de un servicio

Bulkheads: el servicio de pedidos da a cada dependencia su propio pool de hilos pequeño; un proveedor de pagos lento solo llena el pool de pagos y las llamadas a restaurantes y repartidores continúanLlamadasServ. pedidospool de pagos: llenopool restaurantespool repartoPagoslentoRestaurantessanoRepartosano
Con un pool separado por dependencia, un proveedor de pagos lento solo llena el pool de pagos; las llamadas a restaurantes y repartidores continúan con normalidad.

Alternativas (fallbacks) y degradación elegante

Cuando una llamada falla o un breaker está abierto, quien llama debe responder de todos modos. Una alternativa (fallback) decide qué devuelve. Las opciones, de mejor a peor para el usuario, son: un valor en caché u obsoleto (la última carta conocida, con los precios confirmados en el proceso de pago); un valor predeterminado razonable (un tiempo de entrega estimado de 30 a 45 minutos en lugar de una estimación en vivo); una función reducida (platos populares en lugar de recomendaciones personalizadas); aplazar el trabajo (aceptar una reseña y publicarla cuando se recupere el servicio de reseñas); y un error claro y rápido que indique al usuario qué hacer. Diseñarlas función por función, con el responsable de producto, es lo que significa en la práctica la degradación elegante: el recorrido principal (explorar, pedir, pagar, seguir el pedido) sigue funcionando mientras las funciones no esenciales se hacen a un lado. El mismo razonamiento guía el descarte de carga (Sección 9.3): saber de antemano qué funciones desactivar primero.

Las decisiones de seguridad son la excepción: si el servicio de autorización no está accesible, la alternativa segura es denegar (fallar de forma cerrada, fail closed), no permitir. Los profesionales también discrepan sobre las alternativas en general. Nygard y los ingenieros de Netflix las defienden ampliamente. Los ingenieros de Amazon han argumentado, en el artículo de la Builders' Library Avoiding fallback in distributed systems, que las rutas alternativas rara vez se ejercitan, son difíciles de probar y pueden provocar ellas mismas caídas al pasar una flota grande a un modo no probado en el peor momento; prefieren hacer más fiable la ruta principal y fallar rápido. Ambas posturas coinciden en la regla práctica: una alternativa debe ser más sencilla que aquello a lo que sustituye, no debe depender del componente que ha fallado y debe ejercitarse con regularidad, por ejemplo en game days (Sección 9.8), o no debería existir.

Idempotencia: lo que hace seguros los reintentos y las solicitudes de cobertura

La idempotencia es la propiedad de que hacer algo dos veces tenga el mismo efecto que hacerlo una vez. Todos los patrones de esta sección que repiten trabajo dependen de ella: los reintentos tras un timeout, las solicitudes de cobertura, la nueva entrega de mensajes tras la caída de un consumidor y los reprocesamientos desde una cola de mensajes fallidos. La Sección 6.5 mostró cómo una clave de idempotencia convierte POST /orders en un reintento seguro, y la Sección 6.4, cómo los consumidores idempotentes eliminan los mensajes duplicados. La regla arquitectónica es sencilla: antes de añadir un reintento, comprueba que la operación es idempotente y, si no lo es, añade primero una clave o una comprobación de duplicados. Una política de reintentos sobre una llamada de pago no idempotente no es un patrón de resiliencia; es un generador de cobros dobles.

Solicitudes de cobertura

Los reintentos se ocupan de los fallos; las solicitudes de cobertura (hedged requests) se ocupan de la lentitud. La Sección 2.4 las presentó a partir de The Tail at Scale (2013), de Dean y Barroso: envía una solicitud a una réplica y, si no ha respondido dentro de, por ejemplo, la latencia del percentil 95, envía una copia a una segunda réplica y usa la respuesta que llegue primero, cancelando la otra. Como solo se duplica el pequeño porcentaje de solicitudes más lentas, la carga adicional es pequeña, mientras que la cola se reduce de forma espectacular, como mostró la prueba de rendimiento citada en la Sección 2.4.

Las solicitudes de cobertura tienen condiciones previas. La operación debe ser idempotente, normalmente una lectura. Debe haber réplicas independientes, para que sea improbable que la segunda copia sea lenta por el mismo motivo. Las solicitudes adicionales deben estar presupuestadas y desactivarse en situaciones de sobrecarga, porque cuando todas las réplicas son lentas, las solicitudes de cobertura duplican la carga de un sistema que ya tiene dificultades, exactamente igual que un reintento sin presupuesto. Usadas con cuidado en rutas con muchas lecturas y fan-out, como la búsqueda y las consultas de la carta, son una de las herramientas más eficaces contra la latencia de cola.

Los patrones de un vistazo

PatrónProtege frente aPrincipal costo o riesgo
TimeoutEsperar indefinidamente; recursos retenidos por llamadas muertasDemasiado corto hace fallar llamadas lentas sanas; demasiado largo no protege nada
Propagación de plazos (deadline propagation)Trabajo que continúa después de que quien llama se ha rendidoRequiere soporte en cada servicio y biblioteca de la ruta
Reintento con backoff y jitterFallos transitoriosCarga adicional; duplicados salvo que sea idempotente
Presupuesto de reintentosTormentas de reintentos que multiplican la cargaAlgunos fallos transitorios no se reintentan en los periodos malos
Circuit breaker (disyuntor)Llamar repetidamente a una dependencia que está caídaAjuste; puede cortar una dependencia que solo falla en parte
Bulkhead (mamparo)Que una dependencia o un llamante consuma todos los recursosLa capacidad reservada no se puede compartir
Alternativa y degradaciónFallo total de una funciónRutas de código sin probar; resultados obsoletos o aproximados
Solicitud de cobertura (hedged request)Latencia de cola por réplicas lentasCarga adicional; solo lecturas

Lista de comprobación: cada llamada remota

Revisa cada punto de integración, incluidas las bases de datos, las cachés, los brokers y las API de terceros, con esta lista.

  • Los timeouts de conexión y de solicitud se fijan explícitamenteElegidos a partir de los percentiles altos medidos de la dependencia y del plazo propio de quien llama, nunca dejados en el valor predeterminado de una biblioteca.
  • Se propaga el plazo restanteLas llamadas posteriores reciben el tiempo que queda, y el trabajo se detiene cuando se agota.
  • Los reintentos son limitados y segurosSolo errores transitorios, solo operaciones idempotentes o con clave de idempotencia, en una sola capa, con backoff exponencial limitado y jitter.
  • Un presupuesto de reintentos limita la carga adicionalLos reintentos siguen siendo una pequeña fracción del tráfico (por ejemplo, el 10%), así que una caída no puede multiplicar la carga.
  • Un circuit breaker protege la dependenciaLos umbrales, el tiempo de espera y lo que cuenta como fallo están ajustados, y su estado se exporta como métrica.
  • La concurrencia hacia la dependencia está acotadaUn bulkhead (pool dedicado o semáforo) impide que esta dependencia consuma todos los hilos o conexiones.
  • El comportamiento ante fallos está decididoUna alternativa, una respuesta degradada o un error claro, acordados con el responsable de producto, y fallar de forma cerrada en las decisiones de seguridad.
  • Los timeouts, los reintentos, los disparos del breaker y las alternativas son observablesSe cuentan, se representan en gráficos y generan alertas, para que se detecte la degradación silenciosa (Sección 9.6).
  • La ruta de fallo está probadaLa inyección de fallos o un game day demuestran que los timeouts, el breaker y la alternativa funcionan de verdad (Sección 9.8).

Comprueba lo que has entendido

Un servicio recibe 50 solicitudes por segundo, y cada una llama a una dependencia con un timeout de 4 segundos. Si la dependencia se cuelga, ¿cuántos hilos pueden retener estas llamadas?

Según la ley de Little, unos 50 × 4 = 200 hilos, y por eso el timeout y el límite de un bulkhead deben ajustarse al tamaño del pool.

¿Por qué el jitter completo es mejor que el backoff exponencial simple cuando muchos clientes fallan a la vez?

El backoff simple hace que todos los clientes reintenten en los mismos momentos, en oleadas sincronizadas; los retrasos aleatorios reparten los reintentos, de modo que la dependencia ve una carga uniforme y más baja.

¿En qué estado no hace un circuit breaker ninguna llamada remota, y qué pone fin a ese estado?

En el estado abierto; cuando vence el periodo de espera pasa a semiabierto y deja pasar llamadas de prueba.

¿Por qué una comprobación de autorización no debe recurrir a permitir el acceso cuando el servicio de autorización está caído?

Las decisiones de seguridad deben fallar de forma cerrada: permitir el acceso durante una caída dejaría entrar a cualquiera siempre que el servicio fallara o fuera atacado.

Siguiente · 9.5SLO y presupuestos de erroresCómo medir la fiabilidad desde el punto de vista del usuario con SLI, fijar objetivos de SLO, convertirlos en presupuestos y políticas de errores, alertar según la tasa de consumo (burn rate) y mantener los SLA, con margen, menos exigentes que los objetivos internos.Continuar →

Fuentes y lecturas recomendadas

¿Tienes una pregunta sobre esta sección? ArchitectBot, el asistente de estudio con IA del curso, responde primero a partir del curso.

Pregunta: “Nuestro proceso de pago llama de forma síncrona a tres servicios externos. ¿Con qué ajustes de timeouts, reintentos y circuit breaker deberíamos empezar, y cómo evitamos una tormenta de reintentos?”