Đây là một đoạn code trông vô hại mà hầu hết hệ thống event-driven đều viết lúc đầu: nhận request, ghi đơn hàng vào database, rồi publish một message "đơn hàng đã tạo" sang Kafka để các service khác biết. Hai dòng, hai bước. Vấn đề: hai bước đó không nguyên tử. Giữa lúc database commit và lúc message được gửi, đủ thứ có thể xảy ra — process crash, mạng rớt, Kafka tạm sập. Kết quả: database có đơn hàng mà message không bao giờ được gửi. Đơn hàng đó trở nên "tàng hình" với phần còn lại của hệ — kho không trừ hàng, email không gửi, thống kê sai. Đây gọi là vấn đề dual-write, và lời giải kinh điển là outbox pattern. Bài này (phần 5 loạt Message Queue) chạy thật để đo mức lệch của dual-write và cách outbox xoá sạch nó.
Dual-write: hai bước, không nguyên tử
Vấn đề cốt lõi: database và message broker là hai hệ thống khác nhau, không chia sẻ transaction. Bạn không thể "commit cả hai hoặc không cái nào" theo cách thông thường:
INSERT order (DB commit — thành công)
✗ crash ✗
publish(order) → Kafka # không bao giờ chạy → message MẤT
Dù bạn đảo thứ tự (publish trước, ghi DB sau) cũng chỉ đổi kiểu lệch chứ không xoá được: publish xong mà DB ghi hỏng → có message cho đơn hàng không tồn tại. Không có thứ tự nào của hai thao tác độc lập cho bạn tính nguyên tử. Đây là một hệ quả cơ bản của việc ghi vào hai hệ thống, không phải lỗi code bất cẩn.
Outbox pattern lật ngược vấn đề: thay vì ghi vào hai hệ, ghi cả hai vào cùng một database trong một transaction. Message được lưu tạm vào một bảng outbox ngay cạnh business data; một tiến trình relay riêng đọc bảng này và publish sang Kafka sau.
-- Ghi business + outbox trong CÙNG một transaction (nguyên tử)
BEGIN;
INSERT INTO orders ...;
INSERT INTO outbox(payload, sent) VALUES (..., false);
COMMIT; -- cả hai cùng có, hoặc cùng không
-- RELAY riêng, chạy lặp được:
SELECT payload FROM outbox WHERE NOT sent;
-- → publish sang Kafka → UPDATE outbox SET sent = true;

Hình 1: Dual-write ghi vào hai hệ riêng nên crash giữa chừng làm message mất. Outbox ghi business row và outbox row trong một transaction (nguyên tử — cả hai hoặc không cái nào), rồi một relay riêng đọc outbox chưa gửi và publish sang Kafka, đánh dấu sent. Message nằm cùng database với business data nên transaction đảm bảo không bao giờ lệch.
Đo thật: dual-write lệch 40, outbox khớp 100%
Mình chạy trên pg-lab (PostgreSQL 16) và kafka-lab, so sánh hai cách với cùng 100 đơn hàng:

