Alejandría

Busca en Alejandría

Técnicas

Transactional outbox

Publica eventos de forma fiable escribiéndolos en la misma transacción que el cambio de estado.

01

En pocas palabras

Patrón que guarda el mensaje a publicar en una tabla outbox dentro de la transacción de negocio; un proceso aparte lo lee y lo entrega al broker.

Qué problema resuelve

Dual write: actualizar la base de datos y publicar en el broker son dos operaciones que pueden fallar por separado, dejando estado y eventos inconsistentes.

Cuándo utilizarlo

Servicios que persisten estado y notifican a otros mediante eventos. Ejemplo: registrar un desembolso y avisar a contabilidad y notificaciones.

02

De la idea a la práctica

Funcionamiento, ejemplos y decisiones para pasar al siguiente nivel.

El problema del dual write

1. BEGIN; UPDATE creditos SET estado='DESEMBOLSADO'; COMMIT;   ✔
2. kafka.send("credito-desembolsado")                          ✘ timeout

El crédito queda desembolsado, pero contabilidad nunca se entera. Invertir el orden tampoco sirve: el evento sale y la transacción hace rollback. Sin transacciones distribuidas no hay forma de que ambas cosas sean atómicas, salvo que se conviertan en una sola escritura local.

Cómo funciona

  1. En la misma transacción se modifica el agregado y se inserta una fila en outbox.
  2. Un relay lee las filas nuevas y las publica en el broker.
  3. Tras confirmar la publicación, marca o elimina la fila.

Hay dos formas de implementar el relay:

Variante Funcionamiento Ventajas / costes
Polling publisher Un job consulta outbox cada pocos ms o segundos Sencillo; carga y latencia en la base de datos
Log tailing (CDC) Debezium lee el WAL/binlog y emite cada inserción Baja latencia, sin consultas; otra pieza que operar

Ejemplo en PostgreSQL

CREATE TABLE outbox (
  id            uuid PRIMARY KEY,
  aggregate_type text        NOT NULL,   -- 'credito'
  aggregate_id   text        NOT NULL,   -- se usa como clave de partición
  type           text        NOT NULL,   -- 'CreditoDesembolsado'
  payload        jsonb       NOT NULL,
  created_at     timestamptz NOT NULL DEFAULT now()
);

BEGIN;
UPDATE creditos SET estado = 'DESEMBOLSADO' WHERE id = '42';
INSERT INTO outbox (id, aggregate_type, aggregate_id, type, payload)
VALUES (gen_random_uuid(), 'credito', '42', 'CreditoDesembolsado',
        '{"creditoId":"42","monto":1500000,"moneda":"COP"}');
COMMIT;

Un relay por polling que admite varias réplicas sin duplicar trabajo:

SELECT * FROM outbox
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;

Con Debezium, la transformación io.debezium.transforms.outbox.EventRouter enruta cada fila al topic correspondiente a aggregate_type y usa aggregate_id como clave del mensaje.

Orden y duplicados

  • Usa el id del agregado como clave de partición del topic: los eventos de un mismo crédito llegan en orden.
  • El relay puede publicar y caerse antes de marcar la fila: el evento saldrá dos veces. Incluye el id del evento en las cabeceras y deduplica en el consumidor (tabla processed_events o inbox pattern).

Antipatrones frecuentes

  • Publicar en el broker «después del commit» con reintentos en memoria: se pierden eventos si el proceso muere.
  • Tabla outbox sin limpieza: crece sin límite y degrada el relay. Programa un purgado de filas publicadas.
  • Usar el outbox como fuente de verdad o historial de eventos: es un buffer, no event sourcing.

Implementación con Spring y polling

@Service
class OriginacionService {
    @Transactional
    public void aprobar(String solicitudId) {
        var solicitud = repo.findById(solicitudId).orElseThrow();
        solicitud.aprobar();
        outbox.save(OutboxEvent.de("solicitud", solicitudId, "SolicitudAprobada",
                                   json.write(new SolicitudAprobada(solicitudId, Instant.now()))));
    }
}

@Component
class OutboxRelay {
    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publicar() {
        List<OutboxEvent> pendientes = jdbc.query("""
            SELECT * FROM outbox WHERE published_at IS NULL
            ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED""", mapper);
        for (var e : pendientes) {
            var registro = new ProducerRecord<>(e.topic(), e.aggregateId(), e.payload());
            registro.headers().add("event-id", e.id().toString().getBytes());
            kafka.send(registro).get();   // esperar la confirmación del broker
            jdbc.update("UPDATE outbox SET published_at = now() WHERE id = ?", e.id());
        }
    }
}

FOR UPDATE SKIP LOCKED permite varias réplicas del relay sin publicar dos veces el mismo lote. Esperar la confirmación del broker antes de marcar la fila garantiza que no se pierdan eventos.

Configuración de Debezium con Outbox Event Router

{
  "name": "outbox-originacion",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "pg-originacion",
    "database.dbname": "originacion",
    "table.include.list": "public.outbox",
    "plugin.name": "pgoutput",
    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.route.by.field": "aggregate_type",
    "transforms.outbox.table.field.event.key": "aggregate_id",
    "transforms.outbox.route.topic.replacement": "${routedByValue}-eventos"
  }
}

Con CDC, la tabla outbox puede vaciarse inmediatamente después de insertar (Debezium lee del WAL), lo que evita que crezca.

Polling o CDC

Criterio Polling CDC (Debezium)
Piezas nuevas Ninguna (código del servicio) Kafka Connect y conector
Latencia Intervalo de polling Milisegundos
Carga en la base de datos Consultas periódicas Lectura del WAL, slot de replicación
Orden global Por created_at (cuidado con transacciones concurrentes) Orden de commit
Adecuado para Un servicio, volumen moderado Varios servicios, plataforma de eventos

Inbox en el consumidor

CREATE TABLE inbox (event_id uuid PRIMARY KEY, procesado_en timestamptz NOT NULL DEFAULT now());

BEGIN;
INSERT INTO inbox (event_id) VALUES ($1) ON CONFLICT DO NOTHING;   -- 0 filas = ya procesado
-- si se insertó: aplicar el efecto (asiento contable, notificación…)
COMMIT;

Outbox en el productor + inbox en el consumidor dan, en la práctica, un procesamiento efectivo de una sola vez. Ver idempotencia.

Criterios de implementación

Diseña el payload como contrato público y versiónalo (Avro, Protobuf o JSON Schema). Monitoriza el lag del outbox: filas pendientes y edad de la más antigua. Documenta la decisión en un ADR y combínalo con una saga cuando el proceso involucre varios servicios.

Continúa en las fuentes

Documentación oficial para verificar detalles y explorar más.