Patrón Outbox: evitando la inconsistencia de datos en sistemas distribuidos y microservicios

Cuando empezamos a trabajar con microservicios aparece un problema que, a primera vista, parece sencillo:

Guardar información en una base de datos y, al mismo tiempo, publicar un mensaje en RabbitMQ o Kafka.

Por ejemplo, imaginemos un microservicio de Pedidos. Cuando un usuario confirma una compra necesitamos:

  1. Guardar el pedido en la base de datos.
  2. Publicar un evento PedidoCreado.
  3. Permitir que otros servicios —Facturación, Stock, Notificaciones, etc.— reaccionen a ese evento.

El código podría parecer conceptualmente así:

BEGIN TRANSACTION

INSERT INTO pedidos (...)

COMMIT

PublicarEvento("PedidoCreado")

El problema está justamente entre el COMMIT y la publicación del evento.

¿Qué pasa si la base de datos confirma correctamente la transacción, pero RabbitMQ o Kafka no están disponibles?

Terminamos con un pedido que existe en nuestra base de datos pero que ningún otro sistema sabe que existe.

Y eso es uno de los problemas clásicos de consistencia en arquitecturas distribuidas.

El problema de las dos transacciones

Cuando una operación involucra dos sistemas diferentes tenemos, en realidad, dos transacciones independientes:

La base de datos puede confirmar su transacción, pero eso no significa que el broker vaya a aceptar posteriormente el mensaje.

También podría ocurrir lo contrario:

1. Publicamos el mensaje
2. El broker confirma
3. Intentamos guardar en la base
4. La transacción de base de datos falla

Ahora otros servicios reciben un evento indicando que ocurrió algo que, desde el punto de vista de nuestro sistema, nunca ocurrió.

¿Por qué no meter todo dentro de una única transacción?

Sería ideal poder hacer algo así:

BEGIN TRANSACTION

INSERT INTO pedidos (...)

PUBLISH PedidoCreado

COMMIT

El problema es que el COMMIT de nuestra base de datos solamente controla recursos pertenecientes a esa base de datos.

RabbitMQ o Kafka son sistemas externos.

Para intentar garantizar atomicidad entre ambos podríamos utilizar mecanismos de transacciones distribuidas, generalmente asociados a protocolos como:

Two-Phase Commit (2PC)

Pero esto introduce bastante complejidad.

Entre otras cosas:

  • mayor acoplamiento entre componentes;
  • peor disponibilidad;
  • bloqueo de recursos;
  • infraestructura adicional;
  • mayor dificultad operativa;
  • problemas de compatibilidad entre tecnologías;
  • menor tolerancia a fallas.

En arquitecturas modernas de microservicios normalmente intentamos evitar este tipo de coordinación distribuida.

Y es justamente aquí donde aparece el Transactional Outbox Pattern.

¿Qué es el patrón Outbox?

La idea es sorprendentemente simple.

En lugar de intentar guardar información en la base de datos y publicar directamente el mensaje, guardamos ambas cosas en la misma base de datos y dentro de la misma transacción.

Para eso agregamos una tabla llamada, por ejemplo:

Outbox

El flujo pasa a ser:

BEGIN TRANSACTION

INSERT INTO pedidos (...)

INSERT INTO outbox (...)

COMMIT

La tabla podría tener una estructura similar a esta:

CREATE TABLE outbox_messages (
    id UUID PRIMARY KEY,
    event_type VARCHAR(100) NOT NULL,
    aggregate_id VARCHAR(100) NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMP NOT NULL,
    processed_at TIMESTAMP NULL
);

Cuando creamos un pedido:

INSERT INTO pedidos (
    id,
    cliente_id,
    total
)
VALUES (
    'PED-123',
    'CLI-456',
    25000
);

dentro de la misma transacción insertamos:

INSERT INTO outbox_messages (
    id,
    event_type,
    aggregate_id,
    payload,
    created_at
)
VALUES (
    'EVT-789',
    'PedidoCreado',
    'PED-123',
    '{
        "pedidoId": "PED-123",
        "clienteId": "CLI-456",
        "total": 25000
    }',
    NOW()
);

Entonces ocurre algo importante:

Pedido + Evento

se guardan dentro de una única transacción ACID.

Si la transacción falla:

Pedido      ❌
Evento      ❌

Si la transacción funciona:

Pedido      ✅
Evento      ✅

Desde el punto de vista de nuestra base de datos ya no puede existir uno sin el otro.

Pero todavía no publicamos nada

Exactamente.

El registro de la tabla Outbox todavía está solamente en nuestra base de datos.

Ahora necesitamos otro componente que se encargue de leer los eventos pendientes y publicarlos.

Podemos llamarlo:

Outbox Publisher

o:

Message Relay

La arquitectura queda así:

El publisher busca periódicamente mensajes pendientes:

SELECT *
FROM outbox_messages
WHERE processed_at IS NULL
ORDER BY created_at;

