Chapter 14 — Key Takeaways (Change Data Capture)
The page for setting up CDC, and for the night a slot fills a disk.
What CDC is
Reading the log the database was already writing for crash recovery. Ordered, complete, durable,
and physical — which is why it needs logical decoding (wal_level = logical, set in Ch. 5
because changing it requires a restart).
What it solves, and the price
| Solves | Price |
|---|---|
| Deletes — as events, and the only strategy needing nothing from the source team | the operational burden of §14.6 |
The watermark disappears — no updated_at, none of Ch. 13's four lies |
an LSN you must not lose |
| Every intermediate state — 4 events where batch sees 1 row | volume |
| Lower, steadier source load | it never stops |
CDC for mutable, high-value, inconvenient-to-reload tables. Batch for the rest. Kestrel: CDC on 5 tables, full loads on 7.
productsat 47,000 rows and a 12-second load does not need connectors, slots, and snapshots.
Three kinds
| Source impact | Deletes | Needs | |
|---|---|---|---|
| Log-based | lowest | ✅ | replication privilege · highest operational burden |
| Trigger-based | highest — a write per write | ✅ | schema change. The fallback when you cannot get replication. |
| Query-based | moderate | ❌ | nothing. This is Ch. 13 with a marketing name. |
The Debezium envelope — four things matter
{"op": "u", // c create · u update · d delete · r snapshot
"before": {...}, "after": {...}, // before is null on create, after on delete
"source": {"lsn": 55016704, // ← ORDERING and DEDUP key
"ts_ms": 1764340879002}, // ← EVENT time
"ts_ms": 1764340879184} // ← PROCESSING time
Dedup on LSN, not timestamp — two changes can share a millisecond, not an LSN.
Use source.ts_ms for anything analytical.
⚠️
REPLICA IDENTITYdefaults toDEFAULT— only the primary key goes inbefore. Every SCD2 build and audit silently gets nothing. The most common Debezium misconfiguration, and its symptom is not an error.
ALTER TABLE orders REPLICA IDENTITY FULL;— and it is not free: +31% WAL onorders, +18% oncustomersat Kestrel. Decide per table, and record it beside the dimension design.
The initial snapshot — plan it before the stream
| Approach | Kestrel 28 GB | Source impact | On failure |
|---|---|---|---|
| Locking | ~40 min | table locked | restart from scratch |
| Consistent, no lock | ~40 min | 40 min of vacuum blocked ← Ch. 7 §7.3 | restart from scratch |
| Incremental, 10k chunks | ~70 min | chunk-sized, throttleable | resumes |
75% slower and the only operationally acceptable one. At 10× Kestrel the other two stop being options at all.
⚠️ Operating a replication slot
A slot guarantees the primary retains WAL until the consumer confirms it. That is what makes CDC reliable and what makes it dangerous.
consumer stops → WAL accumulates without bound → disk full
→ PostgreSQL CANNOT WRITE WAL → CANNOT COMMIT → THE STOREFRONT STOPS
Four controls, all required
max_slot_wal_keep_size— converts an outage into a broken stream. The one that would have prevented it. Size it for a weekend; a consumer down two weeks should break.- Monitor retained WAL — warn at 25% of the ceiling, page at 50%.
- Alert on
active = false← the highest-value alert in the chapter — it fires at the crash, not when the disk is filling. - Drop unused slots. Name them
cdc_orders_dataeng, neverdebezium_poc— the name is the only metadata a slot carries, and at 02:56 it is all you have.
And alert on the mechanism, not the symptom
du -sh $PGDATA/* | sort -h | tail -5 # pg_wal should be roughly CONSTANT
"pg_wal exceeds 4 × max_wal_size" fires in week two and names the cause.
"Disk 90% full" fires in week ten and names nothing.
⚠️ Never delete files from pg_wal by hand
It converts a 50-minute incident into a restore. The four safe actions, filed under "database disk full", not under "CDC" — the person who meets this is a backend engineer:
SELECT slot_name, active, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) FROM pg_replication_slots;(also checkarchive_commandfailures — same mechanism)SELECT pg_drop_replication_slot('...');if abandoned- Add disk to buy time
- Never hand-delete WAL
Consuming
Order is per-key — Debezium keys by primary key, Kafka orders within a partition. Changes to different rows are not ordered relative to each other, even in one source transaction.
At-least-once, always. Dedup on LSN.
A delete emits TWO messages — the delete event, then a tombstone (null value) for log compaction. Naive consumers null-pointer on it.
Stream → table
MERGE INTO silver.orders t USING (
SELECT * FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY order_id
ORDER BY lsn DESC) rn
FROM bronze.orders_cdc) WHERE rn = 1) s
ON t.order_id = s.order_id
WHEN MATCHED AND s.op = 'd' THEN DELETE
WHEN MATCHED AND s.lsn > t.source_lsn THEN UPDATE SET ... -- ← IDEMPOTENT
WHEN NOT MATCHED AND s.op <> 'd' THEN INSERT ...;
Keep a history table alongside. That is where the intermediate states live, and it is what nothing else gives you.
Two settings that prevent specific incidents
heartbeat.action.query — writes to a captured table so the slot can advance. Without it, a
connector on a low-volume table can fill the disk of a high-volume database, because the slot only
advances on messages the connector receives.
errors.tolerance: none — skipping an unconvertible record is silent data loss.
Five reasons NOT to use CDC
- No replication privileges (common) → trigger-based or batch
- Small enough that a full load is simpler (common)
- You need cross-table transactional consistency
- Nobody can operate it — the second operator is not optional here
- The source is not a database
Two assertions worth stealing
-- Assert the SHAPE of events, not just their arrival.
SELECT COUNT(*) FROM bronze.customers_cdc
WHERE op = 'u' AND NOT (before ? 'region' AND before ? 'segment');
-- expect 0
-- What fields ACTUALLY appear in before images?
SELECT jsonb_object_keys(before), COUNT(*) FROM bronze.customers_cdc
WHERE op = 'u' GROUP BY 1 ORDER BY 2 DESC;
A fallback that produces a plausible answer hides its cause forever. When you write one, ask whether it is plausible — and if it is, raise instead, with a message that names the fix.