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.

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_createdlê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.

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:
- ghi thay đổi business dữ liệu;
- 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_countvàlast_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ột relay outbox production-grade thường làm theo vòng lặp:
- claim một batch row
pendingtheo thứ tự ổn định; - publish từng event hoặc từng nhóm event sang broker;
- chỉ sau khi có tín hiệu thành công đủ rõ mới mark
sent; - nếu lỗi transient, backoff rồi thử lại;
- 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 breaker và request 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_created → order_paid → order_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
sendingbị 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.