Saltar a contenido

Sistemas distribuidos

De un vistazo

Materia: Capa 3 · Sobrevivir en producción · Tiempo de lectura: ~35 min · Requisitos previos: Networking, SO y concurrencia, Bases de datos.

En una frase: distribuir un sistema es meter la red —que es lenta y poco fiable— en medio de tu programa, y a partir de ahí casi todas las decisiones de diseño salen de una sola idea incómoda: las cosas fallan, por separado, en el peor momento, y tú no te enteras.

Por qué esto importa

Durante años programas en una máquina. Llamas a una función y o devuelve un valor, o lanza una excepción. Punto. No hay un tercer estado. Esa certeza —binaria, instantánea, fiable— es el suelo sobre el que aprendiste a razonar sobre código.

El día que tu programa habla con otra máquina por la red, ese suelo desaparece. Y no lo hace del todo evidente: casi siempre funciona, hasta que un martes a las 3 de la mañana no funciona, y descubres que llevabas meses sin entender lo que realmente pasaba. Preguntas que en una sola máquina no existen:

  • Llamas a un servicio de pago, la petición se queda colgada 30 segundos y luego da error. ¿Se cobró al cliente o no? ¿Reintento y arriesgo cobrarle dos veces, o no reintento y arriesgo no cobrarle?
  • Tu base de datos tiene una réplica "para ir más rápido". Un usuario cambia su email, recarga la página y ve el email viejo. No es un bug de tu código. ¿Entonces qué es?
  • Un servicio responde perfectamente a tus pings pero rechaza todas las peticiones reales. ¿Está "caído" o no? ¿Qué significa siquiera "caído"?
  • Metes una cola de mensajes para desacoplar dos servicios y de repente el mismo pedido se procesa dos veces. ¿Cómo? Si solo lo mandaste una vez.

Ninguna de estas se resuelve "programando mejor" en el sentido clásico. Todas nacen de la misma raíz: has puesto una red poco fiable entre dos trozos de tu sistema, y la red puede fallar de formas que una llamada a función local jamás falla. Sin este modelo mental, cada incidente es un misterio que parcheas con reintentos a ciegas y rezos. Con él, son consecuencias predecibles de una decisión que tomaste al distribuir. Ese es el salto de "monté unos microservicios" a "entiendo el precio que estoy pagando por montarlos".

Intuición primero: de la conversación cara a cara al correo postal

Piensa en dos formas de coordinarte con un compañero.

En una sola máquina, es como hablar con alguien sentado a tu lado. Le preguntas algo, te contesta al instante, y si no está —si se ha desmayado— lo ves: no responde y punto. Sabes en todo momento en qué estado está. La comunicación es rápida, fiable y con estado observable.

Entre máquinas por la red, es como coordinarte con alguien en otra ciudad solo por correo postal. Y aquí está la clave: cuando mandas una carta pidiendo algo y no recibes respuesta, no puedes distinguir entre estos casos:

  • Tu carta se perdió y nunca llegó.
  • Llegó, pero él aún la está procesando (es lento).
  • La procesó, hizo lo que pedías, y su carta de respuesta se perdió.
  • Se murió antes de abrirla.
  • Se murió justo después de hacer lo que pedías, pero antes de responder.
graph LR
  A["Tú<br/>(mandas la carta:<br/>cóbrale 100€)"] -->|"¿llegó?"| RED["EL CORREO<br/>(la red)<br/>lento · pierde cartas ·<br/>a veces las duplica"]
  RED -->|"¿respondió?"| B["El otro servicio<br/>(¿lo hizo?<br/>¿está vivo?)"]
  B -.->|"silencio"| A

Desde tu lado, los cinco casos se ven exactamente igual: silencio. Y son radicalmente distintos: en unos debes reintentar, en otros reintentar significa cobrar dos veces. Toda la dificultad de los sistemas distribuidos cabe en esa frase: el silencio es ambiguo, y tienes que diseñar sabiendo que no puedes resolverlo.

Guarda esta imagen del correo postal. Vamos a colgar de ella el resto de la lección.

El detalle

1. Por qué distribuimos, si duele tanto

Nadie distribuye por gusto. Una sola máquina es infinitamente más fácil de razonar. Se distribuye por dos razones, y conviene tenerlas separadas porque piden cosas distintas:

  • Escala. Una máquina tiene un techo: hay una CPU más grande que puedas comprar, una cantidad de RAM máxima, un límite de peticiones por segundo. Cuando lo superas, la única salida es repartir el trabajo entre muchas máquinas (escalado horizontal). Esto es escalar para aguantar carga.
  • Disponibilidad. Una máquina es un único punto de fallo: si se cae, se cae todo. Si el negocio no puede permitirse estar caído, necesitas redundancia: varias máquinas de forma que la caída de una no tumbe el servicio. Esto es distribuir para sobrevivir a fallos.

