Bỏ qua để đến nội dung
Knowledge Hub

Transactional outbox: DB đã commit nhưng event chưa tới broker Trung cấp

Thời gian đọc ước tính: 7 phút

Order service cập nhật orders.status = 'paid' rồi publish OrderPaid lên Kafka. Database commit xong, process chết trước khi gọi broker: đơn đã paid nhưng Shipping không bao giờ nhận event. Đảo thứ tự cũng hỏng: publish trước rồi DB rollback thì Shipping xử lý một đơn chưa paid.

Đây là dual write: một yêu cầu ghi hai hệ thống có transaction độc lập. try/catch và retry trong process không đóng được khoảng hở, vì chính process có thể chết.

Transactional outbox: trong một DB transaction, đổi trạng thái đơn và insert một dòng vào bảng outbox. Một relay riêng đọc dòng pending rồi publish. Hai dòng cùng commit hoặc cùng rollback, nên không còn trạng thái “paid mà không ai nhớ phải phát event”. Outbox chống mất event, không chống trùng.

Một DB transaction ghi order và outbox; relay phát event sau
Một DB transaction ghi order và outbox; relay phát event sauBước 1/4: App ghi trạng thái đơn và outbox event trong cùng transaction DB.AppDB + OutboxBrokerCOMMIT
App → DB + OutboxCOMMIT
Bước 1/4

App ghi trạng thái đơn và outbox event trong cùng transaction DB.

CREATE TABLE outbox (
id bigserial PRIMARY KEY,
event_id uuid NOT NULL UNIQUE, -- ổn định qua mọi lần retry
aggregate_id text NOT NULL, -- dùng làm message key
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz -- NULL = pending
);
-- partial index: chỉ index dòng pending nên luôn nhỏ
CREATE INDEX outbox_pending_idx ON outbox (id) WHERE published_at IS NULL;

Ghi cùng transaction với nghiệp vụ:

BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 42;
INSERT INTO outbox (event_id, aggregate_id, event_type, payload)
VALUES (gen_random_uuid(), 'order-42', 'OrderPaid', '{"orderId":42}');
COMMIT;

Payload là contract công khai qua broker: không serialize nguyên ORM entity, không để lộ secret.

Nhiều instance relay cùng chạy được nếu mỗi instance claim một lô dòng khác nhau. FOR UPDATE SKIP LOCKED bỏ qua dòng đang bị relay khác khóa (xem NOWAIT và SKIP LOCKED).

BEGIN;
SELECT id, event_id, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- publish lô này lên broker, chờ ack/confirm
UPDATE outbox SET published_at = now() WHERE id = ANY($1);
COMMIT;

Đã chạy trên PostgreSQL 17 (database scratch) với 5 dòng: SELECT ... LIMIT 3 FOR UPDATE SKIP LOCKED trả ba dòng đầu theo id, sau UPDATE còn 2 dòng pending.

Lưu ý về transaction: giữ transaction mở trong lúc publish nghĩa là giữ khóa dòng và một connection. Giữ lô nhỏ và đặt timeout cho publish. Nếu relay chết, transaction rollback, khóa nhả, dòng pending được relay khác nhặt lại.

Thứ tự. SKIP LOCKED cho phép relay B lấy dòng sau trong lúc relay A đang giữ dòng trước, nên hai event cùng aggregate có thể bị phát đảo. Hai cách: chạy một relay duy nhất cho mỗi shard (ví dụ hash(aggregate_id) mod N), hoặc để consumer kiểm tra version theo aggregate. created_at không phản ánh commit order, nên dùng id hoặc version.

Polling publisher CDC (Debezium Outbox Event Router)
Cách đọc Query bảng outbox Đọc WAL, chuyển dòng outbox thành event
Độ trễ Bằng chu kỳ poll cộng thời gian publish Thấp hơn, gần thời gian thực
Tải lên DB Query lặp lại (nhẹ nếu có partial index) Replication slot; slot kẹt làm WAL phình
Hạ tầng thêm Chỉ code relay Kafka Connect, Debezium, quyền replication
Cần cột published_at Có Không; docs Debezium ghi SMT tự lọc bỏ thao tác DELETE trên bảng outbox, nên có thể xóa dòng sau insert (kiểm tra với cấu hình connector của bạn)
Thứ tự Phải tự thiết kế (bảng trên) Theo thứ tự đọc log của connector; aggregateid làm message key để giữ thứ tự trong partition Kafka
Khi nào hợp Quy mô vừa, muốn ít thành phần Throughput cao, cần độ trễ thấp, đã có Kafka Connect

