Módulo 1: Por qué fallan los sistemas distribuidos

2. La falla parcial: en distribuido, algo siempre está roto

Descripción

En la lección 1 nombramos la falla parcial de pasada; aquí la abrimos en canal, porque es el concepto raíz del que cuelgan los siete módulos restantes. La idea es sencilla de enunciar y difícil de interiorizar: en un sistema distribuido, en cualquier instante, unas partes funcionan y otras no —y esa mezcla es el estado normal de operación, no una emergencia—. No es que "de vez en cuando algo se cae". Es que, con suficientes piezas conectadas por la red, la probabilidad de que todas estén sanas al mismo tiempo es baja, y baja rápido conforme agregas piezas. Un sistema distribuido pasa la mayor parte de su vida en algún estado parcialmente degradado. Diseñar para "todo funciona o todo se cae" —el modelo mental del monolito— es diseñar para un estado en el que tu sistema casi nunca está.

Vas a aprender tres cosas concretas. Primero, los cuatro desenlaces de una llamada remota —éxito, error explícito, lentitud y el temido ambiguo— y por qué el ambiguo (no sé si mi cobro ocurrió) es cualitativamente distinto de todo lo que conocías en un solo proceso. Segundo, por qué la lentitud y la ambigüedad no existen dentro de un proceso y aparecen en el momento en que cruzas la red. Y tercero —el corazón medible de la lección— el espacio de estados de un sistema con varias dependencias: vas a ejecutar en Python un conteo que muestra que con solo tres dependencias, cada una en up/slow/down, hay 27 estados posibles y uno solo es "todo sano" —el 3.7%—. Ese número es la justificación numérica de toda la guía: si el estado feliz es 1 de 27, más te vale diseñar para los otros 26.

Conexión con el módulo: la lección 1 te mostró un estado degradado (shipping lento) y su consecuencia (la cascada). Esta lección generaliza: te muestra que ese no es un mal día, sino uno de una montaña de estados posibles, y te da el vocabulario preciso (los cuatro desenlaces) para hablar de cada uno. Con eso, la lección 3 puede explicar por qué nuestra intuición nos falla aquí —las falacias— y la lección 4 puede diseccionar cómo un estado degradado se propaga —la cascada— sabiendo ya con precisión qué es "estar degradado".

La analogía: la carta y la conversación

Piénsalo así. Cuando hablas con alguien en la misma mesa, la comunicación tiene dos desenlaces: te escucha y responde, o —si se desmaya— la conversación entera se acaba de golpe, y tú te das cuenta al instante. No existe el estado "le hablé, no sé si me oyó, y no sé si me va a contestar en diez minutos o nunca". Están juntos; la respuesta es inmediata o el fin es evidente.

Ahora imagina que le mandas una carta por correo. Todo cambia. Puede llegar y te responden (éxito). Puede volver marcada "dirección no existe" (error explícito: malo, pero sabes qué pasó). Puede tardar tres semanas en llegar mientras tú esperas sin noticias (lentitud). Y —el caso que no tenía equivalente en la mesa— puedes quedarte sin saber: ¿la carta se perdió y nunca llegó? ¿Llegó, la persona la leyó, te contestó, y su respuesta se perdió? Desde tu lado, esos dos casos se ven idénticos: silencio. Si tu carta decía "transfiéreme $1000", el silencio es aterrorífico: no sabes si debes volver a pedirlo (y arriesgarte a que te transfieran $2000) o esperar (y arriesgarte a que nunca pase nada).

La mesa es una llamada de función en un proceso. La carta es una llamada de red. Todo lo que hace difícil a un sistema distribuido está en esa diferencia: la red introduce la lentitud y, sobre todo, la ambigüedad —el silencio que no distingue "no llegó" de "llegó pero se perdió la respuesta"—. La falla parcial es vivir en el mundo de las cartas, permanentemente, con cientos de cartas en vuelo a la vez, algunas llegando, algunas perdidas, algunas contestadas cuyo acuse se perdió. Este es su enunciado:

Una llamada de red no tiene dos desenlaces (funcionó / murió todo), sino cuatro: éxito, error explícito, lentitud, y ambigüedad (no sé si ocurrió). En un sistema con muchas dependencias, distintas llamadas están en distintos desenlaces al mismo tiempo, y esa mezcla —la falla parcial— es el estado normal. "Todo sano" es solo uno de una explosión de estados posibles, y casi nunca es donde estás.

Los cuatro desenlaces de una llamada remota

Cuando orders llama a payments.charge(), no hay dos resultados posibles, hay cuatro. Vale la pena verlos uno por uno, porque cada patrón de la guía nace para atacar a uno de ellos.

1. Éxito. payments recibió la petición, cobró, y respondió "cobrado, id pay_123". Es el caso para el que escribiste el código, y el único que sueles probar. En un sistema sano la mayoría de las llamadas caen aquí —pero "la mayoría" no es "todas", y la confiabilidad se juega en las demás—.

2. Error explícito. payments recibió la petición y respondió "no puedo": tarjeta rechazada, saldo insuficiente, error interno 500. Es una falla honesta: es mala noticia, pero sabes qué pasó y sabes que no cobraste. Puedes actuar con certeza —mostrar "pago rechazado", no reintentar un rechazo de tarjeta—. Los errores explícitos son los fáciles; son los que tu try/except ya maneja.

3. Lentitud (timeout). payments recibió la petición y está trabajando… o no… pero no responde todavía. No te dijo que no; simplemente te deja esperando. Este es el desenlace del que trata media guía, porque mientras esperas retienes un recurso (el hilo, la conexión), y si muchas llamadas caen aquí a la vez, agotas el pool —la cascada de la lección 1—. La lentitud es peor que el error explícito precisamente porque no falla: te mantiene en el limbo, consumiendo recursos, sin darte una respuesta con la cual seguir.

4. Ambiguo. No recibiste respuesta —se venció el timeout, se cortó la conexión—. Y aquí está lo profundo: no sabes en qué estado quedó el otro lado. Hay dos posibilidades que se ven idénticas desde tu lado:

  • Tu petición nunca llegó a payments. No cobraste. Reintentar es seguro y correcto.
  • Tu petición llegó, payments cobró, pero su respuesta se perdió en el camino de vuelta. Ya cobraste. Reintentar cobra dos veces.

Desde orders, ambos casos son el mismo silencio. No hay forma —con la sola información de la llamada— de distinguir "no pasó" de "pasó pero no me enteré". Este es el desenlace que hace peligroso el reintento y que obliga a la idempotencia (módulo 4): como no puedes saber si tu cobro ocurrió, tienes que hacer que reintentarlo sea inofensivo. El desenlace ambiguo es, quizá, la diferencia más importante entre programar en un proceso y programar en distribuido.

Aquí están los cuatro, con lo que sabes y lo que debes hacer en cada uno:

Desenlace¿Respondió?¿Sabes qué pasó?Peligro principalPatrón que lo ataca
ÉxitoSí, bienNinguno
Error explícitoSí, "no puedo"Sí (no ocurrió)Reintentar algo no reintentableRetry selectivo (M3)
LentitudTodavía noNo aúnAgotar el pool esperandoTimeout (M2)
AmbiguoNo respondióNo (¿ocurrió?)Reintentar y duplicarIdempotencia (M4)

Por qué esto no existía en el monolito

Detente en algo que parece obvio y no lo es: los desenlaces 3 y 4 —lentitud y ambigüedad— no existen dentro de un solo proceso. Cuando orders y payments viven en el mismo proceso y charge() es una llamada de función normal, solo hay dos resultados: la función retorna (con un valor o lanzando una excepción, que es el "error explícito"), o el proceso entero se cae (y entonces orders también murió, así que no hay nadie que se pregunte "¿cobré o no?"). No existe "la función está viva pero no me contesta en 3 segundos", ni existe "llamé a la función y no sé si se ejecutó". La ejecución es determinista y compartida: o los dos avanzan juntos, o los dos mueren juntos.