La ironía central

Distribuyes, entre otras cosas, para tolerar fallos (que una máquina caída no te tumbe). Pero al distribuir multiplicas las cosas que pueden fallar y, sobre todo, añades la red, que es la parte menos fiable de todas. Has cambiado "un componente que falla raramente" por "muchos componentes y una red que fallan a menudo, pero de forma parcial". La apuesta solo sale a cuenta si diseñas asumiendo esos fallos. Si distribuyes y luego rezas para que nada falle, tienes lo peor de los dos mundos: la complejidad de lo distribuido sin la robustez.

La regla de criterio, antes de seguir: no distribuyas hasta que el dolor de una sola máquina sea real y medible. "Microservicios desde el día uno" es, en la enorme mayoría de proyectos, comprarse todos los problemas de esta lección sin tener aún ninguno de los problemas que resuelven. Un monolito bien hecho aguanta mucho más de lo que la moda sugiere.

2. Las falacias de la computación distribuida

En los años 90, en Sun Microsystems, se recopiló una lista de suposiciones falsas que todo el mundo hace la primera vez que programa en red. Son famosas porque todas parecen obviamente ciertas hasta que te cuestan un incidente. Las ocho, con su corrección:

Falacia (lo que asumes sin pensar) La realidad
La red es fiable Las peticiones se pierden. Los cables se desconectan. Los routers se reinician. Pasará.
La latencia es cero Ir a otra máquina cuesta de milisegundos a segundos. No es "gratis" como una llamada local.
El ancho de banda es infinito Mandar mucho dato tarda y se congestiona.
La red es segura Cualquiera puede estar escuchando o suplantando.
La topología no cambia Máquinas que entran, salen, cambian de IP constantemente.
Hay un solo administrador Distintos equipos, distintas políticas, nadie ve el todo.
El coste de transporte es cero Serializar, mandar, deserializar cuesta CPU y dinero.
La red es homogénea Distintos protocolos, versiones, velocidades conviviendo.

No hace falta memorizarlas. Lo que hay que interiorizar es la primera y la segunda, porque de ellas cuelga casi todo el diseño:

  1. La red no es fiable → tu código debe funcionar aunque un mensaje se pierda o se duplique.
  2. La latencia no es cero → dónde pones las cosas y cuántas veces cruzas la red importa muchísimo.

El resto de la lección es, básicamente, aprender a vivir con estas dos.

3. Fallos parciales: el fantasma que define todo

En una sola máquina, los fallos son totales: o el proceso vive o se muere, y todos sus componentes viven o mueren con él. Es un mundo binario y honesto.

En un sistema distribuido aparece el monstruo: el fallo parcial. Unas partes funcionan y otras no, al mismo tiempo, y ninguna tiene la foto completa. El servicio A cree que B está caído; B está perfectamente pero no le llegan las cartas de A; C habla con B sin problema. No hay un "estado global" que nadie pueda consultar. Cada nodo solo sabe lo que ha visto llegar por la red, que es información vieja e incompleta.

El caso que lo resume todo es el timeout ambiguo del correo postal. Mandas una petición, arrancas un cronómetro, y a los N segundos no ha llegado respuesta. ¿Qué ha pasado?

sequenceDiagram
  participant A as Servicio A
  participant B as Servicio B
  A->>B: "cóbrale 100€ al cliente"
  Note over A: arranca timeout (5s)
  Note over B: procesa... ¿lo hizo?
  Note over A: 5s sin respuesta ⏰
  Note over A: ¿reintento?<br/>si B SÍ cobró → cobro doble<br/>si B NO cobró → pérdida

Aquí está el corazón de la lección: un timeout no te dice si la operación se hizo. Solo te dice que no recibiste respuesta a tiempo. La operación pudo completarse entera y perderse la respuesta. Por eso "reintentar cuando hay error" —que en local es inofensivo— en distribuido es una decisión con consecuencias graves, y por eso vas a oír tanto la palabra idempotencia más adelante: es la única forma de reintentar sin miedo.

El timeout que eliges es una decisión de diseño, no un detalle

Un timeout corto declara "muerto" a un servicio que solo iba lento (y reintentas de más). Un timeout largo hace que tu sistema tarde una eternidad en reaccionar a un fallo real (tus usuarios esperan 30s a que algo dé error). No hay valor correcto universal; depende de cuánto te cuesta cada error. Pero no tener timeout es siempre un bug: significa esperar para siempre a una carta que quizá nunca llegue, agotando conexiones e hilos hasta tumbar tu propio servicio.

4. Consistencia frente a disponibilidad: el teorema CAP por intuición

