Cuando un servicio modifica su base y quiere avisar al resto del sistema enviando un evento a Kafka, RabbitMQ o SQS, el código ingenuo se parece a esto: escribir en base y luego publicar. Si la publicación falla tras el commit, el evento se pierde. Si la publicación tiene éxito pero el commit falla, el evento habla de un estado que no existe. Ambos casos son la definición del dual-write problem.
El patrón Transactional Outbox resuelve esa incoherencia con una idea simple: nunca publicar directamente. El evento se escribe en una tabla outbox dentro de la misma transacción SQL que el cambio de negocio. Un proceso separado lee esa tabla y publica al broker. Mientras la transacción SQL sea atómica, la base y el futuro evento son coherentes por construcción.
Este segundo artículo de la serie sobre patrones de arquitectura distribuida sigue al patrón Saga, al que completa de forma natural para los pasos que deben emitir eventos de manera fiable.
El dual-write problem: dos escrituras sin atomicidad
Volvamos al ejemplo del pedido e-commerce. Cuando el estado pasa a confirmado, varios sistemas deben ser avisados: el servicio de emails, el servicio de analítica, el data warehouse. Publicamos un evento PedidoConfirmado en Kafka.
def confirmar_pedido(pedido_id: int) -> None:
with transaction.atomic():
Pedido.objects.filter(id=pedido_id).update(estado="confirmado")
kafka.publish("pedidos.confirmados", {"id": pedido_id})
Existen cuatro casos:
- La transacción hace commit, la publicación tiene éxito. Caso nominal.
- La transacción falla, la publicación no ocurre. Coherente.
- La transacción hace commit, la publicación falla (broker caído, timeout). El pedido está confirmado en base, pero nadie lo sabe. El email nunca sale.
- La transacción hace commit, la publicación tiene éxito, pero la red se corta antes del ack. El worker reintenta y publica una segunda vez. Duplicado.
Puedes invertir el orden (publicar primero, commit después), pero eso convierte el caso 3 en su opuesto: un evento que habla de un pedido nunca confirmado. El problema no desaparece, cambia de forma.
Ninguna combinación de try/except arregla esto. Hace falta un mecanismo que haga que la publicación misma sea transaccional.
El principio: una transacción para dos destinos
La idea del Outbox: la publicación no escribe a Kafka, escribe a una tabla SQL outbox. Esa escritura forma parte de la transacción de negocio. Si el commit pasa, el evento existe en base. Si falla, el evento nunca existió.
Un proceso separado, llamado relay o dispatcher, lee la tabla outbox, publica cada fila al broker, y luego marca la fila como publicada (o la borra). Ese proceso puede crashear, reiniciarse, reintentar cuanto quiera. Mientras publique al menos una vez lo que encuentre en la tabla, el evento llega.
El sistema se vuelve at-least-once: un consumidor puede recibir el mismo evento varias veces. Esa garantía es aceptable si los consumidores son idempotentes. En la práctica es la única garantía alcanzable en un sistema distribuido, y mucho más útil que el “exactly-once” que nadie tiene realmente.
La tabla outbox
Un esquema mínimo:
from django.db import models
from django.db.models import Q
class OutboxEvent(models.Model):
id = models.BigAutoField(primary_key=True)
aggregate_type = models.CharField(max_length=64)
aggregate_id = models.CharField(max_length=64)
event_type = models.CharField(max_length=128)
payload = models.JSONField()
created_at = models.DateTimeField(auto_now_add=True)
published_at = models.DateTimeField(null=True)
class Meta:
indexes = [
models.Index(
fields=["id"],
name="outbox_unpublished_idx",
condition=Q(published_at__isnull=True),
),
]
El índice parcial solo contiene las filas no publicadas. En una tabla que crece (millones de filas archivadas), se mantiene casi de tamaño cero y la consulta del relay queda constante. Django soporta esta sintaxis vía condition= desde la versión 2.2 y la traduce a CREATE INDEX ... WHERE en PostgreSQL. El id monótono garantiza un orden estable de publicación por agregado.
La escritura se convierte en:
def confirmar_pedido(pedido_id: int) -> None:
with transaction.atomic():
Pedido.objects.filter(id=pedido_id).update(estado="confirmado")
OutboxEvent.objects.create(
aggregate_type="pedido",
aggregate_id=str(pedido_id),
event_type="PedidoConfirmado",
payload={"id": pedido_id},
)
Se acabó el kafka.publish en el código de negocio. La transacción es puramente SQL, atómica de extremo a extremo.
El relay: publicar lo que se ha escrito
El relay bucla, lee los eventos no publicados, los envía al broker, y los marca.
from celery import shared_task
from django.db import transaction
from django.utils import timezone
BATCH_SIZE = 100
@shared_task
def relay_outbox() -> None:
with transaction.atomic():
events = list(
OutboxEvent.objects
.select_for_update(skip_locked=True)
.filter(published_at__isnull=True)
.order_by("id")[:BATCH_SIZE]
)
if not events:
return
for event in events:
kafka.publish(
topic=f"{event.aggregate_type}.{event.event_type}",
key=event.aggregate_id,
value=event.payload,
)
OutboxEvent.objects.filter(
id__in=[e.id for e in events]
).update(published_at=timezone.now())
Ahí se esconden tres decisiones de diseño importantes.
El select_for_update(skip_locked=True) permite ejecutar varios workers de relay en paralelo. Cada worker toma un lote que los demás ignoran. Es lo que hace posible el escalado horizontal sin riesgo de duplicado por concurrencia interna.
La publicación ocurre dentro de la transacción que marca published_at. Si Kafka confirma pero la transacción luego falla, republicamos en la siguiente vuelta. At-least-once por diseño. skip_locked evita que ese bloqueo algo largo afecte a los demás workers: simplemente saltan las filas bloqueadas y toman otro lote.
El orden por id garantiza que los eventos de un mismo agregado se publican en el orden en que fueron creados, siempre que se particione el topic por aggregate_id (la key de Kafka).
La tarea procesa un único batch y devuelve. Celery Beat la vuelve a llamar cada pocos segundos. Eso mantiene transacciones cortas, evita timeouts de tarea y deja al worker libre entre pasadas.
Idempotencia del lado del consumidor
El contrato es at-least-once. El consumidor debe ser idempotente. Dos enfoques.
Deduplicación por event_id. Se añade un UUID estable a OutboxEvent y cada consumidor mantiene una tabla processed_events(event_id) con índice único. Antes de procesar, intenta el insert. Si falla (violación de restricción única), el evento ya ha sido procesado.
Operación naturalmente idempotente. Si la consecuencia del mensaje es “poner el estado a confirmado”, reproducirlo no tiene efecto. Es el caso más simple, y el que hay que perseguir cuando el diseño lo permite.
Sin idempotencia del lado consumidor, el patrón Outbox produce duplicados silenciosos. Es una disciplina a aplicar desde el primer consumidor.
Outbox + Saga: la combinación natural
Un paso de Saga que debe publicar un evento usa el Outbox sin hacer nada más. La escritura de negocio del paso y el evento a emitir comparten la misma transaction.atomic. La compensación, si ocurre, también escribe su propio evento en el Outbox (PagoReembolsado, PedidoCancelado), de manera estrictamente simétrica.
Sin Outbox, un paso de Saga que publica directamente a Kafka puede tener éxito en la publicación y fallar en el commit DB. El ecosistema cree que un pedido fue creado cuando no lo fue. Ambos patrones se refuerzan: la Saga gestiona la coherencia del workflow, el Outbox garantiza que los eventos de cada paso se emitan si y solo si el paso realmente ocurrió.
CDC: la alternativa sin relay aplicativo
Una variante del patrón delega el relay a una herramienta externa basada en Change Data Capture. Debezium lee el WAL de PostgreSQL, ve los inserts en la tabla outbox, y publica a Kafka. Cero código aplicativo para el relay.
Ventajas: nada de carga aplicativa, latencia más baja (el WAL se lee casi en tiempo real), sin polling. Desventajas: un componente más que operar (Kafka Connect, Debezium), una configuración más compleja, y un acoplamiento a PostgreSQL o MySQL vía sus mecanismos de replicación.
Para un proyecto Django de tamaño modesto, un relay Celery es más que suficiente. Para un sistema de alto rendimiento o con requisitos de latencia estrictos, el CDC se vuelve pertinente.
Trampas frecuentes
No purgar el Outbox. La tabla crece indefinidamente si no se borra nada. O se borran las filas tras la publicación, o se conservan unos días y luego se archivan. Mantener un histórico tiene valor de auditoría, pero un outbox con millones de filas acaba ralentizando la selección de no publicados a pesar del índice.
Confundir payload y estado actual. El payload del evento debe reflejar el estado en el momento de la escritura, no el estado actual leído en el momento de la publicación. Si el relay lee Pedido al vuelo, publica el estado presente, que quizá ya cambió. Serializar el payload en la transacción original evita esa trampa.
Olvidar el particionado por agregado. Sin key=aggregate_id del lado Kafka, dos eventos del mismo agregado pueden caer en particiones distintas, y llegar desordenados al consumidor. La garantía de orden de Kafka es por partición, no global.
Cuándo no usar el Outbox
El patrón añade una tabla, un índice, un worker, y una disciplina de idempotencia del lado consumidor. Para una aplicación monolítica que no publica nada al exterior, es inútil.
El Outbox se vuelve pertinente en cuanto:
- publicas a un broker externo (Kafka, RabbitMQ, SQS, Redis Streams)
- la coherencia entre tu base y el evento publicado es crítica
- quieres sobrevivir a las caídas del broker sin perder eventos
- lo combinas con una Saga u otro workflow largo
A la inversa, para una notificación cuya pérdida es aceptable (un log de analítica, un ping de monitoring), publicar directamente es más simple y suficiente.
Conclusión
El Transactional Outbox transforma un problema distribuido (dos escrituras a coordinar) en un problema local (una sola transacción SQL). Todo el trabajo difícil se delega a un relay que puede crashear, reiniciarse y reintentar sin arriesgar la coherencia.
Como la Saga, es un patrón cuya utilidad se revela en el momento en que empiezas a observar lo que realmente ocurre en producción. El día que un broker está caído treinta segundos y ningún evento se pierde, el coste de una tabla extra parece insignificante.
