Khi một service vừa phải cập nhật database vừa phải publish event cho Kafka, RabbitMQ hoặc bất kỳ message broker nào, rất nhiều team vô tình rơi vào bài toán dual write: ghi dữ liệu nội bộ ở một nơi, rồi ghi tín hiệu tích hợp ở một nơi khác. Trên slide, flow này nhìn đơn giản. Trong production, nó là nguồn gốc của vô số lỗi lệch trạng thái: đơn hàng đã paid trong database nhưng event không được phát ra, hoặc event đã đi sang downstream trong khi transaction local lại rollback.

Sơ đồ Transactional Outbox với DB transaction, outbox row và relay publish event
Transactional Outbox đặt atomicity ở database transaction và tách việc publish sang một relay bất đồng bộ có thể retry an toàn.

Nếu anh đang xây backend có event-driven integration, microservice, CDC pipeline hoặc workflow cần đồng bộ trạng thái ra hệ khác, Transactional Outbox là một pattern rất đáng hiểu kỹ. Nó không biến hệ thống thành exactly-once thần kỳ, nhưng nó giúp loại bỏ failure mode khó chịu nhất của dual write: một bên commit, một bên thất bại, còn team thì không biết phải tin dữ liệu nào.

Bài này đi thẳng vào góc production engineering: Transactional Outbox giải quyết đúng bài toán gì, nó khác gì với 2PC hay CDC, relay/poller nên vận hành thế nào, consumer vẫn phải idempotent ra sao, nên đo metric nào, và khi nào pattern này không đáng dùng.

Dual write là gì và vì sao nó thường vỡ trong production?

Dual write xảy ra khi cùng một business action phải cập nhật ít nhất hai hệ thống độc lập, ví dụ:

  • ghi đơn hàng vào PostgreSQL rồi publish order_created lên Kafka;
  • cập nhật trạng thái invoice trong MySQL rồi đẩy event sang RabbitMQ cho billing worker;
  • ghi user profile vào database rồi phát event cho search index hoặc analytics pipeline.

Vấn đề là hai thao tác này hiếm khi nằm trong cùng một transactional boundary thật sự. Database có transaction riêng. Broker có ACK/offset/confirm riêng. Nếu viết code kiểu:

BEGIN;
UPDATE orders SET status = 'paid' WHERE id = $1;
COMMIT;
publish('order_paid', payload);

thì chỉ cần broker timeout sau khi transaction DB đã commit là trạng thái đã lệch. Downstream inventory, email, loyalty hoặc reporting không bao giờ nhận được event mà business lại tưởng order đã xử lý xong.

Đổi thứ tự sang publish trước rồi mới commit DB cũng không cứu được. Khi broker nhận event thành công mà local transaction rollback, downstream sẽ nhìn thấy một sự kiện cho trạng thái chưa từng tồn tại bền vững ở source of truth.

Ma trận failure của dual write giữa database và message broker
Dual write hỏng không phải vì code quá ngắn, mà vì database và broker không commit cùng một nhịp.

Transactional Outbox giải quyết bài toán gì chính xác?

Ý tưởng cốt lõi rất thực dụng: đừng cố vừa ghi business row vừa publish ra broker trong cùng request path. Thay vào đó, trong cùng một database transaction, anh:

  1. ghi thay đổi business dữ liệu;
  2. ghi thêm một record vào bảng outbox_events.

Nếu transaction commit thành công, cả business state lẫn “ý định publish event” đều được lưu bền vững. Sau đó một tiến trình relay riêng sẽ đọc bảng outbox và publish event sang broker. Nếu relay fail, nó có thể retry sau mà không làm mất fact rằng event này cần được gửi đi.

BEGIN;
UPDATE orders
SET status = 'paid', paid_at = NOW()
WHERE id = $1;

INSERT INTO outbox_events (
  event_id,
  aggregate_type,
  aggregate_id,
  event_type,
  payload_json,
  status,
  created_at
) VALUES (
  gen_random_uuid(),
  'order',
  $1,
  'order_paid',
  $2::jsonb,
  'pending',
  NOW()
);
COMMIT;

Điểm mạnh của pattern này là atomicity được giải đúng ở nơi app kiểm soát tốt nhất: database local. Nó không bắt broker phải tham gia distributed transaction, cũng không cần 2PC nặng nề giữa nhiều hạ tầng khác nhau.

Transactional Outbox không tạo exactly-once, nó tạo at-least-once có kiểm soát

Đây là chỗ nhiều người hiểu nhầm nhất. Outbox không đảm bảo “event chỉ được publish đúng một lần” theo nghĩa tuyệt đối. Relay có thể publish xong nhưng crash trước khi đánh dấu row là sent. Khi restart, nó có thể publish lại cùng event.