Imagina que replicas tu base de datos en dos centros de datos, Madrid y Berlín, para sobrevivir a que uno se caiga. Todo va bien mientras se hablan. Un día, el cable entre Madrid y Berlín se corta: cada uno sigue vivo, pero no pueden sincronizarse. Esto es una partición de red (la P de CAP), y no es un caso exótico: pasa, y no puedes evitar que pase.

Llega una escritura a Madrid: "cambia el saldo de la cuenta a 500€". Madrid no puede avisar a Berlín. Ahora tienes exactamente dos opciones, y solo dos:

graph TB
  P["PARTICIÓN: Madrid y Berlín no se hablan<br/>llega una escritura a Madrid"]
  P --> C["Elegir CONSISTENCIA<br/>Madrid RECHAZA la escritura<br/>(o deja de servir)<br/>→ nadie ve datos incorrectos<br/>→ pero el sistema NO está disponible"]
  P --> A["Elegir DISPONIBILIDAD<br/>Madrid ACEPTA la escritura<br/>→ el sistema sigue respondiendo<br/>→ pero Berlín sirve datos VIEJOS<br/>(inconsistencia temporal)"]
  • Consistencia (C): te niegas a dar una respuesta que pueda ser incorrecta. Madrid rechaza la escritura porque no puede garantizar que Berlín se entere. Nadie ve datos erróneos, pero el sistema deja de funcionar durante la partición.
  • Disponibilidad (A): sigues respondiendo pase lo que pase. Madrid acepta la escritura. El sistema funciona, pero Berlín está sirviendo el saldo viejo a otros usuarios hasta que se restablezca el cable. Datos divergentes.

Eso es el teorema CAP, sin formalismo: cuando hay partición (y la habrá), tienes que elegir entre ser consistente o estar disponible. No puedes tener ambas. No es una ley que puedas esquivar con más ingeniería; es lógica pura del correo postal: si no puedes hablar con el otro, o das una respuesta arriesgándote a que esté desactualizada, o no das respuesta.

CAP no es un interruptor global, es una decisión por operación

La lectura ingenua de CAP ("elige, ¿eres CP o AP?") es engañosa. Los sistemas reales eligen por tipo de operación. El saldo de un banco quiere consistencia (mejor rechazar que descuadrar). El contador de "me gusta" de un vídeo quiere disponibilidad (que se vea aproximado un rato no le importa a nadie). El carrito de la compra quiere disponibilidad (nunca rechaces "añadir al carrito"). La pregunta útil no es "¿CP o AP?" sino "para esta operación, ¿qué duele más: negar servicio un momento, o servir un dato desactualizado un momento?".

De aquí sale un concepto que vas a ver por todas partes: la consistencia eventual. Significa "acepto servir datos temporalmente desactualizados; si dejo de escribir y espero, todas las réplicas acabarán poniéndose de acuerdo". Es el modelo de casi toda la web a gran escala, y explica el bug del email viejo del principio: leíste de una réplica que aún no se había enterado del cambio. No era un bug; era el precio elegido —a menudo sin saberlo— a cambio de velocidad y disponibilidad.

5. Latencia: por qué cambia el diseño, no solo el rendimiento

La segunda falacia —"la latencia es cero"— parece un problema de velocidad, pero en realidad reescribe tu arquitectura. Unos números para anclar la intuición (órdenes de magnitud, no exactos):

Operación Tiempo aproximado En escala humana (×1.000 millones)
Leer de caché de CPU ~1 ns 1 segundo
Leer de RAM ~100 ns ~2 minutos
Leer de disco SSD ~100 µs ~1 día
Red dentro del mismo centro de datos ~500 µs ~6 días
Red entre continentes (ida y vuelta) ~150 ms ~5 años

La lección brutal está en la última fila: una llamada de red entre continentes es, a escala de la CPU, cinco años. Esto tiene una consecuencia de diseño demoledora: el número de veces que cruzas la red domina tu rendimiento, muy por encima de lo que hace tu código.

// ❌ El patrón "N+1" que en local es inofensivo y en red te mata
$pedidos = $servicioPedidos->listar();      // 1 llamada de red
foreach ($pedidos as $p) {
    $cliente = $servicioClientes->buscar($p->clienteId);  // ¡1 llamada de red POR pedido!
    // ... con 200 pedidos → 201 viajes de red, secuenciales
}
// ✅ Agrupa: paga la latencia una vez, no N veces
$pedidos = $servicioPedidos->listar();               // 1 llamada
$ids = array_map(fn($p) => $p->clienteId, $pedidos);
$clientes = $servicioClientes->buscarVarios($ids);   // 1 llamada con todos los ids