La red rompe esa garantía. Al poner a payments en otro proceso, en otra máquina, al otro lado de un cable, aparecen los dos desenlaces nuevos: puede estar vivo pero lento (porque su CPU está saturada, o la red está congestionada), y puede haber ejecutado tu petición sin que tú te enteres (porque su respuesta se perdió). La lección 6 mide exactamente cuánto cuesta ese cable —una llamada en proceso son nanosegundos; una de red, milisegundos, mil veces más, y con posibilidad de no volver nunca—. Por ahora la conclusión es conceptual: la falla parcial es el precio de la distribución. No es un defecto de tu código; es una propiedad del terreno en el que ahora juegas.

El ejemplo trabajado: el espacio de estados que explota

Ahora midámoslo. La afirmación "en distribuido, casi siempre algo está roto" suena a exageración retórica hasta que cuentas los estados. Modelemos el checkout de Mercado: orders depende de tres servicios en su ruta crítica —catalog, payments, shipping—, y simplifiquemos a que cada uno puede estar en uno de tres estados: up (sano), slow (lento) o down (caído). ¿Cuántos estados globales tiene el sistema, y cuántos de ellos son "todo perfecto"?

# l02_states.py -- el espacio de estados del checkout de Mercado.
# orders depende de 3 servicios; cada uno puede estar up / slow / down.
# Un monolito tiene 2 estados (vivo/muerto). Un distribuido, MUCHOS.
from itertools import product

DEPS = ["catalog", "payments", "shipping"]
STATES = ["up", "slow", "down"]

healthy = risky = failing = 0
for combo in product(STATES, repeat=len(DEPS)):
    if all(s == "up" for s in combo):
        healthy += 1
    elif any(s == "down" for s in combo):
        failing += 1          # una dependencia caida -> el checkout falla
    else:
        risky += 1            # ninguna caida, alguna lenta -> riesgo de exhaustion

total = len(STATES) ** len(DEPS)
print(f"dependencies on the critical path = {len(DEPS)}")
print(f"states per dependency             = {len(STATES)} {STATES}")
print(f"total system states               = {len(STATES)}^{len(DEPS)} = {total}")
print(f"  fully healthy (all up)          = {healthy}")
print(f"  degraded / at risk (some slow)  = {risky}")
print(f"  failing (some down)             = {failing}")
print(f"fraction fully healthy            = {healthy}/{total} = {healthy/total:.1%}")

# y si Mercado agrega una 4a y 5a dependencia sincronica...
for k in range(1, 6):
    print(f"  {k} deps -> {len(STATES)**k:>4} states, "
          f"only 1 is 'all up' ({1/len(STATES)**k:.2%})")

Qué esperar. Corriendo python l02_states.py con Python 3.14.0:

dependencies on the critical path = 3
states per dependency             = 3 ['up', 'slow', 'down']
total system states               = 3^3 = 27
  fully healthy (all up)          = 1
  degraded / at risk (some slow)  = 7
  failing (some down)             = 19
fraction fully healthy            = 1/27 = 3.7%
  1 deps ->    3 states, only 1 is 'all up' (33.33%)
  2 deps ->    9 states, only 1 is 'all up' (11.11%)
  3 deps ->   27 states, only 1 is 'all up' (3.70%)
  4 deps ->   81 states, only 1 is 'all up' (1.23%)
  5 deps ->  243 states, only 1 is 'all up' (0.41%)

