Change Data Capture con Debezium y outbox pattern

Avanzado
SQL
SQL
Actualizado: 19/04/2026

En arquitecturas event-driven, los servicios necesitan enterarse de cambios en otras bases de datos sin acoplarse a la implementación. Change Data Capture (CDC) convierte el flujo de modificaciones SQL en un stream de eventos consumible por cualquier sistema. Combinado con el outbox pattern, resuelve el problema clásico del dual-write con garantías ACID.

El problema del dual-write

Imagina un servicio que crea un pedido y debe publicar un evento "pedido creado" en Kafka para que otros servicios reaccionen (notificaciones, inventario, facturación):

# Pseudocodigo problematico
db.insert("pedidos", pedido)
kafka.publish("pedido_creado", pedido)

Los dos pasos están en sistemas distintos. Si el INSERT triunfa pero kafka.publish falla, hay inconsistencia: el pedido existe pero los servicios downstream no se enteran. Si invertimos el orden, puede publicarse un evento de un pedido que no se persistió. Esto es el dual-write problem.

Soluciones tradicionales (transacciones distribuidas con XA, sagas) son complejas. CDC + outbox es más simple y ofrece garantías equivalentes.

Replicación lógica en PostgreSQL

PostgreSQL ofrece logical replication desde la versión 10. A diferencia de la replicación física (streaming) que copia bloques, la lógica decodifica el WAL en operaciones por fila (INSERT, UPDATE, DELETE) y las emite en formato consumible.

Para habilitarla:

# postgresql.conf
wal_level = logical
max_replication_slots = 10
max_wal_senders = 10
-- Crear una publicacion para una tabla concreta
CREATE PUBLICATION pub_pedidos FOR TABLE pedidos;

-- O para todas las tablas (cuidado en produccion)
CREATE PUBLICATION pub_todo FOR ALL TABLES;

Y un slot de replicación que reserva los WAL hasta que el consumidor los procese:

SELECT pg_create_logical_replication_slot('debezium_slot', 'pgoutput');

El slot retiene WAL mientras no se consuma. Si Debezium se cae y nadie lo levanta, el WAL crece y puede llenar el disco. Monitoriza siempre pg_replication_slots.confirmed_flush_lsn.

Debezium: el conector

Debezium es el conector CDC más popular del ecosistema, parte de la comunidad Red Hat. Se ejecuta como Kafka Connect source connector y publica los cambios a topics de Kafka, uno por tabla.

Configuración típica del conector:

{
  "name": "pedidos-cdc-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres.prod.local",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "${SECRET}",
    "database.dbname": "produccion",
    "database.server.name": "prod",
    "plugin.name": "pgoutput",
    "publication.name": "pub_pedidos",
    "slot.name": "debezium_slot",
    "table.include.list": "public.pedidos,public.pedido_lineas"
  }
}

Con esto, cada INSERT/UPDATE/DELETE en pedidos aparece como mensaje en el topic prod.public.pedidos. La estructura del mensaje es JSON con before (estado anterior), after (estado posterior) y metadata (timestamp, operación, LSN).

{
  "op": "u",
  "ts_ms": 1712956800000,
  "source": {"db": "produccion", "table": "pedidos"},
  "before": {"id": 42, "estado": "PENDIENTE"},
  "after":  {"id": 42, "estado": "PAGADO"}
}

Los topics se nombran <server>.<schema>.<tabla>. La aplicación que consume puede reaccionar en tiempo casi real (latencia típica < 1 segundo).

El outbox pattern

CDC sobre la tabla pedidos directamente tiene un problema: los eventos reflejan cambios de datos, no eventos de negocio. Un cambio de estado de "PENDIENTE" a "PAGADO" puede ser un pago, un ajuste manual o una corrección. Los consumidores no saben distinguirlo.

El outbox pattern resuelve esto: dentro de la misma transacción que modifica la tabla de negocio, se inserta un registro en una tabla outbox específica para eventos:

CREATE TABLE outbox (
    id BIGSERIAL PRIMARY KEY,
    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()
);

La transacción típica:

BEGIN;

UPDATE pedidos SET estado = 'PAGADO' WHERE id = 42;

INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES (
    'Pedido',
    '42',
    'PedidoPagado',
    jsonb_build_object('pedido_id', 42, 'total', 99.99, 'metodo', 'tarjeta')
);

COMMIT;

Ambas escrituras están en la misma transacción PostgreSQL: o las dos persisten o ninguna. No hay posibilidad de inconsistencia.

Debezium se configura para escuchar solo la tabla outbox y un Single Message Transform (SMT) específico de Debezium reescribe el mensaje al topic adecuado:

{
  "transforms": "outbox",
  "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
  "transforms.outbox.route.topic.replacement": "${routedByValue}",
  "transforms.outbox.table.fields.additional.placement": "type:header:eventType"
}