Luego publica cada mensaje en el broker.

Por ejemplo:

Exchange / Topic:

pedidos.events

con:

{
  "eventId": "EVT-789",
  "eventType": "PedidoCreado",
  "pedidoId": "PED-123",
  "clienteId": "CLI-456",
  "total": 25000
}

Una vez confirmado por RabbitMQ o Kafka, podemos marcarlo como procesado:

UPDATE outbox_messages
SET processed_at = NOW()
WHERE id = 'EVT-789';

¿Qué ocurre si RabbitMQ está caído?

Acá aparece una de las principales ventajas del patrón.

Supongamos que tenemos:

No perdemos el evento.

Simplemente queda pendiente:

processed_at = NULL

Cuando RabbitMQ vuelva a estar disponible, el publisher podrá volver a intentarlo

Esto desacopla completamente la disponibilidad del broker de la transacción del negocio.

La creación del pedido no necesita que RabbitMQ esté disponible exactamente en ese instante.

Entonces conseguimos consistencia eventual

Es importante entender que Outbox no proporciona consistencia inmediata entre todos los microservicios.

Proporciona algo diferente:

Consistencia eventual

Durante algunos segundos podríamos tener:

Servicio Pedidos
Pedido PED-123 → existente

Servicio Facturación
Pedido PED-123 → todavía desconocido

Después de que el evento sea publicado y procesado:

Servicio Pedidos
Pedido PED-123 → existente

Servicio Facturación
Pedido PED-123 → factura generada

Los sistemas terminan convergiendo hacia un estado consistente.

Este modelo encaja mucho mejor con arquitecturas distribuidas donde buscamos:

desacoplamiento + resiliencia + escalabilidad

en lugar de intentar que todos los componentes participen en una única transacción global.

Hay un problema adicional: mensajes duplicados

Imaginemos esta situación:

Cuando el proceso vuelva a levantarse encontrará:

EVT-789
processed_at = NULL

y volverá a publicarlo.

Resultado:

PedidoCreado
PedidoCreado

dos veces.

Por eso un sistema basado en Outbox normalmente trabaja con una garantía similar a:

At Least Once Delivery

Es decir:

El evento se publicará al menos una vez, pero podría publicarse más de una vez.

Y esto introduce otro concepto fundamental.

Los consumidores deben ser idempotentes

Supongamos que Facturación recibe:

eventId = EVT-789

La primera vez:

Genera factura

Si recibe nuevamente:

eventId = EVT-789

no debería generar otra factura.

Una estrategia habitual consiste en guardar los eventos ya procesados:

processed_events

Por ejemplo:

CREATE TABLE processed_events (
    event_id UUID PRIMARY KEY,
    processed_at TIMESTAMP NOT NULL
);

Antes de procesar:

SELECT event_id
FROM processed_events
WHERE event_id = 'EVT-789';

Si existe:

ignorar evento

Si no existe:

procesar evento
guardar event_id

De esta forma podemos hacer que:

Procesar 1 vez
Procesar 2 veces
Procesar 10 veces

termine produciendo el mismo resultado.

Esta propiedad se conoce como:

Idempotencia

Y es uno de los conceptos más importantes cuando trabajamos con sistemas orientados a eventos.

Outbox + Idempotencia

En la práctica ambos patrones suelen aparecer juntos.

El productor utiliza:

Transactional Outbox

para garantizar que el evento no se pierda.

El consumidor utiliza:

Idempotent Consumer

para garantizar que un evento duplicado no genere inconsistencias.

El flujo completo queda:

¿Cómo ejecutar el Outbox Publisher?

Hay varias estrategias.

1. Polling

La opción más sencilla.

Un proceso ejecuta periódicamente:

SELECT *
FROM outbox_messages
WHERE processed_at IS NULL
LIMIT 100;

Por ejemplo:

cada 1 segundo

o:

cada 5 segundos

Ventajas:

  • simple;
  • fácil de implementar;
  • fácil de diagnosticar;
  • prácticamente independiente de la tecnología.

Desventajas:

  • agrega consultas periódicas;
  • introduce cierta latencia;
  • necesita controlar concurrencia.

Para muchos sistemas empresariales esta solución es más que suficiente.

2. Worker permanente

En lugar de un proceso periódico podemos tener un servicio dedicado:

Outbox Worker

que se ejecuta permanentemente.

Por ejemplo:

.NET Worker Service
Java Spring Worker
Node.js Worker
PHP Worker

El worker puede procesar registros constantemente y aplicar políticas de retry.

Por ejemplo:

retry 1 → 5 segundos
retry 2 → 30 segundos
retry 3 → 2 minutos
retry 4 → 10 minutos

Esto suele implementarse mediante:

exponential backoff

3. Change Data Capture

Una alternativa más sofisticada consiste en no consultar constantemente la tabla.

Podemos utilizar:

