notes~/ notes/cdc-debezium
~/ cat notes/cdc-debezium.md

CDC, Debezium & the transactional outbox.

How a committed database row becomes a Kafka event without the application ever performing an unsafe dual write — and why the dangerous failure mode is not a lost event but a full database disk.

WAL → logical decoding1 connector taskat-least-once end to endslot lag is a disk risk

00The 30-second model

say this first

“The application writes business state and an event-intent row in the same PostgreSQL transaction, so they commit together or not at all. PostgreSQL writes that to the WAL. Logical decoding turns WAL into row-level changes, a replication slot holds the reader's position, and Debezium streams from that slot into Kafka. The API never waits for Kafka — it waits for the Postgres commit and nothing else.”

The problem being solved is the dual write: commit to the database, then publish to Kafka. Those are two systems and they cannot commit together, so a crash in between loses the event with no record anywhere that it should have existed. CDC does not make the publish atomic — it moves the atomic part into the database, where a transaction already exists.

01High-level design

applicationone transaction:business row + outboxPostgreSQL owns all of thisWALpg_walsegment fileslogicaldecodingpgoutputslotposition +WAL retentionDebeziumon Kafka ConnectONE task per connectorKafkadurable logidempotent consumersdedupe on event_idthe dual write this replacescommit to Postgres, then publish to Kafka — crash in between and the event is simply gone,with no record anywhere that it was ever supposed to exist. Two systems cannot commit together.the outbox makes the intent part of the same ACID transaction as the business rowAPI latencystops hereKafka can be downand writes still work
The API waits for the Postgres commit and nothing else. Everything right of the slot is asynchronous.
ComponentOwns
ApplicationThe business transaction, including the outbox insert.
PostgreSQLACID, WAL, and exposing logical changes over the replication protocol.
PublicationWhich tables are eligible to appear in the stream.
Replication slotThe durable read position — and the WAL retention that comes with it.
DebeziumConsumes the stream, converts to change events, hands them to Connect.
Kafka ConnectRuns the task and persists source offsets.
ConsumersBusiness effects — and idempotency, because replay is guaranteed to happen.

02What PostgreSQL is actually doing

Four facts worth being exact about, because they are the most common follow-up questions.

  • WAL is not a table. It is binary segment files under pg_wal.
  • Debezium does not read those files. PostgreSQL decodes WAL itself and streams logical changes out over its native replication protocol — a long-lived TCP connection, not REST or a WebSocket.
  • It is not polling. In steady state there is no repeated SELECT over business tables. Debezium still uses ordinary SQL connections for metadata, schema discovery and snapshots.
  • Debezium is not in the write path. It is a replication consumer hanging off the side of the database, not a proxy between the app and Postgres.
wal_level = logical
max_replication_slots = <connectors + reserve>
max_wal_senders       = <logical + physical replication needs>

CREATE PUBLICATION app_cdc FOR TABLE public.outbox_events;

03The outbox transaction

BEGIN;
  INSERT INTO orders (id, customer_id, amount, status)
  VALUES ('order-123', 'customer-7', 500, 'CREATED');

  INSERT INTO outbox_events (event_id, aggregate_type, aggregate_id, event_type, payload)
  VALUES ('evt-987', 'Order', 'order-123', 'ORDER_CREATED', '{"order_id":"order-123"}');
COMMIT;

Two outcomes only: both rows commit, or neither does. Debezium then captures the committed outbox insert and publishes it. The event can be late, but it cannot be missing.

the distinction people miss

The outbox does not give you API idempotency. It guarantees that a committed order produces an event. It does nothing about a retried POST /orders creating two orders — that needs an application-level idempotency key or a unique business constraint.

Outbox CDC vs raw table CDC

Raw table CDCOutbox CDC
Every row mutation is an event.The app emits explicit domain intent.
Great for replication, search indexing, analytics.Great for service-to-service business events.
Consumers infer meaning from columns.Meaning is explicit: ORDER_CANCELLED.
Internal maintenance updates leak into the event stream.Only deliberate events are routed.

