Todo servicio orientado a eventos acaba escribiendo código como este: guardar el pedido y luego publicar OrderPlaced. En las pruebas funciona. En producción, el proceso se cae entre las dos líneas y el pedido existe sin el evento. O la publicación tiene éxito, la transacción de la base de datos se revierte y el evento anuncia un pedido que no existe.
Ese es el problema de la doble escritura (dual write), y el patrón transactional outbox es la solución estándar. Este artículo explica el problema, el patrón, las dos formas de construir el relay y los detalles que deciden si de verdad funciona: orden, duplicados y limpieza.
El problema de la doble escritura
Una base de datos y un broker de mensajes son dos sistemas sin una transacción compartida. Cualquier código que escriba en ambos tiene una ventana en la que una escritura ha tenido éxito y la otra no:
| Orden de las operaciones | Qué puede salir mal |
|---|---|
| Commit y luego publicar | Caída después del commit: el evento se pierde para siempre |
| Publicar y luego commit | El commit falla: los consumidores actúan sobre un pedido que nunca existió |
| Publicar dentro de la transacción | La llamada al broker tiene éxito y la transacción igualmente se revierte |
Los reintentos no cierran la ventana; la mueven. Los trucos de orden, como publicar desde un hook posterior al commit, solo la reducen: una caída entre el commit y el hook sigue perdiendo el evento. Las transacciones distribuidas entre la base de datos y el broker rara vez están disponibles, y son lentas cuando lo están.
El patrón
Escribe el evento en la base de datos, en la misma transacción que los datos de negocio, y publícalo más tarde desde allí:
- El servicio inserta la fila del pedido y una fila de outbox (ID del evento, ID del agregado, tipo, payload) en una sola transacción local. Las dos se confirman o ninguna.
- Un relay independiente lee las filas nuevas de la outbox y las publica en el broker.
- El relay marca las filas como publicadas, o las borra, cuando el broker confirma la recepción.
Ahora la garantía es sencilla: si el pedido existe, su evento se publicará al menos una vez. La descripción del patrón de Chris Richardson usa la misma estructura.
CREATE TABLE outbox (
id uuid PRIMARY KEY,
aggregate_id bigint NOT NULL,
type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Dos formas de construir el relay
Publicador por polling. Un bucle selecciona por lotes las filas no publicadas, las publica y las borra. En Postgres, SELECT ... FOR UPDATE SKIP LOCKED permite que varias instancias del relay se repartan el trabajo sin bloquearse entre sí; consulta la documentación de la cláusula de bloqueo. Es fácil de construir y de entender. Los costes son la latencia y la carga del polling, y una sutileza de orden: las filas con ID más bajos pueden confirmarse después que filas con ID más altos, así que un poller que ordena por ID puede saltarse una fila que aún no es visible.
Lectura del log (change data capture). Una herramienta de CDC lee el log de transacciones de la base de datos y convierte las inserciones confirmadas en la outbox en mensajes. El outbox event router de Debezium hace exactamente eso para las tablas outbox y enruta cada fila a un topic según sus columnas. Los eventos salen en el orden de los commits, con baja latencia y sin consultas de polling, a cambio de tener que ejecutar y operar el pipeline de CDC.
| Polling | CDC | |
|---|---|---|
| Latencia | Intervalo de polling | Cerca del momento del commit |
| Orden | Requiere cuidado | Orden de commit |
| Piezas móviles | Un worker | Un conector y la configuración del log |
| Encaja bien con | Volúmenes moderados, equipos pequeños | Mucho volumen, muchas outbox |
Al menos una vez significa duplicados
El relay puede publicar una fila y caerse antes de marcarla como publicada. Al reiniciarse, la publica de nuevo. Es inevitable, así que los consumidores deben ser idempotentes:
- Cada evento lleva un ID único (el ID de la fila de la outbox).
- Los consumidores registran los ID que ya han procesado, idealmente en la misma transacción que el efecto del procesamiento (una tabla inbox), y saltan los ID que ya han visto.
- O haz que el efecto sea idempotente por naturaleza, como un upsert por el ID del pedido.
Orden
A los consumidores suele importarles el orden por agregado: OrderPlaced antes que OrderCancelled para el mismo pedido. Usa el ID del agregado como clave del mensaje para que todos los eventos de un pedido vayan a la misma partición, y publica en el orden de commit (CDC lo hace; un poller debe ordenar por una secuencia asignada en el momento del commit o procesar en serie los eventos de cada agregado). El orden global entre todos los pedidos rara vez hace falta y es caro de ofrecer.
Limpieza
Una tabla outbox que nunca se vacía crece sin límite y ralentiza el poller. Borra las filas después de publicarlas, o guárdalas durante una breve ventana de auditoría y elimina las particiones antiguas. Con CDC, las filas pueden borrarse justo después de insertarlas en la misma transacción, porque el conector lee la inserción del log, no de la tabla.
Repaso de los fallos
Comprueba el diseño frente a cada punto de caída:
- Caída antes del commit. No existen ni el pedido ni el evento. El cliente recibe un error y puede reintentar.
- Caída después del commit, antes de que se ejecute el relay. Las dos filas existen; el relay publica el evento al arrancar.
- Caída del relay después de publicar y antes de borrar la fila. El evento se vuelve a publicar al reiniciar, y los consumidores descartan el duplicado por su ID.
- Broker caído. Las filas se acumulan en la outbox; el servicio sigue aceptando pedidos y el relay se pone al día cuando el broker vuelve. Crea una alerta sobre la antigüedad de la fila no publicada más vieja.
En todos los casos, la base de datos sigue siendo la fuente de verdad, que es justo la propiedad que el patrón existe para garantizar.
El diagrama de arquitectura
Contenido del diagrama
- Order service
- Postgres: one transaction
- orders
- outbox
- Outbox relay
- order-events
- Shipping service
- Email service
- Processed events
- both rows
- new rows
- publish
- OrderPlaced
- seen it?
Cómo leerlo:
- Izquierda: el servicio de pedidos escribe dos filas, en las tablas orders y outbox, dentro de una sola transacción de Postgres. El marco señala el límite de la transacción.
- Centro: el relay de la outbox, por polling o CDC, lee las filas nuevas y las publica en el topic order-events.
- Derecha: los servicios de envíos y de correo consumen los eventos. El servicio de correo consulta una tabla de ID de eventos procesados antes de actuar; así se absorben los duplicados.
- El bloque SQL muestra la transacción, para que nadie tenga que adivinar qué significa aquí «atómicamente».
Qué poner en el diagrama
- El límite de la transacción. Dibuja las dos tablas dentro; ese es todo el patrón.
- El relay y su tipo. Polling o CDC cambia la latencia y el orden.
- La semántica de entrega. Escribe «al menos una vez» en el relay e «idempotente» en cada consumidor.
- La clave del mensaje. Anota que los eventos usan como clave el ID del agregado.
El artículo sobre arquitectura orientada a eventos trata con más detalle el lado del consumidor, y el diagrama del patrón saga muestra dónde hace falta una outbox en cada paso de un workflow entre varios servicios. El artículo sobre replicación de bases de datos explica el write-ahead log que lee CDC.
Constrúyelo en Archboard
- Inicia sesión con Google o GitHub y crea un diagrama.
- Añade un Contenedor del pack Modelo C4 para el servicio de pedidos y otro para el relay de la outbox.
- Añade un Marco del pack Básico con el nombre de la base de datos y la transacción, y coloca dentro dos nodos Entidad del pack Entidad-relación para las tablas orders y outbox.
- Añade Kafka del pack Datos para el topic, nodos Contenedor para los consumidores y una Base de datos para los ID de eventos procesados.
- Añade un bloque Markdown del pack Básico con el SQL de la transacción.
El ejemplo usa 10 nodos de diagrama, dentro del plan Free.
Preguntas frecuentes
¿Qué problema resuelve la transactional outbox? Perder o inventar eventos cuando un servicio escribe en su base de datos y publica un mensaje en dos pasos separados. La outbox hace que el evento forme parte de la misma transacción de base de datos.
¿El patrón outbox garantiza exactly once? No. Garantiza la entrega al menos una vez. Los consumidores deben gestionar los duplicados, normalmente registrando los ID de eventos procesados.
Polling o CDC: ¿qué relay debo usar? El polling es más sencillo y suficiente para volúmenes moderados. CDC ofrece menor latencia y entrega en orden de commit, y compensa con mucho volumen o cuando muchos servicios usan outbox.
¿Sigo necesitando la outbox si uso event sourcing? No cuando el propio event store es la fuente a la que se suscriben los consumidores. La necesitas siempre que un servicio guarde su estado en tablas y además tenga que publicar eventos.
¿Cuánto crece la tabla outbox? Tanto como le dejes. Borra pronto las filas publicadas o particiona la tabla por tiempo y elimina las particiones antiguas.