Hình 2: Kết quả thật. Dual-write: ghi 100 đơn vào DB nhưng crash sau khi publish được 60 → DB=100, Kafka=60, lệch 40 đơn hàng không có message. Outbox: ghi 100 order + 100 outbox trong một transaction, relay publish → DB=100, Kafka=100, chưa gửi=0 → khớp 100%; relay chạy lại lần 2 thấy 0 dòng chưa gửi nên publish thêm 0 (không trùng).
Đọc kết quả:
- Dual-write → lệch 40: 100 đơn hàng ghi vào DB, nhưng process "crash" sau khi publish được 60 message. Kết quả: DB có 100, Kafka chỉ có 60 — 40 đơn hàng tàng hình. Chúng tồn tại trong DB nhưng không service nào khác biết tới. Đây không phải tình huống hiếm: bất kỳ lỗi nào giữa commit DB và publish (restart deploy, OOM, Kafka timeout) đều tạo ra lệch kiểu này, và nó im lặng — không exception, không log lỗi rõ ràng.
- Outbox → khớp 100%: ghi 100 order và 100 outbox row trong một transaction PostgreSQL. Vì nguyên tử, hoặc cả order lẫn outbox cùng được ghi, hoặc không gì cả — không bao giờ có order mà thiếu outbox. Relay sau đó đọc 100 outbox chưa gửi, publish hết sang Kafka, đánh dấu
sent. Kết quả: DB=100, Kafka=100, chưa gửi=0 — khớp tuyệt đối. - Relay chạy lại an toàn: đây là tính chất quan trọng. Relay lần 2 query
WHERE NOT sentthấy 0 dòng → publish thêm 0. Nếu relay crash sau khi publish mà trước khi đánh dấu sent, lần sau nó publish lại dòng đó (at-least-once) — nên consumer phía sau vẫn cần idempotency (bài 4). Nhưng nó không bao giờ mất message: một dòng chưasent=truesẽ được thử lại mãi cho tới khi gửi được.
Điểm mấu chốt: dual-write dựa vào may mắn (cả hai bước cùng thành công), còn outbox dựa vào tính nguyên tử của database — thứ đã được đảm bảo chắc chắn. Nó biến một bài toán "hai hệ thống" nan giải thành một transaction đơn trong một hệ.
Relay đọc outbox thế nào: polling vs CDC
Trong demo, relay dùng polling — query WHERE NOT sent định kỳ. Đơn giản, dễ hiểu, nhưng tạo tải query lên DB và có độ trễ (bằng chu kỳ poll). Cách tinh vi hơn là CDC (Change Data Capture): dùng công cụ như Debezium đọc write-ahead log của PostgreSQL để bắt mọi INSERT vào bảng outbox ngay lập tức và đẩy sang Kafka, không cần poll. CDC cho độ trễ thấp và không thêm tải query, nhưng phức tạp vận hành hơn (thêm Debezium, cấu hình replication slot). Với quy mô nhỏ, polling là đủ; quy mô lớn thì CDC đáng giá.
Đánh đổi cần cân nhắc
Outbox thêm độ trễ và tải cho database. Message giờ phải đi qua DB rồi mới tới Kafka, nên có độ trễ (chu kỳ relay) so với publish thẳng. Bảng outbox cũng phình nhanh — phải dọn các dòng đã sent định kỳ (DELETE hoặc partition theo thời gian), nếu không nó nuốt disk và làm chậm query relay. Đây là cái giá của tính đúng đắn: bạn đổi một chút độ trễ và công dọn dẹp lấy đảm bảo không mất message.
Relay là at-least-once, không phải exactly-once. Như đã nói, relay có thể publish trùng nếu crash sau publish trước khi đánh dấu sent. Outbox không giải quyết việc trùng — nó giải quyết việc mất. Phải ghép với consumer idempotent (bài 4) để có bức tranh đầy đủ: outbox đảm bảo "không mất message", idempotency đảm bảo "trùng không gây hại". Hai pattern bổ sung nhau, không thay nhau.
Chỉ cần outbox khi message và DB phải nhất quán. Không phải mọi publish đều cần outbox. Nếu message là thứ "mất cũng được" (log phân tích, metric), publish thẳng đơn giản hơn và đủ. Outbox dành cho các sự kiện mà mất là hỏng nghiệp vụ — đơn hàng, thanh toán, thay đổi trạng thái quan trọng. Dùng outbox cho mọi message là thêm phức tạp không cần thiết; dùng nó đúng chỗ là cứu dữ liệu.
Ba ý mang về
- Dual-write lệch vì hai hệ không chia sẻ transaction: đo thật crash giữa ghi DB và publish → DB=100, Kafka=60, lệch 40 đơn hàng tàng hình; không thứ tự nào của hai thao tác độc lập cho tính nguyên tử, và lỗi này im lặng.
- Outbox dựa vào tính nguyên tử của DB nên luôn đúng: đo thật ghi business + outbox trong một transaction rồi relay publish → DB=100, Kafka=100, khớp 100%; relay chạy lại thấy 0 chưa gửi nên không trùng và không bao giờ mất.
- Outbox chống mất, idempotency chống trùng — ghép cả hai: relay là at-least-once (có thể publish lại khi crash), nên cần consumer idempotent đi kèm; đổi lại độ trễ relay và công dọn bảng outbox, và chỉ dùng khi message thật sự phải nhất quán với DB.
Nguồn
- Chris Richardson — Pattern: Transactional outbox: https://microservices.io/patterns/data/transactional-outbox.html
- Debezium — Outbox Event Router / CDC: https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html
- Confluent — Transactional outbox with Kafka: https://www.confluent.io/blog/dual-write-problem/
Phần sau ta xử lý message độc — message mà consumer không tài nào xử lý được: dead letter queue giúp chúng không chặn cả hàng đợi, demo thật một message lỗi bị tách sang DLQ trong khi phần còn lại tiếp tục chạy.