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
INSERTen outbox se sigue de unDELETEen 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_lsndebe avanzar continuamente. - Alertar si Debezium se cae: el slot crece y el disco se llena.
- Versionar los eventos: añadir
event_versionen 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
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