---
id: case-outbox
title: "Database và event: Transactional Outbox"
shortTitle: "Transactional Outbox"
track: cases
trackTitle: "Tình huống vận hành"
order: 13
level: core
status: published
estimatedMinutes: 40
prerequisites:
  - case-kafka
next: case-serverless-events
reviewedAgainst: 2026-10-12
sources:
  - title: "Transactional outbox pattern"
    url: https://microservices.io/patterns/data/transactional-outbox.html
  - title: "AWS Prescriptive Guidance — Transactional outbox"
    url: https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html
---

# Database và event: Transactional Outbox

Service lưu order vào database rồi publish event `OrderCreated`. Đây là hai thao tác trên hai hệ khác nhau. Khi không có transaction chung, một thao tác có thể thành công trong khi thao tác kia thất bại.

## Để làm gì

Bạn cần đảm bảo event không bị mất khi broker tạm chết, và không có event nào được publish cho một transaction database đã rollback.

## Gồm những gì

- Lỗi dual write.
- Bảng outbox nằm trong cùng transaction với dữ liệu nghiệp vụ.
- Relay publish event, retry, và consumer idempotent.

Bài không dùng distributed two-phase commit. Với luồng này, phần lớn broker và database trên cloud không nên bị buộc vào 2PC.

## Dùng khi nào

Order được lưu vào PostgreSQL, rồi cần đẩy qua Kafka hoặc RabbitMQ để inventory, email và analytics xử lý tiếp. Pattern này cũng áp dụng khi một serverless function vừa ghi database vừa phát event.

## Kiến thức cốt lõi

Hai cách sắp xếp ngây thơ đều có lỗi:

1. Commit database rồi mới publish. Nếu broker chết ngay sau khi commit, order tồn tại nhưng không có event nào.
2. Publish trước rồi mới commit database. Nếu database rollback, consumer sẽ nhận event của một order không hề tồn tại.

Outbox giải quyết bằng cách ghi order và một row event trong cùng một transaction database. Hoặc cả hai cùng commit, hoặc cả hai cùng rollback. Sau đó một relay đọc những row chưa publish, gửi chúng tới broker, rồi đánh dấu là đã gửi.

Relay có thể crash sau khi publish nhưng trước khi kịp đánh dấu, và khi chạy lại nó sẽ publish lần nữa. Vì vậy outbox không loại bỏ duplicate; nó chỉ loại bỏ việc mất event do dual write. Consumer vẫn phải idempotent.

Mỗi row cần có `event_id`, type, aggregate id, payload và thời điểm tạo. Payload nên kèm schema version. Đừng serialize nguyên ORM object, vì lần đổi model tiếp theo sẽ khiến consumer cũ không đọc được.

Khi nghi outbox có vấn đề, hãy xem tuổi của row cũ nhất chưa publish trước, rồi mới xem tình trạng broker. Nếu row chưa gửi cứ tăng trong khi broker vẫn khỏe, thì relay đã chết hoặc query của relay đang bị kẹt. Hãy alert theo số row chưa gửi và tuổi của row cũ nhất. Chỉ alert theo tổng số row là không đủ, vì row đã gửi có thể vẫn được giữ lại một thời gian.

## Lệnh và code mẫu

Schema rút gọn:

```sql
CREATE TABLE outbox (
  event_id uuid PRIMARY KEY,
  event_type text NOT NULL,
  aggregate_id text NOT NULL,
  payload jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  published_at timestamptz
);
```

Ghi trong cùng transaction:

```sql
BEGIN;
INSERT INTO orders(id, status) VALUES ('o-1', 'created');
INSERT INTO outbox(event_id, event_type, aggregate_id, payload)
VALUES (
  gen_random_uuid(),
  'OrderCreated.v1',
  'o-1',
  '{"orderId":"o-1"}'
);
COMMIT;
```

Relay lấy từng batch với cơ chế lock phù hợp với database, publish từng event, rồi cập nhật `published_at`. Nếu chạy nhiều relay, bạn cần `FOR UPDATE SKIP LOCKED` hoặc cơ chế lease để các relay không cùng lấy một row.

## Thực hành

1. Mô phỏng broker chết ngay sau khi order commit theo cách cũ. Ghi lại thứ bị mất.
2. Với outbox, cho broker chết: row vẫn chưa có `published_at`, và relay thử lại sau.
3. Mô phỏng relay publish xong rồi crash: duplicate xảy ra, và bảng inbox của consumer chặn lại theo `event_id`.
4. Đặt alert khi row cũ nhất chưa publish quá 2 phút, không chỉ theo tổng số row.

## Kết quả mong đợi

Không có order nào đã commit mà event của nó biến mất vĩnh viễn. Duplicate được tính trước và xử lý bằng idempotency.

## Checklist

- [ ] Dữ liệu nghiệp vụ và outbox nằm trong cùng transaction.
- [ ] Event có ID và schema version.
- [ ] Relay retry có backoff, không chạy vòng lặp nóng.
- [ ] Consumer idempotent.
- [ ] Có alert theo tuổi của event chưa publish.

## Lỗi thường gặp

**Đánh dấu published trước khi broker confirm.** Nếu broker fail sau lệnh update đó, event bị mất. Chỉ đánh dấu sau khi broker đã confirm.

**Xóa row ngay khi publish xong.** Lúc điều tra incident sẽ không còn dấu vết. Hãy giữ row theo retention, rồi cleanup theo batch.

**Payload phụ thuộc vào trạng thái hiện tại của bảng.** Nếu outbox chỉ lưu ID, đến lúc publish relay mới query order, mà order đã bị đổi thì event không còn phản ánh đúng thời điểm transaction. Hãy lưu đủ payload cần thiết ngay trong outbox.

## Nguồn

1. [Transactional outbox](https://microservices.io/patterns/data/transactional-outbox.html) — dual write và relay.
2. [AWS Prescriptive Guidance](https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html) — implementation và duplicate.

## Tiếp theo

[Serverless xử lý event an toàn](./14-serverless-events.md)
