← Back
Home › Systems › Data processing and pipelines

cdc

Turns a database's own change record into an ordered stream of row-level events that other systems such as caches and search indexes can consume.

Debezium

Open the cdc pagecdc Consumer offsetsdistributed: Each consumer group stores one integer per partition, the next position to read, so a restarted consumer resumes where it left off with at-least-once delivery.›Exactly once processingdistributed: Idempotent producers plus transactional offset+output commits for effectively-once resultsLog compactiondistributed: Keep only the latest record per key in a log, with tombstones and a cleanerTransactional outboxdistributed: Write the event as a row in the same local transaction as the state change; a relay publishes it later at-least-once, so state and events never diverge.›Idempotency keysfinance: Client-chosen unique key per logical operation, stored with a request fingerprint and the final response, so retries replay the first result instead of acting twice.›Protobuf wire formatnetworking: Protocol Buffers wire format: tags, varints, length-delimited fields and schema evolutionChange data capturestorage: Decode the database's own commit log into row-change events in commit order, with a resume position, so any consumer can mirror the data exactly.›Chunked backfillstorage: Process old data in bounded key ranges, one short transaction per chunk, with a saved checkpoint and idempotent writes, so a crash resumes and live traffic never waits.›Schema evolutionstorage: Compatibility rules (backward/forward/full) for adding, removing and renaming fieldsWrite-ahead logstorage: Append each change to a checksummed sequential log and fsync it before touching data pages; the basis of crash durability in nearly every database.›Fencing tokensdistributed: A number that grows with every lock grant and is checked by the protected resource, so a paused ex-holder's late write is rejected.›Segmented logdistributed: Split an append-only log into files named by their first offset, write only the newest, and find any record by binary search plus a sparse index.›Two-phase commitdistributed: Atomic commit across shards: participants durably vote yes, then the coordinator logs and broadcasts one decision; blocks if the coordinator is lost.›Swiss tabledata-structures: Open-addressing hash table that filters 8-16 slots at once with 1-byte hash tags and SIMD, giving fast lookups at 87.5 % load.›Event deduplication windowdistributed: Keep the ids of recently accepted events for a time window and drop repeats; memory stays bounded by rotating time buckets instead of per-entry timers.›Message sequence numbersdistributed: A per-stream counter on every message lets the receiver detect loss, duplicates and reordering, ack cheaply and resume after reconnect with "send from N".›Leasesdistributed: A lock that expires on its own: bounded-time ownership that survives crashes and partitions, given bounded clock drift and fencing tokens on writes.›Cryptographic hashes (SHA-2,BLAKE3)security: Collision-resistant fingerprints of data; SHA-256 for interoperability, BLAKE3 for speed, HMAC/HKDF for keyed uses.›Exponential backoff with jittersystems: Retry after delays that grow geometrically and are randomized, so failed clients spread out instead of hammering a recovering service in synchronized waves.›Incremental sync cursorsdistributed: The client keeps one opaque token; each pull returns only the changes after it plus the next token, so sync cost scales with what changed, not with the dataset.›JSON Schema validationsecurity: Compile a JSON Schema once, then check every untrusted document against it, within hard size and depth limits, and report failures as JSON Pointers.›CRC32C checksumshardware-os: The Castagnoli CRC-32 with dedicated instructions on x86 (SSE4.2) and ARMv8. It is the standard fast integrity check for storage blocks, logs and network frames.›+ 1 morePRNGs (PCG, xoshiro)numerics: Small, fast, statistically strong non-cryptographic generators: PCG32 and xoshiro256**, with seeding, parallel streams and unbiased ranges.›Cursor paginationnetworking: Keyset/opaque-cursor pagination for stable incremental reads from APIs and tablesSIMD programminghardware-os: Process 4-16 lanes per instruction with AVX2, AVX-512 or NEON via Rust std::arch, with runtime dispatch and a portable fallback.›

How it is built

Architecture overview

A change data capture (CDC) pipeline turns a database's own record of what happened into a stream of events other systems can consume: row inserted, row updated (before and after), row deleted, in commit order. The reason to build one is the problem of keeping several stores in step: the primary database, a search index, a cache, a warehouse, another service's read model. The two obvious solutions both fail. Dual writes (the application writes to both stores) break whenever one write succeeds and the other does not; distributed transactions across heterogeneous systems are rarely available and slow. CDC avoids both by reading a single source of truth, the database's commit log, after the fact. The log already holds every committed change in order, so the pipeline needs no change to the application and sees every writer, including batch jobs and manual fixes. Debezium is the best-known open implementation; it typically runs inside Apache Kafka Connect and writes to Kafka topics (source: Debezium documentation, architecture).

