Arquitectura de software

Transacciones distribuidas y consistencia: 2PC, sagas, outbox y CAP

Avanzado13 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Cuando el estado se reparte entre servicios y bases de datos, la transacción de una sola base desaparece y algo tiene que sustituirla. Esta guía explica qué restringen realmente CAP y PACELC, por qué el commit en dos fases rara vez es la respuesta fuera de una única pila de confianza, cómo se comportan de verdad las sagas con compensación y cómo el outbox y la idempotencia convierten la entrega at-least-once en resultados de negocio correctos.

Lo que aprenderás

  • Enunciar con precisión lo que CAP y PACELC dicen, y lo que no dicen, sobre un sistema
  • Explicar el protocolo de commit en dos fases y los fallos que hacen que se bloquee
  • Diseñar una saga con acciones compensatorias y elegir entre coreografía y orquestación
  • Implementar el outbox transaccional para que los cambios de estado y los mensajes no puedan divergir
  • Razonar sobre la entrega at-least-once, la idempotencia y el espejismo del exactly-once

Dentro de una sola base de datos, la consistencia viene de serie: empiezas, haces varias cosas, haces commit y, o ha ocurrido todo, o no ha ocurrido nada. Esa garantía ha moldeado la forma en que la mayoría de los desarrolladores piensa sobre la corrección, y desaparece en cuanto una operación de negocio abarca dos bases de datos o dos servicios. Nada la sustituye exactamente. Lo que existe en su lugar es un conjunto de protocolos y patrones, cada uno de los cuales renuncia a algo concreto, y el trabajo de ingeniería consiste en elegir con qué pérdida puedes vivir.

Esta guía empieza por lo que la teoría restringe de verdad, porque CAP en particular se cita más de lo que se entiende. Después cubre las dos familias de respuesta: los protocolos de commit atómico, que intentan conservar la garantía de siempre, y los enfoques tipo saga, que abandonan el aislamiento y reconstruyen la corrección a partir de compensaciones e idempotencia. El catálogo de patrones de diseño de microservicios los presenta por su nombre; aquí se examina cómo fallan en la práctica.

ACID, BASE y lo que aporta el aislamiento

ACID describe la transacción local: atomicidad (todo o nada), consistencia (las restricciones se cumplen en el commit), aislamiento (las transacciones concurrentes no observan los estados intermedios de las demás) y durabilidad (los datos confirmados sobreviven a una caída).

El aislamiento es la propiedad que más se echa de menos cuando falta, y conviene recordar que incluso en local suele ser más débil que «serializable». La mayoría de las bases de datos usan por defecto read committed, donde una transacción puede ver filas que otra confirmó mientras ella se ejecutaba, así que las lecturas no repetibles y los fantasmas ya son posibles. Entender las anomalías que permite tu nivel de aislamiento (lectura sucia, lectura no repetible, fantasma, write skew) es imprescindible antes de distribuir nada, porque la distribución solo ensancha esas ventanas.

BASE es el contraste deliberado: basically available, soft state, eventually consistent. El sistema sigue disponible y acepta escrituras, las réplicas pueden discrepar durante un tiempo y, si no llegan más actualizaciones, convergen. No es un estándar de ingeniería más bajo; es un contrato distinto, y exige más trabajo de diseño, no menos, porque cada consumidor tiene que estar escrito para tolerar un valor brevemente desactualizado.

La pregunta de negocio que decide entre ambos no es «¿necesitamos consistencia?» (todo el mundo responde que sí), sino «¿qué pasa si estos dos hechos discrepan durante dos segundos?». Dos cuentas de un libro contable en desacuerdo son inaceptables. Que el número de reseñas de un producto vaya con retraso, no. La mayoría de los sistemas reales necesitan consistencia fuerte en un núcleo pequeño y toleran la consistencia eventual en todo lo demás, y por eso importan los agregados: el agregado es exactamente el límite dentro del cual exiges ACID.

CAP y PACELC, enunciados con cuidado