Resultado: los eventos PedidoPagado van al topic Pedido con header eventType=PedidoPagado. Los servicios que escuchan Pedido reciben todos los eventos del agregado y filtran por header.

Limpieza de la tabla outbox

La tabla outbox crece indefinidamente. Como Debezium publica al instante y los registros ya no son necesarios tras la publicación, se pueden borrar. Hay dos enfoques:

  • DELETE inmediato: cada INSERT en outbox se sigue de un DELETE en la misma transacción. Debezium captura el INSERT (lo publica) y el DELETE (lo ignora con SMT). La tabla queda vacía.
INSERT INTO outbox (...) VALUES (...) RETURNING id;
DELETE FROM outbox WHERE id = lastval();
  • DELETE asíncrono: un job periódico (pg_cron, crontab) borra registros antiguos:
DELETE FROM outbox WHERE created_at < NOW() - INTERVAL '7 days';

El primer enfoque es más limpio pero genera más WAL. El segundo es más simple y conserva los eventos por si hay que reproducirlos. Decisión según volumen y necesidad de auditoría.

Diferencias frente a otras alternativas

| Aspecto | CDC + outbox | Webhooks | Replicación tradicional | |---------|--------------|----------|-------------------------| | Garantía transaccional | Sí | No | Sí | | Latencia | Casi tiempo real | Inmediata | Casi tiempo real | | Robustez ante fallos | Alta (replay desde slot) | Baja (perdida si downstream cae) | Alta | | Complejidad operacional | Media (Kafka + Debezium) | Baja | Media | | Acoplamiento | Bajo (eventos) | Alto (URL hardcoded) | Alto (esquema replicado) |

CDC con outbox combina las garantías de la base de datos con la flexibilidad de los eventos. Es el patrón estándar en arquitecturas event-driven modernas.

Caso de uso típico: sincronización con un data warehouse

Otro uso muy común es alimentar un data warehouse sin afectar a la base operacional:

flowchart LR
    A[Aplicacion] --> B[(PostgreSQL OLTP)]
    B -->|WAL| C[Debezium]
    C -->|Kafka topics| D[Sink Connectors]
    D --> E[(Snowflake / BigQuery)]
    D --> F[(Elasticsearch)]
    D --> G[(Otro servicio)]

Snowflake, BigQuery o Redshift se actualizan con latencia de segundos sin que la base operacional ejecute ningún ETL pesado. Cualquier query analítica corre contra el warehouse, no contra producción.

Buenas prácticas

  • Monitorizar el lag del slot: pg_replication_slots.confirmed_flush_lsn debe avanzar continuamente.
  • Alertar si Debezium se cae: el slot crece y el disco se llena.
  • Versionar los eventos: añadir event_version en el payload para evolución sin romper consumidores.
  • Probar reprocesamientos: en algún momento querrás replay desde un punto del histórico.
  • No mezclar tablas operacionales con outbox en la misma publicación: separa intereses.
  • Tabla outbox indexada por created_at para que las queries de mantenimiento sean rápidas.

Limitaciones

CDC con Debezium no es una bala de plata:

  • Operaciones DDL no se replican (cambios de esquema requieren coordinación manual).
  • TRUNCATE se emite como evento especial pero algunos consumidores lo ignoran.
  • Carga inicial: Debezium puede hacer un snapshot inicial, pero en tablas enormes lleva horas.
  • Schema drift: si añades columnas, debes regenerar el schema en Kafka Connect.
flowchart TB
    A[Logica de negocio] --> B[BEGIN TRANSACTION]
    B --> C[UPDATE pedidos]
    C --> D[INSERT outbox]
    D --> E[COMMIT]
    E --> F[(WAL)]
    F --> G[Logical decoding]
    G --> H[Debezium connector]
    H --> I[Kafka topic Pedido]
    I --> J[Servicio Notificaciones]
    I --> K[Servicio Inventario]
    I --> L[Servicio Facturacion]

CDC con Debezium y outbox pattern resuelve uno de los problemas más espinosos de las arquitecturas distribuidas: garantizar que un cambio en la base de datos se propaga fiablemente a otros sistemas sin caer en transacciones distribuidas. Bien implementado, escala a miles de eventos por segundo y se convierte en la columna vertebral de un sistema event-driven.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, SQL es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de SQL

Explora más contenido relacionado con SQL y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Configurar replicación lógica con wal_level logical y publication. Conectar Debezium a PostgreSQL con un slot de replicación. Aplicar el outbox pattern para garantizar consistencia transaccional. Diferenciar CDC de webhooks y de replicación tradicional. Conocer cómo se enrutan los eventos a Kafka topics.

Cursos que incluyen esta lección

Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje