Funcionamiento de la Decodificación del WAL
El Write-Ahead Log (WAL) de PostgreSQL registra cada cambio a la base de datos a nivel físico. La replicación lógica agrega una capa de decodificación que traduce estos cambios físicos en operaciones lógicas.
-- 1. Habilitar replicación lógica en postgresql.conf
-- wal_level = logical
-- max_replication_slots = 10
-- 2. Crear una publicación (qué tablas capturar)
CREATE PUBLICATION my_publication FOR TABLE
orders, order_items, customers
WITH (publish = 'insert, update, delete');
-- 3. Crear un replication slot (cursor del consumidor)
SELECT pg_create_logical_replication_slot(
'debezium_slot', 'pgoutput'
);
-- Monitorear lag del slot
SELECT slot_name,
pg_size_pretty(pg_wal_lsn_diff(
pg_current_wal_lsn(), confirmed_flush_lsn
)) AS lag_pretty
FROM pg_replication_slots
WHERE slot_type = 'logical';
El Patrón Transactional Outbox
Este es el patrón que cambia fundamentalmente el enfoque de sistemas event-driven con bases de datos relacionales. En vez de intentar escribir atómicamente tanto a Postgres como a Kafka, escribirs el evento a una tabla outbox dentro de la misma transacción:
CREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(255) NOT NULL,
aggregate_id VARCHAR(255) NOT NULL,
event_type VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
BEGIN;
UPDATE orders SET status = 'shipped', shipped_at = now()
WHERE id = 9001;
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES (
'Order', '9001', 'OrderShipped',
'{"order_id": 9001, "shipped_at": "2026-02-28T10:30:00Z",
"tracking_number": "1Z999AA10123456784"}'::jsonb
);
COMMIT;
-- Ambos tienen éxito o ambos fallan. Sin problema de dual-write.
Debezium captura el insert al outbox y lo rutea al topic de Kafka apropiado. La tabla outbox se puede truncar periódicamente porque Debezium ya capturó los eventos.
Gotchas de Producción
Columnas TOAST y eventos UPDATE
PostgreSQL almacena valores de columnas grandes en tablas TOAST. Cuando hacés UPDATE de una fila pero no se modifica una columna TOASTed, el registro WAL no incluye el valor TOAST sin cambios. Debezium ve null.
-- Solución: REPLICA IDENTITY FULL
ALTER TABLE orders REPLICA IDENTITY FULL;
Cambios de schema rompen todo
Agregar una columna a una tabla capturada puede causar errores de mismatch de schema. Siempre agregar con un default primero.
Bloat del slot durante mantenimiento
Durante pg_dump o vacuum full, el LSN no avanza. Si tu backup tarda 4 horas y teners 10K escrituras/segundo, son ~144M registros WAL retenidos.
La Arquitectura Final
┌─────────────────┐ ┌──────────────┐ ┌───────────────┐
│ PostgreSQL │ │ Debezium │ │ Kafka │
│ │ WAL │ (Kafka │ │ │
│ orders ├────→│ Connect) ├────→│ domain.events │
│ outbox │ │ │ │ .Order │
│ order_items │ │ Transforms: │ │ .Customer │
│ │ │ - Outbox │ │ .OrderItem │
└─────────────────┘ │ - Route │ └───────┬───────┘
└──────────────┘ │
┌──────┴──────┐
│ Consumers │
│ - Search │
│ - Analytics │
│ - Notify │
└─────────────┘
El patrón outbox maneja eventos de dominio. CDC directo maneja actualizaciones de vistas materializadas para índices de búsqueda y analytics. Los dos patrones se complementan, outbox para eventos intencionales, CDC para sincronización de datos.
Latencia end-to-end total desde commit en Postgres hasta consumer en Kafka: p50 = 45ms, p99 = 180ms. Para un sistema event-driven construido encima de una base de datos monolítica, ese es números son más que aceptables.