CAP dice que un sistema distribuido no puede garantizar a la vez consistencia (cada lectura ve la última escritura), disponibilidad o availability (cada petición a un nodo que no ha fallado recibe respuesta) y tolerancia a particiones (el sistema sigue funcionando aunque se pierdan mensajes entre nodos). Y como las particiones ocurren tanto si las planificas como si no, la elección real es qué hacer durante una partición: rechazar peticiones para preservar la consistencia (CP) o responder a partir de un estado posiblemente obsoleto para preservar la disponibilidad (AP).

Tres aclaraciones eliminan la mayor parte de la confusión:

  • La elección se aplica solo durante una partición. Un sistema sano puede ser consistente y disponible a la vez, y normalmente lo es.
  • Es una elección por operación y por conjunto de datos, no por sistema. La misma plataforma puede rechazar una transferencia de saldo durante una partición y seguir sirviendo un catálogo de productos.
  • La «consistencia» de CAP significa linealizabilidad, una garantía concreta y fuerte. No es la C de ACID, y esa coincidencia accidental de letras ha provocado años de discusiones confusas.

PACELC amplía esto de forma útil: si hay una Partición, elige entre disponibilidad (Availability) y Consistencia; si no (Else), elige entre Latencia y Consistencia. La segunda cláusula cubre el 99,9 % del tiempo en que la red funciona bien, y pone nombre al compromiso que haces con muchísima más frecuencia. Confirmar una escritura en cuanto la tiene una réplica es rápido y se arriesga a perderla; esperar a un quórum es más lento y más seguro. Todo almacén replicado expone esto como un parámetro ajustable (write concern, nivel de consistencia, acuses de recibo necesarios), y fijarlo deliberadamente por operación tiene más consecuencias que cualquier etiqueta que se le ponga a la base de datos.

Commit en dos fases y XA

La respuesta clásica a la atomicidad entre varios recursos es el commit en dos fases (2PC), coordinado por un gestor de transacciones.

  1. Fase 1 — preparación

    El coordinador pregunta a cada participante si puede hacer commit. Cada uno realiza el trabajo, lo escribe de forma duradera en su log, adquiere los bloqueos que necesita y vota sí o no. Un voto afirmativo es una promesa vinculante: el participante debe poder hacer commit más adelante, así que mantiene sus bloqueos.
  2. Fase 2 — commit o aborto

    Si todos votaron sí, el coordinador escribe su decisión en su propio log y ordena a todos hacer commit. Si alguno votó no, ordena a todos hacer rollback. Los participantes aplican la decisión y liberan los bloqueos.

El protocolo es correcto, y su modo de fallo es que se bloquea. Si el coordinador cae después de recoger los votos afirmativos pero antes de difundir la decisión, los participantes se quedan atascados: han prometido poder hacer commit, así que no pueden abortar por su cuenta, y no conocen la decisión, así que tampoco pueden hacer commit. Los bloqueos se mantienen hasta que el coordinador se recupera y lee su log. Estas transacciones en duda son un problema operativo conocido, y resolverlas a mano corre el riesgo de romper la atomicidad de verdad.

Los demás costes son estructurales. Todos los participantes deben implementar el protocolo, lo que excluye a la mayoría de los servicios HTTP modernos y a muchos almacenes de datos. La disponibilidad se multiplica a la baja: la transacción solo tiene éxito si todos los participantes están en pie. La latencia incluye al menos dos viajes de ida y vuelta más escrituras duraderas en el log de cada participante. Y el throughput se resiente porque los bloqueos se mantienen durante todo el protocolo, de modo que las filas más disputadas serializan el sistema entero.

XA es la interfaz estándar para esto, implementada en la JVM mediante JTA con un XAResource por cada recurso participante. Sigue siendo realmente adecuado en una franja estrecha: un único servidor de aplicaciones que coordina una base de datos y un proveedor JMS, ambos locales, ambos a cargo de un mismo equipo de operaciones y con un throughput modesto; es el caso que se trata en transacciones y JTA. Es la herramienta equivocada entre servicios desplegados de forma independiente, y «las transacciones distribuidas no funcionan con microservicios» es una forma abreviada de decir exactamente esto.

El commit en tres fases y el commit basado en consenso (con Paxos o Raft para replicar la decisión del coordinador) resuelven el problema del bloqueo a costa de más viajes de ida y vuelta y de bastante más maquinaria. Los sistemas que necesitan atomicidad entre shards, como las bases de datos SQL distribuidas, implementan internamente algo de esta familia, lo que es una buena razón para dejar que lo haga una base de datos en lugar de construirlo tú.

