La lección anterior terminó con la pregunta más incómoda del módulo. En el monolito de 01-06, crear un pedido era una transacción: BEGIN, insertar el pedido, descontar el stock, registrar el cobro, COMMIT; si cualquier paso fallaba, ROLLBACK y como si nada hubiera pasado. Hoy esa misma operación cruza tres servicios con tres bases de datos, km0_pedidos, km0_inventario y km0_pagos, cada una replicada como vimos en 03-04, y ninguna transacción de PostgreSQL abarca las tres. Si el cobro de Marc es rechazado después de haber descontado el último queso-curado, alguien tiene que devolverlo al stock; si pedidos muere entre el descuento y el cobro, el pedido de Marc queda en un limbo que nadie ha diseñado.
Esta lección presenta las dos familias de respuesta. La primera intenta conservar la atomicidad del monolito con un protocolo de commit en dos fases (2PC), que PostgreSQL soporta con PREPARE TRANSACTION; veremos cómo funciona, por qué bloquea cuando el coordinador cae y por qué los microservicios lo evitan. La segunda renuncia a la atomicidad y la sustituye por una saga: una secuencia de transacciones locales, cada una con su compensación, coordinada por eventos (coreografía) o por un orquestador con estado persistido. Implementaremos el orquestador de pedidos en Python, con su tabla sagas en km0_pedidos, y lo veremos compensar el stock cuando pagos rechaza a Marc; después escribiremos la misma saga como coreografía con consumidores Kafka y compararemos ambas. Terminaremos con lo que las sagas pierden respecto a ACID (el aislamiento) y las contramedidas, y con el patrón TCC. La conclusión cierra el Módulo 3 y abre el Módulo 4, dedicado a dónde y cómo se almacenan físicamente los datos que ya sabemos replicar y coordinar.
Contenido
- ACID y la transacción que ya no existe
- Commit en dos fases (2PC)
- Por qué 2PC bloquea, y 3PC
- 2PC real: XA y
PREPARE TRANSACTIONen PostgreSQL - Por qué los microservicios evitan 2PC
- Sagas: transacciones locales con compensaciones
- Coreografía: la saga como cadena de eventos
- Orquestación:
saga_pedido.pycon estado persistido - Tabla coreografía vs orquestación
- Compensaciones, idempotencia y el aislamiento perdido; TCC
- Errores comunes y consejos
- Ejercicios
- Conclusión
- ACID y la transacción que ya no existe
Recordemos qué prometía la transacción del monolito, porque cada letra de ACID va a tener un destino distinto en el sistema distribuido:
| Propiedad | Qué garantiza | Qué le pasa al distribuir |
|---|---|---|
| Atomicidad | Todos los pasos o ninguno | Es lo que 2PC intenta conservar y lo que las sagas sustituyen por compensaciones |
| Consistencia (de ACID) | Se respetan los invariantes del esquema (claves, restricciones) | Sigue siendo local a cada base de datos; los invariantes entre servicios ("no hay pedido pagado sin stock reservado") pasan a ser responsabilidad de la aplicación |
| Aislamiento | Las transacciones concurrentes no se ven a medias | Se pierde entre servicios: otros ven los pasos intermedios de la saga (apartado 10) |
| Durabilidad | Lo confirmado sobrevive a fallos | La da cada base de datos por separado (con la replicación de 03-04) |
La transacción original, en km0/sql/monolito.sql de 01-06, era esencialmente esta:
BEGIN;
INSERT INTO pedidos (id, cliente, estado, total) VALUES ('P-2026-000124', 'Marc', 'confirmado', 24.90);
UPDATE stock SET unidades = unidades - 1 WHERE producto = 'queso-curado' AND unidades >= 1;
INSERT INTO cobros (pedido_id, importe, estado) VALUES ('P-2026-000124', 24.90, 'cobrado');
COMMIT;Si el UPDATE no afectaba a ninguna fila (sin stock) o la pasarela de pago fallaba, un ROLLBACK deshacía todo. Hoy las tres sentencias viven en tres servicios, y la pregunta es qué sustituye al COMMIT.
- Commit en dos fases (2PC)
El commit en dos fases (Gray, 1978) es el protocolo clásico de commit atómico: hacer que varios participantes, cada uno con su propia transacción local, confirmen todos o aborten todos. Sus roles:
- Coordinador: el proceso que dirige el protocolo (en nuestro caso lo sería
pedidos, o un gestor de transacciones externo). - Participantes: las bases de datos o servicios con una transacción local abierta (
km0_pedidos,km0_inventario,km0_pagos).
sequenceDiagram
participant C as Coordinador (pedidos)
participant P as km0_pedidos
participant I as km0_inventario
participant G as km0_pagos
Note over C,G: Fase 0: trabajo (transacciones locales abiertas)
C->>P: INSERT pedido
C->>I: UPDATE stock
C->>G: INSERT cobro
Note over C,G: Fase 1: PREPARE (votación)
C->>P: prepare
C->>I: prepare
C->>G: prepare
P-->>C: sí (escrito en disco, bloqueos retenidos)
I-->>C: sí
G-->>C: sí
Note over C: Decisión: COMMIT, escrita en el log del coordinador
Note over C,G: Fase 2: COMMIT
C->>P: commit
C->>I: commit
C->>G: commit
P-->>C: hecho
I-->>C: hecho
G-->>C: hecho
Fase 1, prepare. El coordinador pregunta a cada participante "¿puedes confirmar?". Un participante que responde sí se compromete de forma irrevocable: escribe la transacción en disco de manera que pueda confirmarla aunque se reinicie, y mantiene sus bloqueos hasta que llegue la decisión. Un participante puede responder no (violación de restricción, sin stock, tarjeta rechazada), y entonces la decisión será abortar.
Fase 2, commit o abort. Si todos han dicho sí, el coordinador escribe la decisión en su propio log (este es el punto de no retorno: a partir de aquí la transacción está confirmada aunque nadie lo sepa aún) y envía commit a todos; si alguno dijo no, envía abort. Los participantes ejecutan la decisión y responden; el coordinador puede reintentar la fase 2 tantas veces como haga falta, porque un participante en estado "preparado" siempre puede obedecer.
La corrección del protocolo se apoya en dos promesas: el participante preparado nunca decide por su cuenta, y el coordinador nunca olvida una decisión tomada. Ambas exigen escrituras en disco (fsync) en el momento adecuado, y por eso 2PC cuesta al menos dos viajes de red y dos fsync por participante, además del trabajo en sí.
- Por qué 2PC bloquea, y 3PC
El punto débil está en la palabra "nunca" de la primera promesa. Imagina que los tres participantes han respondido sí y el coordinador muere justo después, antes de enviar la decisión (o incluso antes de escribirla). Los participantes están en estado preparado: con los bloqueos retenidos (la fila de stock de queso-curado bloqueada, el pedido de Marc invisible para las lecturas que necesitan el bloqueo) y sin poder decidir nada:
- No pueden abortar, porque quizá el coordinador había decidido commit y ya se lo había dicho a otro participante.
- No pueden confirmar, porque quizá otro participante dijo no.
- Preguntar a los otros participantes no siempre ayuda: si todos están preparados, ninguno sabe la decisión.
Solo pueden esperar a que el coordinador vuelva y lea su log. Si el coordinador tarda una hora, esa fila de stock está bloqueada una hora; es lo que se llama el bloqueo (blocking) de 2PC, y es un problema de disponibilidad en el sentido de 03-02: el sistema sacrifica A (participantes vivos que no pueden avanzar) para preservar la atomicidad. En la práctica, los operadores acaban resolviendo transacciones preparadas a mano (decidiendo commit o abort por inspección), lo que se llama heurística, con el riesgo evidente de decidir distinto de lo que decidió el coordinador.
El commit en tres fases (3PC, Skeen, 1981) añade una fase intermedia (pre-commit) para que los participantes puedan deducir la decisión sin el coordinador, y elimina el bloqueo... a costa de asumir una red síncrona con retrasos acotados y sin particiones, precisamente lo que 01-02 y 03-02 nos dijeron que no tenemos. Con una partición, 3PC puede llevar a un lado a confirmar y a otro a abortar. Por eso casi nadie lo usa, y por eso la solución moderna al bloqueo de 2PC es hacer al coordinador tolerante a fallos con consenso (03-03): Spanner, por ejemplo, ejecuta 2PC entre grupos Paxos, de modo que el "coordinador" es un grupo replicado que no muere. Es correcto y es carísimo.
- 2PC real: XA y
PREPARE TRANSACTION en PostgreSQL
PREPARE TRANSACTION en PostgreSQLEl estándar XA (X/Open, años 90) define la interfaz entre un gestor de transacciones y los recursos participantes (bases de datos, colas); Java lo expone como JTA, y los servidores de aplicaciones clásicos lo usaban para transacciones que abarcaban una base de datos y una cola JMS. PostgreSQL implementa el lado del participante con tres sentencias: PREPARE TRANSACTION 'id' (fase 1: la transacción actual queda preparada y sobrevive a reinicios), COMMIT PREPARED 'id' y ROLLBACK PREPARED 'id' (fase 2). Requiere max_prepared_transactions > 0 en postgresql.conf (por defecto es 0, deliberadamente). Un coordinador mínimo en Python entre km0_inventario y km0_pedidos:
# km0/simulaciones/dos_fases_pg.py
import psycopg # pip install "psycopg[binary]"
DSN_PEDIDOS = "host=localhost port=5432 dbname=km0_pedidos user=km0 password=km0"
DSN_INVENTARIO = "host=localhost port=5434 dbname=km0_inventario user=km0 password=km0"
ID_TX = "pedido-P-2026-000124" # identificador global de la transacción
def dos_fases(pedido_id: str, cliente: str, producto: str, total: float) -> None:
ped = psycopg.connect(DSN_PEDIDOS, autocommit=True)
inv = psycopg.connect(DSN_INVENTARIO, autocommit=True)
participantes = [ped, inv]
try:
# Fase 0: trabajo en transacciones locales abiertas
ped.execute("BEGIN")
ped.execute("INSERT INTO pedidos (id, cliente, estado, total) VALUES (%s, %s, 'confirmado', %s)",
(pedido_id, cliente, total))
inv.execute("BEGIN")
afectadas = inv.execute("UPDATE stock SET unidades = unidades - 1 WHERE producto = %s AND unidades >= 1",
(producto,)).rowcount
if afectadas == 0:
raise RuntimeError(f"sin stock de {producto}")
# Fase 1: prepare (cada participante vota; si falla, lanza excepción = voto "no")
for conn in participantes:
conn.execute(f"PREPARE TRANSACTION '{ID_TX}'")
print("fase 1: todos preparados; la decisión es COMMIT")
# >>> Si el coordinador muere AQUÍ, ambas bases de datos quedan preparadas y bloqueadas <<<
# Fase 2: commit
for conn in participantes:
conn.execute(f"COMMIT PREPARED '{ID_TX}'")
print("fase 2: confirmado en ambas")
except Exception as e:
print("abortando:", e)
for conn in participantes:
try:
conn.execute(f"ROLLBACK PREPARED '{ID_TX}'") # si llegó a prepararse
except psycopg.Error:
conn.execute("ROLLBACK") # si no llegó
finally:
ped.close(); inv.close()
if __name__ == "__main__":
dos_fases("P-2026-000124", "Marc", "queso-curado", 24.90)Para ver el bloqueo con tus propios ojos, mata el proceso (o pon un sys.exit()) justo después de la fase 1 y consulta en cualquiera de las bases de datos:
gid | prepared | owner | database ------------------------+-------------------------------+-------+--------------- pedido-P-2026-000124 | 2026-09-14 10:41:07.113+02 | km0 | km0_inventario
La transacción preparada sobrevive incluso a un reinicio del servidor, y mientras exista, la fila de queso-curado está bloqueada: cualquier otro UPDATE sobre ella espera indefinidamente. Solo un COMMIT PREPARED o ROLLBACK PREPARED explícito la libera, y decidir cuál es la resolución heurística del apartado 3. (Lo mismo que PREPARE TRANSACTION existe en MySQL con XA PREPARE, y en colas como ActiveMQ o IBM MQ; Kafka no participa en XA, y RabbitMQ tampoco, lo que ya limita mucho su uso con nuestra arquitectura.)
- Por qué los microservicios evitan 2PC
Con 2PC funcionando en dos líneas de SQL, la pregunta natural es por qué no usarlo para el pedido de Marc. Las razones son todas prácticas:
- Acoplamiento: el coordinador necesita acceso transaccional directo a las bases de datos de
inventarioypagos, lo que rompe la regla "cada servicio es dueño de sus datos" de 01-06. La alternativa, que cada servicio exponga operacionesprepare/commit/abortpor gRPC, es posible pero convierte a cada servicio en un gestor de recursos XA, con sus logs, su recuperación y sus transacciones huérfanas. - Disponibilidad: el bloqueo del apartado 3 significa que la caída de
pedidos(el coordinador) deja bloqueadas filas eninventarioypagos. Un fallo en un servicio se convierte en un fallo en tres: lo contrario del aislamiento de fallos que buscábamos al separar los servicios (01-03). - Latencia y rendimiento: dos fases con
fsyncen cada participante, con los bloqueos retenidos durante todo el intercambio, limitan el número de pedidos por segundo a lo que tolere el participante más lento. - Heterogeneidad:
pagoshabla con una pasarela externa (02-05) que no participa en ningún 2PC: no se puede "preparar" un cobro con tarjeta. Kafka y Redis tampoco son participantes XA. En cuanto un paso no es una base de datos relacional, 2PC ya no cubre la operación completa. - Operación: las transacciones preparadas huérfanas requieren intervención manual, y los equipos que han operado XA en producción suelen describirlo como la fuente más frecuente de incidentes nocturnos.
2PC sigue siendo la herramienta correcta dentro de un sistema que lo controla todo (una base de datos distribuida como Spanner o CockroachDB lo usa entre sus propios nodos, con coordinador replicado por consenso), pero entre servicios independientes la industria ha convergido en la alternativa que renuncia a la atomicidad: la saga.
- Sagas: transacciones locales con compensaciones
El patrón saga (García-Molina y Salem, 1987, para transacciones de larga duración en una sola base de datos) se reformuló para microservicios hace una década. Una saga es una secuencia de transacciones locales T1, T2, ..., Tn, cada una en un servicio y confirmada por separado, tal que:
- Si todas tienen éxito, la operación de negocio está completa.
- Si Ti falla, se ejecutan las transacciones compensatorias C(i-1), ..., C1 de las que ya se habían confirmado, en orden inverso, dejando el sistema en un estado semánticamente equivalente al inicial (no idéntico: el pedido cancelado sigue existiendo como registro, el cobro reembolsado aparece en el extracto de Marc).
Para el pedido de Kilómetro Cero:
| Paso | Transacción local | Servicio | Compensación |
|---|---|---|---|
| T1 | Crear pedido en estado pendiente |
pedidos |
C1: marcar pedido cancelado |
| T2 | Reservar stock (ReservarStock de 02-03, con id_reserva) |
inventario |
C2: liberar la reserva |
| T3 | Cobrar (con Idempotency-Key de 02-05) |
pagos |
C3: reembolsar |
| T4 | Marcar pedido confirmado |
pedidos |
(ninguna: es el último paso) |
Los pasos se clasifican en tres tipos, y el orden en que se colocan importa:
- Compensables: tienen compensación (T1, T2). Van al principio.
- Pivote: el paso que decide si la saga tendrá éxito; una vez confirmado, la saga no puede abortar (T3, el cobro: si se cobra, el pedido sale). Suele ser el paso con más probabilidad de fallar o el que no puede compensarse limpiamente, y se coloca lo más tarde posible.
- Reintentables: después del pivote, pasos que no pueden fallar de forma permanente, solo transitoria, y se reintentan hasta que funcionen (T4).
sequenceDiagram
participant Ped as pedidos
participant Inv as inventario
participant Pag as pagos
Note over Ped,Pag: Saga feliz: el pedido de Ana
Ped->>Ped: T1 crear P-2026-000123 (pendiente)
Ped->>Inv: T2 ReservarStock(queso-curado, id_reserva)
Inv-->>Ped: reservado
Ped->>Pag: T3 cobrar(24,90 €, Idempotency-Key)
Pag-->>Ped: cobrado
Ped->>Ped: T4 marcar confirmado
sequenceDiagram
participant Ped as pedidos
participant Inv as inventario
participant Pag as pagos
Note over Ped,Pag: Saga con compensación: el pedido de Marc
Ped->>Ped: T1 crear P-2026-000124 (pendiente)
Ped->>Inv: T2 ReservarStock(queso-curado, id_reserva)
Inv-->>Ped: reservado
Ped->>Pag: T3 cobrar(24,90 €)
Pag-->>Ped: RECHAZADO (tarjeta)
Ped->>Inv: C2 LiberarReserva(id_reserva)
Inv-->>Ped: liberada
Ped->>Ped: C1 marcar cancelado (motivo: pago rechazado)
Hay dos formas de coordinar quién ejecuta cada paso y cada compensación, y son el objeto de los dos apartados siguientes.
- Coreografía: la saga como cadena de eventos
En una saga coreografiada no hay coordinador: cada servicio reacciona a los eventos de los demás y publica los suyos, por el tópico pedidos.eventos de 02-04 y 02-05 (o tópicos por servicio). La cadena de eventos del pedido:
flowchart LR
A[pedidos:<br/>pedido.creado] --> B[inventario:<br/>stock.reservado]
A --> B2[inventario:<br/>stock.insuficiente]
B --> C[pagos:<br/>pago.confirmado]
B --> C2[pagos:<br/>pago.rechazado]
C --> D[pedidos:<br/>pedido.confirmado]
C2 --> E[inventario:<br/>stock.liberado]
B2 --> F[pedidos:<br/>pedido.cancelado]
E --> F
Cada servicio implementa su parte como un consumidor idempotente de 02-05 que, en la misma transacción, aplica el efecto local y escribe el evento siguiente en su outbox. El fragmento de inventario, que reacciona a pedido.creado y a pago.rechazado:
# km0/servicios/inventario/saga_consumidor.py
import json
import uuid
import psycopg
from confluent_kafka import Consumer
DSN = "host=inventario-db dbname=km0_inventario user=km0 password=km0"
consumidor = Consumer({"bootstrap.servers": "kafka:9092", "group.id": "inventario-saga",
"enable.auto.commit": False, "auto.offset.reset": "earliest"})
consumidor.subscribe(["pedidos.eventos", "pagos.eventos"])
def publicar(cur, tipo: str, pedido_id: str, datos: dict) -> None:
"""Escribe en la outbox (02-05); el relay lo publicará en inventario.eventos."""
cur.execute("INSERT INTO outbox (id, agregado, agregado_id, tipo, carga) VALUES (%s, 'pedido', %s, %s, %s)",
(uuid.uuid4(), pedido_id, tipo, json.dumps({"pedido_id": pedido_id, **datos})))
def procesar(conn: psycopg.Connection, evento: dict) -> None:
pedido_id = evento["datos"]["pedido_id"]
with conn.transaction():
cur = conn.cursor()
cur.execute("INSERT INTO mensajes_procesados (id_mensaje, consumidor) VALUES (%s, 'inventario.saga') "
"ON CONFLICT DO NOTHING", (evento["id_evento"],))
if cur.rowcount == 0:
return # duplicado: ya procesado
if evento["tipo"] == "pedido.creado": # T2: reservar
ok = True
for linea in evento["datos"]["lineas"]:
cur.execute("UPDATE stock SET unidades = unidades - %s WHERE producto = %s AND unidades >= %s",
(linea["cantidad"], linea["producto"], linea["cantidad"]))
ok = ok and cur.rowcount == 1
if not ok:
raise psycopg.Rollback # deshace los UPDATE parciales...
cur.execute("INSERT INTO reservas (pedido_id, lineas) VALUES (%s, %s)",
(pedido_id, json.dumps(evento["datos"]["lineas"])))
publicar(cur, "stock.reservado", pedido_id, {"lineas": evento["datos"]["lineas"]})
elif evento["tipo"] == "pago.rechazado": # C2: liberar
cur.execute("DELETE FROM reservas WHERE pedido_id = %s RETURNING lineas", (pedido_id,))
fila = cur.fetchone()
if fila: # si no hay reserva, ya se liberó
for linea in json.loads(fila[0]) if isinstance(fila[0], str) else fila[0]:
cur.execute("UPDATE stock SET unidades = unidades + %s WHERE producto = %s",
(linea["cantidad"], linea["producto"]))
publicar(cur, "stock.liberado", pedido_id, {"motivo": "pago rechazado"})
def procesar_sin_stock(conn: psycopg.Connection, evento: dict) -> None:
"""Rama de fallo de T2: se publica en una transacción aparte, tras el rollback."""
with conn.transaction():
cur = conn.cursor()
cur.execute("INSERT INTO mensajes_procesados (id_mensaje, consumidor) VALUES (%s, 'inventario.saga') "
"ON CONFLICT DO NOTHING", (evento["id_evento"],))
publicar(cur, "stock.insuficiente", evento["datos"]["pedido_id"], {})
with psycopg.connect(DSN) as conn:
while True:
msg = consumidor.poll(1.0)
if msg is None or msg.error():
continue
evento = json.loads(msg.value())
try:
procesar(conn, evento)
except psycopg.Rollback:
procesar_sin_stock(conn, evento)
consumidor.commit(message=msg)(psycopg.Rollback es la excepción que conn.transaction() captura para deshacer el bloque sin propagarla; aquí la reutilizamos como señal de "sin stock". En el mismo estilo, pagos consume stock.reservado, cobra con la Idempotency-Key de 02-05 y publica pago.confirmado o pago.rechazado; y pedidos consume pago.confirmado para T4 y stock.insuficiente/stock.liberado para C1.)
La coreografía es atractiva por su simplicidad aparente: no hay ningún componente nuevo, solo consumidores. Sus problemas aparecen al crecer: para saber en qué estado está el pedido de Marc hay que reconstruirlo a partir de eventos repartidos por tres tópicos; añadir un paso (por ejemplo, "asignar repartidor" entre el cobro y la confirmación) obliga a tocar varios servicios; y las dependencias cíclicas (pedidos escucha a pagos, que escucha a inventario, que escucha a pedidos) hacen difícil razonar sobre el flujo completo. Con tres pasos es manejable; con ocho, no.
- Orquestación:
saga_pedido.py con estado persistido
saga_pedido.py con estado persistidoEn una saga orquestada, un componente, el orquestador, sabe cuál es la secuencia, invoca cada paso (por gRPC síncrono o publicando comandos), interpreta la respuesta y decide el siguiente paso o la compensación. El orquestador es una máquina de estados cuyo estado se persiste tras cada transición, de modo que si el proceso muere, otra instancia (o la misma al reiniciar) la retoma donde estaba. En Kilómetro Cero vive en pedidos, porque es el servicio dueño del concepto "pedido" y quien inicia la operación, con esta tabla en km0_pedidos:
-- km0/sql/pedidos/sagas.sql
CREATE TABLE sagas (
id UUID PRIMARY KEY,
tipo TEXT NOT NULL, -- 'pedido'
pedido_id TEXT NOT NULL UNIQUE,
estado TEXT NOT NULL, -- INICIADA, STOCK_RESERVADO, PAGO_CONFIRMADO, COMPLETADA,
-- COMPENSANDO, CANCELADA
paso_actual INTEGER NOT NULL DEFAULT 0, -- último paso confirmado con éxito
datos JSONB NOT NULL, -- lo que necesitan los pasos y compensaciones
historial JSONB NOT NULL DEFAULT '[]',
creada_en TIMESTAMPTZ NOT NULL DEFAULT now(),
actualizada_en TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX sagas_en_curso ON sagas (actualizada_en) WHERE estado NOT IN ('COMPLETADA', 'CANCELADA');El orquestador define los pasos como pares (acción, compensación), avanza persistiendo, y compensa hacia atrás si un paso falla de forma permanente. Para que el ejemplo sea ejecutable sin infraestructura, los clientes de inventario y pagos y el repositorio de sagas tienen versiones simuladas; las reales serían el stub gRPC de 02-03, el cliente de la pasarela de 02-05 y psycopg sobre la tabla anterior.
# km0/servicios/pedidos/saga_pedido.py
import json
import uuid
from dataclasses import dataclass, field
from typing import Callable, Protocol
class FalloPermanente(Exception):
"""El paso no tendrá éxito aunque se reintente: hay que compensar."""
class FalloTransitorio(Exception):
"""El paso puede tener éxito si se reintenta (timeout, 503, deadline gRPC)."""
# --- Estado persistido -------------------------------------------------------
@dataclass
class Saga:
id: str
pedido_id: str
estado: str = "INICIADA"
paso_actual: int = 0
datos: dict = field(default_factory=dict)
historial: list = field(default_factory=list)
class Repositorio(Protocol):
def guardar(self, saga: Saga) -> None: ...
def cargar(self, saga_id: str) -> Saga: ...
class RepositorioMemoria:
"""Para la simulación. La versión real hace UPDATE sagas SET ... WHERE id = %s con psycopg."""
def __init__(self) -> None:
self.filas: dict[str, str] = {}
def guardar(self, saga: Saga) -> None:
self.filas[saga.id] = json.dumps(saga.__dict__) # como haría JSONB
def cargar(self, saga_id: str) -> Saga:
return Saga(**json.loads(self.filas[saga_id]))
# --- Pasos -------------------------------------------------------------------
@dataclass
class Paso:
nombre: str
estado_tras_exito: str
accion: Callable[[Saga], None]
compensacion: Callable[[Saga], None] | None # None = paso pivote o posterior
class OrquestadorSagaPedido:
def __init__(self, repo: Repositorio, inventario, pagos, pedidos_db, max_reintentos: int = 3):
self.repo, self.max_reintentos = repo, max_reintentos
self.pasos = [
Paso("reservar_stock", "STOCK_RESERVADO",
accion=lambda s: inventario.reservar(s.datos["id_reserva"], s.pedido_id, s.datos["lineas"]),
compensacion=lambda s: inventario.liberar(s.datos["id_reserva"])),
Paso("cobrar", "PAGO_CONFIRMADO",
accion=lambda s: pagos.cobrar(s.datos["clave_idempotencia"], s.datos["cliente"], s.datos["total"]),
compensacion=lambda s: pagos.reembolsar(s.datos["clave_idempotencia"])),
Paso("confirmar_pedido", "COMPLETADA",
accion=lambda s: pedidos_db.cambiar_estado(s.pedido_id, "confirmado"),
compensacion=None),
]
self.pedidos_db = pedidos_db
def _transicion(self, saga: Saga, estado: str, nota: str) -> None:
saga.estado = estado
saga.historial.append(f"{estado}: {nota}")
self.repo.guardar(saga) # persistir ANTES de seguir
print(f" [saga {saga.pedido_id}] -> {estado} ({nota})")
def iniciar(self, pedido_id: str, cliente: str, lineas: list[dict], total: float) -> Saga:
saga = Saga(id=str(uuid.uuid4()), pedido_id=pedido_id,
datos={"cliente": cliente, "lineas": lineas, "total": total,
"id_reserva": f"res-{pedido_id}", "clave_idempotencia": f"cobro-{pedido_id}"})
self.pedidos_db.crear(pedido_id, cliente, total) # T1, en la MISMA transacción que la saga
self._transicion(saga, "INICIADA", "pedido creado en estado pendiente")
return self.continuar(saga.id)
def continuar(self, saga_id: str) -> Saga:
"""Avanza (o compensa) desde el estado persistido. Reentrante: se puede llamar tras un reinicio."""
saga = self.repo.cargar(saga_id)
if saga.estado == "COMPENSANDO":
return self._compensar(saga)
while saga.paso_actual < len(self.pasos):
paso = self.pasos[saga.paso_actual]
for intento in range(1, self.max_reintentos + 1):
try:
paso.accion(saga)
break
except FalloTransitorio as e:
print(f" [saga {saga.pedido_id}] {paso.nombre}: fallo transitorio ({e}), intento {intento}")
if intento == self.max_reintentos and paso.compensacion is None:
return saga # paso reintentable: dejarlo para un proceso periódico
except FalloPermanente as e:
self._transicion(saga, "COMPENSANDO", f"{paso.nombre} falló: {e}")
return self._compensar(saga)
else:
self._transicion(saga, "COMPENSANDO", f"{paso.nombre} agotó los reintentos")
return self._compensar(saga)
saga.paso_actual += 1
self._transicion(saga, paso.estado_tras_exito, f"{paso.nombre} ok")
return saga
def _compensar(self, saga: Saga) -> Saga:
while saga.paso_actual > 0:
paso = self.pasos[saga.paso_actual - 1]
if paso.compensacion is not None:
paso.compensacion(saga) # debe ser idempotente
print(f" [saga {saga.pedido_id}] compensado {paso.nombre}")
saga.paso_actual -= 1
self.repo.guardar(saga)
self.pedidos_db.cambiar_estado(saga.pedido_id, "cancelado") # C1
self._transicion(saga, "CANCELADA", "todas las compensaciones aplicadas")
return saga
# --- Servicios simulados -------------------------------------------------------
class InventarioSimulado:
def __init__(self) -> None:
self.stock = {"queso-curado": 1, "vino-crianza": 12}
self.reservas: dict[str, list[dict]] = {}
def reservar(self, id_reserva: str, pedido_id: str, lineas: list[dict]) -> None:
if id_reserva in self.reservas:
return # idempotente (02-03)
for l in lineas:
if self.stock[l["producto"]] < l["cantidad"]:
raise FalloPermanente(f"sin stock de {l['producto']}")
for l in lineas:
self.stock[l["producto"]] -= l["cantidad"]
self.reservas[id_reserva] = lineas
print(f" inventario: reservado {lineas} -> stock {self.stock}")
def liberar(self, id_reserva: str) -> None:
lineas = self.reservas.pop(id_reserva, None) # idempotente: si no existe, nada
if lineas:
for l in lineas:
self.stock[l["producto"]] += l["cantidad"]
print(f" inventario: liberada {id_reserva} -> stock {self.stock}")
class PagosSimulado:
def __init__(self) -> None:
self.cobros: dict[str, float] = {}
self.tarjetas_rechazadas = {"Marc"}
def cobrar(self, clave: str, cliente: str, importe: float) -> None:
if clave in self.cobros:
return # idempotente (02-05)
if cliente in self.tarjetas_rechazadas:
raise FalloPermanente("tarjeta rechazada por el emisor")
self.cobros[clave] = importe
print(f" pagos: cobrados {importe:.2f} EUR a {cliente}")
def reembolsar(self, clave: str) -> None:
if clave in self.cobros:
print(f" pagos: reembolsados {self.cobros.pop(clave):.2f} EUR")
class PedidosDbSimulada:
def __init__(self) -> None:
self.pedidos: dict[str, dict] = {}
def crear(self, pedido_id: str, cliente: str, total: float) -> None:
self.pedidos[pedido_id] = {"cliente": cliente, "total": total, "estado": "pendiente"}
def cambiar_estado(self, pedido_id: str, estado: str) -> None:
self.pedidos[pedido_id]["estado"] = estado
if __name__ == "__main__":
inventario, pagos, pedidos_db = InventarioSimulado(), PagosSimulado(), PedidosDbSimulada()
orq = OrquestadorSagaPedido(RepositorioMemoria(), inventario, pagos, pedidos_db)
print("Saga 1: Ana compra el último queso curado")
orq.iniciar("P-2026-000123", "Ana", [{"producto": "queso-curado", "cantidad": 1}], 24.90)
print("\nSaga 2: Marc intenta comprar queso curado (ya no hay)")
orq.iniciar("P-2026-000124", "Marc", [{"producto": "queso-curado", "cantidad": 1}], 24.90)
print("\nSaga 3: Marc compra vino crianza, pero su tarjeta es rechazada")
orq.iniciar("P-2026-000125", "Marc", [{"producto": "vino-crianza", "cantidad": 2}], 31.80)
print("\nEstado final de los pedidos:")
for pid, p in pedidos_db.pedidos.items():
print(f" {pid}: {p['estado']}")
print("stock final:", inventario.stock, "| cobros:", pagos.cobros)Salida:
Saga 1: Ana compra el último queso curado
[saga P-2026-000123] -> INICIADA (pedido creado en estado pendiente)
inventario: reservado [{'producto': 'queso-curado', 'cantidad': 1}] -> stock {'queso-curado': 0, 'vino-crianza': 12}
[saga P-2026-000123] -> STOCK_RESERVADO (reservar_stock ok)
pagos: cobrados 24.90 EUR a Ana
[saga P-2026-000123] -> PAGO_CONFIRMADO (cobrar ok)
[saga P-2026-000123] -> COMPLETADA (confirmar_pedido ok)
Saga 2: Marc intenta comprar queso curado (ya no hay)
[saga P-2026-000124] -> INICIADA (pedido creado en estado pendiente)
[saga P-2026-000124] -> COMPENSANDO (reservar_stock falló: sin stock de queso-curado)
[saga P-2026-000124] -> CANCELADA (todas las compensaciones aplicadas)
Saga 3: Marc compra vino crianza, pero su tarjeta es rechazada
[saga P-2026-000125] -> INICIADA (pedido creado en estado pendiente)
inventario: reservado [{'producto': 'vino-crianza', 'cantidad': 2}] -> stock {'queso-curado': 0, 'vino-crianza': 10}
[saga P-2026-000125] -> STOCK_RESERVADO (reservar_stock ok)
[saga P-2026-000125] -> COMPENSANDO (cobrar falló: tarjeta rechazada por el emisor)
inventario: liberada res-P-2026-000125 -> stock {'queso-curado': 0, 'vino-crianza': 12}
[saga P-2026-000125] compensado reservar_stock
[saga P-2026-000125] -> CANCELADA (todas las compensaciones aplicadas)
Estado final de los pedidos:
P-2026-000123: confirmado
P-2026-000124: cancelado
P-2026-000125: cancelado
stock final: {'queso-curado': 0, 'vino-crianza': 12} | cobros: {'cobro-P-2026-000123': 24.9}Los puntos importantes del código, para quien lo lea por primera vez:
- Persistir antes de seguir.
_transicionguarda la saga en cada cambio de estado, ypaso_actualse incrementa solo tras el éxito del paso. Si el proceso muere entrepaso.accion(saga)y_transicion, al reiniciarcontinuar(saga_id)volverá a ejecutar el mismo paso, y por eso cada acción debe ser idempotente:reservarcon el mismoid_reservano descuenta dos veces (02-03),cobrarcon la mismaclave_idempotenciano cobra dos veces (02-05). La saga hereda directamente el trabajo del Módulo 2. - Los identificadores se deciden al inicio.
id_reservayclave_idempotenciase derivan delpedido_idy se guardan endatosen la primera transición, para que un reintento tras un reinicio use exactamente los mismos. - Fallo permanente frente a transitorio. Un
FalloPermanente(sin stock, tarjeta rechazada) dispara la compensación; unFalloTransitorio(deadline gRPC, 503 de la pasarela) se reintenta, y si es en un paso posterior al pivote se deja para un proceso periódico que llame acontinuarsobre las sagas en curso (el índicesagas_en_cursoexiste para eso). - Las compensaciones también son idempotentes (
liberarde una reserva inexistente no hace nada) y se ejecutan en orden inverso, sin tocar los pasos que no llegaron a ejecutarse (en la saga 2, no hay nada que liberar). - T1 y la saga en la misma transacción. En la versión real,
pedidos_db.creary el primerguardarde la saga van en una única transacción dekm0_pedidos(y, si la saga se conduce por eventos, el primer comando va a laoutboxen esa misma transacción). Así no puede existir un pedido pendiente sin saga ni una saga sin pedido.
En las tres sagas simuladas, el resultado es el que el monolito habría dado con ROLLBACK: Ana tiene su queso y su cobro; Marc no tiene ni queso ni vino, no se le ha cobrado nada, y el stock de vino ha vuelto a 12. La diferencia es que ha ocurrido en pasos separados, visibles desde fuera, y que los pedidos cancelados existen como registro.
- Tabla coreografía vs orquestación
| Aspecto | Coreografía | Orquestación |
|---|---|---|
| Quién sabe la secuencia | Nadie en concreto: está repartida en los consumidores | El orquestador |
| Componentes nuevos | Ninguno (consumidores + outbox) | El orquestador y su tabla de estado |
| Acoplamiento | Bajo entre servicios, pero cada uno conoce los eventos de los demás | Los servicios solo conocen sus comandos; el orquestador los conoce a todos |
| Saber en qué estado está el pedido de Marc | Reconstruir a partir de eventos en varios tópicos | SELECT estado FROM sagas WHERE pedido_id = ... |
| Añadir un paso | Tocar varios servicios | Tocar el orquestador (y el servicio nuevo) |
| Dependencias cíclicas | Fáciles de crear sin querer | Imposibles: el orquestador es el único que llama |
| Riesgo de "dios" | No | El orquestador puede acumular lógica de negocio que no le corresponde |
| Pruebas | Integración con broker | El orquestador se prueba en aislamiento con dobles (como en la simulación) |
| Recomendación habitual | Sagas de 2-3 pasos, flujos estables | Sagas de 4+ pasos, flujos que cambian, necesidad de consultar el estado |
| Herramientas | Kafka/RabbitMQ + outbox | Temporal, Camunda/Zeebe, AWS Step Functions, o un orquestador propio como el del apartado 8 |
En Kilómetro Cero, la saga del pedido tiene tres pasos hoy pero crecerá (asignación de repartidor, aviso al productor, cupones de campaña), y el equipo de soporte necesita saber en qué estado está cada pedido; por eso la elección es la orquestación en pedidos. La coreografía se reserva para reacciones simples y estables, como que analitica consuma pedido.confirmado.
- Compensaciones, idempotencia y el aislamiento perdido; TCC
Compensar no es deshacer
Una compensación es una nueva transacción de negocio con efectos propios, no un ROLLBACK: el reembolso aparece en el extracto de Marc, la reserva liberada genera un stock.actualizado que catalogo mostrará, el pedido cancelado se queda en el historial con su motivo. Algunas acciones no tienen compensación posible (un correo enviado, un repartidor que ya salió) y deben colocarse después del pivote. Y toda compensación debe ser idempotente y no puede fallar de forma permanente: si reembolsar devuelve un error definitivo, la saga queda en un estado que exige intervención humana, y hay que diseñar para ello (un estado COMPENSACION_FALLIDA con alerta, 07-01).
El aislamiento perdido
Las sagas renuncian a la I de ACID, y eso produce anomalías concretas entre T1 y T4:
| Anomalía | Ejemplo en el pedido | Contramedida |
|---|---|---|
| Actualizaciones perdidas | Mientras la saga de Marc está en STOCK_RESERVADO, otra saga cancela el pedido y libera; luego la primera lo confirma |
Bloqueo semántico: marcar el pedido como pendiente (o en_saga) y rechazar otras operaciones sobre él hasta que la saga acabe; es lo que hace T1 |
| Lecturas sucias | catalogo muestra el stock descontado por T2 antes de que T3 decida, y luego vuelve a subir |
Estados "pendiente de confirmar": distinguir stock reservado de stock vendido; el catálogo muestra disponible = unidades - reservado y sabe que es provisional |
| Lecturas no repetibles / fuzzy | analitica suma las ventas del día y cuenta el pedido de Marc antes de que se cancele |
Lectura pesimista: solo contar pedidos en estado confirmado; o reordenar la saga para que el paso más volátil vaya primero (reordenación) |
| Decisiones sobre datos intermedios | Un cupón de "Semana del Queso Artesano" se aplica a un pedido que luego se cancela, y el contador de cupones no se restaura | Valor conmutativo: usar operaciones que se puedan compensar exactamente (incrementar/decrementar, como el PNCounter de 03-01) en lugar de asignaciones |
Estas contramedidas (bloqueo semántico, lecturas pesimistas, reordenación, valores conmutativos, y "versión de archivo" para conservar el estado previo) las catalogó Chris Richardson a partir del artículo original de García-Molina, y la mayoría se reducen a una idea: hacer visible que el estado es provisional en lugar de fingir aislamiento.
TCC: Try-Confirm/Cancel
Una variante de la saga que recupera algo de aislamiento es TCC (Try-Confirm/Cancel): cada paso se divide en try (reservar el recurso de forma provisional, sin consumirlo), confirm (consumirlo definitivamente) y cancel (liberar la reserva). El orquestador ejecuta todos los try, y solo si todos tienen éxito ejecuta los confirm; en caso contrario, los cancel. Es 2PC a nivel de negocio, sin bloqueos de base de datos: inventario con ReservarStock y ConfirmarReserva/LiberarReserva ya es, de hecho, un recurso TCC, y la pasarela de pago con pre-autorización y captura, también. El precio es que cada servicio tiene que implementar las tres operaciones y gestionar reservas que caducan (un try sin confirm ni cancel porque el orquestador murió), lo que hace de TCC la elección cuando los recursos son escasos y disputados (la última unidad, una plaza, un asiento) y de la saga simple la elección para todo lo demás.
Errores Comunes y Consejos
- Usar 2PC entre microservicios "porque es lo que hace la base de datos". Acopla, bloquea ante la caída del coordinador y no cubre pasarelas ni brokers. Reserva 2PC para el interior de un sistema que lo controla todo.
- Sagas con pasos no idempotentes. Tras un reinicio, el orquestador reejecuta el último paso. Sin
id_reserva, sin clave de idempotencia, el stock se descuenta dos veces y el cliente paga dos veces. Los identificadores de idempotencia se generan al iniciar la saga y se persisten con ella. - Compensaciones que pueden fallar sin plan. Diseña el estado de "compensación fallida", con alerta y procedimiento manual, antes de que ocurra en producción.
- El pivote en el sitio equivocado. Si el paso que más falla (el cobro) va primero, se compensa poco; si va último, se compensa todo lo anterior. Ordena: compensables, después el pivote, después los reintentables.
- Fingir aislamiento. Mostrar el stock descontado por una saga en curso como definitivo, o contar pedidos pendientes como ventas, produce los errores de la tabla del apartado 10. Modela explícitamente los estados provisionales.
- Orquestador con lógica de negocio de otros servicios. El orquestador decide el orden y las compensaciones; no decide si hay stock ni si la tarjeta es válida. Si empieza a hacerlo, se ha convertido en un monolito distribuido.
- Sagas sin timeout. Una saga en
STOCK_RESERVADOdurante horas porquepagosno responde retiene stock que otros querrían comprar. Define un plazo máximo por paso y por saga, tras el cual se compensa. - Consejo: guarda el
historialde la saga. Cuando un cliente pregunte por qué se canceló su pedido, "cobrar falló: tarjeta rechazada por el emisor" en la fila desagasvale más que cualquier log. - Consejo: prueba el orquestador con dobles que fallen en cada paso, de forma permanente y transitoria, y tras un "reinicio" (llamar a
continuarcon el estado guardado). Es el tipo de prueba que en 07-06 automatizaremos con ingeniería del caos.
Ejercicios
Ejercicio 1: Reinicio a mitad de saga
Con la simulación del apartado 8, modela una caída del orquestador justo después de que inventario reserve el vino de Lucía (P-2026-000126, 2 unidades de vino-crianza, tarjeta válida) pero antes de la transición a STOCK_RESERVADO. Hazlo con un InventarioSimulado cuya reservar lance una excepción SystemExit la primera vez, tras haber reservado. Después crea un nuevo OrquestadorSagaPedido con el mismo RepositorioMemoria, los mismos servicios simulados, y llama a continuar(saga.id). ¿Qué pasos se reejecutan? ¿Se descuenta el stock dos veces? ¿Qué cambiarías en la implementación para que iniciar devolviera el saga.id aunque el proceso muera a mitad?
Ejercicio 2: Añadir un paso a las dos versiones
Kilómetro Cero quiere añadir "asignar repartidor" (reparto.asignar(pedido_id, ciudad), compensación reparto.desasignar(pedido_id), puede fallar de forma permanente si no hay repartidores en la ciudad) entre el cobro y la confirmación. Describe qué hay que cambiar en la orquestación (apartado 8) y en la coreografía (apartado 7): qué ficheros, qué eventos nuevos, qué consumidores. ¿Es correcto colocarlo después del cobro? ¿Qué implicaría para Marc, cuya tarjeta sí es válida esta vez, un fallo permanente en ese paso?
Ejercicio 3: Elegir 2PC, saga o TCC
Para cada operación, elige 2PC, saga coreografiada, saga orquestada o TCC, y justifica en dos o tres frases:
- Trasladar 50 unidades de
tomate-rosaentre las tablasstockdeinv-bcneinv-vlc(ambas son PostgreSQL del mismo servicioinventario, sin pasarelas externas). - Registrar una entrega:
repartomarca el pedido entregado,pedidoscambia el estado,analiticaactualiza el tiempo medio de entrega, y se envía un correo a Ana. - Reservar las últimas 3 botellas de
vino-crianzapara un pedido de Lucía y cobrarlas con una tarjeta que requiere autenticación reforzada (el cliente puede tardar minutos en confirmarla en la app del banco).
Soluciones
Solución 1:
class InventarioQueMuere(InventarioSimulado):
def __init__(self) -> None:
super().__init__(); self.ya_murio = False
def reservar(self, id_reserva, pedido_id, lineas):
super().reservar(id_reserva, pedido_id, lineas) # la reserva SÍ se hace
if not self.ya_murio:
self.ya_murio = True
raise SystemExit("el proceso de pedidos muere aquí")
repo, inventario, pagos, pedidos_db = RepositorioMemoria(), InventarioQueMuere(), PagosSimulado(), PedidosDbSimulada()
try:
OrquestadorSagaPedido(repo, inventario, pagos, pedidos_db).iniciar(
"P-2026-000126", "Lucía", [{"producto": "vino-crianza", "cantidad": 2}], 31.80)
except SystemExit as e:
print("!!!", e)
saga_id = next(iter(repo.filas)) # en la realidad: SELECT ... WHERE estado NOT IN (...)
orq2 = OrquestadorSagaPedido(repo, inventario, pagos, pedidos_db) # "nueva instancia" tras el reinicio
orq2.continuar(saga_id)
print(inventario.stock, pedidos_db.pedidos["P-2026-000126"]["estado"])La saga quedó persistida en INICIADA con paso_actual = 0, así que continuar reejecuta reservar_stock. Como InventarioSimulado.reservar es idempotente por id_reserva (res-P-2026-000126 ya está en reservas), no descuenta dos veces: el stock queda en 10, se cobra a Lucía y la saga llega a COMPLETADA. Si quitas la comprobación if id_reserva in self.reservas, verás el stock en 8: es exactamente el fallo que la idempotencia de 02-03 evita. Sobre iniciar: en la implementación real no devuelve nada a nadie si el proceso muere; lo importante es que la saga esté en la tabla, y un proceso periódico (SELECT id FROM sagas WHERE estado NOT IN ('COMPLETADA','CANCELADA') AND actualizada_en < now() - interval '1 minute') llame a continuar sobre las sagas huérfanas. Además, iniciar debería crear el pedido y la saga en una sola transacción y responder al cliente "pedido recibido, pendiente" antes de ejecutar los pasos, que pueden tardar.
Solución 2:
Orquestación: un solo cambio en saga_pedido.py: insertar un Paso("asignar_repartidor", "REPARTIDOR_ASIGNADO", accion=reparto.asignar(...), compensacion=reparto.desasignar(...)) en la lista self.pasos entre cobrar y confirmar_pedido, añadir ciudad a datos, y admitir el nuevo estado en la tabla sagas. Los servicios inventario y pagos no cambian. Coreografía: reparto necesita un consumidor nuevo de pago.confirmado que publique repartidor.asignado o reparto.imposible; pedidos deja de confirmar al recibir pago.confirmado y pasa a hacerlo con repartidor.asignado; pago.rechazado sigue igual, pero reparto.imposible debe disparar dos compensaciones: pagos debe consumirlo y reembolsar (nuevo consumidor) y luego inventario debe liberar al recibir pago.reembolsado (evento nuevo y consumidor nuevo). Tres servicios tocados, dos eventos nuevos, y la cadena de compensación se alarga.
¿Después del cobro? Si "asignar repartidor" puede fallar de forma permanente, colocarlo después del pivote (cobro) obliga a reembolsar, que es la compensación más visible y molesta para el cliente (Marc vería un cargo y un abono en su extracto). Sería mejor colocarlo antes del cobro (reservar repartidor es compensable y barato de deshacer), convirtiendo el cobro de nuevo en el último paso compensable-o-pivote. Regla general: los pasos que pueden fallar permanentemente van antes del pivote.
Solución 3:
- 2PC (o, mejor, una única transacción): ambas tablas pertenecen al mismo servicio,
inventario, así que no hay acoplamiento indebido; si están en la misma base de datos lógica no hace falta nada distribuido, y si son dos instancias PostgreSQL,PREPARE TRANSACTIONentre ellas, coordinado porinventario, es adecuado: participantes homogéneos, sin pasarelas, transacción corta y el propio servicio puede resolver transacciones preparadas huérfanas al arrancar (consultandopg_prepared_xacts). Una saga sería sobreingeniería. - Saga coreografiada:
repartopublicapedido.entregadoen la misma transacción que su cambio de estado (outbox), ypedidos,analiticay el servicio de notificaciones lo consumen de forma independiente e idempotente. No hay compensación posible ni necesaria (una entrega no se "des-entrega"), no hay pivote, y ningún paso depende del resultado de otro: es la reacción simple y estable para la que la coreografía encaja. - TCC: las tres últimas botellas son un recurso escaso y disputado, y el cobro puede tardar minutos por la autenticación reforzada.
inventariohacetry(reserva provisional con caducidad, por ejemplo 15 minutos),pagoshacetry(pre-autorización pendiente de confirmación del cliente); cuando el banco confirma, el orquestador ejecutaconfirmen ambos (consumir la reserva, capturar el cobro); si Lucía no confirma a tiempo,cancelen ambos. Durante la espera, el catálogo muestra las botellas como reservadas, no vendidas ni disponibles: el estado provisional explícito del apartado 10. Una saga simple con cobro inmediato no puede esperar minutos con el stock descontado sin aparecer como vendido, y 2PC no puede incluir a la pasarela.
Conclusión
La transacción del monolito, con su COMMIT que lo confirmaba todo o nada, no existe cuando el pedido cruza pedidos, inventario y pagos, y esta lección ha mostrado las dos maneras de vivir sin ella. El commit en dos fases conserva la atomicidad con un coordinador que recoge votos en la fase de prepare y difunde la decisión en la de commit; PostgreSQL lo soporta con PREPARE TRANSACTION, y lo hemos visto bloquear filas indefinidamente cuando el coordinador muere con los participantes preparados. Ese bloqueo, junto con el acoplamiento, la latencia y la imposibilidad de incluir pasarelas y brokers, es la razón por la que los microservicios evitan 2PC (3PC no ayuda sin red síncrona; el remedio real es un coordinador replicado por consenso, que solo las bases de datos distribuidas se permiten). La saga sustituye la atomicidad por una secuencia de transacciones locales con compensaciones, ordenadas en compensables, pivote y reintentables. La hemos implementado como coreografía, con consumidores idempotentes y outbox encadenando pedido.creado, stock.reservado, pago.rechazado y stock.liberado, y como orquestación en saga_pedido.py, con la tabla sagas de km0_pedidos persistiendo cada transición y un continuar reentrante que retoma la saga tras un reinicio gracias a la idempotencia de id_reserva y de la clave de cobro heredadas del Módulo 2; la simulación ha compensado la reserva de Marc cuando pagos lo rechazó y ha dejado el stock intacto. La tabla del apartado 9 justifica la elección de la orquestación para el pedido de Kilómetro Cero, y el apartado 10 ha puesto nombre a lo que la saga pierde, el aislamiento, con contramedidas (bloqueo semántico, estados provisionales, lecturas pesimistas, valores conmutativos) y con TCC como variante para recursos disputados.
Con esta lección se cierra el Módulo 3. Sabemos ya decir con precisión qué garantiza un almacén replicado (modelos de consistencia), qué hay que sacrificar cuando la red se parte y qué cuesta la consistencia cuando no (CAP y PACELC), cómo un grupo de nodos elige líder y acuerda valores (Paxos, Raft, etcd), cómo se copian los datos entre nodos y con qué anomalías (líder-seguidor, multilíder, quórums), y cómo una operación de negocio atraviesa varios servicios sin una transacción global (2PC y sagas). Todo ello trata los datos como si ya estuvieran en algún sitio. El Módulo 4 se ocupa de ese sitio: dónde y cómo se guardan físicamente los pedidos, las fotos de los productos y los eventos que ya sabemos replicar y coordinar, cuando son demasiados para un solo nodo. Empieza por la pregunta previa a cualquier réplica: cómo repartir datos distintos entre muchos nodos, con el particionado y el hashing consistente.
Curso de Arquitecturas Distribuidas
Módulo 1: Introducción a los Sistemas Distribuidos
- Conceptos Básicos de Sistemas Distribuidos
- Modelos de Sistemas Distribuidos
- Ventajas y Desafíos de los Sistemas Distribuidos
- Las Falacias de la Computación Distribuida
- Tiempo, Relojes y Ordenación de Eventos
- Del Monolito a la Plataforma Distribuida: el Caso Kilómetro Cero
Módulo 2: Comunicación en Sistemas Distribuidos
- Protocolos de Comunicación
- RPC y RMI
- gRPC y Serialización de Datos
- Mensajería y Colas de Mensajes
- Patrones de Comunicación Asíncrona
Módulo 3: Consistencia y Replicación
- Modelos de Consistencia
- El Teorema CAP y PACELC
- Algoritmos de Consenso
- Replicación de Datos
- Transacciones Distribuidas y Sagas
Módulo 4: Almacenamiento Distribuido
- Particionado de Datos y Hashing Consistente
- Sistemas de Archivos Distribuidos
- Almacenamiento de Objetos
- Bases de Datos Distribuidas
- Cachés Distribuidos
Módulo 5: Computación Distribuida
- Modelos de Computación Distribuida
- MapReduce y Hadoop
- Spark y Computación en Memoria
- Procesamiento de Flujos de Datos
- Planificación de Trabajos y Pipelines de Datos
Módulo 6: Seguridad en Sistemas Distribuidos
- Autenticación y Autorización
- Cifrado y Protección de Datos
- Gestión de Identidades
- Seguridad entre Servicios: mTLS y Gestión de Secretos
- Puertas de Enlace, Limitación de Tasa y Auditoría
Módulo 7: Monitoreo y Mantenimiento
- Monitoreo de Sistemas Distribuidos
- Logs Centralizados y Trazabilidad Distribuida
- Gestión de Fallos y Recuperación
- Patrones de Resiliencia: Timeouts, Reintentos y Circuit Breaker
- Automatización y Orquestación
- Pruebas en Sistemas Distribuidos e Ingeniería del Caos