Las dos versiones son idénticas a ojos de la lógica. La primera tarda 201 veces la latencia de red; la segunda, 2 veces. En una sola máquina esta diferencia es despreciable (llamar a una función 200 veces no se nota). En distribuido es la diferencia entre 40 ms y 8 segundos. Por eso el diseño distribuido obsesiona con agrupar (batching), traer los datos cerca (cachés, réplicas) y hacer cosas en paralelo en vez de en serie: no es micro-optimización, es la diferencia entre un sistema usable y uno inservible.

6. Replicación y particionado: las dos formas de repartir

Hay exactamente dos maneras de repartir datos entre máquinas, y hacen cosas opuestas. Confundirlas es un error clásico.

Replicación: poner la misma copia de los datos en varias máquinas. Sirve para disponibilidad (si una cae, otra tiene los datos) y para escalar lecturas (muchas réplicas atendiendo lecturas en paralelo). Su problema es mantenerlas de acuerdo: cada escritura hay que propagarla, y ahí aparece toda la tensión de CAP y la consistencia eventual.

Particionado (sharding): trocear los datos y poner cada trozo en una máquina distinta (los clientes A–M en la máquina 1, N–Z en la máquina 2). Sirve para escalar escrituras y almacenamiento (ninguna máquina guarda el todo). Su problema es que las operaciones que cruzan trozos se vuelven caras (contar todos los clientes ahora requiere preguntar a todas las máquinas y sumar) y que elegir mal la clave de partición te crea hotspots (un trozo mucho más caliente que el resto).

graph TB
  subgraph REP["Replicación · misma copia N veces"]
    direction LR
    W["escritura"] --> L["Líder<br/>(datos completos)"]
    L -.copia.-> S1["Réplica 1<br/>(datos completos)"]
    L -.copia.-> S2["Réplica 2<br/>(datos completos)"]
  end
  subgraph SHARD["Particionado · trozos distintos"]
    direction LR
    D["datos<br/>completos"] --> H1["Shard 1<br/>(clientes A-M)"]
    D --> H2["Shard 2<br/>(clientes N-Z)"]
  end

Los dos se combinan en la práctica: particionas para repartir el volumen, y replicas cada partición para que ningún trozo sea un punto único de fallo. Y el patrón de replicación más común es el de líder y seguidores: una réplica manda (recibe todas las escrituras) y las demás la copian. Es simple y evita conflictos, pero el líder es un cuello de botella para escribir y —lo importante— si el líder cae, hay que elegir uno nuevo. Y elegir de acuerdo, entre máquinas que se comunican por correo postal, es sorprendentemente difícil. Eso es lo siguiente.

7. Consenso: por qué es tan difícil ponerse de acuerdo

Suena trivial: varios nodos tienen que acordar un único valor (quién es el nuevo líder, en qué orden se aplicaron unas operaciones, si una transacción se confirma o se aborta). En una sala, lo resuelves levantando la mano. Por correo postal, con cartas que se pierden y remitentes que se mueren a mitad, es uno de los problemas más difíciles de la informática.

¿Por qué? Porque no puedes distinguir un nodo caído de un nodo lento (otra vez el silencio ambiguo). Si esperas la respuesta de todos para decidir, un solo nodo caído te bloquea para siempre. Si decides sin esperar a todos, dos grupos que no se ven podrían decidir cosas distintas (el problema del split-brain: dos líderes a la vez, cada uno creyéndose el único, corrompiendo los datos por separado).

La idea que rescata la situación es el quórum: en vez de exigir todos, exiges la mayoría (más de la mitad). Si tienes 5 nodos, una decisión necesita 3 de acuerdo.

graph LR
  subgraph Cluster["5 nodos · quórum = 3"]
    N1["nodo 1 ✅"]
    N2["nodo 2 ✅"]
    N3["nodo 3 ✅"]
    N4["nodo 4 ❌ caído"]
    N5["nodo 5 ❌ caído"]
  end
  N1 --- Q["3 de 5 de acuerdo<br/>→ HAY decisión<br/>(se tolera perder 2)"]
  N2 --- Q
  N3 --- Q

La magia del quórum es doble. Primero, tolera fallos: con 5 nodos y quórum de 3, sobreviven la caída de 2. Segundo, y más sutil, impide el split-brain: como dos mayorías de 5 tienen que solaparse (3+3=6>5, comparten al menos un nodo), es imposible que dos grupos separados formen quórum a la vez. Ese solapamiento matemático es lo que garantiza que no haya dos decisiones contradictorias. Por eso los clústeres de consenso van casi siempre en número impar (3, 5, 7): maximiza la tolerancia por nodo y evita empates.

Paxos, Raft y por qué no los vas a implementar