Sagas

Una saga sustituye una transacción distribuida por una secuencia de transacciones locales, cada una confirmada de forma independiente, más una acción compensatoria por paso capaz de deshacer su efecto si un paso posterior falla.

La contrapartida es explícita y conviene decirla sin rodeos: conservas la atomicidad en el sentido débil de que la saga o se completa o se compensa, y pierdes por completo el aislamiento. Los estados intermedios son visibles. Entre reservar el stock y cobrar el pago, el stock está realmente reservado y otras transacciones pueden verlo. Por tanto, todo diseño de saga debe responder qué observan los demás actores a mitad del proceso, y o bien hacerlo aceptable, o bien ocultarlo tras un estado.

Compensar no es hacer rollback. Es una nueva acción de negocio con su propia semántica: un reembolso no borra un pago, y puede que un correo de cancelación ya se haya leído. Algunos pasos no se pueden compensar en absoluto (un correo enviado, un fichero transmitido a una cámara de compensación), y por eso el consejo habitual es ordenar los pasos de modo que primero vayan los compensables, después un paso pivote que decide el resultado y, por último, los pasos reintentables, que deben acabar teniendo éxito y nunca se deshacen.

Coreografía

Cada servicio reacciona a eventos y emite los suyos. No existe coordinador.

OrderService      → OrderCreated
InventoryService  → (on OrderCreated) reserve stock → StockReserved | StockUnavailable
PaymentService    → (on StockReserved) charge      → PaymentCaptured | PaymentDeclined
InventoryService  → (on PaymentDeclined) release stock            [compensation]
OrderService      → (on PaymentCaptured) confirm | (on PaymentDeclined) cancel

El acoplamiento se mantiene bajo y cada equipo es dueño de su paso. Los costes aparecen a medida que el flujo crece: no hay un único sitio que describa el proceso, así que entenderlo implica leer todos los servicios; se cuelan dependencias cíclicas entre eventos; y responder a «¿dónde está atascado el pedido 4711?» obliga a correlacionar eventos entre sistemas.

Orquestación

Un coordinador guarda el estado de la saga y emite comandos.

// Orchestrator: explicit state machine, persisted after every transition.
@Component
class PlaceOrderSaga {

    @SagaStep(order = 1, compensate = "releaseStock")
    void reserveStock(SagaState s) { inventory.reserve(s.orderId(), s.lines()); }

    @SagaStep(order = 2, compensate = "refund")           // pivot: after this, we go forward
    void capturePayment(SagaState s) { payments.capture(s.orderId(), s.total(), s.orderId()); }

    @SagaStep(order = 3)                                   // retriable: never compensated
    void arrangeShipment(SagaState s) { shipping.schedule(s.orderId(), s.address()); }

    void releaseStock(SagaState s) { inventory.release(s.orderId()); }
    void refund(SagaState s)       { payments.refund(s.orderId(), s.total(), s.orderId() + ":refund"); }
}

El flujo es explícito, testeable y observable; los timeouts y la escalada viven en un solo lugar; una herramienta de soporte puede mostrar cada instancia en curso. Los costes son un componente que debe conocer a todos los participantes, el riesgo de que la lógica de negocio se acumule en el orquestador en vez de en los servicios, y una pieza más que hacer altamente disponible. Fíjate en que tanto capture como refund pasan una clave de idempotencia estable: sin ella, una compensación reintentada reembolsa dos veces.

CriterioCoreografíaOrquestación
AcoplamientoBajo; los servicios solo conocen eventosEl coordinador conoce a cada participante
Visibilidad del flujoImplícita, repartida entre serviciosExplícita y consultable de forma centralizada
Tamaño adecuado3–4 pasosCualquiera, e imprescindible a partir de unos 5
Timeouts y escaladaPor servicio, duplicadosCentralizados
Probar el flujo completoDifícilSencillo
Fallo que vigilarCadenas de eventos cíclicasUn monolito distribuido en el coordinador

