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
Cần đọc trước
Vấn đề
Phần tiêu đề “Vấn đề”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.
COMMITApp ghi trạng thái đơn và outbox event trong cùng transaction DB.
Schema
Phần tiêu đề “Schema”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.
Relay polling với SKIP LOCKED
Phần tiêu đề “Relay polling với SKIP LOCKED”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/confirmUPDATE 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 hay CDC (Debezium)
Phần tiêu đề “Polling hay CDC (Debezium)”| 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.
Các điểm dừng và cách phục hồi
Phần tiêu đề “Các điểm dừng và cách phục hồi”| Đ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.
Dọn outbox
Phần tiêu đề “Dọn outbox”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.
Demo mô hình RAM
Phần tiêu đề “Demo mô hình RAM”npx --prefix examples ts-node examples/src/transactional-outbox/outbox-model.tsScript 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).
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Khi nào dùng / không dùng
Phần tiêu đề “Khi nào dùng / không dùng”- 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.
Checklist production
Phần tiêu đề “Checklist production”- Insert outbox trong cùng transaction với nghiệp vụ.
event_idsinh một lần, unique, ổn định qua retry.- Partial index trên dòng pending.
- Claim bằng
FOR UPDATE SKIP LOCKEDtheo lô nhỏ, publish có timeout. - Mark sent chỉ sau broker ack/confirm.
- Thứ tự theo aggregate: sharding relay hoặc version ở consumer.
- Consumer idempotent.
- Dọn dẹp: xóa lô hoặc drop partition; giám sát tuổi pending, retry, độ trễ.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”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?