Transactional Outbox Pattern
Transactional Outbox Pattern
Overview
If you UPDATE the DB then Publish to a queue, crashes create lost or double events. The outbox writes the event in the same transaction as business data; a relay publishes asynchronously.
begin
update orders
insert outbox(event)
commit
→ relay reads outbox → publish → mark sent
Schema sketch
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);Write path
tx, _ := db.BeginTx(ctx, nil)
_, err = tx.ExecContext(ctx, `UPDATE orders SET status=$1 WHERE id=$2`, "paid", id)
_, err = tx.ExecContext(ctx, `INSERT INTO outbox(topic,payload) VALUES($1,$2)`, "order.paid", payload)
err = tx.Commit()Relay worker
rows, _ := db.QueryContext(ctx, `
SELECT id, topic, payload FROM outbox
WHERE published_at IS NULL
ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED`)
// for each: publish; UPDATE outbox SET published_at=now() WHERE id=?SKIP LOCKED allows multiple relays safely.
At-least-once
Consumers still need idempotency (chapter 155)—outbox gives reliable publish attempt, not exactly-once end-to-end.
Rules
| Do | Don’t |
|---|---|
| Same TX as state change | Publish then commit (or reverse) without outbox |
| Idempotent consumers | Assume no duplicates |
| Metric lag of unpublished rows | Silent outbox growth |
Try next
- Simulate crash after commit before publish; relay catches up.
- Two relays with SKIP LOCKED.
- Dashboard: count unpublished > 5m.