Change Data Capture

para detectar nuevos registros.

Herramientas como:

Debezium
Kafka Connect

pueden leer directamente el transaction log de la base de datos.

La arquitectura podría ser:

La aplicación sigue escribiendo:

Negocio + Outbox

en una única transacción, pero la publicación de eventos puede realizarse observando los cambios del log de la base de datos.

Esto evita gran parte del polling y puede funcionar muy bien en arquitecturas de gran volumen.

A cambio introduce más infraestructura y complejidad operativa.

Un Outbox real necesita algo más que un payload

En producción probablemente nuestra tabla termine teniendo más información.

Por ejemplo:

CREATE TABLE outbox_messages (
    id UUID PRIMARY KEY,
    aggregate_type VARCHAR(100),
    aggregate_id VARCHAR(100),
    event_type VARCHAR(100),
    payload JSONB,
    created_at TIMESTAMP,
    processed_at TIMESTAMP,
    retry_count INT DEFAULT 0,
    last_error TEXT
);

Esto nos permite observar cosas como:

Evento: PedidoCreado
Pedido: PED-123
Intentos: 4
Último error: RabbitMQ unavailable
Estado: Pending

Y ahí aparece algo que muchas veces se subestima:

El Outbox también se convierte en una herramienta de observabilidad.

Podemos saber exactamente qué eventos:

  • fueron generados;
  • fueron publicados;
  • están pendientes;
  • fallaron;
  • fueron reintentados.

Esto facilita muchísimo investigar problemas en producción.

¿Y qué hacemos con los registros procesados?

Una tabla Outbox puede crecer rápidamente.

Supongamos:

100 eventos por segundo

Eso representa aproximadamente:

8.640.000 eventos diarios

Por eso normalmente necesitamos una política de limpieza.

Por ejemplo:

DELETE FROM outbox_messages
WHERE processed_at < NOW() - INTERVAL '7 days';

También podemos:

  • archivar eventos;
  • mover registros históricos;
  • particionar la tabla;
  • ejecutar jobs de housekeeping.

La estrategia dependerá de cuánto valor tenga conservar el historial.

Outbox no significa exactamente once

Es común escuchar:

“Con Outbox conseguimos exactamente una entrega.”

No necesariamente.

El patrón Outbox garantiza principalmente que:

Cambio de negocio
      +
Creación del evento

ocurran atómicamente.

Pero la comunicación con un broker sigue teniendo las características propias de los sistemas distribuidos.

Por eso el diseño más realista suele ser:

Transactional Outbox
        +
At-Least-Once Delivery
        +
Idempotent Consumers

En muchos sistemas esta combinación es muchísimo más robusta que intentar construir una garantía global de exactly-once.

Ejemplo completo

Imaginemos un e-commerce.

Un usuario realiza una compra.

Paso 1 — Pedidos

El servicio de Pedidos ejecuta:

BEGIN TRANSACTION

crear Pedido PED-123

crear evento EVT-789
PedidoCreado

COMMIT

Tenemos:

pedidos
──────────────
PED-123

outbox
──────────────────────
EVT-789 | PedidoCreado

Paso 2 — Publicación

El Outbox Publisher lee:

EVT-789

y publica:

pedidos.creado

en Kafka o RabbitMQ.

Luego marca:

EVT-789 → processed

Paso 3 — Consumidores

El evento llega a:

Stock
Facturación
Notificaciones
Analytics

Cada sistema reacciona de manera independiente:

Stock
→ reserva productos

Facturación
→ genera factura

Notificaciones
→ envía email

Analytics
→ registra la compra

Ninguno necesita que el servicio de Pedidos llame directamente a los demás.

¿Cuándo usar Transactional Outbox?

Es especialmente útil cuando tenemos:

  • microservicios;
  • arquitectura event-driven;
  • RabbitMQ;
  • Apache Kafka;
  • Azure Service Bus;
  • AWS SQS/SNS;
  • procesos de integración;
  • sincronización entre sistemas;
  • operaciones que no pueden perder eventos.

Por ejemplo:

Pedido creado
Pago confirmado
Usuario registrado
Factura emitida
Stock actualizado
Alumno inscripto
Documento generado
Transferencia iniciada

Si perder uno de estos eventos puede dejar distintos sistemas en estados inconsistentes, Outbox es un patrón que vale la pena considerar.

¿Cuándo quizás no hace falta?

No toda aplicación necesita Outbox.

Si tenemos:

Frontend
   ↓
Backend monolítico
   ↓
Una única base de datos

y todas las operaciones ocurren dentro de esa misma base de datos, probablemente una transacción tradicional sea suficiente.

Agregar:

Outbox
Workers
Broker
Retries
Idempotencia
Observabilidad

sin una necesidad real simplemente aumenta la complejidad.

Como casi siempre en arquitectura:

un patrón resuelve un problema; no debería utilizarse solamente porque está de moda.