Lập trình · 19/09/2026

Transactional Outbox thực chiến: Phát sự kiện đáng tin cậy sau transaction

Một request tạo đơn hàng thường phải làm hai việc: lưu đơn vào database và phát sự kiện OrderCreated sang message broker. Nếu commit database trước rồi publish thất bại, hệ thống có đơn hàng nhưng kho hoặc email không biết. Nếu publish trước rồi transaction rollback, consumer lại xử lý một đơn không tồn tại. Đây là bài toán dual write.

Transactional Outbox thực chiến: Phát sự kiện đáng tin cậy sau transaction

Một request tạo đơn hàng thường phải làm hai việc: lưu đơn vào database và phát sự kiện OrderCreated sang message broker. Nếu commit database trước rồi publish thất bại, hệ thống có đơn hàng nhưng kho hoặc email không biết. Nếu publish trước rồi transaction rollback, consumer lại xử lý một đơn không tồn tại. Đây là bài toán dual write.

Transactional Outbox giải quyết bằng cách ghi dữ liệu nghiệp vụ và event vào cùng một database transaction. Một relay độc lập chỉ đọc event đã commit rồi chuyển chúng sang Kafka, RabbitMQ, SQS hoặc broker khác.

Vì sao “commit rồi publish” không đủ?

$order = $orders->create($input);
$database->commit();

$broker->publish('OrderCreated', $order);

Process có thể crash sau commit nhưng trước publish. Retry HTTP request cũng không chắc sửa được vì client có thể đã nhận timeout, hoặc idempotency của API chưa đúng. Đảo thứ tự publish trước commit tạo lỗi ngược lại.

Hai hệ thống độc lập không thể được bọc bằng transaction database thông thường. Distributed transaction/2PC có chi phí và mức hỗ trợ khác nhau; với nhiều kiến trúc web, outbox là lựa chọn thực dụng hơn.

Luồng hoạt động của Transactional Outbox

  1. Ứng dụng bắt đầu database transaction.
  2. Ghi thay đổi nghiệp vụ, ví dụ tạo order.
  3. Insert một event vào bảng outbox trong cùng transaction.
  4. Commit. Hoặc cả hai cùng tồn tại, hoặc cả hai cùng rollback.
  5. Relay đọc event chưa phát và publish tới broker.
  6. Relay đánh dấu event đã phát hoặc CDC ghi nhận vị trí đã đọc.
  7. Consumer xử lý event theo cơ chế idempotent.
Outbox bảo đảm event không bị tách khỏi transaction nguồn; nó không tự biến toàn bộ pipeline thành exactly-once.

1. Thiết kế bảng outbox

CREATE TABLE outbox_events (
    id uuid PRIMARY KEY,
    aggregate_type varchar(100) NOT NULL,
    aggregate_id varchar(100) NOT NULL,
    event_type varchar(150) NOT NULL,
    event_version integer NOT NULL DEFAULT 1,
    payload jsonb NOT NULL,
    occurred_at timestamptz NOT NULL DEFAULT now(),
    available_at timestamptz NOT NULL DEFAULT now(),
    published_at timestamptz,
    attempts integer NOT NULL DEFAULT 0,
    last_error text
);

CREATE INDEX idx_outbox_pending
ON outbox_events (available_at, occurred_at)
WHERE published_at IS NULL;

id là định danh event duy nhất dùng để deduplicate. aggregate_id thường làm message key để các event của cùng order/customer vào cùng partition. event_version là phiên bản schema payload, không phải phiên bản application.

Payload nên chứa dữ liệu consumer cần tại thời điểm sự kiện xảy ra, nhưng tránh copy secret hoặc toàn bộ record không cần thiết. Event là hợp đồng bất biến; đừng sửa payload cũ sau khi đã insert.

2. Ghi business data và event trong cùng transaction

BEGIN;

INSERT INTO orders (id, customer_id, total, status)
VALUES (:order_id, :customer_id, :total, 'pending');

INSERT INTO outbox_events (
    id, aggregate_type, aggregate_id,
    event_type, event_version, payload
) VALUES (
    :event_id, 'order', :order_id,
    'OrderCreated', 1, :payload::jsonb
);

COMMIT;

Không publish broker bên trong transaction. Network call kéo dài thời gian giữ lock và broker vẫn không tham gia atomic commit của database. Transaction chỉ làm công việc local, ngắn và có thể rollback.