Chưa có số đo độ trễ hay throughput trong bài này vì chúng phụ thuộc workload và cấu hình; hãy đo bằng chính hệ thống của bạn. Cả hai cách đều at-least-once.

Điểm process dừng DB order Outbox Broker Phục hồi
Trước DB commit Chưa paid Không có Không có Request retry theo nghiệp vụ
Sau commit, trước relay Paid Pending Chưa có Relay đọc pending rồi publish
Sau broker nhận, trước mark sent Paid Pending Có thể đã có Relay publish lại; consumer dedupe
Sau mark sent Paid Sent Đã nhận Theo dõi consumer riêng

Chỉ mark sent sau khi có tín hiệu broker: Kafka send ack theo acks, hoặc RabbitMQ publisher confirm. Dù vậy luôn còn khoảng hở publish xong rồi chết trước mark, nên consumer phải idempotent theo event_id. Kafka idempotent producer không cứu được ca này vì relay mới là một producer mới.

Bảng outbox chỉ tăng. Pending không bao giờ được xóa vì “cũ”.

Chiến lược Cách làm Khi nào
Xóa theo lô DELETE ... WHERE published_at < now() - interval '7 days' trong lô nhỏ Khối lượng thấp tới vừa
Partition theo ngày DROP PARTITION dòng cũ đã sent Khối lượng cao: không để dead tuple, không cần vacuum nặng
CDC Có thể xóa dòng sau insert (Debezium lọc bỏ DELETE); log vẫn có bản ghi insert Dùng Debezium

Giữ dòng sent đủ lâu để điều tra và replay (ví dụ vài ngày; số cụ thể do bạn chọn). Theo dõi: tuổi dòng pending cũ nhất, số retry, và độ trễ từ commit tới broker.

Terminal window
npx --prefix examples ts-node examples/src/transactional-outbox/outbox-model.ts

Script in ba trạng thái. Sau “DB commit”: order paid, event pending. Sau relay crash: broker mô phỏng đã nhận một event nhưng dòng vẫn pending. Sau retry: mảng published chứa event-42 hai lần, outbox được mark sent. Nó minh họa outbox chống mất yêu cầu phát nhưng không chống trùng. Map trong demo không phải transaction thật; SQL ở trên mới là bản bảo vệ thật.

Ba test đáng làm: rollback ngay sau insert outbox (cả order lẫn event đều biến mất); chặn broker sau commit rồi mở lại (relay phải phát); crash sau ack, trước mark sent (consumer vẫn áp một lần).

  • Dùng: cần cập nhật DB và phát event, không chấp nhận mất event.
  • Không cần: mất event chấp nhận được (log, metric), hoặc event do chính một hệ thống ghi thẳng vào Kafka trong transaction Kafka.
  • Không đủ: cần thứ tự toàn cục, hay phản hồi đồng bộ tới consumer.
  1. Insert outbox trong cùng transaction với nghiệp vụ.
  2. event_id sinh một lần, unique, ổn định qua retry.
  3. Partial index trên dòng pending.
  4. Claim bằng FOR UPDATE SKIP LOCKED theo lô nhỏ, publish có timeout.
  5. Mark sent chỉ sau broker ack/confirm.
  6. Thứ tự theo aggregate: sharding relay hoặc version ở consumer.
  7. Consumer idempotent.
  8. Dọn dẹp: xóa lô hoặc drop partition; giám sát tuổi pending, retry, độ trễ.

Tự kiểm tra: (1) Vì sao ghi outbox ở transaction khác vẫn mất event? (2) Relay chết sau broker ack, trước mark sent dẫn tới gì? (3) Vì sao SKIP LOCKED có thể đảo thứ tự event?