Parts:

 application ──writes──► database ──► write-ahead log / binlog
                          │ (outbox table rows are ordinary rows)        │
                          │                                              ▼
                          │                     log reader (replication slot, position = LSN / binlog offset)
   snapshot / backfill ◄──┘  select in chunks         │  decode: schema at the time of each change
        │  (interleaved with the log)                 ▼
        └───────────────────────────────► event stream: topic per table, key = primary key
                                                      │  at-least-once, ordered per key
                       ┌──────────────┬───────────────┼────────────────┐
                       ▼              ▼               ▼                ▼
                 search index      cache          warehouse     other services (idempotent consumers)

There are two products of one design: the capture side (one reader per source database, correctness of order and position matters most) and the consumer side (many independent subscribers, each tracks its own offsets and must tolerate repeats).

How the parts fit together

Log reader

Every serious database writes changes to a log before it touches data pages (the Write-ahead log), so crash recovery and replication can replay them. Change data capture reads that log. In PostgreSQL this is logical decoding: the server decodes the write-ahead log into an application-level form, and a replication slot represents a stream of changes that can be replayed to a client in the order they were made, persisting independently of the connection and surviving crashes (source: PostgreSQL documentation, logical decoding concepts). In MySQL the reader consumes the binary log. Each change has a position (an LSN in PostgreSQL, a file and offset in the binlog) and the reader stores the last position it has handed on; that is the Consumer offsets contract, and it is what makes restarts safe. Positions also act as Message sequence numbers: a consumer that sees a gap knows something was lost, and one that sees a repeat can skip it. A consumer that wants only the changes after a point it already holds uses an Incremental sync cursor, an opaque token that stands for a log position.

Two details of the source matter. PostgreSQL stores a slot's position only at checkpoint, so after a crash it may go back to an earlier LSN and send recent changes again, and the documentation says plainly that logical decoding clients are responsible for avoiding ill effects from handling the same message more than once (source: same page). And the log must contain enough to reconstruct a row: updates and deletes only carry the old row's key unless the table is configured to log the full old row, which is why Debezium's PostgreSQL documentation has a dedicated section on replica identity (source: Debezium PostgreSQL connector documentation).

Snapshot and backfill

A log does not hold history from the beginning of time, so a new consumer first needs the current state of every table. Debezium's initial snapshot starts a transaction, reads the existing rows and then switches to the log from the position recorded at the start (source: Debezium PostgreSQL documentation, snapshots). A long snapshot of a large table blocks streaming and, if it fails, restarts from scratch, so newer designs copy in pieces. Chunked backfill processes old data in bounded key ranges, one short transaction per chunk, with a saved checkpoint and idempotent writes, so a crash loses at most a chunk. Debezium's incremental snapshots do this while the log keeps flowing: each table is captured in configurable chunks, triggered by a signal, and based on the DBLog design (source: Debezium documentation, incremental snapshots). The DBLog paper describes the key trick: a watermark-based approach that interleaves transaction-log events with rows selected directly from tables, so the full state is captured without halting log processing (source: Andreakis and Papapanagiotou, DBLog). The rule that makes the interleave correct is that a chunk's rows are discarded if the log shows a newer change for the same key inside the chunk window.

Position tracking

Where the reader keeps its position decides the delivery semantics. In Kafka Connect the connector's offsets are stored in Kafka itself and committed periodically; a crash between the event being written and the offset being committed produces a duplicate on restart. Debezium says so directly: after a fault no events are lost, but the connector might emit some duplicates, and like Kafka it provides at-least-once delivery of change events (source: Debezium PostgreSQL documentation, behaviour when things go wrong). The reverse hazard is on the database side: the server keeps log files for as long as a slot has not confirmed them. In PostgreSQL the default for max_slot_wal_keep_size is -1, meaning slots may retain an unlimited amount of WAL (source: PostgreSQL documentation, replication settings), so a stalled reader fills the database disk. Debezium's documentation has a section on WAL disk space consumption for this reason.

Schema handling

Events must be interpretable years later, while tables change. For the MySQL connector this is concrete: the connector must know the schema at the time of each insert, update or delete, not today's schema, so it keeps a schema history topic with all DDL statements and rebuilds table structures from it on restart (source: Debezium MySQL connector documentation). For consumers, the contract is a compatibility rule (schema-evolution, no page yet): backward means new readers can read old data, forward means old readers can read new data, full means both; adding an optional field is the usual safe change, while renaming a column is a breaking change in disguise (source: Confluent Schema Registry documentation). A compact binary wire format with field numbers instead of names, such as protobuf-wire-format (no page yet), is one way to make additions safe by construction.

Delivery guarantee

At-least-once capture plus consumers that tolerate repeats is the practical default. There are three ways to deal with the repeats. Idempotent application: an upsert keyed by primary key is naturally repeatable, and an Idempotency key (the log position) makes non-naturally-idempotent side effects safe. Deduplication: a consumer keeps the identifiers of recently seen events for a bounded window and drops repeats (Event deduplication). Transactional commit: when the consumer writes to a transactional store, it writes the result and the offset in one transaction, so both happen or neither does; across systems this needs Two-phase commit and is the exactly-once-processing (no page yet) pattern. Choose the cheapest that works: for index and cache updates, idempotent upserts are enough.

