Outbox Pattern
Cómo garantizar la publicación confiable de eventos en sistemas distribuidos usando transactional outbox.
Qué problema resuelve
En sistemas basados en eventos, un microservicio necesita hacer dos cosas cuando procesa una operación: guardar datos en su base de datos y publicar un evento para notificar a otros servicios. El problema es que estas son dos operaciones en dos sistemas diferentes (base de datos y broker de mensajes), y no hay una transacción atómica que las cubra a ambas.
El problema del dual write
Servicio de Pedidos:
1. Guardar pedido en PostgreSQL ✅
2. Publicar "PedidoCreado" en Kafka ❌ (fallo de red)
Resultado: El pedido existe en la base de datos,
pero ningún otro servicio se enteró.
Este escenario se llama dual write (escritura dual) y puede causar:
- Datos guardados, evento perdido: El pedido existe pero el inventario nunca se reservó
- Evento publicado, datos no guardados: Otros servicios reaccionan a un pedido que no existe
- Inconsistencia silenciosa: El sistema parece funcionar pero los datos entre servicios divergen
Intentar resolver esto con “publicar primero, guardar después” solo invierte el problema. Y usar transacciones distribuidas (2PC) introduce latencia y puntos de fallo.
Escenarios reales donde ocurre
| Escenario | Consecuencia sin Outbox |
|---|---|
| Crear pedido + notificar inventario | Stock no se reserva, se vende producto agotado |
| Procesar pago + notificar envíos | Pago cobrado pero envío nunca se programa |
| Actualizar perfil + notificar servicios | Datos desincronizados entre servicios |
| Completar saga paso N + publicar evento | Saga se queda atascada en paso intermedio |
Cómo funciona
El Outbox Pattern resuelve el dual write usando una sola transacción de base de datos para ambas operaciones. En lugar de publicar el evento directamente al broker, el servicio escribe el evento en una tabla outbox dentro de la misma transacción que guarda los datos de negocio.
Flujo básico
┌─────────────────────────────────────────────┐
│ Transacción de base de datos (atómica) │
│ │
│ 1. INSERT INTO pedidos (...) │
│ 2. INSERT INTO outbox (evento, payload...) │
│ │
│ COMMIT │
└─────────────────────────────────────────────┘
│
│ (proceso separado)
▼
┌─────────────────────┐ ┌──────────────┐
│ Relay / Publisher │────►│ Kafka / │
│ (lee tabla outbox) │ │ RabbitMQ │
└─────────────────────┘ └──────────────┘
La clave es que ambas escrituras (datos de negocio + evento) ocurren en la misma transacción de base de datos. Si la transacción falla, ninguna de las dos se persiste. Si tiene éxito, ambas se persisten. No hay ventana de inconsistencia.
Estructura de la tabla outbox
CREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(255) NOT NULL, -- "Pedido", "Pago"
aggregate_id VARCHAR(255) NOT NULL, -- ID del agregado
event_type VARCHAR(255) NOT NULL, -- "PedidoCreado"
payload JSONB NOT NULL, -- datos del evento
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
published_at TIMESTAMP NULL -- NULL = pendiente
);
-- Índice para el publisher
CREATE INDEX idx_outbox_pending
ON outbox (created_at)
WHERE published_at IS NULL;
Ejemplo de uso en código
// Dentro de una transacción de base de datos
BEGIN;
-- 1. Operación de negocio
INSERT INTO pedidos (id, cliente_id, total, estado)
VALUES ('ped-001', 'cli-789', 150.00, 'confirmado');
INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio)
VALUES ('ped-001', 'prod-42', 2, 75.00);
-- 2. Evento en la tabla outbox (misma transacción)
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES (
'Pedido',
'ped-001',
'PedidoConfirmado',
'{"pedidoId": "ped-001", "clienteId": "cli-789", "total": 150.00}'
);
COMMIT;
-- Ambas escrituras son atómicas: o se guardan las dos, o ninguna
Estrategias de publicación
Hay dos formas principales de leer la tabla outbox y publicar los eventos al broker:
Polling Publisher (Publicador por sondeo)
Un proceso periódico consulta la tabla outbox buscando eventos no publicados, los envía al broker y los marca como publicados.
┌──────────────┐ ┌──────────┐
│ Outbox │ SELECT * FROM │ Polling │
│ Table │◄─── outbox WHERE ──│ Publisher│
│ │ published_at NULL │ │
└──────────────┘ └────┬─────┘
│
Publicar evento
│
▼
┌──────────┐
│ Broker │
└──────────┘
Ventajas: Simple de implementar, no requiere infraestructura adicional. Desventajas: Latencia (depende del intervalo de polling), carga en la base de datos.
Implementación típica del polling:
// Cada 500ms (configurable)
loop {
eventos = SELECT * FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED; -- evita conflictos entre publishers
for evento in eventos {
broker.publish(evento.event_type, evento.payload);
UPDATE outbox
SET published_at = NOW()
WHERE id = evento.id;
}
}
Change Data Capture (CDC)
Un conector CDC (como Debezium) monitorea el log de transacciones de la base de datos y captura automáticamente las inserciones en la tabla outbox.
┌──────────────┐ ┌──────────┐
│ PostgreSQL │ WAL / binlog │ Debezium │
│ Transaction │──────────────────► │ (CDC) │
│ Log │ └────┬─────┘
└──────────────┘ │
Publicar evento
│
▼
┌──────────┐
│ Kafka │
└──────────┘
Ventajas: Latencia muy baja (casi real-time), no genera carga adicional con queries. Desventajas: Requiere infraestructura adicional (Debezium, Kafka Connect), más complejo de operar.
Comparación de estrategias
| Aspecto | Polling | CDC |
|---|---|---|
| Latencia | 100ms - 5s (configurable) | < 100ms (casi real-time) |
| Complejidad | Baja (un cron o proceso simple) | Alta (Debezium + Kafka Connect) |
| Carga en BD | Queries periódicos | Lee del WAL, sin queries extra |
| Infraestructura | Mínima | Debezium, Kafka Connect, Zookeeper |
| Escalabilidad | Limitada por la BD | Alta (CDC es muy eficiente) |
| Recomendado para | Volumen bajo-medio | Volumen alto, baja latencia |
Garantías de entrega
El Outbox Pattern garantiza at-least-once delivery (entrega al menos una vez). Esto significa que un evento puede publicarse más de una vez. Por eso, los consumidores deben ser idempotentes.
Escenario de duplicación:
1. Publisher lee evento de outbox
2. Publisher publica evento en Kafka ✅
3. Publisher falla antes de marcar como publicado ❌
4. Publisher se reinicia y vuelve a publicar el mismo evento
Solución: El consumidor detecta el duplicado por el ID del evento
y lo ignora (Idempotency Pattern).
Limpieza de la tabla outbox
La tabla outbox crece continuamente. Necesitas una estrategia de limpieza:
-- Opción 1: Borrar eventos publicados hace más de 7 días
DELETE FROM outbox
WHERE published_at IS NOT NULL
AND published_at < NOW() - INTERVAL '7 days';
-- Opción 2: Particionado por fecha (más eficiente)
-- Crear particiones mensuales y dropear particiones antiguas
CREATE TABLE outbox (
...
) PARTITION BY RANGE (created_at);
-- Opción 3: Mover a tabla de archivo antes de borrar
INSERT INTO outbox_archive SELECT * FROM outbox
WHERE published_at < NOW() - INTERVAL '30 days';
Ventajas
- Consistencia garantizada: Datos y eventos se escriben en la misma transacción, eliminando el dual write
- Sin transacciones distribuidas: No necesitas 2PC ni XA transactions
- Auditoría natural: La tabla outbox funciona como log de todos los eventos publicados
- Resiliencia: Si el broker está caído, los eventos se acumulan y se publican cuando vuelva
- Desacoplamiento temporal: El servicio no depende de la disponibilidad del broker
- Compatible con cualquier base de datos relacional: Solo necesitas una tabla adicional
Trade-offs / Desventajas
- Latencia adicional: Los eventos no se publican instantáneamente (especialmente con polling)
- Complejidad operacional: Necesitas mantener el publisher o el conector CDC
- Tabla outbox crece: Necesitas una estrategia de limpieza periódica
- At-least-once: Los consumidores deben manejar duplicados (idempotencia obligatoria)
- Acoplamiento con la base de datos: CDC depende de features específicas del motor (WAL, binlog)
- Overhead en escritura: Cada operación escribe una fila adicional en la tabla outbox
Cuándo usar
- Sistemas event-driven donde la confiabilidad de los eventos es crítica
- Microservicios que necesitan notificar cambios a otros servicios de forma confiable
- Cuando implementas el Saga Pattern y necesitas garantizar que los eventos de cada paso se publiquen
- Sistemas financieros o de e-commerce donde perder un evento tiene impacto en el negocio
- Cuando ya usas una base de datos relacional y quieres evitar transacciones distribuidas
- Flujos donde la consistencia eventual es aceptable pero la pérdida de eventos no
Cuándo evitar
- Sistemas que requieren publicación de eventos en tiempo real estricto (microsegundos)
- Cuando usas una base de datos que no soporta transacciones ACID (algunos NoSQL)
- Aplicaciones simples donde publicar directamente al broker es suficiente
- Sistemas con muy bajo volumen de eventos donde la complejidad no se justifica
- Cuando ya tienes Event Sourcing implementado (el event store ya es tu “outbox”)
Tecnologías e implementaciones comunes
| Categoría | Opciones |
|---|---|
| CDC | Debezium, Maxwell (MySQL), AWS DMS |
| Brokers | Apache Kafka, RabbitMQ, Amazon SNS/SQS, Azure Service Bus |
| Bases de datos | PostgreSQL (WAL), MySQL (binlog), SQL Server (CT) |
| Frameworks | Eventuate Tram, MassTransit Outbox (.NET), Wolverine (.NET) |
| Conectores | Kafka Connect, Debezium Server |
| Limpieza | Cron jobs, pg_partman (particionado por fecha) |
Relación con otros patrones
El Outbox Pattern es una pieza fundamental en la infraestructura de sistemas event-driven:
┌─────────────────────────────────────────────────┐
│ Outbox en el ecosistema │
│ │
│ ┌──────────┐ necesita ┌──────────────────┐ │
│ │ Saga │ ──────────► │ Outbox Pattern │ │
│ │ Pattern │ publicar │ (este patrón) │ │
│ └──────────┘ eventos └────────┬─────────┘ │
│ │ │
│ entrega al │
│ menos una vez │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Idempotency │ │
│ │ Pattern │ │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ usa ┌──────────────┐ │
│ │ Event-Driven │ ────────►│ Outbox │ │
│ │ Architecture │ para │ (confiable) │ │
│ └──────────────┘ eventos └──────────────┘ │
└─────────────────────────────────────────────────┘
- Saga Pattern: Cada paso de una saga necesita publicar eventos de forma confiable. El Outbox garantiza que ningún evento se pierda entre pasos
- Idempotency Pattern: Como el Outbox garantiza at-least-once, los consumidores deben ser idempotentes para manejar duplicados
- Event-Driven Architecture: El Outbox es la infraestructura que hace confiable la publicación de eventos en una arquitectura event-driven
- Circuit Breaker: Si el publisher del outbox no puede conectar al broker, el circuit breaker evita reintentos infinitos
- DDD: Los Domain Events que genera cada bounded context se publican de forma confiable a través del outbox
Errores comunes
| Error | Consecuencia | Solución |
|---|---|---|
| No limpiar la tabla outbox | Tabla crece sin límite, queries lentos | Cron de limpieza o particionado |
| Publisher sin idempotencia | Eventos duplicados en el broker | Consumidores idempotentes + dedup |
| Payload demasiado grande | Tabla outbox ocupa mucho espacio | Guardar solo IDs, consumidor consulta detalles |
| Un solo publisher | Cuello de botella en alto volumen | Múltiples publishers con SKIP LOCKED |
| No monitorear el lag | Eventos se acumulan sin detectar | Alertas por tamaño de cola pendiente |
Próximos pasos
Con la publicación confiable de eventos resuelta, el siguiente desafío es proteger tus servicios cuando las dependencias fallan. El Circuit Breaker es el patrón que evita que un servicio caído arrastre a todo el sistema. También explora el Idempotency Pattern para asegurar que los consumidores de tus eventos manejen duplicados correctamente.