sehaj/learn
System design/07 Async work and streams

07.06 · concept · 11 min

The outbox pattern and change data capture

Changing the database and publishing an event can't be made atomic across two systems. The outbox pattern writes the event into the database in the same transaction; a relay or change data capture publishes it afterwards. Tested code and the trade-offs.

The problem: the dual write

Snip publishes a link.created event to Kafka for the preview, webhook and search services (07.01 Why move work off the request path). The obvious code writes to two systems in a row:

Two writes, and a crash between them
Snip appPostgresKafka1 INSERT x7Kp; COMMIT2 publish link.createdx7Kp exists, but no preview, no webhook, no search entry, ever

2 messages. Use Next and Back to follow them in order.

Text version
  1. Snip app → Postgres: INSERT x7Kp; COMMIT — The link is saved.
  2. Snip app → Kafka: publish link.created (fails) — The app is killed by a deploy, or Kafka times out. The event is never published.
  3. Note over Postgres and Kafka: x7Kp exists, but no preview, no webhook, no search entry, ever

Reversing the order is no better: publish first, and if the insert then fails, every service reacts to a link that doesn't exist. Retrying helps with timeouts but not with a process that died. This is the dual write problem: there's no transaction that spans Postgres and Kafka, so any code that writes to both can leave them disagreeing.

The outbox

The fix is to make it a single write. Alongside its normal tables, the database gets an outbox table, and the event is inserted into it in the same transaction as the change:

sql
CREATE TABLE outbox (
  id           bigserial   PRIMARY KEY,
  topic        text        NOT NULL,
  key          text        NOT NULL,
  payload      jsonb       NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),
  published_at timestamptz
);
CREATE INDEX outbox_unpublished ON outbox (id) WHERE published_at IS NULL;

BEGIN;
INSERT INTO links (code, long_url, owner_id) VALUES ('x7Kp', 'https://shop.example/spring', 4821);
INSERT INTO outbox (topic, key, payload)
  VALUES ('link-events', 'x7Kp', '{"type": "link.created", "code": "x7Kp", "owner": 4821}');
COMMIT;

Either both rows commit or neither does. Then a separate relay copies committed outbox rows to Kafka. The microservices.io description of the pattern is honest about the catch: the relay "might publish a message more than once".

Relay 1: polling the table

ts
export async function relayOnce(db: pg.Pool, publish: (topic: string, key: string, event: object) => Promise<void>) {
  const client = await db.connect();
  try {
    await client.query("BEGIN");
    const { rows } = await client.query(
      `SELECT id, topic, key, payload FROM outbox
        WHERE published_at IS NULL
        ORDER BY id LIMIT 100
        FOR UPDATE SKIP LOCKED`,
    );
    for (const r of rows) await publish(r.topic, r.key, { ...r.payload, eventId: r.id }); // may repeat after a crash
    await client.query("UPDATE outbox SET published_at = now() WHERE id = ANY($1)", [rows.map((r) => r.id)]);
    await client.query("COMMIT");
    return rows.length;
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

Tested against Postgres with 250 links:

  • A transaction that inserted a link and its outbox row, then rolled back, left neither.
  • A relay that crashed partway through a batch of 100 republished that batch after restarting: 300 sends, 250 distinct events. Every event arrived; some arrived twice.
  • Three relays running at once, without crashes, published each of the 250 events once, thanks to SKIP LOCKED.

So consumers must deduplicate, using the eventId the relay adds (07.03 Delivery guarantees and idempotent consumers). Mark old rows published and delete them after a few days, or the table grows for ever.

The ordering catch. bigserial IDs are handed out when rows are inserted, but transactions commit in their own order. Transaction A takes ID 10, transaction B takes ID 11 and commits first; a relay polling at that moment sees 11 but not 10, publishes 11, and publishes 10 on its next pass. For one key this is rare, since Snip's writes to one link are seconds apart, but consumers that care should compare a version number on the entity rather than trust arrival order.

Relay 2: change data capture

Postgres already writes every committed change, in commit order, to its write-ahead log (04.06 Write-ahead logs and crash recovery), and logical decoding can stream those changes out as rows. Change data capture (CDC) tools such as Debezium connect like a replica, through a replication slot, and turn each committed change into an event. Point one at the outbox table and it publishes every new outbox row, in commit order, without any polling. Debezium ships an "outbox event router" for exactly this.

Polling relayCDC (log-based)
LatencyThe poll intervalClose to real time
Load on the databaseA query every pollReads the log stream; no queries on the table
OrderBy ID, with the commit-order catchCommit order
Running itA small loop in your appA connector service and a replication slot to monitor
DuplicatesAfter a relay crashAfter a connector restart

CDC can also capture changes to ordinary tables, with no outbox at all. That's how the cache invalidator in 06.04 Invalidation and staleness learns about every edit, whoever made it. The outbox is still better for events other services depend on: it publishes a deliberate, stable event shape, not your internal table layout, which you'll want to change.

A replication slot keeps the WAL until the connector has read it. If the connector stops for a day, the WAL piles up on the primary's disk. Monitor slot lag the way you'd monitor replica lag.

Seen in production

microservices.io

Pattern

Transactional outbox

The pattern states the problem as how to atomically update the database and send messages to a message broker, and the solution as storing the message in the database as part of the transaction that updates the business entities, with a separate message relay sending stored messages to the broker. It notes that the relay might publish a message more than once, so consumers must handle duplicates.

What we foundStore the message in the database as part of the business transaction; a separate process sends it.

Pattern: Transactional outbox (Chris Richardson)microservices.io ↗

Debezium

Docs

Outbox event router and Postgres connector

To implement the outbox pattern with Debezium, you configure a connector to capture changes to an outbox table and apply its outbox event router. The expected table has an id, which consumers can use to drop duplicates, an aggregatetype used to choose the topic, an aggregateid, a type and a payload. Debezium's PostgreSQL connector uses Postgres's logical decoding and the streaming replication protocol, creating its replication slot if it doesn't exist.

What we foundA CDC connector that captures outbox rows and routes each to a topic by aggregate type.

Outbox Event Router (Debezium documentation)debezium.io ↗
Predict first

Snip's service updates the links table and then publishes to Kafka. To avoid losing events, a developer wraps both in a database transaction: BEGIN; INSERT; publish; COMMIT. Does that fix the dual write?

Commit to an answer before you read on.

Recap

Interview questions

SeniorfreqMust-knowConcept

How do you reliably publish an event when you update the database?

Report a mistakeEdit this page