M5 · Saga + Outbox + Inbox

5 câu hỏi~4–5 ngày codeTừ khóa: saga, choreography, orchestration, compensation, transactional outbox, relay, inbox, dedupe, Kafka, at-least-once, idempotent consumer

M4 xong, gọi đồng bộ đã an toàn. Giờ làm M5: chuyển luồng giữ ghế → thanh toán → xác nhận sang Kafka, kill service giữa chừng cũng không sai dữ liệu.

Đây là milestone khó nhất của trục lõi. Mục tiêu một câu: mỗi hiệu ứng xảy ra đúng một lần, dù message có thể tới nhiều lần hoặc trễ.

1. Bối cảnh & bài toán

Booking và payment có DB riêng, không có transaction chung. Các lỗi xảy ra nếu làm ngây thơ:

2. Ôn lại trước khi code

3. Đề bài

Luồng saga (choreography, booking-service giữ state machine)

booking: POST /api/bookings/{id}/confirm
  tx{ booking HELD → PAYMENT_REQUESTED ; outbox += BookingPaymentRequested }
        │ relay
        ▼ booking.events
payment: inbox dedupe → charge PSP
  tx{ payments += row ; inbox += eventId ; outbox += PaymentSucceeded | PaymentFailed }
        │ relay
        ▼ payment.events
booking: inbox dedupe
  PaymentSucceeded → tx{ booking CONFIRMED ; seats SOLD   ; outbox += BookingConfirmed }
  PaymentFailed    → tx{ booking CANCELLED ; seats AVAILABLE (compensation) ; outbox += BookingCancelled }

State machine booking: HELD → PAYMENT_REQUESTED → CONFIRMED | CANCELLED, thêm EXPIRED (từ M2). Chuyển trạng thái không hợp lệ thì bỏ qua và log, không ném lỗi làm kẹt consumer.

API

MethodPathBodyResponse
POST/api/bookings/{id}/confirm{"paymentMethod":"CARD"}, header Idempotency-Key202 {"status":"PAYMENT_REQUESTED"} (bất đồng bộ)
GET/api/bookings/{id}–200 {status, seats, paymentId?}
GET/admin/outbox?status=PENDING–số event chưa relay (debug)

Schema (mỗi service có outbox và inbox riêng trong DB của mình):

CREATE TABLE outbox (
  id            UUID PRIMARY KEY,
  aggregate_type TEXT NOT NULL,          -- 'booking' | 'payment'
  aggregate_id  UUID NOT NULL,           -- dùng làm Kafka key
  event_type    TEXT NOT NULL,
  payload       JSONB NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at  TIMESTAMPTZ,             -- NULL = chưa relay
  attempts      INT NOT NULL DEFAULT 0
);
CREATE INDEX outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;

CREATE TABLE inbox (
  event_id      UUID NOT NULL,
  consumer      TEXT NOT NULL,           -- tên handler
  processed_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (event_id, consumer)
);

Event envelope (đặt trong common/):

{
  "eventId": "6f1c…",            // UUID, = outbox.id
  "eventType": "PaymentFailed",
  "aggregateId": "booking-uuid",
  "occurredAt": "2026-10-14T10:00:00Z",
  "version": 1,
  "payload": { "bookingId": "…", "paymentId": "…", "reason": "CARD_DECLINED" }
}

Topic: booking.events, payment.events, key = bookingId.

Cấu hình cần có

spring.kafka:
  bootstrap-servers: ${KAFKA_BOOTSTRAP}
  producer: { acks: all, properties: { enable.idempotence: true } }
  consumer: { enable-auto-commit: false, isolation-level: read_committed }
  listener: { ack-mode: RECORD }
outbox.relay:
  poll-interval: ?      # ms
  batch-size: ?

Skeleton

public interface OutboxWriter {
    /** Phải được gọi TRONG transaction nghiệp vụ hiện tại (Propagation.MANDATORY). */
    void append(String aggregateType, UUID aggregateId, String eventType, Object payload);
}

@Component
class OutboxRelay {
    // @Scheduled: SELECT ... WHERE published_at IS NULL ORDER BY created_at
    //             LIMIT :batch FOR UPDATE SKIP LOCKED
    // gửi Kafka, chờ ack, rồi set published_at
    void relayBatch() { /* TODO */ }
}