Léelo con calma, porque es el argumento numérico de toda la guía:

  • Con tres dependencias hay 27 estados, y "todo sano" es uno solo. El cálculo es directo: 3 estados por dependencia, 3 dependencias, 3³ = 27 combinaciones. De esas 27, solo una tiene a los tres servicios en up. Las otras 26 tienen algo lento o algo caído. Si tu código solo maneja bien el estado "todo up", maneja bien 1 de 27 situaciones.
  • Los 19 estados "failing" son los que tienen algo caído. En cuanto una dependencia crítica está down, el checkout no puede completarse (sin degradación, que es el módulo 7). Hay 19 combinaciones con al menos un down —la mayoría del espacio—.
  • Los 7 estados "risky" son los peligrosos: algo lento, nada caído. Son las combinaciones donde nadie murió pero alguien está slow. Parecen "casi sanos" y son los que causan cascadas, porque el sistema sigue aceptando tráfico mientras sus recursos se agotan en silencio. El estado de la lección 1 (shipping lento) es uno de estos siete.
  • Y crece de forma brutal. Con 4 dependencias, "todo sano" es 1 de 81 (1.23%); con 5, es 1 de 243 (0.41%). Cada dependencia que agregas triplica el número de estados y hace más raro el estado feliz. Esto no es pesimismo: es aritmética. Mientras más piezas conectas, más tiempo pasa tu sistema en algún estado degradado.

Una advertencia honesta sobre el modelo: esto es una simplificación —los estados reales no son igual de probables (un servicio bien operado está up casi siempre), y el conteo trata a los 27 como si pesaran igual, lo cual no es cierto—. El punto no es que tu sistema pase 96.3% del tiempo roto; un sistema bien operado pasa la mayoría del tiempo en o cerca del estado sano. El punto es cualitativo y estructural: existe una explosión de estados degradados, tu código tiene que hacer algo razonable en ellos, y "todo sano" es matemáticamente raro. Diseñar solo para el estado 1-de-27 es diseñar para el caso que menos ayuda necesita.

Los grados de la falla parcial

El conteo anterior usó tres estados por dependencia para que quepan en una tabla, pero conviene ver que la "falla parcial" es en realidad un espectro, no tres cajones. Una dependencia puede estar:

up ──────── slow ──────── very slow ──────── timing out ──────── down
 |            |               |                    |                |
sana      responde        al borde           casi nunca        no responde
          tarde        del exhaustion       responde a         (falla rapido)
                                             tiempo

Fíjate en la ironía de los extremos. Un servicio up (sano) es fácil: responde rápido. Un servicio down (muerto) también es relativamente fácil: falla rápido —te devuelve "conexión rechazada" en milisegundos, liberas el recurso y sigues—. El infierno está en medio: el servicio que está casi respondiendo a tiempo, o respondiendo justo al filo del timeout. Ese es el que te mantiene esperando, retiene tus recursos, y te hace dudar si reintentar. Por eso a lo largo de la guía verás una y otra vez que la muerte limpia es más manejable que la degradación. Un sistema que se cae por completo es un mal día; un sistema que se pone lento es una cascada.

Errores comunes

Modelar el mundo como "arriba o abajo" (el pensamiento binario del monolito). Qué pasa: se diseña con un if servicio_disponible: ... else: ..., como si solo hubiera dos estados. Por qué es un error: ignora los dos desenlaces que sí importan —lentitud y ambigüedad—. Un servicio "disponible" que responde en 8 segundos rompe tu sistema tanto como uno caído, pero tu if lo cuenta como "arriba". Cómo detectarlo: si en tu código no existe el concepto de "tardó demasiado" ni el de "no sé si ocurrió", estás modelando el mundo binario. Cómo corregirlo: diseña para los cuatro desenlaces, no para dos. La lentitud necesita timeouts (M2); la ambigüedad necesita idempotencia (M4).

