Transactional Outbox Pattern là gì? Cách ngăn webhook “biến mất” khỏi hệ thống
Transactional Outbox Pattern giúp hệ thống cập nhật dữ liệu và lưu webhook cần gửi trong cùng một transaction, nhờ đó event không bị mất khi service gặp sự cố. Tôi từng thấy lỗi này xảy ra theo cách rất đơn giản: database đã commit, nhưng service crash ngay trước lệnh gửi HTTP. Hệ thống ghi nhận người dùng đã thanh toán, còn backend của khách hàng thì không hề hay biết. Vì vậy, đội ngũ backend của OTTclouds và MonetKit đã sử dụng transaction outbox để xây dựng luồng webhook đáng tin cậy hơn cho các hệ thống thanh toán, subscription và monetization.
Trong bài viết này, chúng ta sẽ tìm hiểu từ nguyên nhân khiến webhook bị mất đến cách Outbox lưu event, gửi lại khi có lỗi và giữ trạng thái giữa các hệ thống luôn nhất quán.
Transactional Outbox Pattern là gì?
Transactional Outbox Pattern là một giải pháp thiết kế hệ thống giúp duy trì tính nhất quán khi ứng dụng cần vừa cập nhật cơ sở dữ liệu, vừa gửi event ra bên ngoài. Thay vì gửi webhook trực tiếp, hệ thống sẽ lưu “ý định gửi webhook” vào database trong cùng transaction với thay đổi nghiệp vụ.
Pattern này thường liên quan đến 3 khái niệm chính: dual-write problem hay vấn đề ghi dữ liệu kép, at-least-once delivery hay cơ chế giao nhận ít nhất một lần và idempotent consumer giúp xử lý event trùng lặp an toàn.
Nếu transaction thành công, cả dữ liệu nghiệp vụ và event cần gửi đều được lưu. Nếu transaction thất bại, cả 2 cùng rollback. Sau đó, một tiến trình chạy nền sẽ đọc các event đang chờ, gửi chúng đi và tự động retry khi xảy ra lỗi.
Đây cũng là cách MonetKit xây dựng luồng webhook đáng tin cậy hơn cho các hệ thống payment, subscription và monetization.

