Pattern hỏng kinh điển:

await db.insert(order);      // OK
await broker.publish(event); // crash / timeout ở đây
// → DB có order, không ai nhận event — hoặc ngược lại nếu đảo thứ tự

Hai hệ thống (DB và queue/broker) không chung một transaction phân tán rẻ tiền. “2 phase commit khắp nơi” hiếm khi là câu trả lời bạn muốn vận hành.

Transactional outbox: ghi business row hàng outbox cùng một transaction DB. Process riêng (poller / CDC) đọc outbox rồi publish — at-least-once ra broker. Phía nhận: inbox (dedup) để không xử lý trùng.

Demo mô phỏng crash giữa chừng: Outbox vs publish thẳng.

Outbox flow

BEGIN;
  INSERT INTO orders …;
  INSERT INTO outbox (id, topic, payload, created_at)
    VALUES (…, 'order.created', …);
COMMIT;

-- publisher (định kỳ / LISTEN)
SELECT * FROM outbox WHERE published_at IS NULL LIMIT 100;
publish(row);
-- mark published (hoặc delete) — ideally sau ack broker
UPDATE outbox SET published_at = now() WHERE id = …;

Crash sau commit, trước publish: outbox vẫn còn row → publisher chạy lại. Crash sau publish, trước mark: publish trùng → consumer phải idempotent / idempotency key / inbox.

Inbox (consumer dedup)

BEGIN;
  INSERT INTO inbox (event_id) VALUES ($1);  -- unique
  -- nếu conflict → đã xử lý, skip
  -- apply business logic
COMMIT;

Hoặc “claim + process” theo unique constraint / TOCTOU: đừng check-then-act thiếu atomic.

Vì sao không “publish rồi ghi DB”

  • Publish OK, DB fail → event “ma”, consumer tạo state không có source of truth.
  • Khó compensate hơn outbox (outbox trễ event còn hơn event giả).

Rule ngón tay cái: source of truth trước, fan-out sau — outbox bám DB (hoặc store chính).

CDC vs poller

  • Poller: đơn giản, latency theo chu kỳ, dễ hiểu.
  • CDC (Debezium…): thấp trễ hơn, ops nặng hơn; vẫn at-least-once về semantic.

Sketch publisher (ý)

async function flushOutbox(db, broker, limit = 50) {
  const batch = await db.claimOutboxBatch(limit); // FOR UPDATE SKIP LOCKED
  for (const row of batch) {
    try {
      await broker.publish(row.topic, row.payload, {
        messageId: row.id, // cho inbox
      });
      await db.markPublished(row.id);
    } catch (e) {
      // để lại row — lần sau; không xóa
      await db.bumpAttempt(row.id, e);
    }
  }
}

SKIP LOCKED: nhiều publisher instance không giành cùng row — tránh race ngớ ngẩn.

Liên hệ series

Một câu để nhớ

Không atomic được hai store thì ghi một store hai việc (business + outbox), rồi fan-out có retry. Inbox ở đầu kia khép dual-write: trễ một nhịp vẫn hơn mất event hoặc double apply mù.