En ambos casos, las sagas necesitan timeouts en cada paso (un paso que nunca responde debe acabar tratándose como fallido), persistencia del estado de la saga para que un reinicio del coordinador reanude en lugar de volver a empezar, y un plan para el caso en que la propia compensación falle, lo que normalmente implica reintentarla indefinidamente y lanzar una alerta operativa, porque no hay ningún otro recurso.

El problema de la doble escritura y el outbox

Debajo de todo esto hay un fallo más sutil. Un servicio que actualiza su base de datos y después publica un evento está haciendo dos escrituras en dos sistemas sin atomicidad. Si se cae entre ambas, el estado ha cambiado y no se ha publicado ningún evento; si publica primero y el commit falla, los consumidores actúan sobre algo que nunca ocurrió. Ninguno de los dos casos se arregla reintentando a ciegas.

El outbox transaccional elimina el problema haciendo locales ambas escrituras:

BEGIN;
  UPDATE orders SET status = 'CONFIRMED' WHERE id = '8f21c' AND status = 'PENDING';

  INSERT INTO outbox (id, aggregate_type, aggregate_id, type, payload, created_at)
  VALUES (gen_random_uuid(), 'order', '8f21c', 'OrderConfirmed',
          '{"orderId":"8f21c","total":"89.90","currency":"EUR"}'::jsonb, now());
COMMIT;

Un relay independiente publica después las filas de outbox en el broker y las marca o las borra. Si se cae después de publicar pero antes de marcar, el mensaje se envía dos veces: at-least-once, algo que los consumidores ya deben tolerar. El relay puede consultar la tabla periódicamente o leer el write-ahead log de la base de datos mediante change data capture, lo que evita la carga del polling y conserva el orden de los commits.

La imagen especular en el lado del consumidor es el inbox: registrar el identificador de cada mensaje procesado en la misma transacción que su efecto y descartar todo lo que ya esté registrado. Juntos, outbox e inbox convierten una pareja de sistemas poco fiables en un pipeline fiable sin ningún protocolo distribuido.

Una alternativa para los servicios con event sourcing es el patrón listen-to-yourself: añadir el evento al event store como única escritura y publicar desde el store. No hay doble escritura porque el store es la fuente de verdad.

At-least-once, idempotencia y el espejismo del exactly-once

Todo sistema de mensajería ofrece elegir entre at-most-once (dispara y olvida; puede perder mensajes), at-least-once (reintenta hasta recibir confirmación; puede duplicar) y exactly-once (anunciado, y conviene leer la letra pequeña). Casi todos los sistemas reales funcionan en at-least-once, porque perder una orden de pago es peor que procesar un duplicado que puedes detectar.

La entrega exactly-once es imposible en una red que puede perder mensajes: un emisor que no recibe confirmación no puede distinguir un mensaje perdido de una confirmación perdida, y tiene que elegir entre reenviar (arriesgándose a duplicar) o no hacerlo (arriesgándose a perder). Lo que sí es alcanzable es el efecto exactly-once, y hay dos caminos. El primero es el procesamiento idempotente: el consumidor detecta e ignora las repeticiones usando un identificador de mensaje almacenado, una clave de negocio natural o una actualización condicionada al estado, como UPDATE ... WHERE status = 'PENDING', que simplemente afecta a cero filas la segunda vez. El segundo es un límite transaccional dentro de un único sistema: el productor transaccional de Kafka con lectura-procesamiento-escritura, por ejemplo, confirma de forma atómica los registros producidos y los offsets consumidos, lo que da semántica exactly-once dentro de Kafka pero no dice nada de la base de datos externa en la que también escribiste. Las garantías y sus límites se tratan en patrones y garantías de mensajería.

Implicaciones prácticas: haz idempotente a cada consumidor, prometa lo que prometa el broker; propaga de extremo a extremo una clave de idempotencia de negocio en lugar de depender de los identificadores de mensaje del broker, que cambian cuando un mensaje se vuelve a publicar; y conserva los registros de deduplicación durante más tiempo que la ventana de reintentos más larga que sea plausible.

La consistencia eventual en el diseño