Vì sao webhook có thể biến mất?
Webhook có thể “biến mất” khi database đã cập nhật thành công nhưng service bị crash trước khi kịp gửi HTTP request. Lúc này, hệ thống nội bộ đã ghi nhận thay đổi, chẳng hạn subscription đã được kích hoạt, nhưng backend của khách hàng không nhận được event tương ứng. Nguyên nhân là database transaction không thể đồng thời bảo vệ một network call nằm bên ngoài transaction đó.
Webhook thoạt nhìn là một cơ chế khá đơn giản. Khi một sự kiện xảy ra, hệ thống tạo payload rồi gửi một HTTP request đến endpoint của khách hàng. Endpoint nhận thông tin và tiếp tục xử lý nghiệp vụ tương ứng.
Trong nhiều ứng dụng, cách làm này vẫn có thể được sử dụng. Tuy nhiên, với hệ thống billing hoặc subscription, webhook không còn là một thông báo phụ. Đây có thể là cách duy nhất để backend của khách hàng biết rằng:
- Người dùng đã thanh toán thành công.
- Subscription vừa được gia hạn.
- Invoice đã được thanh toán.
- Quyền truy cập nội dung cần được mở khóa.
- Một gói đăng ký đã hết hạn hoặc bị hủy.
Vấn đề xuất hiện khi một request phải thực hiện 2 việc cùng lúc: cập nhật database và gửi webhook ra bên ngoài.
Giả sử database đã commit thành công, nhưng service bị crash ngay trước khi webhook được gửi. Hệ thống của bạn đã ghi nhận subscription ở trạng thái active, trong khi backend của khách hàng không bao giờ nhận được thông báo.
Webhook đã biến mất.
Nếu đổi thứ tự, gửi webhook trước rồi mới commit database, hệ thống lại gặp tình huống ngược lại. Webhook đã đến nơi, nhưng database commit thất bại. Khách hàng nhận được thông báo về một thay đổi chưa từng thực sự xảy ra.
Đây được gọi là dual-write problem: hệ thống cần cập nhật hai nơi khác nhau, nhưng lại muốn cả hai thao tác cùng thành công hoặc cùng thất bại như một transaction duy nhất.
Transactional Outbox Pattern xử lý bài toán này bằng cách đưa các thay đổi nghiệp vụ và event cần gửi về cùng một database transaction. Event được lưu trước như một bản ghi bền vững, sau đó mới được tiến trình nền gửi ra ngoài.
Nói đơn giản hơn:
Đừng đặt toàn bộ số phận của webhook vào một lệnh httpPost() ngay sau khi commit. Hãy lưu event trước, rồi để hệ thống gửi lại cho đến khi thành công.
Transactional Outbox Pattern trong thiết kế microservices
Trong kiến trúc microservices, một hệ thống lớn được chia thành nhiều service nhỏ. Mỗi service phụ trách một nhóm nghiệp vụ và thường quản lý database riêng. Cách thiết kế này giúp các service có thể phát triển, triển khai và mở rộng độc lập, nhưng cũng khiến việc giữ dữ liệu nhất quán giữa chúng trở nên phức tạp hơn.
Hãy hình dung một hệ thống subscription gồm 3 service. Billing Service ghi nhận thanh toán, Subscription Service cập nhật trạng thái gói đăng ký, còn Entitlement Service mở quyền xem nội dung cho người dùng. Khi thanh toán thành công, Billing Service không chỉ cập nhật database của mình mà còn phải gửi event để các service còn lại tiếp tục xử lý.
Vấn đề là cơ sở dữ liệu transaction của Billing Service chỉ bảo vệ dữ liệu trong database mà nó quản lý. Transaction đó không thể đồng thời đảm bảo rằng message đã được đưa vào Kafka, RabbitMQ hoặc một message broker khác. Nếu database commit thành công nhưng service crash trước khi gửi event, Billing Service ghi nhận người dùng đã thanh toán, trong khi Entitlement Service vẫn chưa mở quyền truy cập.
Nếu gửi event trước rồi mới commit database, rủi ro lại đảo ngược. Các service phía sau có thể đã xử lý event, nhưng giao dịch gốc sau đó bị rollback. Hệ thống lúc này mở quyền truy cập cho một khoản thanh toán chưa từng được ghi nhận.
Đây là bài toán về tính nhất quán (consistency) trong microservices. Mỗi service có thể giữ dữ liệu nội bộ của mình nhất quán, nhưng không có một transaction chung bao phủ database, message broker và database của các service khác. Vì vậy, hệ thống phân tán thường phải chấp nhận eventual consistency: dữ liệu giữa các service có thể chưa đồng nhất ngay lập tức, nhưng phải có cơ chế để cuối cùng đạt đến trạng thái đúng.
Transactional Outbox giải quyết phần đầu tiên của bài toán bằng cách lưu business data và event cần publish trong cùng database transaction của service tạo ra event.
Transaction:
updateSubscription()
insertOutboxEvent()Nếu transaction thành công, cả trạng thái subscription và event đều được lưu. Nếu transaction rollback, cả hai cùng biến mất. Một Outbox Processor chạy nền sẽ đọc event, gửi nó đến message broker và retry khi gặp lỗi. Cách làm này biến hai thao tác có thể lệch nhau, cập nhật database và publish event, thành một transaction cục bộ an toàn kết hợp với một quá trình gửi có thể thử lại.
Tuy nhiên, Outbox không tự động làm cho toàn bộ hệ thống đạt được exactly-once delivery. Processor có thể publish thành công rồi crash trước khi đánh dấu event đã gửi, khiến event được gửi lại sau khi service khởi động. Vì vậy, các service nhận event vẫn cần xử lý theo hướng idempotent, chẳng hạn, lưu event_id đã xử lý để một event trùng không tạo ra kết quả nghiệp vụ lần thứ hai.
Có thể hiểu vai trò của Outbox trong microservices như sau: pattern này không buộc tất cả các service cùng tham gia một transaction lớn. Nó bảo đảm service đầu tiên không làm mất event, sau đó, để retry, message broker và idempotent consumer đưa các service còn lại về trạng thái nhất quán.

Cái bẫy khi gọi API trực tiếp trong request
Trong kiến trúc microservices, một request hiếm khi chỉ cập nhật dữ liệu trong một service. Sau khi thay đổi database, service thường phải publish event đến message broker, gọi API của service khác hoặc gửi webhook đến hệ thống bên ngoài.
Vấn đề nằm ở chỗ database transaction chỉ bảo vệ những thay đổi bên trong database. Ngay khi ứng dụng thực hiện một side effect bên ngoài transaction, chẳng hạn gửi HTTP request hoặc publish message, thao tác đó không còn được database bảo vệ.
Ví dụ, Billing Service cần ghi nhận thanh toán và thông báo cho các service còn lại:
async handleInvoicePaid(event):
transaction:
writeBillingTransaction()
updateSubscription()
# commit xong ở đây
httpPost(customerWebhookUrl, payload) # ❌ ngoài transactionĐoạn code này có hai trạng thái cần được giữ đồng bộ:
- Giao dịch đã được ghi nhận trong Billing Service.
- Event thanh toán thành công đã được publish cho các service khác.
Tuy nhiên, chỉ trạng thái đầu tiên nằm trong database transaction.
Nếu Billing Service bị crash ngay sau khi database commit nhưng trước khi event được publish, hệ thống sẽ rơi vào trạng thái không nhất quán. Billing Service đã ghi nhận người dùng thanh toán thành công, nhưng Entitlement Service chưa mở quyền truy cập và Notification Service cũng chưa gửi thông báo.
Nếu đổi thứ tự, publish event trước rồi mới commit database, hệ thống lại gặp tình huống ngược lại. Các service phía sau có thể đã xử lý event, trong khi transaction ban đầu thất bại và dữ liệu thanh toán chưa từng được lưu.
Không có cách sắp xếp thứ tự nào loại bỏ hoàn toàn rủi ro:
- Ghi database trước có thể làm mất event.
- Publish event trước có thể tạo ra một event không có thật.
Đây chính là dual-write problem trong microservices. Một service cần ghi dữ liệu vào database và gửi thông tin đến một hệ thống khác, nhưng không có một transaction chung để bảo vệ cả hai thao tác.
Bạn không thực sự loại bỏ được lỗi bằng cách đổi thứ tự. Bạn chỉ đang chọn xem hệ thống sẽ bỏ qua event hay xử lý một event không hợp lệ.
Vì sao không dùng distributed transaction để giải quyết việc ghi đè?
Khi gặp dual-write problem, một giải pháp thường được nhắc đến là distributed transaction, phổ biến nhất là Two-Phase Commit, hay 2PC.
Trong mô hình này, một coordinator đứng ở giữa và yêu cầu các bên tham gia transaction thực hiện 2 giai đoạn.
Ở giai đoạn đầu, coordinator hỏi từng hệ thống xem chúng đã sẵn sàng commit hay chưa. Chỉ khi tất cả cùng xác nhận, coordinator mới chuyển sang giai đoạn commit. Nếu một bên không sẵn sàng, toàn bộ transaction sẽ rollback.
Về lý thuyết, cách này giải quyết đúng điều chúng ta cần: hoặc tất cả cùng thành công, hoặc tất cả cùng thất bại.
Nhưng trong hệ thống microservices thực tế, 2PC thường khó triển khai và vận hành.
Không phải database, message broker hoặc dịch vụ bên ngoài nào cũng hỗ trợ cùng một giao thức distributed transaction. Webhook endpoint của khách hàng lại càng không thể hiểu yêu cầu kiểu “hãy chuẩn bị xử lý nhưng chưa commit, chờ tôi gửi lệnh tiếp theo”.
Ngay cả khi các thành phần đều hỗ trợ 2PC, hệ thống vẫn phải trả thêm nhiều chi phí:
- Cần một coordinator để điều phối transaction.
- Database lock có thể phải được giữ lâu hơn.
- Một service chậm có thể khiến các service khác phải chờ.
- Khả năng mở rộng và tính sẵn sàng của hệ thống bị ảnh hưởng.
- Hệ thống xuất hiện thêm nhiều failure mode khó quan sát và xử lý.
Cách tiếp cận này cũng làm tăng mức độ phụ thuộc giữa các service. Trong khi mục tiêu của microservices là cho phép từng service phát triển và vận hành tương đối độc lập, distributed transaction lại buộc nhiều thành phần phải phối hợp chặt chẽ trong cùng một quy trình commit.
Transactional Outbox chọn một hướng khác.
Thay vì cố tạo một transaction lớn bao phủ nhiều service, pattern này gom những gì cần đảm bảo tính atomic về một resource duy nhất: database của service tạo ra event.
Business data và Outbox event được ghi trong cùng một local transaction. Sau đó, event được publish ra ngoài bằng một tiến trình riêng có khả năng retry.
Nhờ đó, hệ thống duy trì atomicity trong từng service và sử dụng eventual consistency giữa các service.
Outbox không phải design pattern kiểu Singleton
Khi nghe đến từ “pattern”, nhiều lập trình viên thường nghĩ đến Singleton, Factory hoặc Observer.
Transactional Outbox thuộc một nhóm khác.
Singleton và Factory giúp tổ chức object hoặc class bên trong một ứng dụng. Transactional Outbox là một distributed systems pattern, được sử dụng để xử lý dữ liệu đi qua nhiều ranh giới khác nhau như database, message broker, HTTP endpoint và nhiều service.
Có thể phân biệt hai nhóm này bằng một câu đơn giản:
Singleton giúp tổ chức object trong code. Outbox giúp tổ chức side effect trong một hệ thống phân tán.
Trong microservices, database transaction của một service không thể trực tiếp bảo vệ network call hoặc database của một service khác. Transactional Outbox tạo ra một ranh giới bền vững giữa thay đổi nghiệp vụ bên trong service và event cần gửi ra bên ngoài.
Pattern này cũng hiếm khi hoạt động một mình. Một hệ thống sử dụng Outbox thường cần thêm các cơ chế bổ trợ.
- Idempotent Consumer: Giúp phía nhận loại bỏ event trùng lặp. Consumer có thể lưu event_id đã xử lý và bỏ qua nếu nhận lại cùng một event.
- Retry with Backoff: Tăng dần thời gian chờ giữa các lần gửi lại, tránh việc một endpoint đang lỗi tiếp tục bị gọi liên tục.
- Dead Letter Queue: Lưu những event không thể xử lý sau nhiều lần thử để đội ngũ kỹ thuật kiểm tra, sửa lỗi hoặc replay sau đó.
- Saga Pattern: Điều phối các quy trình nghiệp vụ gồm nhiều bước.
Một hệ thống Outbox hoàn chỉnh vì vậy thường đi cùng idempotency, retry, dead-letter và đôi khi là Saga.
Đọc thêm: Idempotency key trong hệ thống thanh toán: Vì sao một request không nên được xử lý hai lần?
Transactional Outbox hoạt động như thế nào?
Transactional Outbox hoạt động bằng cách ghi dữ liệu nghiệp vụ và event cần gửi vào cùng một database transaction. Khi transaction thành công, cả thay đổi trong hệ thống và bản ghi trong Outbox đều được lưu. Nếu transaction thất bại, cả hai cùng rollback. Sau đó, một tiến trình chạy nền sẽ đọc các event chưa được xử lý trong bảng Outbox, publish chúng đến message broker hoặc gửi webhook ra bên ngoài, rồi đánh dấu đã hoàn tất.
Nếu gặp lỗi mạng hoặc service tạm thời ngừng hoạt động, event vẫn còn trong database để hệ thống tiếp tục retry, nhờ đó giảm nguy cơ mất event và hỗ trợ duy trì tính nhất quán giữa các service.
Mental model của Outbox có thể gói gọn trong một câu:
Thay vì gửi event ngay lập tức, hãy ghi lại rằng event cần được gửi, trong cùng transaction với thay đổi đã tạo ra event đó.
Hệ thống sẽ có một bảng, thường được gọi là outbox_events. Mỗi bản ghi trong bảng tương ứng với một event đang chờ được gửi.
Ví dụ, Billing Service xử lý một khoản thanh toán thành công:
transaction:
writeBillingTransaction()
updateSubscription()
insertOutboxRow(event) # ✅ cùng transaction
# commit: hoặc cả ba cùng có, hoặc cả ba cùng khôngNếu transaction commit thành công, hệ thống có đầy đủ hai phần:
- Trạng thái nghiệp vụ đã được cập nhật.
- Một bản ghi Outbox cho biết cần gửi event tương ứng.
Nếu transaction rollback, cả business data và Outbox event đều không tồn tại.
Nhờ vậy, hệ thống không còn rơi vào tình trạng database đã thay đổi nhưng event lại biến mất trước khi đến message broker.
Sau khi transaction hoàn tất, một tiến trình chạy nền sẽ đọc Outbox event và publish nó. Các service như Entitlement Service, Notification Service hoặc Analytics Service có thể subscribe event và cập nhật dữ liệu của mình.
Các service không nhất thiết thay đổi trạng thái cùng một thời điểm. Tuy nhiên, vì event đã được lưu bền vững và có thể retry, hệ thống có cơ sở để cuối cùng đưa tất cả service về trạng thái nhất quán.
Đó là cách Transactional Outbox hỗ trợ eventual consistency trong microservices.