Los algoritmos que hacen esto bien —Paxos (el clásico, famoso por ser difícil de entender) y Raft (diseñado explícitamente para ser comprensible)— son intrincados precisamente porque tienen que ser correctos ante cualquier combinación de mensajes perdidos y nodos que mueren en cualquier instante. La conclusión práctica no es que los memorices, sino esta: el consenso es tan difícil que casi nunca deberías implementarlo tú. Lo delegas en sistemas que ya lo resolvieron —etcd, ZooKeeper, Consul, o la propia base de datos— y los usas como la fuente de verdad para "quién es el líder" o "qué configuración vale". Reinventar consenso es la vía rápida a corromper datos de formas que no sabrás depurar.

8. Semánticas de entrega e idempotencia: la salvación

Volvamos al timeout ambiguo de la sección 3, porque ahora podemos resolverlo. Cuando mandas un mensaje por la red y quieres asegurarte de que se procesa, tienes tres garantías posibles, y ninguna es gratis:

  • Como mucho una vez (at-most-once): mandas y no reintentas nunca. Si se pierde, se perdió. Nunca hay duplicados, pero puedes perder mensajes. Vale para un contador de métricas; no para cobrar.
  • Al menos una vez (at-least-once): reintentas hasta recibir confirmación. Nunca pierdes un mensaje, pero puedes procesarlo varias veces (recuerda: la respuesta pudo perderse después de que se procesara). Este es el modelo realista de casi todas las colas.
  • Exactamente una vez (exactly-once): el ideal —ni se pierde ni se duplica—. El problema es que la entrega exactamente-una-vez es imposible sobre una red poco fiable, por el argumento del correo postal: no puedes saber si la carta llegó, así que o arriesgas perderla o arriesgas duplicarla.

Y aquí viene el giro que lo salva todo. No puedes garantizar exactamente-una-vez en la entrega, pero sí puedes garantizar exactamente-una-vez en el efecto. El truco es la idempotencia: diseñar la operación de forma que procesarla varias veces tenga el mismo efecto que procesarla una.

// ❌ NO idempotente: si el mensaje llega dos veces, cobras dos veces
function procesarPago($pedidoId, $importe) {
    $this->cobrar($importe);   // reintento = cobro doble
}

// ✅ Idempotente: llega dos veces, cobra una. Un id único protege la operación.
function procesarPago($pedidoId, $importe, $idempotencyKey) {
    if ($this->yaProcesado($idempotencyKey)) {
        return;                 // ya lo hicimos: no repetir el efecto
    }
    $this->cobrar($importe);
    $this->marcarProcesado($idempotencyKey);
}

La estrategia ganadora del mundo real es: entrega "al menos una vez" (reintenta sin miedo) + operaciones idempotentes (el duplicado no hace daño). Juntas te dan el efecto de "exactamente una vez" sin necesitar la imposible garantía de entrega. Por eso las APIs de pago serias (Stripe, por ejemplo) te piden una clave de idempotencia en cada petición: es exactamente este patrón. Si interiorizas una sola herramienta de esta lección, que sea esta.

9. Colas de mensajes y comunicación asíncrona

Hasta ahora A llamaba a B y esperaba la respuesta (comunicación síncrona). Esto acopla a A con la salud de B: si B está lento, A está lento; si B está caído, A falla. Una alternativa poderosa es comunicarse a través de una cola: A deja un mensaje en la cola y sigue con su vida; B lo recoge cuando puede y lo procesa. Nadie espera a nadie (comunicación asíncrona).

graph LR
  A["Productor<br/>(deja el mensaje<br/>y sigue)"] --> Q["COLA<br/>(guarda mensajes<br/>hasta que se procesen)"]
  Q --> C1["Consumidor 1"]
  Q --> C2["Consumidor 2"]
  Q --> C3["Consumidor 3<br/>(añade más si hay atasco)"]

Lo que ganas es sustancial:

  • Desacoplo temporal: si B se cae, los mensajes se acumulan en la cola en vez de perderse; cuando B vuelve, los procesa. A ni se entera.
  • Absorber picos: si llegan 10.000 pedidos de golpe, la cola los amortigua y B los va procesando a su ritmo, en vez de morir aplastado.
  • Escalar consumidores: si la cola crece, añades más consumidores tirando de ella en paralelo.

Lo que pagas también es real, y hay que decirlo:

  • Complejidad y latencia extra: ahora hay una pieza más (el broker: RabbitMQ, Kafka, SQS…) que mantener, y las cosas dejan de ser instantáneas.
  • Ya no hay respuesta inmediata: el flujo se vuelve "dispara y olvida". Saber si algo se hizo requiere otro mecanismo (eventos de vuelta, consultar estado).
  • Entrega "al menos una vez" casi siempre: las colas reparten duplicados. Volvemos a lo mismo: tus consumidores tienen que ser idempotentes. No es opcional.

