Sertaç Yıldırımfield notes

Home → Engineering

The Dual Write Problem

The order was written to PostgreSQL. The event was then going to be pushed to a Redis stream, but the connection dropped at that exact moment. Now there is an order that exists in the database and that the streaming engine knows nothing about. The customer cannot see it; support says "it is in the system".

Summary
  • You cannot write atomically to two separate systems. However careful your code is.
  • The fix is not reordering, it is moving the second write into the same database. That is exactly what an outbox does.
  • The same problem exists on the receiving side, and the answer is an inbox: write first, process later.
  • CDC is powerful but makes your table schema the contract. The most robust setup is outbox + CDC.

Why does try/catch not solve this?

The first instinct is usually this:

Code that does not work
db.insert(order);                   // write 1
try {
    redis.xadd("orders", event);    // write 2
} catch (Exception e) {
    log.error("event not published", e);
    // and now what?
}

The problem here is not failing to catch the error; it is that there is nothing useful to do after catching it. Look at your options:

  • Delete the order? The bridge may already have processed it. Deleting creates a bigger inconsistency.
  • Retry? If the process dies on this line, the retry dies with it.
  • Log and move on? What you have done is make the inconsistency invisible.

Reversing the order does not save you either: publish the event first and write to the database second, and now you have published an event for an order that does not exist, and the streaming engine shows a ghost.

OrderWhat breaksSymptom
DB first, then eventThe event is lostThe order exists, nobody knows. Silent.
Event first, then DBThe order is lostA ghost event; consumers look at a record that is not there
Both "succeed"Ordering breaksThe event reaches consumers before the transaction commits

The third row is the least known and it genuinely happened to us: the event was published, a consumer read it immediately and went to the database to fetch that record — but the transaction had not committed yet. The consumer got "record not found". The event had been published before the COMMIT.

If two systems have no shared COMMIT, you cannot write to both atomically. The fix is to move the second write into the same system.

Outbox: write the event to the database too

The idea is surprisingly simple: instead of sending the event to the messaging system, write it to a table in the same database, inside the same transaction.

One transaction, two inserts
CREATE TABLE outbox (
  id           bigserial PRIMARY KEY,      -- this defines the order
  event_type   text        NOT NULL,       -- 'OrderCreated'
  aggregate_id text        NOT NULL,       -- order_id — events of the same order
  payload      jsonb       NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),
  published    boolean     NOT NULL DEFAULT false
);
CREATE INDEX ON outbox (published, id) WHERE NOT published;

-- In the application:
BEGIN;
  INSERT INTO orders (id, symbol, lots, ...) VALUES (991, 'EURUSD', 1.0, ...);
  INSERT INTO outbox (event_type, aggregate_id, payload)
  VALUES ('OrderCreated', '991', '{"orderId":991,"symbol":"EURUSD",...}');
COMMIT;   -- ← either both, or neither

"Order exists but no event" is now physically impossible. Either both are in the table, or neither is.

The relay: reading the outbox and publishing

Relay
-- A separate process, a few times a second:
BEGIN;
  SELECT * FROM outbox
   WHERE NOT published
   ORDER BY id                 -- ← ordering guarantee lives here
   LIMIT 200
   FOR UPDATE SKIP LOCKED;     -- ← several relays can run

  -- for each: redis.xadd(...) / kafka.send(...)

  UPDATE outbox SET published = true WHERE id = ANY(:ids);
COMMIT;

Three details, all learned afterwards:

  • Publishing happens before marking. So if you crash after publishing, the event goes out twice. That is acceptable — at-least-once already works that way and the consumer deduplicates (details). Do it the other way around — mark first, publish second — and you lose events, which is far worse.
  • Ordering is preserved via id. Events for the same order go out in sequence thanks to ORDER BY id. Running multiple relay copies weakens that guarantee; if ordering is critical, partition by aggregate_id (sharding).
  • Cleanup is essential. Published rows pile up, the table grows huge and the SELECTs slow down. A daily job: delete published rows older than three days. The partial index above keeps the query independent of table size.
