El patrón transactional outbox para publicación fiable de eventos
Publicar un evento tras un commit de base de datos crea una ventana de fallo donde la escritura tiene éxito y el mensaje nunca sale. El patrón transactional outbox escribe estado de negocio y eventos salientes en una transacción — luego retransmite de forma asíncrona con entrega at-least-once y consumidores idempotentes.
El pedido quedó marcado como pagado. La base de datos hizo commit. El broker de mensajes nunca recibió OrderPaid — la aplicación crasheó entre COMMIT y publish(). El inventario seguía mostrando stock disponible. El almacén vendió de más. El ingeniero de guardia lo llamó un blip de red transitorio y reprodujo manualmente desde logs. El patrón transactional outbox existe porque las escrituras duales no son atómicas: el cambio de estado y la publicación del evento son dos sistemas sin frontera de transacción compartida a menos que el evento se escriba dentro del mismo commit que la fila de negocio.
Las arquitecturas event-driven prometen acoplamiento laxo — los servicios reaccionan a hechos en lugar de llamarse entre sí de forma síncrona. La promesa se rompe en la costura donde los hechos salen de la base de datos. Sin outbox, los equipos eligen entre publicar antes del commit (eventos por trabajo no confirmado), publicar después del commit (eventos perdidos en crash) o transacciones distribuidas (impracticables entre Postgres y Kafka). El outbox es el tercer camino pragmático: persistir la intención de publicar atómicamente con el cambio de estado, luego retransmitir en un proceso separado que puede reintentar sin mentir sobre durabilidad.
The dual-write problem and why it persists
Escritura dual: actualizar tabla de dominio, luego llamar API del broker de mensajes. Dos operaciones independientes. Modos de fallo:
| Failure point | Database state | Broker state | User-visible result |
|---|---|---|---|
| Crash tras commit, antes de publish | Actualizado | Sin mensaje | Downstream obsoleto; silencioso |
| Publish tiene éxito, DB hace rollback | Sin cambios | Mensaje enviado | Evento fantasma; sobreprocesamiento |
| Timeout de publish, resultado desconocido | Actualizado | Desconocido | Duplicado o faltante en retry |
| Batch parcial en bucle | Algunas filas actualizadas | Algunos mensajes enviados | Vista cross-service inconsistente |
"Usar una transacción" no abarca Postgres y RabbitMQ. El commit en dos fases entre almacenes heterogéneos añade costo operativo que la mayoría de equipos de producto rechazan. La coreografía saga sin eventos salientes duraderos empuja complejidad a consumidores que compensan eventos que nunca llegaron.
Si el evento no está en la misma transacción que el cambio de estado, el evento no está garantizado.
El outbox colapsa la escritura dual en escritura única: una transacción, dos inserts (o update + insert), un commit. La publicación se convierte en un problema de leer-y-reenviar — resoluble con reintentos, idempotencia y monitoreo.
Outbox table design and the atomic write
Esquema mínimo de outbox:
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ,
publish_attempts INT NOT NULL DEFAULT 0
);
CREATE INDEX outbox_unpublished_idx
ON outbox (created_at)
WHERE published_at IS NULL;Transacción de aplicación:
BEGIN;
UPDATE orders
SET status = 'paid', paid_at = now()
WHERE id = $1 AND status = 'pending';
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES (
'order',
$1::text,
'OrderPaid',
jsonb_build_object(
'order_id', $1,
'amount_cents', $2,
'paid_at', now()
)
);
COMMIT;Si la transacción hace commit, el evento existe. Si hace rollback, ni estado ni evento persisten. El timing del crash ya no crea commit-sin-evento — la fila del evento es parte del commit.
Convenciones de payload: incluir versión de esquema, correlation ID y causation ID para trazabilidad. Mantener payloads pequeños — referenciar blobs grandes por ID en lugar de incrustarlos. Los strings de event type deben ser estables entre deploys; los renombres requieren estrategia de compatibilidad con consumidores.
Relay patterns: polling, CDC, and dedicated publishers
Algo debe mover filas de outbox al broker. Tres enfoques comunes:
Polling publisher. Un worker consulta filas no publicadas, publica, marca published_at. Simple, funciona en todas partes, introduce latencia de intervalo de poll.
SELECT id, event_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;FOR UPDATE SKIP LOCKED permite que múltiples instancias de publisher repartan trabajo sin doble publicación de la misma fila en un paso — aunque at-least-once al broker aún requiere idempotencia del consumidor.
Change data capture (CDC). Debezium o replicación lógica transmiten eventos insertados desde la tabla outbox. Menor latencia que polling; añade infraestructura CDC y superficie operativa. Encaja en equipos que ya ejecutan Kafka Connect.
In-process relay after commit. El mismo hilo de aplicación publica post-commit — recrea la ventana de escritura dual salvo implementación cuidadosa con callbacks transaccionales. Generalmente desaconsejado como patrón principal; hooks post-commit aún fallan en crash.
El polling está infravalorado para throughput moderado. CDC gana en alto volumen y requisitos de latencia subsegundo. Ambos marcan published_at solo tras ack del broker — si el marcado falla tras publicación exitosa, ocurre entrega duplicada; los consumidores deben deduplicar.
Manejo de fallos del relay: incrementar publish_attempts, backoff exponencial, dead-letter tras umbral, alertar sobre filas no publicadas más antiguas que el SLO. Filas no publicadas obsoletas son el equivalente outbox de profundidad de cola — ponerlas en dashboard.
Delivery semantics: at-least-once end to end
El outbox garantiza que el evento eventualmente saldrá de la base de datos si el relay corre. No garantiza entrega exactly-once a consumidores. Reintentos del broker, crashes del relay tras publish-antes-de-marcar y semántica at-least-once del broker producen duplicados.
Stack de fiabilidad completo:
Outbox (atomic with state) → relay (at-least-once) → broker (at-least-once)
→ consumer (idempotent handler)
La idempotencia del consumidor usa dedup por event ID — los mismos patrones que upserts idempotentes en Postgres: insertar event ID en tabla processed en la misma transacción que efectos secundarios, o upsert de estado de dominio con guardas de versión monótona.
El event_id del outbox debe propagarse como message key o header. Los consumidores deduplican por event_id, no por message ID del broker — los IDs del broker difieren en redelivery.
Las garantías de orden son por agregado, no globales. Particionar por aggregate_id para que OrderPaid del pedido 42 siempre caiga en la misma partición si el orden importa. El orden cross-aggregate no se provee — diseñar consumidores en consecuencia.
Outbox vs full event sourcing: scope the pattern correctly
El outbox resuelve publicación fiable desde escrituras OLTP. No es event sourcing — aunque ambos componen.
event sourcing without ceremony describe logs de dominio append-only como fuente de auditoría y proyección. El outbox es tabla de integración: los eventos salen del límite del servicio. Un servicio puede hacer event sourcing internamente y aún usar outbox para notificaciones cross-service — o usar solo outbox sin replay de estado interno desde eventos.
| Concern | Outbox | Event store |
|---|---|---|
| Primary goal | Publicación cross-service fiable | Historial de dominio completo + replay |
| Read pattern | Relay escanea filas no publicadas | Consumo de stream, proyecciones |
| Retention | Eliminar tras publish + buffer | Append-only, retención larga |
| Schema evolution | Payloads forward-compatible | Upcasting de eventos versionados |
A veces los equipos almacenan todos los eventos de dominio en una tabla de eventos y tratan filas no publicadas como outbox — viable a escala pequeña. A mayor escala, separar outbox de integración del log de eventos de dominio clarifica retención, indexación y patrones de acceso. Las filas outbox se eliminan o archivan tras publish; los eventos de dominio permanecen.
Operational concerns: cleanup, backpressure, and migrations
Retention and cleanup. Las filas publicadas se acumulan. Archivar en almacenamiento frío o eliminar tras siete a treinta días según necesidades de replay y auditoría. Particionar outbox por semana para drops baratos.
Backpressure. Si el lag del relay crece — broker caído, consumidor lento — las filas no publicadas se acumulan. Alertar sobre conteo y antigüedad de filas. Considerar pausar productores no críticos o shed de carga antes de que el bloat de la tabla outbox afecte rendimiento OLTP.
Schema migrations. Las migraciones de tabla outbox siguen disciplina de migración zero-downtime — el outbox está en el hot path. Añadir columnas nullable; backfill; contraer después. Nunca bloquear relay con locks exclusivos en pico.
Multi-region. El outbox asume escrituras single-primary. Active-active global con outbox requiere resolución de conflictos en aggregate IDs — usualmente evitado. El outbox encaja con single-primary por agregado y réplicas de lectura regionales.
Testing. Los tests de integración afirman: commit sin relay → fila no publicada; relay → broker recibe exactamente un evento lógico por event_id; crash tras publish antes de marcar → redelivery sin efecto duplicado del consumidor cuando es idempotente.
How should teams implement the transactional outbox?
Elecciones prácticas para servicios que envían integraciones event-driven.
Polling interval vs CDC: how to choose?
Polling cada uno a cinco segundos con SKIP LOCKED maneja miles de eventos por minuto en hardware modesto. CDC cuando latencia subsegundo, throughput muy alto o infraestructura Kafka existente justifica costo operativo. Empezar con polling; migrar a CDC cuando las métricas demuestren necesidad.
Should the outbox share a database with domain tables?
Sí, para la garantía atómica. Outbox en base de datos separada recrea escritura dual salvo que ambas participen en XA — lo que derrota la simplicidad. Colocar outbox con la base OLTP primaria del servicio.
When can post-commit publish replace outbox?
Cuando eventos perdidos son aceptables (solo analytics), cuando un batch de reconciliación sana brechas en horas, o cuando el volumen es bajo y replay manual es tolerable. No cuando inventario, facturación o fulfillment downstream dependen de eventos oportunos.
How does outbox interact with sagas?
Las sagas coordinan flujos de trabajo distribuidos multi-paso. La transacción local de cada paso incluye eventos outbox para los triggers del siguiente paso. Los pasos compensatorios emiten eventos compensatorios por el mismo outbox — consumidores idempotentes aplican eventos forward y compensatorios con seguridad.
A common argument runs the other way
La postura opuesta sostiene que brokers modernos y mensajería cloud-managed son lo bastante fiables para publicar directamente tras commit, especialmente con publisher confirms y librerías de retry.
Publisher confirms prueban que el broker recibió el mensaje — no que la aplicación lo envió. La ventana de crash entre commit de base de datos y HTTP POST al broker es microsegundos a milisegundos en cada request — rara individualmente, garantizada a escala. Las librerías de retry ayudan con fallos transitorios; no ayudan con muerte de proceso entre commit y primer intento de publish. El outbox mueve la intención de publicación dentro del límite de commit durable; los reintentos del relay se desacoplan del ciclo de vida del request. Publicación directa es aceptable para analytics best-effort; es insuficiente cuando la corrección downstream depende de que cada cambio de estado produzca un evento.
Key takeaways
- La escritura dual entre base de datos y broker no es atómica — el outbox escribe eventos en la misma transacción que el estado de negocio.
- Los procesos relay hacen poll o CDC de filas no publicadas; marcar publicado solo tras ack del broker.
- La fiabilidad end-to-end requiere consumidores idempotentes keyed en event ID — at-least-once es la garantía realista.
- El outbox es infraestructura de integración, no event sourcing — acotar retención y esquema por separado de logs de eventos de dominio.
- Monitorear antigüedad y conteo de filas no publicadas — el lag del relay es métrica de primera clase.
- Particionar trabajo con
FOR UPDATE SKIP LOCKED; particionar mensajes por aggregate ID para orden. - Probar timing de crash: commit sin relay, publish sin marcar, entrega duplicada al consumidor.
Conclusion
La publicación fiable de eventos es un problema de base de datos disfrazado de problema de mensajería. El patrón transactional outbox hace durable la intención de publicación junto a escrituras de negocio, luego delega la entrega a procesos relay reintentables que toleran fallos de broker y proceso sin brechas silenciosas.
Los servicios que emiten eventos de dominio sin outbox deberían auditar la ventana de crash entre commit y publish — y medir cuántos cambios de estado nunca produjeron un mensaje downstream. La solución es una tabla, un insert extra por transacción y un bucle de publisher que operaciones puede monitorear como cualquier otra métrica de profundidad de cola.