Elegir la consistencia eventual es una decisión de producto tanto como técnica, y los diseños que funcionan hacen visible el retraso en lugar de ocultarlo.

  • Muestra el estado con honestidad. «Pago en proceso» es un estado veraz y aceptable; un saldo que muestra en silencio una cifra obsoleta no lo es.
  • Lee tus propias escrituras. Un usuario que acaba de cambiar su dirección debe ver la nueva. Dirige sus lecturas al primario durante un breve intervalo o sirve el valor pendiente desde el lado de escritura.
  • Haz observable la convergencia. Monitoriza el retraso de replicación y de proyección como métricas de primer nivel con alertas, porque una proyección detenida no hace ruido hasta que alguien se queja.
  • Diseña pensando en la reconciliación. Ejecuta un proceso periódico que compare las visiones que distintos servicios tienen de los mismos hechos e informe de las discrepancias. Todo sistema con consistencia eventual las acumula; la cuestión es si las encuentras tú o un cliente.
  • Versiona y ordena los eventos para que un consumidor pueda descartar lo que ya ha quedado superado, ya que el orden entre particiones no está garantizado por defecto.

Ideas clave

  • Lo que de verdad pierdes cuando una transacción abarca varios servicios es el aislamiento, no la atomicidad, así que todo diseño debe dejar claro qué pueden observar los demás actores a mitad del proceso; CAP restringe el comportamiento solo durante una partición y solo por conjunto de datos; PACELC pone nombre al compromiso que haces constantemente: latencia frente a consistencia en funcionamiento normal.
  • El commit en dos fases es correcto y bloqueante: un fallo del coordinador entre la preparación y la decisión mantiene los bloqueos en todos los participantes, y por eso XA encaja en una única pila y no entre servicios independientes.
  • Una saga es una secuencia de transacciones locales con compensaciones; coloca los pasos compensables antes del pivote, haz idempotente cada paso, persiste el estado de la saga y pon timeouts a todo.
  • La coreografía encaja en flujos cortos y mantiene bajo el acoplamiento; la orquestación se vuelve necesaria en cuanto un flujo tiene varios pasos o el negocio necesita ver dónde está atascada una instancia.
  • El outbox transaccional elimina localmente el problema de la doble escritura, y un inbox en el lado del consumidor hace segura la entrega at-least-once.
  • La entrega exactly-once no existe; el efecto exactly-once sí, mediante consumidores idempotentes y claves de idempotencia de negocio transmitidas de extremo a extremo.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿El teorema CAP significa que tengo que elegir entre consistencia y disponibilidad?
Solo durante una partición de red, y solo para los datos que la partición separa. Cuando la red está sana, que es casi siempre, puedes tener ambas, y el compromiso interesante pasa a ser latencia frente a consistencia, que es lo que añade PACELC. Tratar CAP como una elección de arquitectura permanente entre dos etiquetas lleva a sistemas que renuncian a una consistencia a la que nunca tuvieron que renunciar.
¿Por qué se considera perjudicial el commit en dos fases para los microservicios?
Porque se bloquea. Entre el voto de preparación y la decisión de commit, cada participante mantiene sus bloqueos y no puede actuar por su cuenta, así que un fallo del coordinador en ese momento deja los recursos bloqueados hasta que se recupere. Además exige que todos los participantes soporten el protocolo, acopla su disponibilidad y escala mal. Dentro de un servidor de aplicaciones con dos recursos locales funciona bien; entre servicios desplegados de forma independiente, no.
¿Cómo elijo entre coreografía y orquestación para una saga?
Cuenta los pasos y pregúntate quién necesita ver el proceso. Hasta tres o cuatro pasos con un manejo de fallos sencillo, la coreografía mantiene los servicios desacoplados y no requiere ningún componente nuevo. Más allá de eso, o cuando el negocio necesita preguntar dónde está atascada una instancia concreta, un orquestador hace el flujo explícito, testeable y observable, a cambio de un componente que conoce a todos los participantes.
¿Es posible la entrega exactly-once?
La entrega exactly-once sobre una red poco fiable no es alcanzable, porque el emisor nunca puede distinguir un mensaje perdido de un acuse de recibo perdido. Lo que sí es alcanzable es un efecto exactly-once: entrega at-least-once combinada con procesamiento idempotente, o un ciclo transaccional de lectura-procesamiento-escritura dentro de un único sistema, como el productor transaccional de Kafka. Los sistemas que anuncian semántica exactly-once ofrecen lo segundo, dentro de sus propios límites.