public interface InboxGuard {
    /** true nếu lần đầu thấy (eventId, consumer); insert trong cùng transaction với hiệu ứng. */
    boolean firstTime(UUID eventId, String consumer);
}

@Component
class PaymentEventsHandler {
    // @KafkaListener(topics = "payment.events")
    @Transactional
    void on(EventEnvelope e) { /* TODO: inbox → state transition → outbox */ }
}

4. Acceptance criteria

  1. Không có lời gọi kafkaTemplate.send() trực tiếp trong code nghiệp vụ; mọi event đi qua outbox.
  2. Kill booking-service ngay sau khi commit confirm (trước relay), bật lại: event vẫn được gửi, booking đi tới trạng thái cuối trong ≤ 30s.
  3. Gửi lại cùng một event 5 lần vào payment.events: đúng 1 lần đổi trạng thái, 1 dòng payments, 1 event BookingConfirmed.
  4. Payment fail → booking CANCELLED, mọi ghế của booking về AVAILABLE, held_by NULL.
  5. Chạy 500 saga đồng thời, 20% payment fail, kill ngẫu nhiên booking/payment 3 lần: cuối cùng (sau ≤ 2 phút) số ghế SOLD = số booking CONFIRMED × số ghế/booking, không ghế nào kẹt PAYMENT_REQUESTED/HELD quá hạn.
  6. Hai relay instance chạy song song không gửi trùng (SKIP LOCKED) — hoặc gửi trùng nhưng consumer vẫn đúng nhờ inbox; ghi rõ bạn dựa vào cái nào.
  7. Metric: outbox_pending, outbox_relay_lag_seconds, inbox_duplicates_total có trên Prometheus.
  8. Saga không bao giờ để một booking ở trạng thái trung gian vĩnh viễn: có timeout saga đưa PAYMENT_REQUESTED quá hạn vào đối soát.

5. Test cases

IDKịch bảnInput / điều kiệnKỳ vọng
T5.1Happy pathPSP OKCONFIRMED, seats SOLD, 3 event theo thứ tự
T5.2Payment failPSP trả DECLINEDCANCELLED, seats AVAILABLE
T5.3Rollback không lọt eventNém exception sau outbox.appendKhông dòng outbox, không message Kafka
T5.4Event trùngPublish lại cùng eventId 5 lầnInbox chặn 4 lần, hiệu ứng 1 lần
T5.5Kafka down khi confirmStop Kafka, gọi confirm202 vẫn trả, outbox PENDING, Kafka lên lại thì relay gửi
T5.6Event tới sai thứ tựPaymentSucceeded cho booking đã EXPIREDKhông đổi state; phát yêu cầu hoàn tiền / ghi đối soát
T5.7Consumer chết trước commit offsetKill sau khi DB commit, trước ackNhận lại, inbox chặn, không trùng
T5.8Confirm gọi 2 lầnCùng Idempotency-KeyChỉ 1 event BookingPaymentRequested
@Testcontainers
@SpringBootTest
class SagaCompensationIT {
    @Container static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16");
    @Container static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("apache/kafka:3.8.0"));

    @Test
    void paymentFailed_releasesSeats() {
        UUID bookingId = givenHeldBooking(2);              // TODO
        publish("payment.events", paymentFailed(bookingId)); // TODO

        await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> {
            // TODO: booking CANCELLED, 2 ghế AVAILABLE, outbox có BookingCancelled
        });
    }

    @Test
    void duplicateEvent_appliedOnce() {
        // TODO: publish cùng eventId 5 lần, assert inbox 1 dòng, state chuyển 1 lần
    }
}

6. Failure scenarios

Sự cốCách tạoHành vi mong đợi
Booking chết sau commit, trước relayBreakpoint/docker kill booking-service sau confirmKhởi động lại, relay gửi event còn PENDING
Payment chết giữa lúc chargedocker kill payment-service khi PSP latency 3s (Toxiproxy)Offset chưa commit → xử lý lại; Idempotency-Key với PSP chặn trừ tiền 2 lần
Kafka broker downdocker compose stop kafka 60sAPI vẫn nhận confirm; outbox dồn lại; relay đuổi kịp khi Kafka lên
Relay gửi xong nhưng chết trước khi set published_atKill relay ngay sau ack KafkaEvent gửi lại lần 2; inbox ở consumer chặn trùng
Postgres booking chậmToxiproxy latency 2s trước PostgresConsumer chậm lại, lag tăng, không mất event
Poison messagePublish JSON hỏng vào payment.eventsKhông chặn partition: đưa vào payment.events.DLT, có alert

7. Benchmark / số liệu cần thu

8. Checklist hoàn thành

Mình scale booking lên 2 instance, relay ở cả hai cùng đọc outbox và gửi trùng event. Có nên dùng Redis lock cho relay không?

Có thể, nhưng gợi ý thử cách đơn giản hơn trước:

Mình insert inbox trước, xong mới xử lý ở transaction khác cho gọn. Có vấn đề gì không?

Có. Thử tự trả lời câu này: process chết sau khi insert inbox nhưng trước khi đổi trạng thái booking thì sao?

Có case PaymentSucceeded tới sau khi job expire đã nhả ghế và ghế đã bán cho người khác. Giờ sao?

Đây là case thật nhất của bài. Gợi ý hướng, không có đáp án duy nhất:

Mình làm xong rồi, review và phỏng vấn mình đi.

Checklist review code AI sẽ soi

Câu hỏi phỏng vấn

1. Transactional outbox giải quyết vấn đề gì?

🇻🇳 Dual write: không thể commit DB và gửi Kafka nguyên tử. Ghi event vào bảng outbox cùng transaction với dữ liệu, relay đọc rồi gửi sau. Kết quả at-least-once, không bao giờ mất event.

🇬🇧 The dual-write problem: you can't atomically commit the DB and publish to Kafka. I write the event into an outbox table in the same transaction, and a relay publishes it later. That gives at-least-once delivery with no lost events.

2. Tại sao vẫn cần inbox khi đã có outbox?

🇻🇳 Outbox chỉ đảm bảo không mất, vẫn có thể gửi trùng. Inbox lưu eventId đã xử lý trong cùng transaction với hiệu ứng, nên consumer idempotent.

🇬🇧 The outbox guarantees no loss but can still duplicate. The inbox stores processed event IDs in the same transaction as the side effect, so the consumer is idempotent.

3. Choreography hay orchestration, bạn chọn gì?

🇻🇳 Tôi chọn choreography vì chỉ 2 bước và booking đã giữ state machine. Nếu thêm nhiều bước (voucher, loyalty) tôi sẽ chuyển sang orchestrator để luồng nằm một chỗ, dễ theo dõi.

🇬🇧 Choreography, because there are only two steps and booking already owns the state machine. With more steps like vouchers or loyalty I'd move to an orchestrator so the flow lives in one place.

4. Compensation khác rollback thế nào?

🇻🇳 Rollback huỷ thay đổi chưa commit. Compensation là một hành động nghiệp vụ mới đảo ngược hiệu ứng đã commit, như nhả ghế hay hoàn tiền; nó phải idempotent và có thể không hoàn toàn ngược được.

🇬🇧 Rollback undoes uncommitted changes. Compensation is a new business action that reverses a committed effect, like releasing seats or refunding; it must be idempotent and may not fully undo things.

Cách nhớ ngắn gọn

Outbox để không mất, inbox để không trùng, compensation để quay đầu. Ba thứ cùng nằm trong transaction local của từng service.


Tự kiểm tra

Vì sao không gửi Kafka trong @TransactionalEventListener(AFTER_COMMIT) thay cho outbox?

Process có thể chết sau commit nhưng trước khi gửi, event mất. Outbox lưu event bền vững trong DB nên relay gửi lại được.

Inbox phải insert trong transaction nào?

Cùng transaction với hiệu ứng nghiệp vụ và outbox kế tiếp, để hoặc tất cả cùng xảy ra, hoặc không gì xảy ra.

Vì sao key Kafka là bookingId?

Để mọi event của một booking vào cùng partition, giữ thứ tự xử lý.

Payment fail thì nhả ghế bằng câu SQL nào cho an toàn?

Cập nhật về AVAILABLE với điều kiện held_by = :bookingId và trạng thái đang HELD, tránh nhả ghế đã thuộc booking khác.

FOR UPDATE SKIP LOCKED giúp gì cho relay?

Nhiều instance relay lấy các lô dòng khác nhau mà không chờ nhau, giảm gửi trùng và tăng throughput.