Regla de criterio: síncrono para lo que el usuario espera, asíncrono para lo demás

Si el usuario está mirando la pantalla esperando el resultado (validar una contraseña, cargar su perfil), va síncrono: necesita la respuesta ya. Si es trabajo que puede ocurrir "pronto" pero no "ahora mismo" (enviar el email de bienvenida, generar la factura PDF, actualizar un índice de búsqueda), va asíncrono por cola: desacoplas, absorbes picos y no haces esperar al usuario por algo que no le urge. Meter todo en colas es tan error como no meter nada.

10. Transacciones distribuidas y el patrón saga

En una sola base de datos tienes transacciones ACID: varias operaciones o se hacen todas o ninguna (atomicidad), sin estados intermedios visibles. Es una red de seguridad maravillosa. El problema: esa garantía no cruza máquinas. Si "hacer un pedido" toca el servicio de inventario, el de pagos y el de envíos —tres bases de datos distintas— no hay un BEGIN TRANSACTION que las abarque a las tres.

El instinto es buscar una transacción que abarque todo (existe: se llama two-phase commit, 2PC), pero es frágil y lento en distribuido: bloquea recursos en todos los participantes mientras se coordina, y si el coordinador se cae en el momento justo, deja a todos bloqueados esperando. En la práctica se evita para flujos de negocio normales.

La respuesta madura es renunciar a la atomicidad global y usar el patrón saga: descomponer la operación en una secuencia de pasos locales, cada uno con su propia transacción normal, y para cada paso definir su compensación (cómo deshacerlo). Si un paso falla, ejecutas las compensaciones de los pasos anteriores en orden inverso, deshaciendo lo hecho.

graph LR
  S1["1 · Reservar<br/>inventario"] --> S2["2 · Cobrar<br/>pago"]
  S2 --> S3["3 · Crear<br/>envío"]
  S3 -->|"falla ❌"| C2["Compensar:<br/>reembolsar pago"]
  C2 --> C1["Compensar:<br/>liberar inventario"]
  C1 --> FIN["estado consistente<br/>(como si nada pasó)"]

La diferencia conceptual clave con una transacción ACID: en una saga los estados intermedios SÍ son visibles. Durante un instante, el inventario está reservado y el pago aún no hecho; si algo falla, se compensa, pero hubo un momento en que el mundo estaba "a medias". No es atomicidad de verdad; es consistencia eventual aplicada a un flujo de negocio. Aceptas esa ventana a cambio de no necesitar coordinación global. Y —de nuevo— cada paso y cada compensación debe ser idempotente, porque los reintentos van a repetirlos.

Las compensaciones son la parte difícil, no los pasos felices

Diseñar el camino de éxito de una saga es fácil. Lo difícil —y donde están los bugs— es pensar todas las formas en que cada paso puede fallar y cómo se deshace realmente lo ya hecho. ¿Cómo "descobras" un pago? (un reembolso, que tarda y puede fallar él mismo). ¿Qué pasa si la compensación falla? Una saga mal pensada te deja estados a medias imposibles de arreglar a mano. Si adoptas sagas, el 80% del diseño es el camino de los errores.

11. Observabilidad: por qué deja de ser opcional

En una máquina, cuando algo falla, te conectas y miras: un print, un depurador, los logs de ese proceso. Tienes la foto completa porque solo hay una foto.

En distribuido, una sola petición de usuario puede tocar ocho servicios en cinco máquinas. Cuando falla, ¿dónde miras? El error que ve el usuario ("algo salió mal") apareció en el servicio A, pero la causa real fue un timeout entre F y G que ni siquiera sabías que existían en ese flujo. No hay una foto; hay ocho fotos parciales, cada una en una máquina distinta, y ninguna cuenta la historia entera. Depurar "conectándote a mirar" es imposible: no sabes ni a qué máquina conectarte.

Por eso la observabilidad —poder entender qué está pasando por dentro desde fuera, sin desplegar código nuevo— pasa de ser un lujo a ser infraestructura obligatoria. Se apoya en tres pilares:

  • Logs (registros): qué pasó, con detalle, en cada sitio. Inútiles si están dispersos; imprescindible centralizarlos en un sitio donde puedas buscarlos todos juntos.
  • Métricas: números agregados en el tiempo (peticiones/segundo, latencia, tasa de errores, memoria). Te dicen que algo va mal y dónde mirar, y alimentan las alertas.
  • Trazas (tracing distribuido): el pilar propio de lo distribuido. Se le pega a la petición un identificador único (trace id) que viaja con ella por todos los servicios que toca, de modo que puedes reconstruir el viaje completo y ver que los 30 segundos se fueron en el salto de F a G.