Vì vậy, Transactional Outbox thường đi kèm thực tế sau:

  • producer/relay cố gắng publish ít nhất một lần cho tới khi thành công rõ ràng;
  • consumer/downstream phải chịu được duplicate bằng idempotency key, event_id hoặc business dedupe guard;
  • observability phải giúp phát hiện relay lag, stuck rows và failure loop.

Nếu team đang quen với tư duy “publish event xong là xong”, Outbox buộc team trưởng thành hơn: coi event delivery là một workflow có retry và reconciliation, không phải một dòng code phụ.

Cấu trúc bảng outbox thực dụng cho hệ thống backend

Một bảng outbox tối thiểu thường có:

  • event_id: định danh duy nhất cho event;
  • aggregate_type, aggregate_id: biết event gắn với entity nào;
  • event_type: ví dụ order_paid, invoice_issued;
  • payload_json: payload chuẩn hóa để publish;
  • status: pending, sending, sent, failed;
  • attempt_countlast_error;
  • created_at, sent_at;
  • tuỳ chọn: partition_key, trace_id, tenant_id, schema_version.

Nếu payload quá lớn hoặc nhạy cảm, có thể lưu reference tới data cần hydrate thay vì nhét toàn bộ state. Nhưng đừng để relay phải join quá nhiều bảng phức tạp chỉ để publish một event nhỏ; khi incident xảy ra, complexity sẽ đánh ngược lại anh.

Relay / poller nên chạy thế nào để không tự tạo nghẽn?

Mô hình vận hành relay đọc outbox, publish event và theo dõi lag
Relay tốt cần batch nhỏ, retry có kỷ luật, metrics đủ sâu và không giữ transaction DB quá lâu trong lúc gọi network.

Một relay outbox production-grade thường làm theo vòng lặp:

  1. claim một batch row pending theo thứ tự ổn định;
  2. publish từng event hoặc từng nhóm event sang broker;
  3. chỉ sau khi có tín hiệu thành công đủ rõ mới mark sent;
  4. nếu lỗi transient, backoff rồi thử lại;
  5. nếu lỗi kéo dài, alert theo age/depth chứ không chỉ count.

Với PostgreSQL, nhiều team dùng FOR UPDATE SKIP LOCKED để nhiều worker cùng quét outbox mà không giẫm nhau:

WITH next_batch AS (
  SELECT id
  FROM outbox_events
  WHERE status = 'pending'
  ORDER BY created_at, id
  LIMIT 100
  FOR UPDATE SKIP LOCKED
)
UPDATE outbox_events
SET status = 'sending', picked_at = NOW()
WHERE id IN (SELECT id FROM next_batch)
RETURNING *;

Cách này ổn hơn việc để nhiều worker cùng SELECT bừa rồi race condition khi mark trạng thái. Tuy nhiên vẫn phải cẩn thận với stuck row ở trạng thái sending nếu worker chết giữa chừng; cần có timeout/reaper để trả row về pending sau ngưỡng hợp lý.

Những failure mode team thường bỏ qua khi vừa implement outbox

1. Publish thành công nhưng mark sent thất bại

Đây là lý do duplicate event hoàn toàn có thể xảy ra. Nếu broker đã nhận event nhưng DB update status='sent' lỗi, relay sẽ retry lại sau. Vì vậy downstream phải dedupe theo event_id hoặc business key ổn định.

2. Outbox table phình rất nhanh

Nếu event volume cao mà không có retention, partitioning hoặc cleanup strategy, outbox sẽ trở thành một hot table vừa ghi vừa quét liên tục. Index xấu còn làm relay lag tăng dần. Tình huống này có họ hàng gần với các vấn đề về MVCC, VACUUM và table bloat trong PostgreSQL.

3. Relay giật cục khi broker hoặc downstream bất ổn

Nếu broker chậm, relay có thể retry đồng loạt và làm backlog tăng mạnh. Lúc này các nguyên tắc của circuit breakerrequest coalescing / backpressure vẫn đáng áp dụng ở tầng publish.

4. Ordering không rõ ràng theo aggregate

Nếu một entity có chuỗi event cần đi đúng thứ tự như order_createdorder_paidorder_refunded, relay và broker partition key cần phản ánh yêu cầu đó. Không phải mọi workload đều cần global order, nhưng rất nhiều workload cần per-aggregate order.

Idempotency ở consumer là bắt buộc, không phải khuyến nghị

Outbox giải quyết producer-side consistency tốt hơn, nhưng nó không miễn trừ consumer khỏi duplicate handling. Một consumer an toàn nên ít nhất có một trong các lớp sau:

  • lưu processed_event_id để bỏ qua event đã thấy;
  • dùng unique constraint theo business action, ví dụ shipment(order_id, type);
  • dùng compare-and-set/state machine để cùng event cũ không làm state lùi;
  • gắn idempotency key khi gọi tiếp xuống external side effect như email, billing, webhook.

