Transactional Outbox & CDC
Two lines of code โ db.save(order) then queue.publish(event) โ contain one of the most expensive bugs in distributed systems. Master the dual-write problem, the Transactional Outbox that fixes it, and the Change Data Capture machinery that makes it fast.
๐ฅ 1. The Two-Line Bug: The Dual-Write Problem
Here is code that ships to production every day. It looks correct. It passes every test. It is broken, and it will corrupt your data in a way no amount of retrying can fix.
@Transactional def place_order(cart): order = db.orders.insert(cart) # โ writes to PostgreSQL kafka.publish("order.created", order) # โก writes to Kafka return order # โข transaction COMMITs here # @Transactional covers โ . It has NO power over โก. # Kafka has never heard of your database transaction and cannot roll back.
The order exists. No event was ever emitted. The warehouse never picks it, the confirmation email never sends, the analytics pipeline never counts it. The customer's money is taken and nothing happens. Worst of all: this failure is invisible. There is no error to alert on โ the request returned 200.
A constraint violation or deadlock aborts the transaction after the event is already on the wire. Downstream services now process order-9931, which does not exist. The warehouse ships goods for an order that was never placed. Support gets a ticket no one can reproduce, because the row genuinely isn't there.
Swapping the order of the two calls does not help โ it just changes which failure you get. There is no ordering of two independent writes that makes them atomic. That is the whole point.
The publish actually succeeded but the ack was lost, so your retry emits it twice. The customer is charged twice. This one never fully goes away โ it's the reason idempotent consumers are mandatory even after you fix everything else.
write(A); write(B) and needs both-or-neither is broken โ the only question is how often. The fix is never "add retries" or "be more careful." The fix is to make it one write: put the event inside the same database transaction as the data, then move it to the broker afterwards as a separate, retryable step.It's the textbook answer and it is almost always wrong in practice:
The Outbox pattern gets you the same practical guarantee โ no lost events, no phantom events โ using nothing but a database transaction you already have.
๐ฌ 2. The Fix: Write the Letter Before You Mail It
You sign a contract and must post a copy to your partner. The naive version: sign it, then walk to the post box. If you trip on the way, the contract is signed but never sent โ and nobody knows.
The reliable version: sign the contract and drop the copy in your outbox tray as one motion, at your desk. Both happen or neither does. Separately, a mail clerk empties the tray every few minutes. If the clerk is sick for an hour, nothing is lost โ the letters simply sit there. When the clerk returns, they go out. The tray is durable; the trip to the post box is retryable.
The tray is a table in the same database as your business data. Because it's the same database, one transaction covers both writes:
BEGIN; INSERT INTO orders (id, user_id, total_cents, status) VALUES (9931, 42, 15900, 'PLACED'); -- The event is now just... another row. Same DB. Same transaction. INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload) VALUES ('order', '9931', 'order.created', '{"order_id":9931,"user_id":42,"total_cents":15900}'::jsonb); COMMIT; -- ATOMIC. Both rows, or neither. The DB guarantees it. -- Publishing to Kafka is now a SEPARATE, RETRYABLE step performed by -- a different process reading this table. If it fails, the row waits. -- Nothing is ever lost, and nothing phantom is ever emitted.
CREATE TABLE outbox ( id BIGSERIAL PRIMARY KEY, -- monotonic = a natural cursor aggregate_type TEXT NOT NULL, -- 'order' -> often the topic name aggregate_id TEXT NOT NULL, -- '9931' -> the PARTITION KEY event_type TEXT NOT NULL, -- 'order.created' payload JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), published_at TIMESTAMPTZ -- NULL = still in the tray ); -- PARTIAL index: only unpublished rows are indexed, so the index stays -- tiny (a few hundred rows) even when the table holds billions. -- Without this, your drain query degrades as the table grows. CREATE INDEX outbox_unpublished ON outbox (id) WHERE published_at IS NULL;
It converts "maybe zero, maybe one" into "at least one." It does not give you exactly-once. The publisher can crash after Kafka accepts the message but before it marks the row published โ so on restart it publishes again. This is unavoidable and by design: duplicates are recoverable, lost events are not. Every downstream consumer must therefore be idempotent (see ยง6).
๐ 3. Two Ways to Drain the Tray
The rows are safely in the outbox. Something must now move them to the broker. There are exactly two mechanisms, and the choice is one of the more consequential decisions in this pattern.
BEGIN; SELECT id, aggregate_id, event_type, payload FROM outbox WHERE published_at IS NULL ORDER BY id -- preserves per-aggregate ordering LIMIT 500 FOR UPDATE SKIP LOCKED; -- โ THE critical clause -- SKIP LOCKED lets you run N publishers safely: each grabs a DIFFERENT -- batch instead of all of them blocking on the same rows. Without it, -- publisher #2 waits for #1 and your "scaled out" fleet is serial. -- ... publish the batch to Kafka, wait for acks=all ... UPDATE outbox SET published_at = now() WHERE id = ANY($1); COMMIT;
Every ACID database already maintains a durable, ordered log of every committed change โ Postgres calls it the WAL, MySQL the binlog. That log is how replicas stay in sync. Change Data Capture means attaching to it and treating committed rows as a stream of events. You are not polling for changes; the database is telling you about them.
REPLICATION); DB-version-coupled; and one genuinely dangerous failure mode โ see ยง5.| Dimension | Polling Publisher | CDC / Log Tailing |
|---|---|---|
| Added latency | โ ๏ธ Up to your poll interval (~100 ms) | โ Sub-10 ms |
| Load on the primary | โ Constant queries, even when idle | โ Effectively none |
| Operational weight | โ A cron job / worker you already know | โ Connect cluster, connectors, offsets, monitoring |
| Ordering guarantee | โ ๏ธ You maintain it with ORDER BY id | โ Inherent โ it's the commit log |
| Blast radius when broken | โ Events queue up in a table | โ Can fill the disk and take the DB down |
| Right for | Most teams. Start here. | High volume, tight latency, or many tables |
๐ฌ 4. Inside CDC: WAL, Binlog & Debezium
wal_level = logical โ the WAL normally holds physical block diffs, useless for row events. This setting makes it also emit logical row changes. Requires a restart.Replication slot โ a durable, server-side bookmark. It guarantees Postgres retains WAL until your consumer confirms it. This is the safety mechanism and the footgun.LSN โ Log Sequence Number, the byte-offset cursor into the WAL. Your consumer's position is an LSN.pgoutput โ the built-in output plugin (no extension needed). wal2json is the older alternative.REPLICA IDENTITY FULL โ without it, UPDATE events carry only the key in before. Set it if you need old values โ at a real WAL-size cost.binlog_format = ROW โ mandatory. The default STATEMENT format logs the SQL text, so UPDATE โฆ WHERE status='new' tells you nothing about which rows changed.binlog_row_image = FULL โ include every column, not just changed ones.GTID โ Global Transaction ID. Gives a stable cursor that survives failover to a replica; file+position does not.expire_logs_days โ MySQL deletes old binlogs on a timer. Unlike a Postgres slot, it will not wait for you: a consumer down too long loses data permanently and needs a full re-snapshot.Debezium is a set of Kafka Connect source connectors that speak these replication protocols and turn each row change into a structured event with before and after images:
{
"op": "c", // c=create u=update d=delete r=read (snapshot)
"before": null, // populated on u/d (needs REPLICA IDENTITY FULL)
"after": {
"id": 18,
"aggregate_id": "9931",
"event_type": "order.created",
"payload": "{\"order_id\":9931,\"total_cents\":15900}"
},
"source": {
"db": "shop", "table": "outbox",
"lsn": 24857392, // exact WAL position โ your resume cursor
"txId": 4412, // group events from one transaction
"snapshot": "false"
},
"ts_ms": 1787923200000
}A tempting shortcut is to point Debezium straight at your orders table and skip the outbox entirely. Don't. That publishes your internal schema as your public event contract โ every consumer now breaks when you rename a column, and you can never refactor. It also means you can only emit events that happen to correspond exactly to a row shape.
Instead, capture the outbox table. Debezium's EventRouter SMT then unwraps each row: aggregate_type becomes the destination topic, aggregate_id becomes the Kafka message key (which is what preserves per-order ordering inside a partition), and payload becomes the message body. Consumers see a deliberately designed event, not your table.
๐จ 5. The Outage: How CDC Takes Down Your Database
This is the single most important operational fact in this module, and it surprises teams every year. A stalled replication slot can take your primary database down.
5 MB/s ร 3600 s = 17.6 GB pinned after 1 hour 5 MB/s ร 6 h = 105.5 GB pinned after 6 hours โ overnight 5 MB/s ร 24 h = 421.9 GB pinned after 24 hours An unnoticed weekend outage will exhaust almost any volume you provisioned. A busy primary generating 50 MB/s reaches 176 GB in ONE hour.
pg_replication_slots and alarm on pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) crossing a few GB. This is the metric that predicts the outage; connector uptime does not.max_slot_wal_keep_size (PG 13+). It caps retention: past the limit, Postgres invalidates the slot instead of dying. You lose the stream and must re-snapshot โ a far better outcome than a total outage. Choose your failure mode deliberately.๐ 6. The Other Half: Idempotent Consumers
The outbox guarantees at-least-once delivery. Half the work is therefore on the consumer: duplicates will arrive, and "charge the customer" must not run twice.
-- The INBOX (dedup) table, in the CONSUMER's database CREATE TABLE processed_events ( event_id UUID PRIMARY KEY, -- the producer's event id processed_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Handling an event: the dedup check and the side effect must share ONE -- transaction. Otherwise you have re-created the dual-write problem here. BEGIN; INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT (event_id) DO NOTHING; -- 0 rows affected => already handled => COMMIT and ack. Do nothing else. -- 1 row affected => first time => perform the side effect HERE: UPDATE inventory SET reserved = reserved + 1 WHERE sku = $2; COMMIT;
A dedup table is a crutch. Prefer designs where replay is harmless: UPDATE โฆ SET status='PAID' (assignment, not increment); INSERT โฆ ON CONFLICT DO NOTHING on a natural key; upserts keyed by the event's aggregate id. If the operation is idempotent by construction, duplicates cost you nothing and you store no state at all.
Kafka guarantees order within a partition only. Use aggregate_id as the message key so every event for order 9931 lands in the same partition and is processed in sequence. Key by anything else โ random, round-robin โ and order.shipped can be handled before order.created.
Neither the outbox nor the inbox is self-cleaning, and both grow at your full event rate.
Dedup table at 5,000 events/s with 7-day retention, ~48 B per row: 5,000/s ร 86,400 s ร 7 d = 3.02 billion rows 3.02e9 ร 48 B = 135.2 GB of pure bookkeeping
Use time-based partitioning and DROP whole partitions โ a metadata operation. Do not use a nightly DELETE: mass deletes leave dead tuples, trigger autovacuum storms on your primary, and bloat the very index your publisher depends on. This is the #1 way a correctly-implemented outbox still causes an incident six months later.
๐ป 7. Production Code: Outbox Write + Publisher (Multi-Language)
Each version shows the two halves: the atomic write (business row + outbox row in one transaction) and the drain loop with SKIP LOCKED so you can run many publishers safely.
# Python โ psycopg3 + confluent-kafka import json, time, uuid import psycopg from confluent_kafka import Producer # โโ HALF 1: the atomic write โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ def place_order(conn, cart): # ONE transaction. Both inserts, or neither. No broker involved. with conn.transaction(): order_id = conn.execute( "INSERT INTO orders (user_id, total_cents, status) " "VALUES (%s, %s, 'PLACED') RETURNING id", (cart.user_id, cart.total_cents)).fetchone()[0] conn.execute( "INSERT INTO outbox " " (event_id, aggregate_type, aggregate_id, event_type, payload) " "VALUES (%s, 'order', %s, 'order.created', %s)", (str(uuid.uuid4()), str(order_id), json.dumps({"order_id": order_id, "user_id": cart.user_id, "total_cents": cart.total_cents}))) return order_id # โโ HALF 2: the drain loop (run N of these) โโโโโโโโโโโโโโโโโ BATCH = 500 POLL_S = 0.1 # 500 rows / 100ms = 5,000 events/s per worker def drain_forever(dsn, producer): with psycopg.connect(dsn, autocommit=False) as conn: while True: n = drain_once(conn, producer) # Only sleep when idle: under load we loop with zero added latency. if n < BATCH: time.sleep(POLL_S) def drain_once(conn, producer): with conn.transaction(): rows = conn.execute( """SELECT id, event_id, aggregate_type, aggregate_id, event_type, payload FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT %s FOR UPDATE SKIP LOCKED""", # โ lets workers scale (BATCH,)).fetchall() if not rows: return 0 for (_id, event_id, agg_type, agg_id, ev_type, payload) in rows: producer.produce( topic=f"{agg_type}.events", key=agg_id, # โ per-aggregate ORDERING value=json.dumps(payload), headers=[("event_id", event_id), # โ consumer dedup key ("event_type", ev_type)]) # flush() BEFORE the UPDATE. If flush raises, the transaction rolls # back, rows stay unpublished, and we retry. Marking first would # mean silently LOSING events โ the exact bug we came here to fix. if producer.flush(timeout=10) > 0: raise RuntimeError("kafka flush incomplete; will retry batch") conn.execute("UPDATE outbox SET published_at = now() " "WHERE id = ANY(%s)", ([r[0] for r in rows],)) return len(rows)
Publish, then mark. Never mark, then publish. If you mark rows as published and then crash before Kafka acks, those events are gone forever with no trace โ you have re-introduced the lost-event bug inside the very mechanism built to prevent it. Publishing first means a crash produces a duplicate, which your idempotent consumer absorbs. Always choose the failure mode that is recoverable.
๐งช 8. Interactive Lab: Crash Injection
Dual Write vs Outbox โ Kill the Process Mid-Sequence
๐ก Try this: with Dual Write, crash after step 2 โ the event is on the broker but the order never committed (a phantom). Then crash after step 1 โ the order exists and no event was ever emitted (a silently lost event). Now switch to Outbox and try every crash point: you can produce a duplicate, and you can produce a delay, but you can never produce a lost or phantom event.
๐ฏ 9. Test Your Understanding
A colleague acknowledges the dual-write risk and proposes wrapping the Kafka publish in a retry loop with exponential backoff, plus an alert if all retries fail. Is the problem solved?
You run Debezium against Postgres. At 4 a.m. your primary database starts refusing all writes. Disk is 100% full. Application error rates are at 100%, but the app itself never changed and traffic is at its overnight low. What happened, and what do you check first?
To cut publisher latency, an engineer reorders the drain loop: mark the batch published_at = now() and commit immediately, then send to Kafka asynchronously without waiting for acks. Throughput doubles. What did they just break?