graph LR
  U["Petición<br/>trace-id: abc123"] --> A["Servicio A<br/>abc123 · 5ms"]
  A --> B["Servicio B<br/>abc123 · 12ms"]
  B --> F["Servicio F<br/>abc123 · 8ms"]
  F --> G["Servicio G<br/>abc123 · 29.800ms ⚠️"]
  G --> R["reconstruyes el viaje<br/>→ el culpable es F→G"]

El trace id es el equivalente distribuido del stack trace de una sola máquina: sin él, cada servicio es una caja negra aislada; con él, recuperas la narrativa completa de una petición a través de todo el sistema. En un monolito la observabilidad es conveniente. En distribuido, sin observabilidad estás pilotando a ciegas, y el primer incidente serio te lo demostrará.

Cómo se ve en la práctica

Un caso que junta media lección: un checkout de e-commerce. El usuario pulsa "Pagar" y hay que cobrar, descontar inventario y programar el envío. Tres servicios, tres bases de datos.

La versión ingenua —la que escribe todo el mundo la primera vez— es síncrona, secuencial y optimista:

// ❌ Optimista: asume que la red y los servicios nunca fallan
function checkout($pedido) {
    $this->pagos->cobrar($pedido->total);          // ¿y si da timeout?
    $this->inventario->descontar($pedido->items);  // ¿y si esto falla tras cobrar?
    $this->envios->programar($pedido);             // ¿y si el usuario ya cerró la web?
    return "¡Gracias por tu compra!";
}

Los agujeros son todos de esta lección. Si cobrar da timeout, no sabes si se cobró (sección 3): reintentar a ciegas puede cobrar doble. Si descontar falla después de cobrar, el cliente pagó por algo que no tiene y no hay transacción que abarque las dos bases de datos (sección 10). Y todo el flujo hace esperar al usuario a tres saltos de red en serie (sección 5).

La versión que aguanta producción aplica las herramientas:

// ✅ Robusto: idempotencia + saga + asíncrono para lo que no urge
function checkout($pedido) {
    // Cobro síncrono (el usuario espera saber si su pago pasó),
    // pero IDEMPOTENTE: si reintenta, no cobra dos veces (sección 8)
    $this->pagos->cobrar($pedido->total, idempotencyKey: $pedido->id);

    // El resto va por SAGA con compensación: si algo falla, se deshace (sección 10)
    try {
        $this->inventario->descontar($pedido->items, idempotencyKey: $pedido->id);
    } catch (SinStock $e) {
        $this->pagos->reembolsar($pedido->id);  // compensación
        throw $e;
    }

    // El envío NO urge: va por COLA, asíncrono. No hacemos esperar al usuario (sección 9)
    $this->cola->publicar(new ProgramarEnvio($pedido->id));  // consumidor idempotente

    return "¡Gracias por tu compra!";
}

Y todo el flujo lleva un trace id que permite, cuando un pago tarde 30 segundos, ver exactamente dónde se fueron (sección 11). No es que la segunda versión sea "más profesional" por gusto: es que la primera funciona en tu portátil y en la demo, y falla el primer día de rebajas, cuando la red se pone lenta y los servicios reciben carga. La diferencia entera es haber asumido que las cosas fallan.

Lo que sacrificas / errores comunes

  • Distribuir por moda, no por necesidad. El error número uno. Microservicios "porque es lo moderno" te regala todos los problemas de esta lección sin resolverte ninguno que tuvieras. Un monolito bien estructurado escala más de lo que crees. Distribuye cuando el dolor sea medible, no antes.
  • Tratar una llamada de red como una llamada de función. Asumir que "o devuelve o lanza excepción". La red tiene un tercer estado —el silencio ambiguo— y olvidarlo es la raíz de los cobros dobles y los datos corruptos.
  • Reintentar sin idempotencia. Añadir reintentos "porque la red falla" sin hacer idempotentes las operaciones convierte un fallo transitorio en daño permanente duplicado. Reintentar es correcto; reintentar algo no idempotente es una bomba.
  • Creer que "exactamente una vez" existe en la entrega. Vas a ver colas que lo prometen; leen la letra pequeña. Lo que consigues de verdad es al menos una vez + idempotencia. Diseñar contando con la promesa imposible es diseñar para romperse.
  • Ignorar CAP hasta que muerde. Poner una réplica de lectura "para ir más rápido" y sorprenderse de que los usuarios ven datos viejos. Elegiste consistencia eventual sin saberlo. Está bien elegirla —a menudo es lo correcto— pero a sabiendas.
  • Distribuir sin observabilidad. Montar ocho servicios y depurar con print cuando algo falla. El primer incidente te enseñará, caro, que sin tracing centralizado estás ciego. La observabilidad no es la guinda; es parte del plato.