Chủ đề này liên hệ trực tiếp với bài Dead Letter Queue trong Event-Driven Systems: duplicate event mà consumer không idempotent sẽ biến replay hoặc retry thành incident thứ hai.

Outbox vs CDC vs 2PC: khi nào chọn cách nào?

Transactional Outbox

Phù hợp khi app kiểm soát transaction source, muốn model event rõ ở application layer, và chấp nhận at-least-once với consumer idempotent.

Change Data Capture (CDC)

Nếu muốn bắt thay đổi từ database log như WAL/binlog để stream ra Kafka, CDC như Debezium có thể phù hợp hơn. Tuy nhiên CDC thuần túy capture row change, không phải lúc nào cũng map đẹp sang business event. Khi semantic event quan trọng, nhiều team vẫn thích outbox để định nghĩa payload rõ ràng.

Two-Phase Commit (2PC)

2PC nghe rất hấp dẫn về lý thuyết nhưng thường quá nặng, làm tăng coupling và latency, chưa kể không phải mọi broker/app stack đều hỗ trợ kiểu transactional boundary mà team thực sự muốn. Với đa số backend web/app thông thường, Outbox thường là lựa chọn thực dụng hơn nhiều.

Observability: nên đo gì để biết outbox đang khoẻ hay đang âm thầm rắc rối?

Nếu chỉ nhìn số event publish thành công tổng cộng, anh gần như mù. Dashboard tối thiểu nên có:

  • outbox depth: số row pending/sending/failed;
  • outbox age: tuổi của event già nhất, p95 age của pending rows;
  • publish throughput: event/phút;
  • retry rate và top error class;
  • stuck sending count;
  • duplicate detection ở consumer;
  • lag theo aggregate quan trọng nếu event ảnh hưởng revenue hoặc fulfillment.

Nếu hệ thống đã có tracing, nên nối trace_id từ request business sang outbox row và tiếp tục sang publish/consumer. Cách này giúp debug “vì sao order đã paid nhưng warehouse chưa nhận event” nhanh hơn rất nhiều.

Checklist triển khai Transactional Outbox trong production

  • Business row và outbox row có được ghi trong cùng transaction không?
  • Outbox row có event_id ổn định và payload version rõ chưa?
  • Relay có claim batch an toàn, ví dụ SKIP LOCKED, chưa?
  • Có chiến lược xử lý row sending bị treo khi worker chết chưa?
  • Consumer đã idempotent theo event_id hoặc business key chưa?
  • Có retention/cleanup/partitioning để outbox table không phình vô hạn chưa?
  • Dashboard có depth, age, retry, top error, stuck rows chưa?
  • Broker chậm hoặc unavailable thì relay có backoff/breaker hợp lý chưa?
  • Ordering theo aggregate có được tôn trọng khi cần chưa?
  • Playbook incident đã trả lời: event mất, event duplicate, relay lag, relay stuck?

Khi nào không nên dùng Transactional Outbox?

Không phải flow nào cũng cần pattern này. Nếu event chỉ là analytics mềm, mất một ít cũng không ảnh hưởng business, thì outbox có thể là overkill. Nếu hệ thống đã có CDC platform trưởng thành và event chỉ là projection từ row change đơn giản, CDC có thể gọn hơn. Nếu workload cực nhỏ và không có downstream critical integration, việc tăng complexity bằng relay riêng có thể chưa đáng.

Tôi chỉ thật sự thấy Outbox xứng đáng khi event là một phần của contract nghiệp vụ: thanh toán, fulfillment, entitlement, audit, đồng bộ microservice, integration với hệ thống ngoài. Lúc đó chi phí thêm của pattern này nhỏ hơn nhiều so với chi phí chữa incident dual write sau này.

Kết luận

Transactional Outbox là một pattern rất thực dụng để chống lệch trạng thái giữa database và message broker trong backend production. Nó không trao exactly-once miễn phí, nhưng nó dời atomicity về đúng chỗ app kiểm soát được: transaction ở database. Từ đó, relay chịu trách nhiệm delivery, còn consumer chịu trách nhiệm idempotency.

Nếu anh đang có flow kiểu “ghi DB rồi publish event ngay sau đó”, tôi nghĩ đây là lúc nên audit lại. Chỉ một lần timeout hoặc crash ở điểm giữa là đủ tạo dữ liệu sai kéo dài nhiều ngày. Outbox không làm hệ thống đơn giản hơn về mặt khái niệm, nhưng nó làm failure mode trở nên nhìn thấy được, retry được và vận hành được.