Cấu trúc của bảng Outbox
Mỗi service tạo event có thể duy trì một bảng Outbox trong chính database mà service đó quản lý. Một bảng Outbox cơ bản có thể được thiết kế như sau:
CREATE TABLE outbox_events (
id TEXT PRIMARY KEY,
aggregate_type TEXT NOT NULL, -- "subscription", "invoice", ...
aggregate_id TEXT NOT NULL, -- id của entity sinh ra event
event_type TEXT NOT NULL, -- "RENEWAL", "INITIAL_PURCHASE", ...
payload JSONB NOT NULL, -- body đủ để gửi đi / replay
status TEXT NOT NULL, -- "pending" → "sent"
attempts INT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ, -- backoff cho lần retry kế
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ
);Tên bảng và tên cột có thể thay đổi tùy theo cách thiết kế. Tuy nhiên, một Outbox event thường cần đáp ứng ba yêu cầu chính.
Event cần có ID ổn định
ID này cho phép consumer kiểm tra xem event đã được xử lý hay chưa. Nếu cùng một event được publish lại, consumer có thể nhận diện bản trùng và không thực hiện nghiệp vụ thêm lần nữa.
Một ID ổn định cũng giúp đội ngũ kỹ thuật theo dõi event xuyên suốt nhiều service, từ lúc được tạo trong Billing Service đến khi được xử lý trong Entitlement Service hoặc được gửi ra webhook của khách hàng.
Payload phải đủ để xử lý hoặc gửi lại
Outbox nên lưu đủ thông tin để event có thể được publish lại mà không cần dựng lại toàn bộ dữ liệu từ nhiều bảng khác.
Điều này đặc biệt hữu ích khi cần:
- Gửi lại event sau một sự cố.
- Debug luồng dữ liệu giữa các service.
- Backfill event cho một consumer mới.
- Kiểm tra payload đã được tạo tại một thời điểm cụ thể.
- Replay event sau khi sửa lỗi trong pipeline.
Payload không nhất thiết phải chứa toàn bộ entity. Tuy nhiên, nó cần đủ để consumer hiểu event nào đã xảy ra và dữ liệu nào cần được sử dụng.
Trạng thái publish và retry phải rõ ràng
Outbox Processor cần biết event nào đang chờ publish, event nào đã publish thành công và event nào cần thử lại.
Các trường như status, attempts, next_attempt_at và published_at giúp quản lý vòng đời của event rõ ràng hơn.
Nếu không có trạng thái cụ thể, đội ngũ kỹ thuật sẽ khó biết event đang bị kẹt ở đâu, đã được thử bao nhiêu lần hoặc có cần chuyển sang dead-letter hay không.
Outbox Processor gửi event như thế nào?
Sau khi event được ghi vào Outbox, việc publish event được tách khỏi business request ban đầu.
Một tiến trình chạy nền, thường được gọi là Outbox Processor, Message Relay hoặc đơn giản là relay, sẽ chịu trách nhiệm:
- Tìm các event chưa được publish.
- Claim một nhóm event để tránh nhiều worker cùng xử lý.
- Publish từng event đến message broker hoặc webhook pipeline.
- Đánh dấu event đã publish nếu thành công.
- Lên lịch retry nếu thất bại.
Ví dụ đơn giản:
# Tiến trình relay, chạy nền liên tục
loop:
rows = selectUnsentOutboxRows(limit)
for row in rows:
publish(row) # nếu fail, để nguyên -- vòng sau retry
markAsSent(row)
sleep(interval)Nếu network gặp lỗi hoặc endpoint của khách hàng tạm thời không hoạt động, event vẫn còn nguyên trong database. Relay có thể thử lại ở vòng chạy tiếp theo.
Đây là điểm quan trọng nhất của Outbox.
Nó tách một thao tác không an toàn:
Ghi database → Gửi network request
thành hai thao tác có thể kiểm soát tốt hơn:
Bước đầu tiên được bảo vệ bằng local transaction:
Cập nhật business data + Lưu Outbox event
Bước thứ hai được thực hiện từ một nguồn dữ liệu bền vững:
Đọc Outbox event → Publish có retry
Trong một hệ thống microservices, event có thể được publish vào Kafka, RabbitMQ, Amazon SNS/SQS hoặc một message broker khác. Sau đó, nhiều consumer độc lập có thể xử lý event theo nhu cầu của mình.
Với MonetKit, Outbox event cũng có thể đi qua pipeline fan-out và cuối cùng được gửi dưới dạng webhook đến backend của khách hàng.
Cơ chế vận chuyển có thể khác nhau, nhưng nguyên tắc vẫn giống nhau: event phải được lưu an toàn trước khi được gửi ra ngoài.
Vì sao Transactional Outbox phù hợp với microservices?
Transactional Outbox thường được sử dụng trong các hệ thống cần thay đổi trạng thái trong một service rồi thông báo cho những service khác.
Pattern này không tạo ra một transaction toàn hệ thống. Thay vào đó, nó giúp từng service quản lý local transaction an toàn và cung cấp một event đáng tin cậy để các service còn lại cập nhật theo thời gian.
Atomicity cục bộ mà không cần distributed transaction
Business data và Outbox event được ghi trong cùng một database transaction.
Điều đó có nghĩa là cả hai cùng tồn tại hoặc cùng không tồn tại. Service không thể cập nhật dữ liệu nghiệp vụ mà quên lưu event tương ứng.
Tuy nhiên, cần phân biệt rõ phạm vi của atomicity.
Transactional Outbox chỉ đảm bảo atomicity giữa business data và Outbox event trong database của service tạo event. Pattern này không làm cho database của nhiều service cùng commit trong một transaction.
Sự nhất quán giữa các service vẫn diễn ra theo mô hình eventual consistency.
Đây là một sự đánh đổi có chủ đích. Thay vì buộc tất cả các service cùng chờ một transaction phân tán, mỗi service xử lý local transaction riêng và trao đổi thông tin qua event.
Event không bị mất khi hệ thống gặp sự cố
Network có thể chập chờn. Message broker có thể ngừng hoạt động trong một khoảng thời gian. Service cũng có thể bị restart ngay giữa quá trình xử lý.
Những sự cố này không làm event biến mất vì event đã được lưu trong database trước đó.
Khi message broker hoặc service hoạt động trở lại, relay tiếp tục đọc Outbox và publish những event còn tồn đọng.
Thay vì hy vọng một network call sẽ thành công trong lần đầu tiên, hệ thống lưu event thành dữ liệu bền vững rồi retry nó như một công việc có thể theo dõi.
Producer và consumer ít phụ thuộc trực tiếp hơn
Nếu Billing Service gọi trực tiếp Entitlement Service, cả hai sẽ phụ thuộc vào trạng thái hoạt động của nhau.
Khi Entitlement Service chậm hoặc ngừng hoạt động, request thanh toán có thể bị kéo dài hoặc thất bại.
Với Outbox và message broker, Billing Service chỉ cần hoàn tất local transaction của mình. Entitlement Service có thể xử lý event sau khi sẵn sàng.
Điều này giúp giảm temporal coupling, nghĩa là producer và consumer không nhất thiết phải hoạt động cùng một thời điểm.
Có audit trail và khả năng replay
Nếu được lưu trữ và archive hợp lý, bảng Outbox có thể trở thành một audit trail cho những event mà hệ thống đã phát ra.
Khi có sự cố, đội ngũ kỹ thuật có thể trả lời những câu hỏi như:
- Event đã được tạo chưa?
- Event được tạo vào thời điểm nào?
- Payload lúc đó chứa những gì?
- Hệ thống đã gửi bao nhiêu lần?
- Endpoint nào đã nhận event?
- Lần gửi gần nhất thất bại vì lý do gì?
Khi cần gửi lại hoặc backfill event, dữ liệu cũng đã có sẵn. Đội ngũ kỹ thuật không phải cố gắng dựng lại mọi thứ từ application log.
MonetKit áp dụng Transaction Outbox ở đâu?
MonetKit là một nền tảng monetization. Hệ thống tiếp nhận event từ Stripe, Apple App Store và Google Play, sau đó chuẩn hóa dữ liệu rồi gửi webhook đến hệ thống của khách hàng.
Đây gần như là một trường hợp sử dụng điển hình của Transactional Outbox.
Mỗi event từ payment provider có thể đồng thời tạo ra nhiều thay đổi trong hệ thống:
- Ghi nhận giao dịch.
- Cập nhật trạng thái subscription.
- Cấp hoặc thu hồi entitlement.
- Cập nhật invoice.
- Gửi webhook đến khách hàng.
Với billing infrastructure, webhook không phải là một notification mang tính tham khảo.
Đối với nhiều khách hàng, webhook là cách backend của họ biết người dùng nào vừa được cấp quyền truy cập, subscription nào vừa hết hạn hoặc invoice nào đã được thanh toán.
Chỉ cần mất một webhook, trạng thái giữa MonetKit và hệ thống khách hàng có thể bắt đầu lệch nhau.
Trong hệ thống billing, sai lệch trạng thái không chỉ gây khó chịu. Nó có thể dẫn đến thất thoát doanh thu, cấp sai quyền truy cập và tạo ra những lỗi rất khó truy vết.
Giai đoạn 1: Nhận và xử lý event đầu vào
Một event từ Stripe, Apple hoặc Google được gửi đến MonetKit.
Handler tiếp nhận event và thực hiện các bước nghiệp vụ cần thiết, chẳng hạn như:
- Ghi nhận billing transaction.
- Cập nhật subscription.
- Cấp hoặc thu hồi entitlement.
- Tạo canonical event trong Outbox.
Canonical event được ghi trong cùng transaction với dữ liệu billing.
Nhờ đó, trạng thái billing và “ý định gửi webhook” luôn được commit cùng nhau.
Giai đoạn 2: Relay đọc Outbox
Một tiến trình chạy nền quét bảng Outbox theo chu kỳ và lấy các event chưa được gửi.
Hệ thống có thể chạy nhiều relay worker cùng lúc để tăng khả năng xử lý. Tuy nhiên, các worker cần tránh lấy cùng một event tại cùng thời điểm.
Một cách phổ biến là sử dụng row lock của database. Mỗi worker chỉ claim một nhóm event riêng mà không cần thêm distributed lock.
Dù vậy, toàn bộ quy trình vẫn hoạt động theo cơ chế at-least-once delivery. Một event có thể được gửi nhiều hơn một lần, vì vậy phía nhận vẫn cần xử lý idempotency.
Giai đoạn 3: Fan-out và delivery
Sau relay, quy trình gửi có thể được tách thành hai bước.
Fan-out xác định một event cần được gửi đến những endpoint nào. Một khách hàng có thể cấu hình nhiều endpoint cho các hệ thống hoặc môi trường khác nhau.
Delivery chịu trách nhiệm gửi webhook đến từng endpoint. Bước này có thể bao gồm:
- Ký payload.
- Gửi HTTP POST.
- Ghi lại response.
- Retry theo backoff.
- Kích hoạt circuit breaker.
- Chuyển event sang dead-letter khi không thể gửi.
Mỗi giai đoạn có thể có hàng đợi retry và dead-letter riêng.
Điểm quan trọng là toàn bộ sự phức tạp của quá trình delivery đều nằm phía sau ranh giới bền vững của Outbox.
Một khi event đã được ghi vào Outbox trong business transaction, lỗi mạng, service restart hoặc sự cố message broker không thể làm event biến mất.
Đó chính là lý do Transactional Outbox tồn tại.

Những đánh đổi khi sử dụng Transactional Outbox
Outbox giúp hệ thống đáng tin cậy hơn, nhưng không phải không có chi phí. Dưới đây là những đánh đổi cần được tính đến khi triển khai pattern này.
Có độ trễ do polling
Relay thường quét bảng Outbox theo chu kỳ, chẳng hạn vài giây một lần. Vì vậy, sẽ có một khoảng trễ từ lúc event được ghi vào database đến lúc webhook thực sự được gửi đi. Với webhook, vốn mang tính bất đồng bộ, mức độ trễ này thường có thể chấp nhận được.
Nếu hệ thống yêu cầu độ trễ ở mức mili giây, Outbox kết hợp với polling có thể không phải là lựa chọn phù hợp. Khi đó, đội ngũ phát triển cũng nên xem lại liệu webhook có phải là cơ chế giao tiếp đúng cho yêu cầu đó hay không.
Một cách tối ưu là để business transaction đánh thức relay ngay sau khi commit. Tuy nhiên, polling vẫn nên được giữ lại như một lớp an toàn trong trường hợp tín hiệu đánh thức bị mất.
At-least-once chứ không phải exactly-once
Transactional Outbox không đảm bảo rằng mỗi event chỉ được gửi đúng một lần.
Hãy xem xét tình huống sau:
- Relay gửi webhook thành công.
- Endpoint khách hàng đã nhận và xử lý event.
- Relay bị crash trước khi đánh dấu event là sent.
- Sau khi khởi động lại, relay nhìn thấy event vẫn chưa được gửi.
- Event được gửi thêm một lần nữa.
Đây không phải lỗi trong thiết kế. Đây là bản chất của at-least-once delivery.
Thay vì cố gắng đạt được exactly-once delivery, hệ thống nên hướng đến exactly-once effect: event có thể được gửi nhiều lần, nhưng kết quả nghiệp vụ chỉ được áp dụng một lần.
Để làm được điều này, mỗi event cần một ID ổn định và phía nhận phải lưu lại những ID đã xử lý.
Nếu event cũ được gửi lại, phía nhận chỉ cần trả về kết quả thành công mà không thực hiện nghiệp vụ lần nữa.
Bảng Outbox sẽ ngày càng lớn
Mỗi event tạo ra một bản ghi mới. Theo thời gian, số lượng bản ghi trong bảng Outbox sẽ tiếp tục tăng.
Nếu không có chiến lược dọn dẹp, truy vấn tìm event chưa gửi có thể trở nên chậm hơn. Dung lượng lưu trữ và thời gian bảo trì database cũng tăng theo.
Hệ thống cần có chính sách quản lý vòng đời dữ liệu, chẳng hạn như:
- Xóa các event đã gửi sau một khoảng thời gian.
- Archive event sang hệ thống lưu trữ khác.
- Partition bảng theo ngày hoặc tháng.
- Tạo index phù hợp cho các truy vấn của relay.
Việc lưu giữ bao lâu phụ thuộc vào nhu cầu audit, replay và quy định dữ liệu của từng sản phẩm.
Có thêm một thành phần cần vận hành
Outbox yêu cầu ít nhất một tiến trình relay chạy nền.
Tiến trình này cần được:
- Deploy.
- Theo dõi.
- Thiết lập cảnh báo.
- Đo backlog.
- Kiểm tra retry.
- Xử lý dead-letter.
- Scale khi lưu lượng tăng.
Nếu relay bị kẹt, event không bị mất nhưng sẽ không được gửi đúng thời gian. Vì vậy, đội ngũ vận hành cần theo dõi các chỉ số như:
- Số lượng event đang chờ.
- Tuổi của event cũ nhất.
- Tỷ lệ gửi thành công.
- Số lần retry trung bình.
- Số event trong dead-letter.
- Thời gian từ lúc tạo event đến lúc gửi thành công.
Outbox rất đáng giá khi event không được phép mất.
Ngược lại, nếu side effect chỉ mang tính best-effort, chẳng hạn một log phân tích không quan trọng, việc xây dựng toàn bộ Outbox pipeline có thể là không cần thiết.
Kết luận
Transactional Outbox Pattern không làm cho network trở nên đáng tin cậy hơn. Nó cũng không đảm bảo webhook chỉ được gửi đúng một lần.
Điều pattern này làm được là thay đổi cách hệ thống đối mặt với sự cố.
Thay vì gửi webhook trực tiếp rồi hy vọng request thành công, hệ thống lưu ý định gửi thành một bản ghi bền vững trong database. Từ đó, webhook có thể được retry, theo dõi, audit và replay khi cần.
Business state và Outbox event được ghi trong cùng transaction. Vì vậy, hệ thống không còn phải lựa chọn giữa việc làm mất event và việc gửi một event không có thật.
Với những hệ thống như billing, subscription và monetization, đây không chỉ là một cách tổ chức code tốt hơn.
Đây là nền tảng giúp dữ liệu giữa các hệ thống luôn nhất quán, ngay cả khi service bị crash, network gặp lỗi hoặc các thành phần bên ngoài tạm thời ngừng hoạt động.
Câu hỏi thường gặp về Transactional Outbox Pattern
Transactional Outbox Pattern là một mẫu thiết kế hệ thống giúp lưu business data và event cần publish trong cùng một database transaction. Nếu transaction thành công, cả dữ liệu nghiệp vụ và Outbox event đều được lưu; nếu transaction thất bại, cả hai cùng rollback. Nhờ đó, service không rơi vào tình trạng database đã thay đổi nhưng event lại bị mất trước khi đến message broker hoặc webhook endpoint.
Khi một thay đổi nghiệp vụ xảy ra, service ghi dữ liệu và tạo một Outbox event trong cùng local transaction. Sau khi transaction được commit, Outbox Processor sẽ đọc các event chưa xử lý, publish chúng đến message broker hoặc webhook pipeline, rồi đánh dấu đã hoàn tất. Nếu quá trình gửi thất bại, event vẫn nằm trong database để hệ thống tiếp tục retry.
Trong kiến trúc microservices, mỗi service thường quản lý database riêng và trao đổi dữ liệu qua API hoặc event. Database transaction của một service không thể đồng thời bảo vệ message broker hoặc database của service khác. Transactional Outbox giúp service đảm bảo atomicity cục bộ, đồng thời tạo ra một event đáng tin cậy để các service còn lại cập nhật dữ liệu theo mô hình eventual consistency.
Dual-write problem xuất hiện khi một service phải vừa cập nhật database, vừa publish event ra một hệ thống khác. Nếu database được ghi trước, event có thể bị mất; nếu event được gửi trước, hệ thống có thể publish một thay đổi chưa từng được commit. Transactional Outbox đưa business data và event vào cùng một database transaction, sau đó tách việc publish thành một tiến trình có thể retry.
Không. Transactional Outbox thường hoạt động theo cơ chế at-least-once delivery, nghĩa là một event có thể được publish nhiều hơn một lần. Ví dụ, relay đã gửi event thành công nhưng bị crash trước khi đánh dấu event là published. Khi khởi động lại, relay có thể gửi lại event đó. Vì vậy, hệ thống nên hướng đến exactly-once effect: event có thể đến nhiều lần, nhưng kết quả nghiệp vụ chỉ được áp dụng một lần.
Transactional Outbox giúp event không bị mất, nhưng không loại bỏ hoàn toàn event trùng lặp. Consumer cần sử dụng event_id hoặc idempotency key để kiểm tra xem event đã được xử lý hay chưa. Nếu nhận lại cùng một event, consumer có thể trả về kết quả thành công mà không thực hiện nghiệp vụ lần thứ hai, chẳng hạn không cộng thêm thời hạn subscription hoặc cấp entitlement trùng lặp.
Polling dễ triển khai và phù hợp với những hệ thống chấp nhận độ trễ vài giây. Outbox Processor sẽ quét bảng theo chu kỳ để tìm event chưa được publish. Change Data Capture đọc thay đổi từ transaction log nên thường có độ trễ thấp hơn và giảm số lần truy vấn bảng Outbox, nhưng hạ tầng cũng phức tạp hơn. Lựa chọn phù hợp phụ thuộc vào lưu lượng, yêu cầu về latency và khả năng vận hành của đội ngũ kỹ thuật.
Không nên dùng Transactional Outbox khi side effect chỉ mang tính best-effort và việc mất dữ liệu không ảnh hưởng đến nghiệp vụ, chẳng hạn một analytics log không quan trọng. Pattern này cũng có thể không phù hợp nếu hệ thống yêu cầu đồng bộ tức thời ở mức mili giây hoặc không muốn vận hành thêm Outbox Processor, retry và dead-letter. Outbox đáng giá nhất khi event liên quan trực tiếp đến thanh toán, subscription, đơn hàng, entitlement hoặc những thay đổi không được phép biến mất.