Resumen

  1. Distribuir = meter la red en medio de tu programa. Se hace por escala y disponibilidad, pero el precio es la red poco fiable y lenta, y casi todo el diseño sale de asumir que las cosas fallan por separado.
  2. El silencio es ambiguo. Un timeout no te dice si la operación se hizo. De ahí nacen los cobros dobles, y la salvación es la idempotencia: reintentar "al menos una vez" + operaciones cuyo efecto no se duplica.
  3. Ante una partición eliges consistencia o disponibilidad (CAP), y por operación. Muchos sistemas eligen consistencia eventual: datos temporalmente desactualizados a cambio de velocidad y disponibilidad.
  4. La latencia rediseña, no solo ralentiza. El número de veces que cruzas la red domina el rendimiento: agrupa, cachea, paraleliza. Y replicas (misma copia, para disponibilidad y lecturas) o particionas (trozos, para escribir y almacenar más).
  5. Ponerse de acuerdo entre nodos (consenso) es difícil porque no distingues caído de lento; se resuelve por quórum (mayoría), casi siempre delegándolo. Y para flujos que cruzan servicios, sagas con compensación en vez de transacciones globales. Sin observabilidad (logs, métricas, trazas), vas a ciegas.

Ejercicios socráticos

No busques la respuesta fuera. Razónala con el modelo de esta lección. Si te atascas, la pista está en el texto.

  1. Llamas a un servicio de pago, salta el timeout y no sabes si cobró. Tu compañero propone "reintentamos automáticamente, así seguro que cobra". ¿Qué le respondes, y qué una sola cosa habría que añadir a la operación para que su idea sea segura? Explica por qué esa cosa lo cambia todo.
  2. Un producto tiene un contador de "unidades vendidas" y un saldo de cuenta bancaria. Para cada uno, ¿elegirías consistencia o disponibilidad ante una partición? Justifica en términos de "qué duele más: negar servicio un momento o servir un dato desactualizado un momento".
  3. Tienes un clúster de consenso de 4 nodos. ¿Por qué es una peor idea que uno de 3 o de 5? Piensa en cuántos fallos tolera cada uno y en qué pasa con las mayorías. (Pista: 4 nodos, ¿cuántos fallos aguanta manteniendo mayoría?)
  4. Distingue replicación de particionado: ¿cuál eliges si tu problema es "no puedo servir tantas lecturas" y cuál si es "no me caben tantos datos ni tantas escrituras en una máquina"? ¿Por qué en la práctica se usan juntos?
  5. Una saga de checkout cobra, descuenta inventario y programa envío. Falla el paso de inventario. Describe qué compensaciones se ejecutan y en qué orden. Ahora la parte difícil: ¿qué pasa si la propia compensación (el reembolso) falla? ¿Por qué dijimos que las compensaciones, no el camino feliz, son lo difícil?

Repaso espaciado

Pasa estas a Anki (repasos.md). Formato: pregunta que obligue a razonar, no a reconocer.

  • [ ] ¿Por qué un timeout no te dice si la operación se completó? Enumera los estados que "silencio" no distingue y explica qué patrón (dos piezas) los neutraliza.
  • [ ] Enuncia el teorema CAP con tus palabras, sin fórmulas, y da un ejemplo de operación que quiera C y otra que quiera A.
  • [ ] Diferencia replicación de particionado: qué escala cada uno y qué problema nuevo introduce cada uno.
  • [ ] ¿Qué es un quórum y por qué el solapamiento de mayorías impide el split-brain? ¿Por qué los clústeres van en número impar?
  • [ ] Explica las tres semánticas de entrega (at-most/at-least/exactly-once), por qué exactly-once es imposible en la entrega, y cómo la idempotencia consigue su efecto de todos modos.

Para seguir tirando del hilo

  • Designing Data-Intensive Applications (Martin Kleppmann) — el libro de referencia moderno sobre todo esto (replicación, particionado, consenso, transacciones), con rigor y sin humo. Si lees uno, este.
  • Fallacies of Distributed Computing Explained (Rotem-Gal-Oz) — el ensayo corto que desmenuza las ocho falacias con ejemplos.
  • Charla "Distributed Systems" de Tim Berglund (o el curso homónimo) — buena introducción visual a consenso y replicación si prefieres verlo antes que leerlo.
  • Experimento: monta dos servicios pequeños con una cola en medio (RabbitMQ o SQS local), fuerza que el consumidor falle a la mitad, y observa cómo el mismo mensaje llega dos veces. Luego hazlo idempotente y velo dejar de doler. Verlo con tus ojos asienta la sección 8 más que releerla.