Also: the outbox table is not allowed to grow forever. Retention or time partitioning, planned once events are safely captured — and be careful that the cleanup itself does not become a second event stream if DELETEs are being routed.

04Offsets, LSN and why duplicates exist

the same stream, measured three waysLSN 10001LSN 10002LSN 10003LSN 10004LSN 10005WALDebezium read10004offset persisted10003replay windowcrash here and 10004 is delivered twicewhich is why every consumer must dedupe on event_idnever conflate: current WAL position · slot confirmed position · Connect source offset
Three positions, not one. The gap between read and persisted is why duplicates exist.

An LSN is a position in the WAL — a source-log position, not a business event ID. The reason replay is unavoidable is that the connector can have read further than Kafka Connect has durably persisted. Crash in that gap and records near the boundary come back.

Ordering: the Postgres stream is ordered, but Kafka only guarantees order within a partition. Pick a record key matching the entity whose order matters — aggregate_id or order_id. Never claim global ordering across partitions.

05Lag, slots and the disk

WAL generated100 MB / minCDC consumed80 MB / minbacklog +20 MB / minWAL cannot be reclaimedafter 60 min: ~1.2 GB retainedafter a day: the diskwhat to alert on, in order of usefulnessslot retained WAL bytes · database disk free · connector milliseconds-behind-source · connector statecapacity question to answer in advance: how long can the connector be down before the disk is at risk?and after an outage, normal throughput never clears a backlog — you need catch-up headroom
A stalled connector does not break the API. It fills the database disk, quietly, hours later.

This is the answer that separates people who have run CDC from people who have read about it. A stalled connector does not page you with errors — the API keeps working perfectly. What happens is that the replication slot keeps holding WAL that PostgreSQL is not allowed to reclaim, and some hours later the primary database runs out of disk.

SELECT slot_name, active, restart_lsn, confirmed_flush_lsn
FROM pg_replication_slots;

SELECT * FROM pg_stat_replication;

06Scaling — and the one-task constraint

the trap

You cannot set tasks.max=20 and get twenty parallel WAL readers. A PostgreSQL connector uses one task consuming an ordered replication stream. Parallelism lives downstream, in Kafka partitions and consumer groups.

  • Find the actual bottleneck first: WAL generation, decoding, connector queue, producer, broker, or consumers.
  • Scale Kafka partitions and consumer groups independently of the single source task.
  • Separate databases get separate connectors and separate slots.
  • If one connector genuinely cannot keep up, decompose the source — do not invent parallel WAL readers.
catch-up headroom matters more than steady-state throughput

  normal load        50k events/s
  connector capacity 60k events/s
  headroom           10k events/s

  a 30-minute outage = ~90M events of backlog
  at +10k/s of headroom → ~2.5 hours to drain

07Failure matrix

FailureWhat happensResponse
App crashes before COMMITNeither order nor event existsClient retry + API idempotency
App crashes after COMMITBoth exist; response lostDebezium captures it later; API still needs an idempotency key
Debezium crashesDB fine, slot retains WALRestart; watch lag and disk; expect boundary replay
Kafka unavailableNo forward progress, backlog growsProtect DB disk first, alert early, plan catch-up
Consumer crashes after side effectKafka redeliversDedupe on event_id
Postgres primary fails overSlot continuity is version/platform dependentDesign slot failover deliberately, not as an afterthought
Schema-incompatible eventConnector or consumer loops on failureSchema governance, DLQ policy, deploy compatibility before migrating

08Alternatives

ApproachGood forCost
App dual writeSimple, low delayAn unfixable failure window between two commits
Outbox + polling publisherNo Debezium dependency; simpler opsPolling load, row claiming, batching and cleanup complexity
Outbox + DebeziumAtomic intent, log-based capture, decoupled APIConnect, slots, WAL retention, lag and failover to operate
Raw table CDCReplication, indexing, analyticsDB changes are not domain events; couples consumers to schema
Managed CDCLess operational burdenVendor constraints and semantics you must verify yourself

When not to reach for Debezium: a small system where a polling outbox is simpler; a platform that cannot support logical replication safely; a workflow that genuinely needs synchronous propagation — in which case redesign the workflow rather than pretend CDC is synchronous.

