◐ ai-teacher

⚡ Streaming & Kafka · Chapter 1

CDC with Debezium: reading the database log instead of the database

Polling a production database every five minutes is how analytics teams get their access revoked. CDC reads the log the database already writes — and gets every change, in order, including the deletes.

Study this lesson free in the app →

The basics

Change Data Capture (CDC) means getting every insert, update and delete out of a database by reading its internal change log, instead of repeatedly querying its tables. Debezium is the open-source tool that does this and publishes each change to Kafka.

The problem it solves: analytics needs a copy of production data — orders, payments, rides. The naive approach polls SELECT * FROM orders WHERE updated_at > ? every few minutes. It loads the production database, misses changes that happen between polls, and never sees deletes: a deleted row simply stops appearing, so the warehouse keeps it forever.

The mental model: a bank doesn't photograph your balance every five minutes to know what happened — it keeps a passbook with every transaction in order. Databases keep a passbook too (Postgres calls it the WAL, MySQL the binlog) for crash recovery and replicas. Debezium registers as a replica, reads that passbook, and turns each entry into an event with the row before and after the change.

Words you must know 5

WAL / binlog
the database's ordered log of every committed change.
Change event
before, after, op (c/u/d/r) and source position.
Snapshot
the initial full read of existing rows before streaming starts.
Replication slot
Postgres's bookmark for a CDC reader; it holds WAL until the reader confirms.
LSN
log sequence number, the position in the WAL; used to reject stale events.

Explained

SELECT * FROM orders WHERE updated_at > ? every five minutes.

It misses rows updated inside the same second. It misses deletes entirely. It hammers production. And it silently drifts, so nobody notices for months.

Every transactional database already keeps an ordered log of every change, so it can crash-recover and replicate. Postgres calls it the WAL, MySQL the binlog.

Change Data Capture means reading that instead of querying tables.

Debezium pretends to be a replica. The database streams it every insert, update and delete — in commit order, with the before and after image — and Debezium publishes them to Kafka.

You get the complete change history, in order, at near-zero cost to the source. The database was writing this anyway.

What a change event looks like

Each event carries:

before — the row as it was. null for an insert. after — the row as it now is. null for a delete. op — c create, u update, d delete, r read (snapshot). source — LSN/offset, transaction id, table, timestamp.

The before image is the part polling can never give you. "The fare changed from 240 to 190" is a different fact from "the fare is 190", and reconciliation needs the first one.

Snapshot then stream. On first connect Debezium reads the whole table (op: r), then switches to the log at exactly the right position. Getting that handover right without missing or duplicating is the hard part it solves for you.

The consumer that is right the second time

# Kafka key = primary key -> all changes for one row land in one partition,
# so they stay in order. This is the whole reason the key matters.
for msg in consumer:
    ev = msg.value
    pk = ev['after']['id'] if ev['after'] else ev['before']['id']

    if ev['op'] == 'd':
        target.execute('DELETE FROM rides WHERE id = %s', [pk])
    else:
        # upsert, not insert — the same event may arrive twice
        target.execute('''
            INSERT INTO rides (id, fare, status, _lsn) VALUES (%s,%s,%s,%s)
            ON CONFLICT (id) DO UPDATE SET
              fare = EXCLUDED.fare, status = EXCLUDED.status, _lsn = EXCLUDED._lsn
            WHERE rides._lsn < EXCLUDED._lsn''',        # never apply an older change
            [pk, ev['after']['fare'], ev['after']['status'], ev['source']['lsn']])

Two defences, both required: upsert because delivery is at-least-once, and the _lsn guard because a retry can deliver an older event after a newer one.

A ride is cancelled and the row is deleted.

With polling on updated_at: the row simply stops appearing. Your warehouse still shows it as active, forever. Revenue is overstated and no error is ever raised.

With CDC: you get op: d with the before image. You know it existed, what it was, and exactly when it went.

This is the case that usually justifies the whole project. Deletes are invisible to polling, and finance eventually finds them.

