Debezium y CDC: el notario que escucha los cambios antes de que nadie los pida
TL;DR
Change Data Capture (CDC) con Debezium escucha el Write-Ahead Log de PostgreSQL y convierte cada INSERT, UPDATE y DELETE en un evento Kafka estructurado. A diferencia del polling tradicional (SELECT ... WHERE updated_at > ?), detecta borrados, tiene latencia de decenas de milisegundos y no añade carga extra a la base de datos. En pipelines RAG, esto significa que cuando se borra un documento de Postgres, los chunks de Qdrant desaparecen también —automáticamente, en tiempo real—. La infraestructura de soporte es modesta: el connector consume 2-4 cores y 4-8 GB RAM para procesar miles de eventos por segundo.
La analogía maestra: el notario del registro de la propiedad
Imagina el Registro de la Propiedad. Cada vez que se vende un piso, se hipoteca, o se cancela una hipoteca, el registrador anota la operación en el libro del registro —un diario cronológico e inmutable. Si quieres saber qué ha cambiado en el registro, tienes dos opciones:
Opción A (polling): envías a alguien cada 5 minutos con una lista de fincas a preguntar «¿ha cambiado algo?». Problemas: si se canceló una titularidad (DELETE), la finca ya no existe cuando tu enviado llega —no hay rastro—. Si hay 20 departamentos distintos haciendo lo mismo, hay 20 personas molestando al registrador cada 5 minutos. Y la latencia mínima es el intervalo: 5 minutos.
Opción B (Debezium): contratas a un notario que se sienta directamente en la mesa del registrador. Cada vez que el registrador firma una operación en el libro, el notario la anota al momento y notifica a quien corresponda. Cancelación de titularidad incluida —el notario la ve tan claro como cualquier otra operación, porque estaba allí cuando se firmó—.
En esta analogía:
- El libro del registro es el WAL (Write-Ahead Log) de PostgreSQL.
- El notario es el Debezium connector.
- El marcapáginas del notario —que garantiza que no pierde ninguna página aunque salga un momento— es el slot de replicación lógica.
- El mensajero que lleva las notificaciones a los interesados es Kafka (o Redpanda, o NATS JetStream).
Este hilo lo vamos a retomar en cada sección. Cuando algo no quede claro en los detalles técnicos, vuelve a la imagen del notario.
1. El problema que CDC resuelve
El patrón de sincronización más habitual entre servicios que comparten PostgreSQL es el polling periódico:
SELECT id, content, updated_at
FROM documents
WHERE updated_at > $1
ORDER BY updated_at
LIMIT 1000;
Este patrón tiene tres problemas estructurales:
Los DELETEs son invisibles. Cuando borras una fila, updated_at no se actualiza —la fila desaparece—. La próxima vez que el poller consulte, la fila no existe y no hay forma de saber que existió. En un pipeline RAG, esto se traduce en chunks huérfanos en Qdrant: el documento ya no existe en Postgres, pero sus vectores siguen contaminando los resultados de búsqueda.
La latencia mínima es el intervalo. Si el poller corre cada 5 segundos, la latencia media es 2,5 segundos. Para sincronización near-real-time (dashboards, alertas, RAG con documentos que cambian frecuentemente) esto es demasiado.
La carga escala con el número de consumidores. Si 10 servicios hacen polling cada 5 segundos sobre la misma tabla, son 10 × 12 = 120 queries/minuto que no producen trabajo útil —solo verifican si hay algo nuevo—. En tablas grandes con índices complejos, esto es carga real en la base de datos.
CDC invierte el modelo: la base de datos notifica, los consumidores escuchan. Cero polling, cero carga extra, DELETEs incluidos, latencia de decenas de milisegundos.
2. Qué es el WAL de PostgreSQL
El diario de operaciones
El Write-Ahead Log (WAL) es el registro cronológico e inmutable de todas las operaciones que Postgres realiza. Antes de modificar cualquier página de datos en disco, Postgres escribe la operación en el WAL. Esta secuencia —primero el log, luego los datos— es lo que garantiza la durabilidad (D de ACID) y permite el crash recovery: si Postgres cae a mitad de una transacción, al reiniciar replaye el WAL para devolver la base de datos a un estado consistente.
El WAL es el libro del registro de nuestra analogía: cronológico, inmutable, completo.
Replicación física vs. lógica
PostgreSQL soporta dos modos de replicación basados en el WAL:
Replicación física: replica bloques de disco tal cual. El standby recibe los mismos bytes que el primario. Sirve para high availability y failover, pero el destino debe ser una copia exacta de Postgres —no puedes enviar los cambios a una aplicación externa—.
Replicación lógica: en vez de bloques de disco, replica operaciones semánticas: «se insertó la fila con id=42 en la tabla
documentscon estos valores». El destino puede ser cualquier cosa que entienda el protocolo: otro Postgres, Debezium, o cualquier consumer personalizado.
CDC usa replicación lógica. Es la que permite que Debezium entienda «qué cambió y en qué tabla» en lugar de «qué bloque de disco cambió en qué offset».
El slot de replicación: el marcapáginas del notario
Un slot de replicación lógica es un cursor persistente en el WAL. Postgres mantiene un registro de hasta qué posición del WAL ha consumido cada slot. Mientras un slot existe, Postgres garantiza que no descarta los segmentos WAL que el slot aún no ha leído.
Esto es exactamente el marcapáginas del notario: aunque el notario salga a comer, el libro permanece abierto en la última página que leyó. Cuando vuelve, continúa exactamente donde lo dejó, sin haber perdido nada.
El riesgo es el inverso: si el notario no vuelve, el marcapáginas impide que el registrador archive las páginas antiguas. Si el Debezium connector se cae y no se recupera durante horas, el WAL crece indefinidamente en disco hasta que el slot se elimine manualmente o el consumer vuelva a consumir. Esto se llama WAL disk blowup y es el riesgo operacional más importante de Debezium.
Monitorización obligatoria:
SELECT slot_name,
confirmed_flush_lsn,
pg_current_wal_lsn(),
pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) AS lag_bytes
FROM pg_replication_slots
WHERE slot_type = 'logical';
El plugin pgoutput
El WAL almacena las operaciones en formato binario interno. Para que Debezium las entienda, Postgres necesita decodificarlas en un formato legible. El plugin de decodificación pgoutput —incluido en el core de Postgres desde la versión 10— hace exactamente esto: traduce los eventos binarios del WAL en mensajes con la estructura antes/después de cada fila.
Debezium usa pgoutput por defecto. No requiere instalar extensiones externas (a diferencia del plugin wal2json que fue popular antes de Postgres 10).
3. Arquitectura de Debezium
El connector como plugin de Kafka Connect
Debezium no es un servicio standalone —es un plugin del framework Kafka Connect. Kafka Connect gestiona el ciclo de vida del connector (arranque, parada, reconexión, offset tracking) y provee la infraestructura de paralelismo y fault tolerance.
El connector se comunica con Postgres a través del protocolo de replicación lógica (no por JDBC), usando las credenciales de un usuario con rol REPLICATION.
PostgreSQL (WAL + pgoutput)
│
│ protocolo de replicación lógica
▼
Debezium Connector (Kafka Connect worker)
│
│ Kafka Producer API
▼
Kafka topic: rag.public.documents
│
▼
Consumer (sync a Qdrant, audit log, fine-tuning pipeline...)
Estructura de un evento Debezium
Cada cambio en la tabla se convierte en un mensaje JSON con esta estructura:
INSERT ("op": "c" — create):
{
"before": null,
"after": {
"id": 42,
"content": "Contrato de arrendamiento...",
"tenant_id": "acme",
"updated_at": 1748934000000
},
"op": "c",
"source": {
"version": "2.7.0.Final",
"connector": "postgresql",
"db": "rag_db",
"schema": "public",
"table": "documents",
"lsn": 29823948,
"txId": 1047,
"ts_ms": 1748934000123
}
}
DELETE ("op": "d"):
{
"before": {
"id": 42,
"content": "Contrato de arrendamiento...",
"tenant_id": "acme",
"updated_at": 1748934000000
},
"after": null,
"op": "d",
"source": { "lsn": 29824102, "txId": 1051, "ts_ms": 1748934060200 }
}
El campo before contiene el estado anterior de la fila —disponible porque Postgres puede configurar REPLICA IDENTITY FULL para incluir la fila completa en el WAL al borrar/actualizar—. Sin esta configuración, before solo contiene la clave primaria.
Esta es la clave para el pipeline RAG: el evento DELETE lleva el id del documento. El consumer lo usa para borrar todos los chunks asociados en Qdrant con un filtro doc_id = 42. Sin CDC, esos chunks nunca se habrían borrado.
Snapshot inicial
Cuando el connector arranca por primera vez (o tras un reset), no puede empezar a consumir el WAL desde «el principio de los tiempos» —solo desde el momento en que se crea el slot—. ¿Cómo garantiza la consistencia del estado inicial?
Mediante un snapshot transaccional: el connector abre una transacción en modo REPEATABLE READ, exporta el snapshot ID (pg_export_snapshot()), y hace un SELECT completo de las tablas configuradas dentro de esa transacción. Después empieza a consumir el WAL desde el LSN del snapshot. Así no hay gap: el snapshot cubre el estado hasta un instante, y el WAL cubre desde ese instante en adelante.
Transformaciones SMT (Single Message Transforms)
Antes de emitir el evento al topic de Kafka, el connector puede aplicar transformaciones inline llamadas SMT. Casos de uso habituales:
- Filtrar columnas sensibles (
ReplaceFieldconblacklist): eliminarpassword_hash,phone_numberantes de que lleguen al topic. - Añadir metadata (
InsertField): enriquecer el evento contenant_idextraído del header HTTP original (si está en la fila). - Ruting condicional (
Filter): descartar eventos de filas constatus = 'draft'antes de emitirlos.
Las SMT son configuración pura —no requieren código— y se aplican dentro del proceso del connector, sin latencia adicional perceptible.
4. Debezium vs Outbox pattern
El Outbox pattern es la alternativa más común a CDC puro. La aplicación, en lugar de emitir eventos directamente a Kafka, escribe en una tabla outbox de Postgres dentro de la misma transacción que modifica los datos. Un worker separado lee esa tabla y publica los eventos.
| Criterio | Debezium (CDC puro) | Outbox pattern |
|---|---|---|
| Latencia del evento | ~50-200 ms desde el commit | Depende del intervalo del worker (típico: 1-5 s) |
| Consistencia | At-least-once | At-least-once |
| Detección de DELETEs | Nativa (el evento DELETE incluye before) | Solo si la app escribe en outbox al borrar |
| Complejidad de setup | Alta (Kafka Connect, slot de replicación, permisos) | Baja (tabla extra + worker simple) |
| Dependencia de infraestructura | Requiere Kafka/Redpanda/NATS JetStream | Solo Postgres + worker; Kafka opcional |
| Riesgo WAL disk blowup | Sí, si el slot deja de consumir | No |
| Visibilidad del esquema | Lee el esquema real de la tabla | El esquema del evento lo define la app |
| Migración de esquema | Requiere cuidado (los eventos reflejan DDL changes) | Más flexible (el evento es lo que la app pone) |
| Cuándo usarlo | Cuando necesitas DELETEs, latencia baja o no puedes modificar la app | Cuando la app controla el dominio del evento y la infraestructura es limitada |
Regla práctica: si controlas el código de la aplicación y no necesitas DELETEs nativos, el Outbox es más simple. Si no controlas el código (base de datos legacy, aplicación de terceros) o los DELETEs son críticos (pipeline RAG con borrado de documentos), Debezium es la elección correcta.
5. Matemáticas
Throughput
Debezium en un connector con 4 workers puede procesar entre 10.000 y 50.000 eventos/segundo en hardware modesto (4 cores, 8 GB RAM). El cuello de botella real no es el connector sino el broker de Kafka: con 3 brokers y particiones adecuadas, Kafka puede sostener fácilmente 500.000 mensajes/segundo con mensajes de 1 KB (fuente: benchmarks públicos de Confluent, 2023).
Para un pipeline RAG típico con 100 documentos modificados por minuto:
$$\text{eventos/s} = \frac{100}{60} \approx 1{,}7 \text{ eventos/s}$$
Esto es el 0,0034% de la capacidad del connector. Debezium no será el cuello de botella en ningún escenario RAG realista.
Latencia end-to-end
El camino de un commit en Postgres hasta un upsert en Qdrant tiene estas etapas:
| Etapa | Latencia típica |
|---|---|
| Commit en Postgres → WAL escrito | < 1 ms (sincrónico al commit) |
| WAL escrito → Debezium lo lee (WAL lag) | 10-50 ms |
| Debezium → Kafka produce (ack) | 5-20 ms |
| Kafka → Consumer (poll interval) | 0-100 ms (configurable) |
| Consumer → Qdrant upsert/delete | 5-15 ms |
| Total típico | 30-200 ms |
Con fetch.min.bytes=1 y fetch.max.wait.ms=10 en el consumer, la latencia del Kafka poll se reduce a ~10 ms. El rango realista para un pipeline optimizado es 30-100 ms.
Riesgo de WAL disk blowup
Si el connector deja de consumir, Postgres retiene el WAL a partir del confirmed_flush_lsn del slot. El volumen retenido crece linealmente con el tiempo y la tasa de escrituras:
$$\text{WAL retenido} = \text{tasa de escrituras} \times \text{tamaño medio del evento WAL} \times \text{tiempo sin consumir}$$
Ejemplo con carga moderada (50.000 escrituras/hora, 500 bytes de media por evento WAL):
$$50{.}000 \times 500 \text{ B} \times 1 \text{ h} = 25 \text{ MB/h}$$
Con carga alta (1.000.000 escrituras/hora):
$$1{.}000{.}000 \times 500 \text{ B} \times 1 \text{ h} = 500 \text{ MB/h}$$
Si el connector está caído durante 48 horas con carga alta: 24 GB de WAL retenido. Esto puede llenar el disco y bloquear completamente Postgres.
Alerta recomendada: configurar una alerta cuando lag_bytes > 1 GB o cuando confirmed_flush_lsn no avanza durante más de 15 minutos. Ver la query de monitorización en la sección 2.
6. Casos de uso en LLMOps / RAG
Sincronización RAG con borrado real
Este es el caso de uso que más claramente justifica Debezium sobre el polling. El flujo:
- Un usuario borra el documento
id=42de la interfaz de gestión documental. - Postgres ejecuta
DELETE FROM documents WHERE id = 42. - Debezium detecta el DELETE en el WAL, emite el evento con
"op": "d"y"before": {"id": 42, ...}. - El consumer recibe el evento y ejecuta:
qdrant_client.delete( collection_name="documents", points_selector=Filter(must=[FieldCondition(key="doc_id", match=MatchValue(value=42))]) ) - Todos los chunks con
doc_id=42desaparecen de Qdrant en ~100 ms.
Sin Debezium, esos chunks permanecerían indefinidamente, contaminando los resultados de retrieval con fragmentos de documentos que ya no existen en la fuente de verdad.
Event sourcing para datasets de fine-tuning
Cada vez que un anotador humano actualiza una fila en la tabla annotations (corrigiendo un output del LLM), Debezium emite el UPDATE con before y after. El consumer escribe el par (output_original, corrección) en el pipeline de curación de datasets, sin necesidad de que el anotador haga nada más allá de guardar en la interfaz. El pipeline de fine-tuning sabe exactamente qué cambió y cuándo —sin polling, sin riesgo de duplicados por ventanas de tiempo solapadas—.
Audit log inmutable
Los eventos del WAL son, por definición, el registro más fiel de lo que ocurrió en la base de datos —son los mismos datos que Postgres usa para crash recovery—. Kafka con retention larga (90 días, o retención por tamaño) sirve de audit log inmutable sin modificar el esquema de la aplicación ni añadir triggers. Esto es especialmente útil en entornos regulados donde se requiere trazabilidad de modificaciones de datos.
7. Diagrama de arquitectura
8. Configuración mínima
PostgreSQL: activar replicación lógica
-- Requiere reiniciar Postgres
ALTER SYSTEM SET wal_level = logical;
ALTER SYSTEM SET max_replication_slots = 10;
ALTER SYSTEM SET max_wal_senders = 10;
-- Recargar configuración (wal_level requiere restart completo)
SELECT pg_reload_conf();
-- Usuario dedicado para Debezium
CREATE USER debezium WITH REPLICATION LOGIN PASSWORD 'cambiar_esto';
GRANT SELECT ON TABLE public.documents TO debezium;
-- REPLICA IDENTITY FULL para tener 'before' completo en DELETEs y UPDATEs
ALTER TABLE public.documents REPLICA IDENTITY FULL;
Debezium connector (Kafka Connect REST API)
{
"name": "postgres-debezium",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "debezium",
"database.password": "cambiar_esto",
"database.dbname": "rag_db",
"topic.prefix": "rag",
"table.include.list": "public.documents",
"plugin.name": "pgoutput",
"slot.name": "debezium_rag",
"publication.autocreate.mode": "filtered",
"snapshot.mode": "initial",
"tombstones.on.delete": "true",
"transforms": "unwrap",
"transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
"transforms.unwrap.drop.tombstones": "false",
"transforms.unwrap.delete.handling.mode": "rewrite"
}
}
Registrar el connector:
curl -X POST http://kafka-connect:8083/connectors \
-H 'Content-Type: application/json' \
-d @connector-config.json
Verificar estado:
curl http://kafka-connect:8083/connectors/postgres-debezium/status
Consumer mínimo en Python
from confluent_kafka import Consumer
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue
import json
consumer = Consumer({
"bootstrap.servers": "kafka:9092",
"group.id": "qdrant-sync",
"auto.offset.reset": "earliest",
"enable.auto.commit": False,
})
consumer.subscribe(["rag.public.documents"])
qdrant = QdrantClient("qdrant", port=6333)
while True:
msg = consumer.poll(timeout=0.1)
if msg is None:
continue
event = json.loads(msg.value())
op = event.get("op")
if op in ("c", "u"): # INSERT o UPDATE
doc = event["after"]
# ... vectorizar y upsert en Qdrant
elif op == "d": # DELETE
doc_id = event["before"]["id"]
qdrant.delete(
collection_name="documents",
points_selector=Filter(
must=[FieldCondition(key="doc_id", match=MatchValue(value=doc_id))]
)
)
consumer.commit()
9. Despliegue on-premise
El stack Debezium no compite por GPU. En un nodo con 4×H100 SXM (320 GB, NVLink) sirviendo el LLM de inferencia, el pipeline CDC corre enteramente en nodos de propósito general (CPU-only):
| Componente | Recursos recomendados | Rol |
|---|---|---|
| Kafka Connect + Debezium | 2-4 cores, 4-8 GB RAM | Leer WAL, emitir eventos |
| Kafka brokers (×3) | 4 cores, 32 GB RAM c/u | Alta disponibilidad, retención |
| Consumer Qdrant-sync | 2 cores, 4 GB RAM | Vectorizar + upsert/delete |
| Qdrant | 8 cores, 64 GB RAM | Vector store |
El Debezium connector es notablemente ligero: en producción con 10.000 eventos/segundo, el connector consume habitualmente menos de 1 core y 2 GB de RAM. La memoria de la JVM (Kafka Connect corre en JVM) debe limitarse explícitamente con -Xmx4g para evitar que el GC cause pausas.
Para alta disponibilidad, Kafka Connect soporta modo distribuido con múltiples workers. Si un worker cae, el connector se reasigna automáticamente a otro worker en segundos —el slot de replicación garantiza que no se pierden eventos durante la conmutación—.
Lo que no hemos cubierto
- Debezium con MySQL, MongoDB y Oracle: cada conector usa el mecanismo de log nativo (binlog en MySQL, oplog en MongoDB, LogMiner en Oracle). La API de eventos resultante es similar, pero los detalles de configuración y las limitaciones difieren.
- Debezium Server: modo standalone sin Kafka Connect, con sinks directos a HTTP, S3, Redis Streams o NATS. Útil cuando la infraestructura de Kafka es demasiado compleja para el caso de uso.
- Schema Registry: cómo Avro con Confluent Schema Registry o Apicurio gestiona la evolución del esquema de los eventos —añadir columnas, cambiar tipos— sin romper a los consumers existentes.
- Exactly-once semantics: por qué at-least-once es suficiente para la mayoría de casos RAG (un upsert idempotente en Qdrant con el mismo vector no hace daño) y cuándo se necesita exactly-once (contadores financieros, deducciones de inventario).
- Outbox pattern + Debezium combinados: Debezium leyendo la tabla
outboxen lugar del WAL de la tabla de negocio directamente —el patrón Transactional Outbox + CDC que combina lo mejor de ambos mundos—.
Ver también
- PostgreSQL + Qdrant: ingestión por microservicios — el post donde CDC con Debezium se usa como alternativa al outbox pattern para mantener sincronizados PostgreSQL y Qdrant.
- RAG corpus curation: fundamentos — la curación del corpus que Debezium mantiene fresco en near-real-time.
- Pipeline LLMOps: las seis etapas — la etapa Data del mapa maestro donde CDC es el mecanismo de ingestión continua.
- Data versioning con DVC y lakeFS — versioning del corpus que Debezium alimenta incrementalmente.
- Observabilidad GPU con DCGM y LLM — monitorización del cluster donde corre el consumer de Debezium junto al stack de inferencia.
Referencias
- Debezium Documentation — PostgreSQL Connector. debezium.io/documentation/reference/stable/connectors/postgresql.html
- PostgreSQL Documentation — Logical Replication. postgresql.org/docs/current/logical-replication.html
- PostgreSQL Documentation — Write-Ahead Logging. postgresql.org/docs/current/wal-intro.html
- PostgreSQL Documentation — Replication Slots. postgresql.org/docs/current/logicaldecoding-explanation.html
- Confluent — Kafka Performance Benchmarks (2023). confluent.io/blog/kafka-fastest-messaging-system
- Gunnar Morling — Outbox Pattern. morling.dev/blog/sending-messages-as-part-of-database-transactions
- Debezium — SMT documentation. debezium.io/documentation/reference/stable/transformations
- Qdrant Documentation — Filtering. qdrant.tech/documentation/concepts/filtering