Tạo event ID từ ứng dụng trước khi insert. Payload nên được validate bằng schema/test trước khi commit để tránh event lỗi nằm trong outbox và chặn relay.

3. Relay bằng polling

Polling publisher dễ triển khai khi lưu lượng vừa phải và chưa có hạ tầng CDC. Nhiều worker có thể lấy batch bằng FOR UPDATE SKIP LOCKED:

BEGIN;

SELECT id, aggregate_type, aggregate_id,
       event_type, event_version, payload
FROM outbox_events
WHERE published_at IS NULL
  AND available_at <= now()
ORDER BY occurred_at, id
FOR UPDATE SKIP LOCKED
LIMIT 100;

Sau khi chọn, có hai chiến lược. Giữ transaction trong lúc publish giúp coordination đơn giản nhưng lock lâu và kết nối database bị chiếm. Claim batch bằng trạng thái/lease rồi commit trước khi publish mở rộng tốt hơn, nhưng phải phục hồi event khi worker chết giữa chừng.

SKIP LOCKED phù hợp cho bảng kiểu queue; nó tạo góc nhìn không nhất quán nên không dùng cho truy vấn nghiệp vụ tổng quát.

4. Cửa sổ gửi trùng là không thể tránh hoàn toàn

Relay publish thành công rồi crash trước khi ghi published_at. Lần chạy sau nó sẽ publish lại event. Nếu đánh dấu trước khi publish, crash ở giữa lại làm mất event. Vì vậy polling outbox thường mang ngữ nghĩa at-least-once.

Broker có tính năng dedup hoặc producer idempotent vẫn không loại bỏ mọi duplicate xuyên suốt nhiều hệ thống. Event ID phải đi trong message header hoặc envelope, và consumer phải xử lý lặp an toàn.

5. Consumer idempotent với inbox

CREATE TABLE consumed_events (
    consumer_name varchar(100) NOT NULL,
    event_id uuid NOT NULL,
    consumed_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (consumer_name, event_id)
);

Consumer mở transaction, insert khóa dedup rồi cập nhật business data. Nếu unique conflict, event đã được xử lý và có thể ack mà không thực hiện side effect lần nữa:

BEGIN;

INSERT INTO consumed_events (consumer_name, event_id)
VALUES ('inventory-service', :event_id)
ON CONFLICT DO NOTHING;

-- Chỉ tiếp tục nếu insert thực sự tạo một hàng
UPDATE inventory
SET reserved = reserved + :quantity
WHERE product_id = :product_id;

COMMIT;

Đối với email, thanh toán hoặc API bên thứ ba, database transaction không bao phủ side effect. Dùng idempotency key phía nhà cung cấp, một outbox tiếp theo, hoặc lưu trạng thái state machine để retry không gửi/charge hai lần.

6. Retry, backoff và dead-letter

Lỗi mạng tạm thời cần retry với exponential backoff và jitter. Lỗi payload cố định không nên retry vô hạn với tốc độ cao. Sau số lần nhất định, chuyển event sang trạng thái failed/dead-letter nhưng giữ dữ liệu để điều tra.

UPDATE outbox_events
SET attempts = attempts + 1,
    available_at = now() + (:delay_seconds * interval '1 second'),
    last_error = :safe_error
WHERE id = :event_id;

Không lưu credential hoặc toàn bộ response nhạy cảm vào last_error. Dashboard cần hiển thị tuổi event cũ nhất, pending count, publish latency, retry rate và dead-letter count.

7. Thứ tự event và aggregate version

Thứ tự toàn cục rất đắt và hiếm khi cần. Thông thường chỉ cần giữ thứ tự theo aggregate, chẳng hạn các event của cùng order_id. Dùng aggregate_id làm partition key và thêm sequence/version tăng dần trên aggregate.

Consumer không nên phụ thuộc tuyệt đối vào thời gian tạo từ nhiều máy. Nó có thể lưu version cuối đã áp dụng, bỏ event cũ và tạm giữ event đến sớm hơn version còn thiếu. Cần định nghĩa rõ hành vi khi event đến sai thứ tự hoặc mất quá lâu.

8. Schema event phải tiến hóa được