Replication slots are dangerous. In Postgres, a slot holds WAL until the consumer confirms. Stop your connector on a Friday and by Monday the disk is full and the production database stops accepting writes. Monitor slot lag as a production alert, not a nice-to-have.

The initial snapshot on a 500GB table takes hours and loads the source. Use an incremental snapshot, or a read replica.

Schema changes propagate. Someone adds a column and every downstream consumer sees it. Use a schema registry with compatibility rules or you find out in production.

Topic per table is the default. A hundred tables is a hundred topics to size and monitor.

Not monitoring replication slot lag. The one that takes prod down.

Inserting rather than upserting. Delivery is at-least-once. You will get duplicates.

Ignoring event order on retry. Without an LSN guard, a redelivered old event overwrites a newer value.

Assuming CDC gives you a table. It gives you a stream of changes. Turning that back into current state is your job — that is what the upsert is doing.

Running the snapshot against the primary at peak. Use a replica.

The database already writes the log. Read that, not the tables.

You get every change in commit order, with before and after, including deletes — and production barely notices.

Key by primary key for ordering. Upsert for duplicates. Guard on LSN for out-of-order retries. Alert on slot lag or you will take prod down.

Words to know

CDCChange Data Capture — deriving changes from the database log rather than querying tables.
WAL / binlogThe ordered log of every change, written for crash recovery and replication.
Replication slotPostgres bookmark tracking what a consumer has read. Retains WAL until confirmed.
LSNLog Sequence Number — position in the WAL. Useful as a version guard.
SnapshotThe initial full read of existing rows before streaming begins.
Before imageThe row as it was prior to the change. Impossible to get by polling.

In two minutes

Test yourself

1. What does polling on updated_at miss that CDC catches?

  1. Nothing
  2. Deletes, and multiple updates within the poll window
  3. Inserts
  4. Schema changes
Show the answer

Deletes, and multiple updates within the poll window — A deleted row simply stops appearing, so downstream keeps it forever. Intermediate updates are also collapsed or lost.

2. Your Debezium connector has been down for two days. What is the production risk?

  1. Stale data
  2. The replication slot retains WAL and can fill the disk, halting writes
  3. Duplicate events
  4. Schema drift
Show the answer

The replication slot retains WAL and can fill the disk, halting writes — Postgres cannot discard WAL a slot has not confirmed. Disk fills, and the database stops accepting writes.

3. Why must a CDC consumer upsert rather than insert?

  1. It is faster
  2. Delivery is at-least-once, so events can arrive twice
  3. Inserts are locked
  4. To preserve order
Show the answer

Delivery is at-least-once, so events can arrive twice — Retries redeliver. Upserting on the primary key makes reprocessing harmless.

4. In a Debezium event, what does op: "r" mean?

  1. A row was replaced
  2. A rollback
  3. A replication error
  4. A read from the initial snapshot, not a live change
Show the answer

A read from the initial snapshot, not a live change — During the snapshot Debezium emits every existing row as op = r. Treat it like an insert/upsert. c, u and d are live creates, updates and deletes from the log.

5. What must be configured on a Postgres source before Debezium can stream changes?

  1. An index on updated_at
  2. Triggers on every table
  3. wal_level = logical, plus a replication slot (and usually a publication)
  4. Serializable isolation
Show the answer

wal_level = logical, plus a replication slot (and usually a publication) — Logical decoding needs the WAL to carry row-level detail (wal_level = logical). The slot tracks how far Debezium has read. No triggers or extra columns are needed — that's the point.

6. Why does Debezium use the table's primary key as the Kafka message key?

  1. So all changes to the same row go to the same partition and stay in commit order
  2. Because Kafka requires numeric keys
  3. To compress messages
  4. So each consumer gets one table
Show the answer

So all changes to the same row go to the same partition and stay in commit order — Kafka orders only within a partition. If an insert and a later update for row 42 landed in different partitions, a consumer could apply the update first and then overwrite it with the insert.

There are 8 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →