Saga, Outbox, eventual consistencySagas, outbox, eventual consistency
Order Service vừa giữ đơn vừa là orchestrator của saga. Mọi lệnh đi qua bảng outbox, Debezium đọc WAL và đẩy sang Kafka. Payment, Inventory, Shipping xử lý lệnh trong transaction cục bộ, loại trùng bằng inbox và trả kết quả về. Bật các kịch bản lỗi để xem bù trừ và outbox giữ sự kiện khi relay ngừng.The order service owns orders and acts as the saga orchestrator. Every command goes through the outbox table; Debezium reads the WAL and publishes to Kafka. Payment, Inventory and Shipping handle commands in local transactions, deduplicate with an inbox and reply. Turn on the failure scenarios to see compensations and the outbox holding events while the relay is down.
1Khách đặt đơn 5001. Order Service nhận yêu cầu kèm Idempotency-Key.A customer places order 5001. The order service receives it with an Idempotency-Key.
2PC giữ khoá ở mọi bên cho tới khi coordinator quyết định; coordinator chết thì các bên bị treo. Kafka, nhiều DB managed và API bên ngoài không tham gia XA. Thay bằng chuỗi transaction cục bộ cộng bù trừ.2PC holds locks on every participant until the coordinator decides; if the coordinator dies, participants hang. Kafka, many managed databases and external APIs cannot join XA. Use a chain of local transactions plus compensations instead.
Ghi trạng thái và dòng outbox trong cùng một transaction; relay đọc outbox bằng polling (FOR UPDATE SKIP LOCKED) hoặc CDC qua Debezium rồi đẩy lên Kafka. Relay giao at-least-once nên consumer phải loại trùng.Write the state change and the outbox row in one transaction; a relay reads the outbox by polling (FOR UPDATE SKIP LOCKED) or via CDC with Debezium and publishes to Kafka. The relay is at-least-once, so consumers must deduplicate.
Mỗi service có bảng inbox(message_id) ghi cùng transaction nghiệp vụ. Bù trừ cũng phải idempotent và giao hoán được: hoàn tiền dùng key cố định theo saga, nhả hàng khi chưa giữ thì coi như thành công.Each service keeps an inbox(message_id) table written in the business transaction. Compensations must be idempotent and tolerate reordering too: refunds use a fixed key per saga, and releasing stock that was never reserved counts as success.
Service không trả lời thì saga đứng yên mãi. Lưu deadline_at cho từng bước; job quét saga quá hạn để gửi lại lệnh (có backoff) hoặc chuyển sang bù trừ, quá ngưỡng thì báo người xử lý.If a service never replies, the saga waits forever. Store deadline_at per step; a sweeper resends the command with backoff or starts compensation, and pages a person past a threshold.
Saga không có isolation: service khác có thể thấy đơn đã trừ tiền nhưng chưa giữ hàng. Dùng semantic lock: bản ghi ở trạng thái PENDING thì không cho thao tác xung đột như huỷ hay sửa đơn.Sagas have no isolation: other services may see an order that is charged but not yet reserved. Use a semantic lock: while a record is PENDING, conflicting actions such as cancel or edit are refused.
API trả 202 kèm id đơn; giao diện hiển thị trạng thái đang xử lý và hỏi lại hoặc nhận push. Đọc trạng thái từ Order DB chính (read-your-writes), không từ bản đọc đang trễ.The API returns 202 with the order id; the UI shows a processing state and polls or receives a push. Read the status from the primary order DB (read-your-writes), not a lagging replica.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
saga_instance | id, type, order_id, step, status, payment_id, deadline_at, version, updated_at | STARTED → COMPLETED · COMPENSATING → COMPENSATED · FAILED; version cho optimistic lockSTARTED → COMPLETED · COMPENSATING → COMPENSATED · FAILED; version for optimistic locking |
outbox | id, aggregate_id, topic, type, payload, created_at, published_at | Ghi cùng transaction nghiệp vụ; key Kafka = aggregate_idWritten in the business transaction; Kafka key = aggregate_id |
inbox | message_id, consumer, received_at | PK(message_id, consumer) ở mỗi service để loại trùngPK(message_id, consumer) in every service for deduplication |
orders | id, customer_id, total, status, cancel_reason, idempotency_key, created_at | PENDING là semantic lock; PENDING → CONFIRMED · CANCELLEDPENDING is the semantic lock; PENDING → CONFIRMED · CANCELLED |
Java: orchestrator nhận phản hồi, loại trùng bằng inbox, chuyển bước và ghi lệnh kế tiếp vào outbox. Go: relay polling đẩy outbox lên Kafka.Java: the orchestrator handles replies, deduplicates with the inbox, advances the step and writes the next command to the outbox. Go: a polling relay publishes the outbox to Kafka.
@Service
@RequiredArgsConstructor
public class OrderSagaOrchestrator {
private final SagaRepository sagas;
private final OrderRepository orders;
private final OutboxRepository outbox;
private final InboxRepository inbox;
@KafkaListener(topics = "saga.replies", groupId = "order-saga")
@Transactional
public void onReply(SagaReply reply) {
if (!inbox.tryInsert(reply.messageId())) return; // already handled
Saga saga = sagas.lockById(reply.sagaId()); // SELECT … FOR UPDATE
switch (saga.step()) {
case PAYMENT -> {
if (reply.ok()) send(saga, Step.INVENTORY, new ReserveStock(saga.orderId()));
else finish(saga, SagaStatus.FAILED);
}
case INVENTORY -> {
if (reply.ok()) send(saga, Step.SHIPPING, new CreateShipment(saga.orderId()));
else send(saga, Step.COMPENSATING, new RefundPayment(saga.id(), saga.paymentId()));
}
case SHIPPING -> { // retriable: replies only on success
saga.complete();
orders.confirm(saga.orderId());
}
case COMPENSATING -> finish(saga, SagaStatus.COMPENSATED);
}
}
private void send(Saga saga, Step step, Command cmd) {
saga.moveTo(step, Instant.now().plus(Duration.ofMinutes(15)));
outbox.save(OutboxEvent.of("saga.commands", saga.id(), cmd));
}
private void finish(Saga saga, SagaStatus status) {
saga.end(status);
orders.cancel(saga.orderId());
}
}// Relay polls the outbox. Debezium (CDC) can replace this loop.
type Relay struct {
db *sql.DB
producer Producer
}
func (r *Relay) Run(ctx context.Context) {
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := r.publishBatch(ctx); err != nil {
slog.Warn("relay batch failed, retrying", "err", err)
}
}
}
}
func (r *Relay) publishBatch(ctx context.Context) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, `SELECT id, topic, aggregate_id, payload FROM outbox
WHERE published_at IS NULL ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED`)
if err != nil {
return err
}
var batch []OutboxRow
for rows.Next() {
var e OutboxRow
if err := rows.Scan(&e.ID, &e.Topic, &e.AggregateID, &e.Payload); err != nil {
rows.Close()
return err
}
batch = append(batch, e)
}
rows.Close()
ids := make([]int64, 0, len(batch))
for _, e := range batch {
if err := r.producer.Send(ctx, e.Topic, e.AggregateID, e.Payload); err != nil {
return err // rollback: rows stay unpublished and are retried
}
ids = append(ids, e.ID)
}
if _, err := tx.ExecContext(ctx,
`UPDATE outbox SET published_at = now() WHERE id = ANY($1)`, pq.Array(ids)); err != nil {
return err
}
return tx.Commit() // a crash before this line re-sends the batch
}| OrchestrationOrchestration | ChoreographyChoreography | |
|---|---|---|
| Luồng nằm ở đâuWhere the flow lives | Một orchestrator giữ state machine và saga_instanceOne orchestrator holds the state machine and saga_instance | Rải trong các service, mỗi service phản ứng với sự kiệnSpread across services, each reacting to events |
| Theo dõi, debugTracing, debugging | Dễ: một bảng cho biết saga đang ở bước nàoEasy: one table shows each saga's step | Khó: phải ghép sự kiện từ nhiều serviceHard: events must be stitched from many services |
| Phụ thuộcCoupling | Orchestrator biết mọi serviceThe orchestrator knows every service | Service độc lập hơn, nhưng dễ có vòng phụ thuộc sự kiệnServices are more independent but cyclic event dependencies creep in |
| Chọn khiPick it when | Từ 4 bước trở lên, có bù trừ phức tạpFour or more steps with non-trivial compensation | 2–3 bước, ít nhánh lỗiTwo or three steps with few failure branches |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
2PC là giao thức chặn: các bên giữ khoá chờ coordinator, coordinator lỗi giữa phase prepare và commit thì dữ liệu bị treo. Độ sẵn sàng của cả hệ bằng tích độ sẵn sàng từng bên. Kafka và API ngoài như cổng thanh toán không tham gia XA. Saga đổi tính nguyên tử lấy tính sẵn sàng và bù trừ.2PC is blocking: participants hold locks waiting for the coordinator, and a coordinator failure between prepare and commit leaves data stuck. System availability becomes the product of every participant's. Kafka and external APIs such as payment gateways cannot join XA. A saga trades atomicity for availability plus compensation.
Rollback xoá như chưa từng xảy ra; bù trừ là một transaction mới về mặt nghiệp vụ, ví dụ hoàn tiền chứ không xoá giao dịch trừ tiền. Người khác có thể đã thấy trạng thái trung gian, nên bù trừ phải idempotent và để lại dấu vết.A rollback erases as if nothing happened; a compensation is a new business transaction, such as a refund rather than deleting the charge. Others may already have seen the intermediate state, so compensations must be idempotent and leave an audit trail.
Polling đơn giản, chạy với mọi DB nhưng thêm tải truy vấn và độ trễ theo chu kỳ quét. CDC đọc WAL nên trễ thấp, không truy vấn bảng, giữ thứ tự commit; đổi lại phải vận hành Kafka Connect và để ý replication slot giữ WAL khi connector ngừng.Polling is simple and works with any DB but adds query load and latency tied to the poll interval. CDC reads the WAL, so latency is low, the table is not queried and commit order is kept; in exchange you run Kafka Connect and watch the replication slot retaining WAL while the connector is down.
Màn hình đọc từ read model hoặc replica chưa cập nhật. Sau khi ghi, đọc trạng thái từ DB chính của Order Service, hoặc trả về version và để read model chờ tới version đó; giao diện hiển thị đơn ở trạng thái đang xử lý.The page reads from a read model or replica that has not caught up. After a write, read the status from the order service's primary DB, or return a version and have the read model wait for it; the UI shows the order as processing.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.