Change Data Capture (CDC) & Outbox Pattern
The Dual-Write That Broke Search Consistency
Test your architecture intuition: Pitch a 7-axis solution, survive two aggressive reviewer objections, and inspect the staff-level Teacher Gold Answer.
1. What It Is & Why It Exists
The Core Problem: The Dual-Write Desynchronization Trap
In distributed systems, microservices frequently need to update a primary database and simultaneously notify other systems (e.g. invalidating a Redis cache, updating an Elasticsearch index, or broadcasting an event to Apache Kafka):
Synthesizing vector architecture diagram...
If the application attempts to execute two separate network write operations ("Dual-Writing"):
- DB Succeeds, Broker Fails: Downstream consumers never receive the event (permanent data loss).
- Broker Succeeds, DB Fails: Downstream consumers process ghost records that do not exist in the primary database.
- Concurrent Races: Two concurrent updates execute in order in SQL, but arrive in order in Kafka, corrupting search indexes.
The First-Principles Solution: Transactional Outbox & Log-Based CDC
To guarantee atomicity without a distributed transaction (two-phase commit across the database and the broker), the event publication must be bounded inside the same local ACID database transaction as the business data mutation. A relay (a log reader or a poller) then publishes it after the commit, at least once. See Change Streams & the Transactional Outbox.
2. Core Mechanics: Transactional Outbox vs. Log-Based CDC
Synthesizing vector architecture diagram...
Follow the numbered steps from the top. In the "Single Local ACID Transaction" panel, the service begins one transaction (1), inserts the order (2) and an outbox event describing it (3), and commits (4), so both rows are saved or neither is, and no event can exist for an order that was never saved. In the "Log-Based CDC Extraction Tier" panel, Debezium or DMS reads the database's WAL or binlog, picks out new outbox rows, and publishes them to Kafka or Kinesis. In the "Asynchronous Consumer Execution" panel, consumers update search, delete stale cache keys, and trigger billing. The service never writes to two systems in one step, which is exactly what makes dual writes inconsistent; delivery is at least once, so consumers must handle duplicates.
Outbox Table Schema & DDL
sqlCREATE TABLE outbox_events ( event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), aggregate_type VARCHAR(64) NOT NULL, -- e.g. "ORDER" aggregate_id VARCHAR(64) NOT NULL, -- e.g. "ord_10294" event_type VARCHAR(64) NOT NULL, -- e.g. "ORDER_CREATED" payload JSONB NOT NULL, -- Full event payload headers JSONB, -- Trace ID, correlation ID created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- With log-based CDC the row only has to exist in the WAL, not in the table: Debezium's outbox -- event router reads the INSERT from the log, so the application may DELETE the row in the same -- transaction (or a sweeper can purge it later) and the table never bloats. -- Application Atomic Write Transaction BEGIN; INSERT INTO orders (order_id, user_id, amount_cents, status) VALUES ('ord_10294', 'usr_881', 4999, 'PAID'); INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload) VALUES ('ORDER', 'ord_10294', 'ORDER_CREATED', '{"order_id": "ord_10294", "amount_cents": 4999}'); COMMIT;
3. Comparison Matrix: CDC Extraction Mechanisms
| CDC Extraction Strategy | Latency | Database CPU Overhead | Deletion / Truncation Management | Primary Production Engine |
|---|---|---|---|---|
| Log-Based CDC (WAL Tailer) | The reader's lag behind commit (no fixed number: measure it) | Low: reads the log the database writes anyway (plus decoding); keeps commit order. A stalled reader pins WAL on the writer's disk | Zero table bloat; reads directly from WAL | Debezium on MSK, AWS DMS, DynamoDB Streams |
| Polling Outbox Table | Up to one poll interval | Queries every interval even when idle, claims and deletes on the busiest database. A relay that pages by increasing id can skip a row whose transaction commits after a higher id was already read (the visibility gap) | Requires continuous DELETE / VACUUM maintenance | Quartz Scheduler, custom cron workers |
| Database Triggers | Inside the writing transaction | Added to every write (the trigger runs inside the user's transaction) | Risk of transaction rollbacks on trigger failure | Legacy SQL Server / Oracle triggers |
Which Relay to Start With, and When to Move
One recommendation, with timing: start with a polling relay that claims rows with FOR UPDATE SKIP LOCKED and is woken by a signal after each commit. Move to log-based CDC (or to a broker) when one of the signals below appears. Do not start with CDC for a single service, because it adds a connector, a broker and a replication slot that nobody needs yet. Do not stay with polling after the signals appear, because every extra relay adds claim and delete load to the one database you cannot scale out.
A wake-up signal is a small message that the writer sends to the relay right after its transaction commits, so the relay does not wait for the next timer tick. The timer stays as a safety net, because a signal can be lost (the process restarts, or a row is written by another system). The step-by-step version, with the numbers for 6,000 events a second, is in Outbox loop, step 2.1.
Synthesizing vector architecture diagram...
Follow the arrows from the top. The outbox table and the business transaction stay the same at every step. Only the way the row is noticed and sent changes. Each arrow is labelled with the signal that justifies paying for the next step.
The signals, in the words of the two guides:
| Signal | Meaning | Move to |
|---|---|---|
| Events wait longer than users or consumers accept | The timer interval is the delay | Wake-up signal (no new infrastructure) |
| A second process must consume the module's events | An in-process signal cannot reach it | A broker, then the relay publishes to it |
| Polling load shows in the database: claim queries and delete churn on the busiest table, or vacuum cannot keep up | Every added relay makes the writer busier | Log-based CDC |
| Event rate makes claim batches the bottleneck, or many teams each maintain a relay | The loop shows 6,000 events a second still fits a signalled relay, and 12,000 events a second on one database with forty teams does not | Log-based CDC |
Wake-up options compared, including the LISTEN/NOTIFY ceiling
| Way to wake the relay | Reaches | Cost or limit |
|---|---|---|
| Timer only | Any process | Delay up to the interval; one query per tick even when idle |
| In-process signal after commit | Relays in the same process | A crash between commit and signal loses the signal; the timer catches it |
PostgreSQL LISTEN / NOTIFY | Any process that holds a listening connection | Not durable (a disconnected listener misses messages); payload under 8,000 bytes; needs a dedicated, non-pooled connection (not PgBouncer transaction mode); and a throughput ceiling |
| Log-based CDC | The connector | A connector, a broker and a replication slot to operate |
The ceiling: PostgreSQL takes a global lock during the commit of every transaction that ran NOTIFY, so those commits happen one at a time. One published write-up measured about 2,900 writes a second as the limit. A writer that commits thousands of transactions a second should not use NOTIFY for its outbox. This is the same figure that Outbox loop, step 2.1 teaches; it is a measurement from one source, so measure your own system before relying on it.
Three Ways to Claim an Outbox Row
All three use FOR UPDATE SKIP LOCKED, which lets each worker lock the rows it takes and skip rows another worker has locked. They differ in how long the claim lasts and in what is left behind when the work is done. The first two are claim shapes. The third is a choice of ending that can follow either of them.
Synthesizing vector architecture diagram...
Choose the claim by how long the work takes, then choose the ending by whether you need the rows later.
| Shape 1: hold the row lock | Shape 2: commit a lease | Shape 3: keep and mark | |
|---|---|---|---|
| What the claim is | The row lock, held in one open transaction while the relay publishes | A short committed update: status Processing, the worker's name and a lease expiry time | Not a claim. It is what happens after success: set status Processed and a timestamp instead of deleting |
| Where it is used | Outbox loop, step 1.4 ("no leases, no timers") | The settlement jobs of the crowdfunding loop, step 1.6 | Outbox loop, step 3.2 keeps rows 3 days so it can republish |
| If the worker crashes | The connection closes, the transaction rolls back, the rows are free at once | The row stays Processing until the lease expires, then any worker reclaims it | Same as the claim shape it follows |
| Main cost | An open transaction and a database connection for the whole publish, so use a hard publish timeout, small batches and a small separate connection pool. A long open transaction also delays vacuum | One extra write per claim (more dead row versions to vacuum), two extra columns and an index. A slow worker whose lease expires can publish the same row twice, so consumers must be idempotent. The lease length is a trade-off: short means duplicates, long means slow recovery | The table grows without limit unless a job purges or drops partitions. Index only the pending rows so claims stay fast |
| Use when | The publish is short and bounded | The work is long, calls a system you do not control, or you will not hold a transaction open that long | You need replay, audit or a way to inspect delivered events |
Rule of thumb: hold the row lock across a short publish; commit a lease when handlers run long; keep and mark only when you will use the kept rows, and purge them on a schedule. Whatever you choose, count a reclaim as an attempt so that a message that keeps killing its worker ends in the dead-letter table.
Unlock Complete Architecture & Production Runbooks
You have explored the free architectural preview (~64%). Spend 1 Coin to unlock the remaining 4 production deep-dive sections for a full 24 hours.