09Question drill

Why not just publish to Kafka after the DB commit?

Because those are two systems with no shared transaction. The process can commit the order and die before publishing, and nothing anywhere records that an event was owed. The outbox moves the atomic part inside the database, where atomicity already exists.

Is WAL a table? Does Debezium read the files?

WAL is binary segment files in pg_wal, not a table. Debezium does not open them — PostgreSQL decodes WAL itself and streams logical changes over its replication protocol on a long-lived TCP connection.

Does Debezium continuously run SELECT queries?

Not in steady state — that is the point of log-based CDC. It does use ordinary SQL connections for schema discovery, metadata and snapshots, and an initial snapshot of a large table is a real database workload.

What is a publication? What is a replication slot?

A publication is the Postgres-side answer to “which tables may appear in this stream?”. A replication slot is a durable position in that stream — and, operationally, the thing that makes Postgres retain WAL the consumer has not yet confirmed.

Why can't I set tasks.max=20 for a Postgres connector?

Because it uses one task against one ordered replication stream. Twenty readers of one WAL stream would have no coherent ordering or offset story. Scale downstream instead — Kafka partitions and consumer groups.

What happens if Debezium is down for three days?

The application keeps working, which is exactly what makes this dangerous. The slot retains every WAL segment since the last confirmed position, pg_wal grows, and the risk is the primary database filling its disk. Alert on slot retained bytes and DB disk, not just connector state.

How do duplicates happen if Postgres is transactional?

The database is fine. The duplication comes from the gap between what the connector has read and what Kafka Connect has durably persisted as its source offset. Crash in that window and boundary records replay — so consumers dedupe on event_id.

How do you preserve ordering per order or per customer?

Key the Kafka record on the entity that needs ordering — aggregate_id. Kafka orders within a partition only; there is no global order, and claiming otherwise is a fast way to lose credibility.

Outbox guarantees the event. Does it stop duplicate orders?

No, and this is the distinction worth stating unprompted. The outbox guarantees a committed order produces an event. A retried POST /orders that creates two orders is a different problem, solved by an idempotency key or a unique business constraint.

How would a Postgres failover affect the slot?

It depends on version and platform — replication slot failover is not automatic everywhere. If slots do not survive, the connector can resume from a position that no longer exists, which means a resnapshot or a gap. Treat slot continuity as part of the failover design.

How do you bootstrap a 2 TB existing table?

Deliberately. A snapshot reads table contents and creates real I/O, CPU and long-transaction pressure. Plan snapshot mode, fetch sizes, incremental snapshotting, and whether it runs against a replica. Note that a fresh outbox table usually needs no historical snapshot at all.

What metrics warn you before the database disk fills?
  • Slot retained WAL bytes — the leading indicator.
  • Database disk free.
  • Connector milliseconds-behind-source and connector state.
  • Kafka produce errors and consumer lag downstream.

10What not to say

Do not saySay instead
“Debezium reads the WAL files.”Postgres decodes WAL and streams logical changes; Debezium is a replication client.
“CDC gives exactly-once.”At-least-once end to end; consumers dedupe on event_id.
“The outbox solves duplicate orders.”It solves the lost event. Duplicate orders need API-level idempotency.
“More tasks means more throughput.”One task per Postgres connector; scale downstream.
“If Debezium dies we just restart it.”True, but the clock is the database disk — that is what you are racing.

11Cheat sheet

TermOne line
CDCCapture committed database changes and stream them downstream.
WALPostgres binary write-ahead log, stored as segment files.
Logical decodingTurns storage-level WAL into logical row changes.
pgoutputThe built-in logical replication output plugin.
PublicationWhich tables may appear in the logical stream.
Replication slotDurable stream position — and the cause of WAL retention.
LSNA position in the WAL. Not a business event ID.
Kafka ConnectRuns the connector task and persists source offsets.
OutboxCommit business state and event intent atomically; publish async.
Slot lagHow far behind the consumer is — measured in retained WAL bytes.

Postgres → WAL → slot → Debezium → Kafka. The outbox is what makes the first arrow safe.

← all notes