The latency question

A polling relay delays the event by up to one polling interval. Poll every 100 ms and you add 50 ms of average latency. Too much for a price feed; usually fine for order events.

If you want latency near zero, use LISTEN/NOTIFY instead of polling, or move to the CDC approach in the next section.

Inbox: the same problem on the receiving side

Now the other direction. A fill notification arrives from the bridge. You need to store it, update the position, notify the customer and write to reconciliation. What if you crash halfway?

The answer is the same idea: write first, process later.

The inbox
CREATE TABLE inbox (
  event_id   text PRIMARY KEY,     -- ← dedup lives here
  payload    jsonb NOT NULL,
  received   timestamptz NOT NULL DEFAULT now(),
  processed  boolean NOT NULL DEFAULT false
);

-- Step 1: just store it and ack. Fast.
INSERT INTO inbox (event_id, payload) VALUES (:fill_id, :payload)
ON CONFLICT (event_id) DO NOTHING;     -- second delivery is ignored

-- Step 2: a separate process does the work
BEGIN;
  SELECT * FROM inbox WHERE NOT processed ORDER BY received
   LIMIT 100 FOR UPDATE SKIP LOCKED;
  -- ... do the real work ...
  UPDATE inbox SET processed = true WHERE event_id = ANY(:ids);
COMMIT;

It solves three problems at once:

  • Duplication: the primary key stops the same event entering twice.
  • Crashes: if you die while processing, the event is still in the table and gets reprocessed.
  • Speed: you acknowledge the sender instantly; the heavy work happens in the background. The bridge does not time out.

CDC: without touching the application at all

A third route is to read the database's change log (the WAL in PostgreSQL, the binlog in MySQL) and produce events from it. Debezium does this.

The appeal is obvious: not one line of application code changes. Every row written to the positions table automatically becomes an event and flows to the risk engine.

But there is a price, and it is large

The event CDC produces is the table itself. So when you add a column to positions or rename one, every service listening to that event is affected.

The result: your database schema quietly becomes an inter-service contract. And because changing it looks as easy as writing a migration, people break it without realising.

Best of both: combine them

Write the event to an outbox table (you own the contract) and read that table with CDC (no polling latency). You get both benefits:

application → BEGIN; INSERT orders + INSERT outbox; COMMIT;
                                    ↓
                          Debezium (reads the WAL)
                                    ↓
                          Kafka / Redis stream
                                    ↓
                               consumers

You no longer need to write a relay, event latency drops to milliseconds, and the event schema stays under your control. Debezium even ships an "outbox event router" component for exactly this.

Which one should you pick?

Outbox + pollingCDC (direct on tables)Outbox + CDC
SetupEasy, no extra infraMedium (Debezium, Kafka Connect)Medium
Code changesYesNoneYes
Event schemaYou decideYour table structureYou decide
LatencyPolling intervalMillisecondsMilliseconds
WhenStarting out, one serviceYou cannot touch a legacy systemThe long-term answer

Practical advice: start with outbox + polling. It takes an afternoon, needs no extra infrastructure and solves 90% of the problem. Add CDC when latency genuinely becomes an issue — and because the outbox table already exists, that migration is painless.

Checklist AI agent task list

Everywhere you publish events
  • Is there a "write to DB, then send a message" pattern in the code?
  • Is the event written in the same transaction as the main record?
  • Does the relay mark before publishing? (Wrong — it must mark after.)
  • Is there a cleanup job for the outbox table?
  • Is the age of the oldest unpublished row monitored? (If the relay dies, that is the only signal.)
  • Are incoming events written to an inbox first, or processed directly?
  • Is the event id the primary key in the inbox?
  • If you use CDC: has your table schema become an inter-service contract?

Conclusion

Dual writes are the wall almost every team moving to microservices eventually hits. And most treat it as a bug and try to write more careful code. But it is not a bug; it is a structural consequence of two separate systems having no shared commit point.

The whole solution fits in one sentence: instead of doing two writes, do one write and derive the second from it. Outbox, inbox and CDC are all the same idea applied in different places.