Outbox Pattern trong Backend: thiết kế event-driven system không mất dữ liệu
Outbox Pattern là một pattern backend dùng để đảm bảo khi hệ thống vừa ghi dữ liệu vào database vừa phát event sang message broker, event không bị mất vì lỗi mạng, broker downtime hoặc process crash giữa chừng. Nếu anh đang xây hệ thống event-driven, microservices, saga hoặc integration giữa các bounded context, Outbox gần như là guardrail bắt buộc trước khi nói đến “reliable messaging”.
Bài này không giải thích kiểu glossary. Mình sẽ đi thẳng vào bài toán production: dual-write nguy hiểm ở đâu, schema outbox nên thiết kế thế nào, worker publish event ra sao, xử lý retry/idempotency thế nào và cần monitor gì để biết pipeline đang khỏe.

Vì sao dual-write làm mất event?
Trong nhiều backend, flow ban đầu thường rất tự nhiên:
BEGIN;
INSERT INTO orders (...);
COMMIT;
publish("OrderCreated", payload);
Đây là dual-write: một write vào database, một write vào broker. Hai hệ thống này không nằm trong cùng atomic transaction. Nếu database commit thành công nhưng publish sang Kafka/RabbitMQ/SQS lỗi, service khác sẽ không bao giờ biết order đã được tạo. Nếu publish trước rồi database rollback, downstream lại nhận event về một entity không tồn tại.
Distributed transaction kiểu 2PC thường không thực tế trong kiến trúc web hiện đại: phức tạp, khó vận hành và làm giảm tính độc lập của service. Outbox Pattern chọn cách thực dụng hơn: biến “publish event” thành một record bền vững trong database trước, rồi publish sau bằng worker có retry.
Outbox Pattern hoạt động thế nào?
1. Ghi business data và outbox event trong cùng transaction
Khi xử lý command, service ghi bảng nghiệp vụ và bảng outbox_events trong cùng transaction. Nếu transaction rollback, cả hai cùng mất. Nếu commit thành công, event chắc chắn đã được lưu bền vững.
BEGIN;
INSERT INTO orders (id, user_id, amount, status)
VALUES (:order_id, :user_id, :amount, 'created');
INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload, status, available_at, created_at)
VALUES (:event_id, 'Order', :order_id, 'OrderCreated', :json_payload, 'pending', now(), now());
COMMIT;
2. Worker đọc pending event và publish sang broker
Một worker riêng poll các event pending, publish sang broker, rồi đánh dấu published. Với PostgreSQL, có thể dùng FOR UPDATE SKIP LOCKED để nhiều worker chạy song song mà không tranh cùng một event.
SELECT * FROM outbox_events
WHERE status = 'pending' AND available_at <= now()
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
Worker có thể crash ở bất kỳ bước nào. Vì vậy thiết kế phải chấp nhận khả năng event được publish nhiều hơn một lần, nhưng không được mất event. Đây là lý do consumer cần idempotent.
Schema outbox cho production
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
headers JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_outbox_pending ON outbox_events (available_at, created_at) WHERE status = 'pending';
id nên được dùng làm event_id xuyên suốt sang broker để consumer deduplicate. Nếu aggregate có version, hãy đưa version vào payload/header để downstream xử lý ordering theo từng aggregate.

Retry, idempotency và ảo tưởng exactly-once
Outbox không tự động đem lại exactly-once end-to-end. Nó giúp anh chuyển bài toán từ “có thể mất event” sang “có thể duplicate event”. Duplicate event dễ xử lý hơn nhiều nếu contract được thiết kế đúng.
Producer side
- Dùng
event_idổn định, không tạo lại ID mới trong mỗi lần retry. - Publish kèm key theo
aggregate_idnếu broker hỗ trợ partition ordering. - Đánh dấu
publishedchỉ sau khi broker ack thành công. - Nếu publish thành công nhưng update DB thất bại, worker sẽ retry và có thể publish lại. Đây là trade-off chấp nhận được.
Consumer side
CREATE TABLE processed_events (
event_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Khi nhận event, consumer insert event_id trước hoặc trong cùng transaction với side effect. Nếu insert bị unique violation, bỏ qua event vì nó đã được xử lý.
Nếu anh đang xử lý double-submit ở API layer, nên đọc thêm bài Idempotency là gì? Cách thiết kế API chống double submit và retry lỗi. Outbox và idempotency thường đi cùng nhau: một pattern bảo vệ write đầu vào, pattern còn lại bảo vệ event đầu ra.
Polling outbox hay CDC?
Polling worker
Dễ triển khai, ít dependency, phù hợp phần lớn team backend. Nhược điểm là có độ trễ poll interval và cần tối ưu query/index khi throughput tăng.
CDC với Debezium hoặc logical replication
CDC đọc database change log rồi stream event ra Kafka. Cách này giảm polling load và scale tốt hơn, nhưng vận hành phức tạp hơn: connector, schema evolution, snapshot, offset, monitoring.
Nếu hệ thống của anh đang ở giai đoạn chuẩn bị production, hãy nối Outbox với checklist deploy, migration và rollback trong bài Deploy Backend lên Production Checklist. Với kiến trúc tổng thể, bài System Design cho Backend Developer cũng là nền tảng liên quan.
Observability cho Outbox
Outbox rất dễ “trông có vẻ chạy” nhưng âm thầm backlog. Trước khi production, nên có metric: outbox_pending_count, outbox_oldest_pending_age_seconds, publish_success/failure_total, publish_latency_ms và retry attempts theo event type.

Checklist triển khai Outbox Pattern
- Business write và outbox insert nằm trong cùng database transaction.
- Event có
event_idổn định, payload version rõ ràng. - Worker dùng lock an toàn như
SKIP LOCKEDhoặc lease timeout. - Retry có exponential backoff, max attempts và cơ chế repair thủ công.
- Consumer idempotent bằng bảng processed event hoặc natural unique key.
- Có metric backlog, lag, success/failure, retry và alert.
- Có cleanup policy: archive/delete event đã published sau N ngày.
Lỗi thường gặp khi dùng Outbox
Xóa event quá sớm
Đừng xóa ngay sau publish nếu anh chưa có log/trace đủ tốt. Giữ lại một khoảng thời gian giúp debug incident và replay có kiểm soát.
Payload không có version
Event contract sẽ thay đổi. Không version hóa payload khiến consumer cũ dễ vỡ khi producer deploy trước.
Consumer không idempotent
Đây là lỗi nghiêm trọng nhất. Nếu consumer tạo invoice, gửi email hoặc cập nhật điểm thưởng nhiều lần vì duplicate event, Outbox chỉ chuyển bug từ producer sang downstream.
Kết luận
Outbox Pattern là một trong những pattern đáng học nhất nếu anh làm backend production hoặc hệ thống event-driven. Nó không làm hệ thống “exactly-once” một cách thần kỳ, nhưng loại bỏ rủi ro mất event do dual-write và buộc team thiết kế retry, idempotency, monitoring rõ ràng hơn.
Nếu đang xây backend nhiều service, hãy xem Outbox như lớp nền cạnh các chủ đề Authentication và Authorization trong Backend, tối ưu index PostgreSQL và Backend Engineering.