Consumer có thể triển khai chậm hơn producer nhiều ngày. Không đổi nghĩa field hiện có hoặc xóa field đột ngột. Ưu tiên thay đổi additive: thêm field optional, giữ default hợp lý và version hóa event khi có breaking change.

{
  "event_id": "...",
  "event_type": "OrderCreated",
  "event_version": 1,
  "occurred_at": "2026-09-19T14:00:00+07:00",
  "aggregate_id": "order-123",
  "data": {
    "customer_id": "customer-9",
    "total": 1250000,
    "currency": "VND"
  }
}

Dùng contract test hoặc schema registry khi số producer/consumer tăng. Event name nên mô tả sự kiện đã xảy ra, không phải mệnh lệnh mơ hồ.

9. Polling hay CDC?

Polling đơn giản, dễ debug và phù hợp hệ thống nhỏ/vừa. Đổi lại, bạn phải quản lý worker, lease, retry, cleanup và polling interval.

CDC dùng log thay đổi database, ví dụ Debezium đọc outbox rồi Outbox Event Router chuyển event tới Kafka. Ứng dụng chỉ insert event; connector phụ trách phát. Cách này có throughput và latency tốt nhưng thêm Kafka Connect, offset, replication slot và yêu cầu vận hành phức tạp.

CDC không loại bỏ idempotency ở consumer. Connector hoặc broker vẫn có thể redeliver trong một số failure mode. Chọn theo quy mô và năng lực vận hành, không chỉ theo mức độ “hiện đại”.

10. Dọn bảng outbox

Bảng tăng liên tục sẽ làm index và backup phình to. Không xóa event ngay sau publish nếu còn cần audit hoặc replay, nhưng phải có retention rõ ràng. Với PostgreSQL, xóa theo batch nhỏ và theo dõi vacuum; quy mô lớn có thể partition theo thời gian để drop partition cũ.

DELETE FROM outbox_events
WHERE id IN (
    SELECT id
    FROM outbox_events
    WHERE published_at < now() - interval '14 days'
    ORDER BY published_at
    LIMIT 5000
);

Replay không nên là cập nhật published_at = NULL hàng loạt mà không kiểm soát. Tạo công cụ replay có filter, audit, rate limit và xác nhận tác động duplicate ở consumer.

11. Observability và cảnh báo

  • Tuổi của event pending cũ nhất.
  • Số event pending, retry và dead-letter theo loại.
  • Độ trễ từ occurred_at tới broker acknowledgement.
  • Publish throughput và error rate.
  • Consumer lag, duplicate count và processing failures.
  • Dung lượng bảng/index, vacuum và replication lag.

Cảnh báo theo độ trễ kinh doanh, không chỉ theo việc worker còn chạy. Một worker “healthy” nhưng event cũ nhất đã chờ 30 phút vẫn là sự cố.

12. Những lỗi thiết kế thường gặp

  • Insert outbox sau khi transaction nghiệp vụ đã commit.
  • Gọi broker trong database transaction rồi tưởng đã atomic.
  • Không có event ID duy nhất và consumer dedup.
  • Cho rằng broker bảo đảm exactly-once cho cả side effect bên ngoài.
  • Dùng timestamp làm thứ tự duy nhất giữa nhiều aggregate.
  • Payload chứa model nội bộ khổng lồ, secret hoặc dữ liệu dễ thay đổi.
  • Không có retention, dead-letter, replay tool và metric tuổi event.

Checklist production

  1. Business row và outbox row được ghi trong đúng một local transaction.
  2. Mỗi event có ID, type, version, aggregate ID và timestamp.
  3. Relay retry có backoff, lease/lock an toàn và dead-letter.
  4. Consumer deduplicate trong cùng transaction với business update.
  5. Side effect ngoài database dùng idempotency key hoặc state machine.
  6. Ordering được định nghĩa theo aggregate/partition, không hứa quá mức.
  7. Schema event có chiến lược tương thích ngược.
  8. Có dashboard, retention, replay có kiểm soát và runbook sự cố.

Kết luận

Transactional Outbox biến dual write không an toàn thành hai bước có thể phục hồi: commit dữ liệu cùng ý định phát event, rồi relay event đã commit đến broker. Pattern này chấp nhận thực tế distributed system có retry và duplicate; độ tin cậy đến từ event ID, consumer idempotent, ordering có phạm vi, schema ổn định và khả năng quan sát đầy đủ.

Tài liệu tham khảo

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.