Outbox Pattern — Ghi DB rồi publish event, không lo mất
Outbox Pattern — Ghi DB và publish event đúng, không lo mất mát
Có một lỗi kinh điển mà hầu như backend engineer nào cũng từng dính: vừa ghi trạng thái vào database vừa bắn một event ra message broker (Kafka, RabbitMQ, SQS...). Nghe đơn giản, nhưng đây chính là dual-write problem — viết hai nơi không bao giờ là một transaction. Và nó là cha đẻ của vô số bug "mất event" khó hiểu nhất trong hệ phân tán.
Vì sao dual-write gãy?
Giả sử sau khi tạo đơn hàng, bạn cần thông báo cho service khác qua event:
tx, _ := db.Begin(ctx)
_, err := tx.Exec(ctx, "INSERT INTO orders ...", ...)
tx.Commit(ctx)
// Đã commit rồi mới publish => có thể fail ở đây
err = kafka.Publish(ctx, "order_created", payload)
Vấn đề thấy ngay: Commit xong rồi Publish lỡ fail (broker giật, network rớt) thì event mất hút trong khi order đã tồn tại. Service gửi email, hàng kho... sẽ không bao giờ biết có đơn mới.
Đảo ngược lại — publish trước rồi mới commit — thì còn tệ hơn: event bay đi mà DB chưa có đơn, consumer xử lý rồi ngã ngửa vì không tìm thấy order. Mọi thứ trở nên đúng cái kiểu "mỗi check gần đúng".
Outbox Pattern — ghi event chung một chỗ với dữ liệu
Ý tưởng Outbox cực kỳ đơn giản: đừng publish ra ngoài khi commit dữ liệu. Thay vào đó, cũng chính trong transaction đó, bạn ghi thêm một dòng vào bảng outbox. Rồi một relay/worker riêng đọc bảng outbox và bắn event ra broker.
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ -- NULL = chưa gửi
);
Trong transaction tạo order, ta ghi cả bảng order lẫn outbox:
tx, _ := db.Begin(ctx)
_, err := tx.Exec(ctx, "INSERT INTO orders (id, status) VALUES ($1, 'created')", orderID)
_, err = tx.Exec(ctx, `
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES ($1, 'order_created', $2)`,
orderID, json.RawMessage(payload))
tx.Commit(ctx) // Cả 2 ghi thành công cùng lúc, ko thể nửa vời
Bởi vì hai câu INSERT nằm cùng một transaction, chúng hoặc thành công cùng nhau, hoặc cùng thất bại. Không bao giờ có chuyện "đơn tồn tại mà không có outbox" hay ngược lại — ta đã loại được cái gốc của dual-write.
Relay — bắn event ra thật sự
Worker đọc những dòng published_at IS NULL, gửi lên broker, xong mới đánh dấu đã gửi:
rows, _ := db.Query(ctx, `
SELECT id, event_type, payload FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100`)
for rows.Next() {
// publish lên broker
err := kafka.Publish(ctx, evt.EventType, evt.Payload)
if err != nil { /* bỏ qua, để vòng sau gửi lại */ continue }
db.Exec(ctx, "UPDATE outbox SET published_at = now() WHERE id = $1", evt.ID)
}
Quan trọng: relay là at-least-once. Nếu gửi thành công lên broker nhưng process chết trước khi UPDATE published_at, dòng đó sẽ bị gửi lại ở vòng sau. Consumer phải idempotent (dùng unique key / dedup) để nuốt event trùng mà không sao. Gửi trùng còn dễ chịu hơn nhiều so với mất event.
Đây chính là nguyên tắc mà thư viện như Debezium (Change Data Capture) chuyên nghiệp hơn nhiều so với relay thủ công — nó đọc trực tiếp WAL của Postgres, không cần bảng outbox riêng. Nếu deploy to rồi, cân nhắc Debezium; nhỏ thì bảng outbox + cron là đủ.
Kinh nghiệm thực tế của mình
- Đừng gộp luôn payload nặng vào outbox. Nhiều đối tượng dính vào một event thì ghi lại dữ liệu
aggregate_idđể relay query lại, tránh outbox phình to và payload lỗi thời. - Theo dõi lag của outbox. Nếu bảng outbox có hàng trăm nghìn dòng
published_at IS NULLnghĩa là relay đang chậm/ngừng — báo alert chứ đừng để email "order created" trễ 3 ngày. - Cleanup dòng đã gửi. Outbox sẽ phình vô hạn nếu bạn không dọn định kỳ (
DELETE ... WHERE published_at IS NOT NULL AND created_at < now() - interval '7 days'). - Relay phải chạy nhiều replica để không thành điểm chết đơn lẻ; các replica cạnh tranh đọc cùng dòng thì một row độc thân có thể bị gửi 2 lần — hãy dùng
FOR UPDATE SKIP LOCKEDđể mỗi dòng chỉ một relay xử lý.
Tóm lại
Outbox Pattern biến hai thao tác không nguyên tử (ghi DB + publish event) thành một quyết định duy nhất trong một transaction, rồi để một worker lo phần "đuổi theo" việc gửi ra ngoài. Hệ quả: không mất event, không duplicate rối loạn, và DB là nguồn chân lý duy nhất. Cái giá phải trả là chút độ trễ và sự phức tạp của relay — nhưng so với việc dựng đêm giải cứu vì một loạt order không được xử lý, thì rẻ vô cùng.
Lần tới khi ai đó nói "cứ ghi xong rồi gọi Kafka là xong", hãy nhắc họ về outbox — bởi hệ phân tán không tha thứ cho điều ước "chắc là kịp". 🚀