Outbox

Applications that want to publish an event ("order placed") face the dual-write problem again. The Transactional outbox solves it: the event is written as a row in an outbox table in the same local transaction as the state change; a relay then publishes it later, at least once. CDC is the best relay, because the outbox row appears in the log like any other insert; the relay needs no polling and no extra locking. Debezium ships an Outbox Event Router transformation for this (source: Debezium documentation, outbox event router). Consumers still need an idempotency key or deduplication, because the relay delivers at least once.

Retention and compaction

A topic that records every change grows without bound, but a new consumer rebuilding a table needs only the latest value per key. log-compaction (no page yet) keeps the most recent record per key and removes older ones, with tombstones (a null value) marking deletions; a background cleaner works on a Segmented log, rewriting closed segments and never touching the active one, and it uses a key-to-latest-offset map for which a hash table such as a Swiss table is a good fit. A compacted topic is therefore a replayable copy of the table's current state, while a time-retained topic is a replayable history of changes; a pipeline often wants both.

Data and control flow

One committed transaction, normal operation:

  1. The application commits a transaction; the database writes it to the log.
  2. The log reader, positioned at LSN n, decodes the change into events with before and after images, the table's primary key and the commit position.
  3. Events are published to the topic of their table, keyed by primary key so that all changes of one row stay in one partition and keep their order.
  4. The connector commits its offset periodically.
  5. Consumers apply events idempotently and commit their own offsets.

Adding a new table or consumer:

  1. Start an incremental snapshot of the table through a signal.
  2. The snapshot reads chunk k of primary keys; log events inside the chunk window win over the chunk's rows.
  3. When the last chunk is done, the consumer has a full copy and a continuous stream.

After a failure:

  1. The reader restarts, asks the database for changes from the last committed position.
  2. Events between the last committed offset and the crash are re-emitted; consumers drop or overwrite them.
  3. If the position is gone (log files removed), the pipeline must re-snapshot: protect against this with slot-based retention and alerts on lag.

Scaling

Limits, in the order they bite, and the lever for each:

  1. One log, one reader. Changes are ordered in a single log per database, so one reader per source is the unit of capture. Levers: a fast decoder, filter tables early, read from a replica where the database allows it.
  2. Retention on the source. A slow or stopped reader forces the database to keep log files. Levers: monitor slot lag, cap retained WAL with max_slot_wal_keep_size (accepting that a too-far-behind reader must re-snapshot), alerts on lag.
  3. Snapshot size. Initial snapshots of terabytes take long and load the source. Levers: incremental snapshots in chunks, a read replica, throttling.
  4. Topic throughput. One partition per table caps ordered throughput; more partitions keep per-key order because events are keyed by primary key.
  5. Consumer lag. Each consumer scales independently by its own partitions; compaction shortens the rebuild path for new consumers.
  6. Hot rows. A single row updated thousands of times a second produces that many events. Lever: coalesce downstream, or compact before consumption.

Failure modes

FailureSymptomCauseFix
Source disk fillsdatabase runs out of spacestalled reader keeps a replication slot, log not recycledalert on slot lag, max_slot_wal_keep_size, drop abandoned slots
Duplicate eventsconsumers apply a change twiceoffset commit lags behind publish; slot position goes back after a crashidempotent upserts, deduplication, position as idempotency key
Lost positionreader cannot resumelog files already removedre-snapshot; longer retention; lag alerts
Schema driftconsumers break on a new field or typeincompatible changecompatibility checks in CI, schema history topic, optional fields only
Updates without old valuesconsumers cannot compute deltas or deletes lack datatable does not log full old rowconfigure replica identity or equivalent, test with updates and deletes
Ordering across tableschild row arrives before its parentone topic per table, no cross-topic orderuse transaction metadata, or consume with tolerance (retry until the parent exists)
Snapshot/stream racestale row overwrites a newer onechunk read overlaps with later changeswatermark-based chunk reconciliation (DBLog), version columns
Outbox growthoutbox table keeps growingrows never deleted after capturedelete after capture, or partition and drop old partitions
Dual-write regressionsearch index differs from databasea new code path writes the index directlysingle path through the log; reconcile job comparing counts and checksums

Real implementations

namewhat it isrepolicencenotes
DebeziumCDC connectors for many databases, usually run in Kafka Connectdebezium/debeziumApache-2.0initial and incremental snapshots, outbox router
MaxwellMySQL binlog reader producing JSON to Kafkazendesk/maxwellsee repoMySQL only
PostgreSQL logical decodingbuilt-in server feature: slots and output plugins (pgoutput)part of PostgreSQL, documentationsee projectthe source side of Debezium's PostgreSQL connector
DBLogNetflix's watermark-based CDC frameworkdescribed in the paper, see Sourcesn/asource of the incremental-snapshot idea

Sources

Similar systems

Share the most building blocks with this one.