Skip to main content

Keeping Derived Stores in Sync (CDC & Outbox)

Level 3: Level 3: Scaling the Data Tierhard30 mincdcoutboxdual-write

Never dual-write to a DB and a derived store: use the transactional outbox or log-based CDC through Kafka, with at-least-once delivery and idempotent, versioned consumers.

The dual write: the most common production sync bug

The moment you have a primary database plus any derived store (a Redis cache, an Elasticsearch index, a read replica, an analytics warehouse), you have a sync problem. The naive solution is the dual write: in your request handler, write to the DB, then write to the cache/index in the same code path.

Check yourself
The handler writes the DB, then the cache, then the search index. Each write succeeds 99.9 percent of the time, and you add retries on failure. Is the system safe from drift?
handler:
  db.save(order)          # write 1
  cache.set(order)        # write 2   <-- if this fails or the process
  search.index(order)     # write 3       dies here, stores diverge

This is broken because the writes are not atomic and there is no shared transaction across a database and a cache. Any of these happens routinely: write 1 commits and the process crashes before write 2 (cache stale forever); write 2 succeeds but write 1's transaction rolls back (the cache holds a row the DB never persisted); or two concurrent requests apply their DB writes in one order and their cache writes in the opposite order (the cache ends on the older value). Under load, partial failure is a steady drip of divergence you discover weeks later as "search shows a product that was deleted."

The disciplined fix, in two parts

Transactional outbox: stop writing to the second system from the handler. Instead, in the same database transaction as your business write, insert a row into an outbox table describing the event ({id, aggregate_id, type: OrderPlaced, payload, created_at}). The business change and the intent-to-publish commit together or not at all. A separate relay process reads unpublished outbox rows and publishes them to a message broker (Kafka), marking them sent.

Log-based change data capture (CDC): rather than write an outbox by hand, tap the database's own replication log, which already records every committed change durably and in order. Debezium reads Postgres logical decoding, the MySQL binlog, or the MongoDB oplog and emits an ordered stream of row changes to Kafka. Downstream consumers (a cache updater, an Elasticsearch sink, a warehouse loader) subscribe and apply. The outbox is the right tool when you need domain events (OrderPlaced) rather than raw row diffs; CDC is the right tool when you want to mirror table state to derived stores with no application changes.

Check yourself

Outbox or CDC? Sort each statement under the tool it describes.

Emits rich domain events like 'OrderPlaced' that you shape by hand
Inserts the event row in the same transaction as the business write
Tails Postgres logical decoding or the MySQL binlog with zero application changes
Mirrors raw row state into a search index or warehouse

The honest delivery guarantee

Exactly-once end-to-end is a fantasy across a broker and heterogeneous sinks: the relay can crash after publishing but before marking the outbox row sent, so it republishes. The realistic and correct target is at-least-once delivery plus idempotent consumers. Make every consumer safe to re-apply the same event: key the cache/index write by the event's primary key and use last-writer-wins on a version/LSN, or dedupe on event id. Then a duplicate is a no-op.

Operational reality: you also need backfills and replays (snapshot the current table state to bootstrap a brand-new index, then switch to the live stream), and you must monitor replication slot / consumer lag. A Postgres logical replication slot that a stalled Debezium connector stops advancing will pin WAL and eventually fill the disk, taking the primary down.

Interview nuance: if the interviewer says "just write to the DB and the cache," name the dual-write problem explicitly and reach for outbox or CDC. If they push on "why not exactly-once," say the honest thing: at-least-once plus idempotent, versioned consumers is simpler and strictly more robust, and it is what Kafka-based pipelines actually run.

Recap: never dual-write to a DB and a derived store; commit the change and its event together via a transactional outbox, or tap the DB log with CDC (Debezium), publish through Kafka, and make consumers idempotent so at-least-once delivery is correct, while monitoring replication-slot lag and supporting snapshot backfills.

Check yourself
Your relay crashes after publishing an event to Kafka but before marking the outbox row sent. On restart it publishes the same event again. What must be true downstream?

Apply

Your turn

The task this lesson builds to.

Design how a write to the primary DB reliably updates a Redis cache and an Elasticsearch index without a dual-write race.

Think about

  1. Why can two independent writes partially fail and diverge?
  2. How do the transactional outbox and log-based CDC fix it?
  3. Why is at-least-once + idempotent consumers the realistic target?

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Design the change-propagation pipeline for a marketplace (Shopify-scale: 5M product mutations/day across thousands of merchant DB shards) that must keep a global Elasticsearch search index, a Redis price cache, and a Snowflake analytics warehouse in sync with per-shard Postgres primaries, and explain how you bootstrap a brand-new search index without downtime.

Think about

  1. Why one CDC connector per shard rather than a single global one?
  2. How do three sinks with very different speeds avoid backpressuring each other?
  3. How can a snapshot and the live stream interleave safely into a new index?