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 và 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
- Publish at-least-once → idempotency consumer.
- Publisher fail lặp → DLQ outbox row sau N attempt + alert.
- Đừng spam publish khi broker yếu — backpressure / breaker.
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ù.