Tratar el desenlace ambiguo como un error explícito. Qué pasa: se hace except: retry() —ante cualquier fallo, reintentar—, metiendo en el mismo saco el error honesto y el silencio ambiguo. Por qué es un error: reintentar un error explícito de "tarjeta rechazada" es inútil pero inofensivo; reintentar un ambiguo de un cobro puede cobrar dos veces, porque quizá el primer intento sí ocurrió y solo se perdió la respuesta. Cómo detectarlo: pregúntate "si reintento esto y la primera vez pasó, ¿qué daño hago?". Si la respuesta es "duplico un cobro", estás tratando un ambiguo como si fuera seguro reintentarlo. Cómo corregirlo: los reintentos exigen idempotencia (M4); no todo error se reintenta igual (M3).

Creer que un timeout convierte la ambigüedad en certeza. Qué pasa: se pone un timeout y se asume que, cuando vence, "la operación no ocurrió". Por qué es un error: el timeout te devuelve el control (dejas de esperar, liberas el recurso), pero no te dice qué pasó del otro lado. La petición pudo haber llegado y ejecutádose después de que tú dejaste de esperar. Un timeout resuelve la lentitud (recuperas tu hilo), no la ambigüedad (sigues sin saber si cobraste). Cómo detectarlo: si tu lógica dice "timeout, entonces asumo que no pasó y reintento", tienes un bug latente de doble ejecución. Cómo corregirlo: el timeout (M2) y la idempotencia (M4) son patrones distintos para desenlaces distintos, y casi siempre se usan juntos.

Ejercicios

Ejercicio 1 — Clasifica los desenlaces. Para cada una de estas situaciones de Mercado, di cuál de los cuatro desenlaces es (éxito, error explícito, lentitud, ambiguo) y qué debería hacer orders:

(a) payments responde "tarjeta declinada". (b) orders llama a shipping y a los 10 segundos todavía no hay respuesta. (c) orders llama a payments.charge(), la conexión se corta antes de recibir respuesta, y no hay forma de saber si el cobro se hizo. (d) catalog devuelve la ficha del producto en 15 ms.

Ver solución
  • (a) Error explícito. payments respondió honestamente "no puedo" (tarjeta declinada). orders sabe que no cobró. Acción: mostrar "pago rechazado" al comprador; no reintentar (reintentar una declinación de tarjeta no la va a aprobar).
  • (b) Lentitud. shipping no ha respondido en 10 segundos. orders está reteniendo un worker todo ese tiempo —el inicio de la cascada—. Acción correcta: haber tenido un timeout que ya hubiera cortado en, digamos, 500 ms, para liberar el worker (M2). Sin timeout, este es el desenlace que causa el pool exhaustion.
  • (c) Ambiguo. La conexión se cortó sin respuesta. orders no sabe si cobró o no. Acción: no reintentar a ciegas —podría duplicar el cobro—. Necesita idempotencia: reintentar con la misma idempotency_key para que, si el cobro ya ocurrió, payments no lo repita (M4).
  • (d) Éxito. catalog respondió bien y rápido. orders sigue su flujo. El caso feliz —1 de 27—.

Ejercicio 2 — Cuenta los estados de un checkout más grande. Supón que Mercado agrega dos dependencias síncronas nuevas al checkout: fraud (verificación antifraude) y inventory (reserva de stock). Ahora orders depende de cinco servicios en la ruta crítica, cada uno en up/slow/down. ¿Cuántos estados globales hay? ¿Qué fracción es "todo sano"? ¿Qué te dice eso sobre agregar dependencias síncronas?

Ver solución

Con 5 dependencias y 3 estados cada una: 3⁵ = 243 estados globales. "Todo sano" sigue siendo uno solo, así que la fracción feliz es 1/243 ≈ 0.41% —justo la última fila del output del ejemplo—.

