Việc vừa cập nhật cơ sở dữ liệu vừa bắn một message sang message broker (như Kafka hay RabbitMQ) ngay trong cùng một hàm xử lý là thói quen rất phổ biến nhưng lại tiềm ẩn nhiều rủi ro. Mô hình này—thường được gọi là dual write—sớm muộn gì cũng khiến dữ liệu giữa các dịch vụ bị lệch khi mạng chập chờn, broker quá tải hoặc server ứng dụng bị crash đột ngột. Transactional Outbox Pattern là giải pháp kinh điển và thực dụng nhất để giải quyết triệt để bài toán này bằng chính tính chất ACID của cơ sở dữ liệu.
Lỗ Hổng Chết Người Của Dual Write
Trong các ứng dụng web thông thường, đoạn code tạo đơn hàng thường trông như thế này:
async function handleCreateOrder(orderData: CreateOrderDto) {
const order = await db.orders.create(orderData);
await messageBroker.publish('OrderCreated', { orderId: order.id });
return order;
}
Thoạt nhìn, logic này có vẻ hoàn hảo. Nhưng trên môi trường production, nó chứa hai kịch bản lỗi nghiêm trọng:
- Database ghi thành công nhưng Broker thất bại: Nếu message broker bị ngắt kết nối, timeout hoặc tiến trình Node.js/Go bị kill ngay sau dòng
db.orders.create, đơn hàng vẫn nằm trong database nhưng service trừ kho hay gửi email thanh toán sẽ không bao giờ nhận được event. Kết quả là đơn hàng bị treo vô thời hạn. - Broker nhận event nhưng Database rollback: Nếu đảo ngược thứ tự (bắn event trước rồi mới commit DB), lỡ transaction phía DB thất bại do lỗi unique constraint hay connection pool cạn kiệt, hệ thống hạ nguồn đã vội vã xử lý một event ảo cho dữ liệu không hề tồn tại.
Các cơ chế phân tán như Two-Phase Commit (2PC) trên lý thuyết có thể khóa cả hai tài nguyên, nhưng chúng quá nặng nề, giảm thông lượng nghiêm trọng và hầu hết message broker hiện đại đều không hỗ trợ.
Cách Hoạt Động Của Transactional Outbox Pattern

Thay vì gọi network ra ngoài broker trong lúc xử lý request của người dùng, Transactional Outbox chuyển đổi hành động phát event thành một câu lệnh INSERT vào một bảng nội bộ trong cùng database, thường đặt tên là outbox_events.
Mọi thay đổi nghiệp vụ sẽ ghi cả entity chính lẫn bản ghi event trong cùng một local transaction duy nhất:
BEGIN;
INSERT INTO orders (id, user_id, amount, status)
VALUES ('ord_123', 'usr_456', 99.00, 'PENDING');
INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload)
VALUES (
'evt_789',
'Order',
'ord_123',
'OrderCreated',
'{"userId": "usr_456", "amount": 99.00}'::jsonb
);
COMMIT;
Nhờ cơ chế ACID của database quan hệ, hai câu lệnh này hoặc cùng thành công, hoặc cùng rollback. API trả về kết quả cho client nhanh chóng mà không cần bận tâm broker bên ngoài đang sống hay chết.
Cơ Chế Đẩy Message: Polling Hay CDC?
Sau khi event đã nằm an toàn trong bảng outbox_events, một tiến trình nền (Message Relay) sẽ chịu trách nhiệm đọc dữ liệu này và bắn sang message broker. Có hai cách triển khai phổ biến trong thực tế:
1. Polling Publisher (Quét bảng định kỳ)
Một background worker sẽ chạy định kỳ (mỗi 500ms - 1s), truy vấn các bản ghi chưa gửi trong outbox_events, đẩy lên broker rồi cập nhật trạng thái PROCESSED hoặc xóa dòng đó. Với PostgreSQL, bạn có thể kết hợp SELECT ... FOR UPDATE SKIP LOCKED để nhiều worker có thể chạy song song mà không bị tranh chấp lock.
Ưu điểm của cách này là cực kỳ dễ viết, không cần dựng thêm hạ tầng phụ trợ, phù hợp với hầu hết các dự án quy mô vừa và nhỏ. Điểm trừ là nó tạo ra một lượng tải đọc nhất định lên database chính.
2. Transaction Log Tailing (CDC - Change Data Capture)
Thay vì truy vấn liên tục vào bảng, các công cụ chuyên dụng như Debezium sẽ đọc trực tiếp file log giao dịch của database (như WAL của PostgreSQL hay binlog của MySQL). Bất cứ khi nào có bản ghi mới được commit vào bảng outbox, Debezium sẽ bắt lấy payload và đẩy thẳng vào Kafka với độ trễ chỉ vài mili-giây mà không hề tiêu tốn query throughput của database.
CDC là lựa chọn tối ưu cho hệ thống tải cao, nhưng đòi hỏi chi phí vận hành cụm Kafka Connect và cấu hình replication log chuẩn xác.
Thực Tế Về At-Least-Once Và Tính Idempotency
Cần lưu ý rằng Transactional Outbox Pattern chỉ đảm bảo tính chất At-Least-Once (chuyển giao ít nhất một lần), chứ không phải Exactly-Once.
Hãy tưởng tượng kịch bản: Worker đọc outbox, bắn thành công message sang Kafka, nhưng ngay trước khi kịp cập nhật trạng thái PROCESSED vào database thì máy chủ sập nguồn. Khi khởi động lại, worker sẽ đọc lại bản ghi đó và bắn message lần thứ hai.
Vì vậy, một kiến trúc event-driven hoàn chỉnh bắt buộc phía Consumer phải có cơ chế Idempotency:
- Consumer lưu lại
event_idvào một bảng riêng hoặc Redis cache trước khi xử lý logic. - Sử dụng câu lệnh
INSERT ... ON CONFLICT DO NOTHINGhoặc atomic upsert để đảm bảo một event dù đến 2 hay 3 lần cũng chỉ tạo ra một kết quả duy nhất.
Kinh Nghiệm Triển Khai Thực Chiến
- Đóng gói đủ dữ liệu trong payload: Hãy đưa đầy đủ thông tin cần thiết vào payload của event trong outbox. Tránh việc consumer khi nhận event lại phải quay ngược về database của producer để SELECT thêm dữ liệu.
- Dọn dẹp bảng outbox định kỳ: Bảng outbox sẽ phình to rất nhanh. Hãy thiết lập partition theo ngày để
DROP TABLEdễ dàng, hoặc viết job xóa các bản ghi đã gửi thành công sau 3–7 ngày. - Bắt đầu đơn giản: Đừng vội dựng cả cụm Debezium hay Kafka Connect nếu hệ thống của bạn chỉ xử lý vài chục nghìn transaction mỗi ngày. Một background worker nhỏ dùng
SKIP LOCKEDlà đã đủ giúp bạn ngủ ngon cả đêm mà không sợ mất event.
Dual write là quả bom nổ chậm trong mọi hệ sinh thái microservices. Đưa việc phát event về ranh giới transaction nội bộ sẽ biến một bài toán phân tán phức tạp thành một thao tác cơ sở dữ liệu an toàn và đáng tin cậy.



Đang tải bình luận…