Lo que te dice es contundente: cada dependencia síncrona que agregas a la ruta crítica triplica el espacio de estados y hace más raro el estado sano. Pasar de 3 a 5 dependencias llevó la fracción feliz de 3.7% a 0.41% —casi diez veces más raro—. Esto es un argumento de diseño real: antes de agregar una llamada síncrona más a tu checkout, pregúntate si de verdad tiene que ser síncrona y en la ruta crítica, porque cada una multiplica las formas en que el flujo puede degradarse. A veces la respuesta es hacerla asíncrona (sacarla de la ruta crítica) —pero esa decisión (sync vs async) es de la guía de estilos arquitectónicos, no de esta; aquí solo medimos el costo de tenerla síncrona—.

Ejercicio 3 — El estado más peligroso. De los 27 estados del checkout de tres dependencias, argumenta cuál es el más peligroso de operar: (a) los tres servicios down, o (b) los tres servicios slow. Justifica con lo que sabes de la lección 1.

Ver solución

El más peligroso es (b), los tres slow, aunque intuitivamente "los tres caídos" suene peor.

Con los tres servicios down, cada llamada falla rápido: orders recibe "conexión rechazada" en milisegundos, libera el worker de inmediato, y el checkout falla limpio (0% de éxito, pero sin agotar recursos). Es un mal día honesto: el sistema dice "no puedo" y sigue de pie, listo para recuperarse en cuanto los servicios vuelvan.

Con los tres servicios slow, cada llamada retiene un worker durante segundos mientras espera. Los workers se agotan (la cascada de la lección 1), orders deja de aceptar tráfico, y —peor— el sistema parece estar intentando funcionar, así que suele mantenerse el tráfico entrante en vez de cortarlo. Es el estado que llena los pools, dispara la p99, y hace que orders arrastre consigo a sus propios llamadores (el storefront). La muerte limpia se recupera; la degradación cascadea.

Esta es la intuición clave de todo el módulo, y por eso vale la pena repetirla: en sistemas distribuidos, "lento" suele ser peor que "muerto". Es contraintuitivo, y es la razón por la que el módulo 2 (timeouts) existe antes que cualquier otro: convertir la lentitud en una falla rápida —cortar y seguir— es el primer blindaje.

Resumen y siguiente paso

En esta lección abriste el concepto raíz de la guía: la falla parcial. Una llamada remota no tiene dos desenlaces sino cuatro —éxito, error explícito, lentitud y ambiguo (no sé si ocurrió)—, y los dos últimos no existen dentro de un solo proceso: aparecen en el momento en que cruzas la red. La lentitud retiene recursos y causa cascadas (M2 la ataca con timeouts); la ambigüedad hace peligroso reintentar (M4 la ataca con idempotencia).

Y lo mediste: con la analogía de la carta contra la conversación en la mesa, y con un conteo ejecutado en Python que mostró que el checkout de Mercado, con solo tres dependencias en up/slow/down, tiene 27 estados posibles y uno solo es "todo sano" —el 3.7%—, cayendo a 0.41% con cinco dependencias. El estado feliz es matemáticamente raro; diseñar solo para él es diseñar para el caso que menos lo necesita. Y viste la lección contraintuitiva que atraviesa todo el módulo: "lento" suele ser peor que "muerto", porque la muerte falla rápido y libera recursos, mientras la lentitud los agota en silencio.

Antes de avanzar deberías poder: nombrar los cuatro desenlaces de una llamada remota y qué patrón ataca a cada uno; explicar por qué la lentitud y la ambigüedad no existen en un monolito; leer el conteo de estados y decir por qué "todo sano" se vuelve raro al agregar dependencias; y argumentar por qué tres servicios lentos son más peligrosos que tres caídos.

Lo que sigue es entender por qué nuestra intuición nos traiciona tanto aquí —por qué diseñamos, una y otra vez, como si la red fuera confiable y la latencia cero—. En la lección 3 desglosamos las ocho falacias de la computación distribuida: las suposiciones falsas, tan cómodas que las hacemos sin darnos cuenta, que están detrás de casi todos los bugs distribuidos. Cada falacia la vas a ver morder a Mercado, y vas a medir cómo "la red es confiable" se compone y "la latencia es cero" se suma.

Recursos