M5 · Saga + Outbox + Inbox
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ơ:
- Dual write: booking commit DB rồi
kafkaTemplate.send(), process chết giữa hai bước → event mất, booking treo HELD mãi. - Gửi trước, commit sau: event đã đi nhưng transaction rollback → payment trừ tiền cho booking không tồn tại.
- Xử lý trùng: Kafka at-least-once, consumer chết trước khi commit offset → nhận lại event, trừ tiền 2 lần.
- Thiếu compensation: payment fail nhưng ghế không được nhả → mất doanh thu.
2. Ôn lại trước khi code
- Saga: choreography vs orchestration, compensation, trạng thái trung gian.
- Outbox, Inbox, CDC: ghi event cùng transaction, relay polling vs Debezium, bảng inbox dedupe.
- Order–Inventory–Payment: luồng end-to-end gần giống hệt bài này.
- Kafka delivery semantics: at-least-once, idempotent producer, vì sao EOS của Kafka không bao DB.
- Consumer group & offset: commit offset sau khi xử lý xong.
- Partitioned log: key =
bookingIdđể giữ thứ tự theo booking. - @Transactional và các bẫy: self-invocation, rollback rule.
- Messaging, contract, E2E: test bất đồng bộ với Awaitility.
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
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /api/bookings/{id}/confirm | {"paymentMethod":"CARD"}, header Idempotency-Key | 202 {"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
- Không có lời gọi
kafkaTemplate.send()trực tiếp trong code nghiệp vụ; mọi event đi quaoutbox. - 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.
- 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òngpayments, 1 eventBookingConfirmed. - Payment fail → booking
CANCELLED, mọi ghế của booking vềAVAILABLE,held_byNULL. - 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.
- 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.
- Metric:
outbox_pending,outbox_relay_lag_seconds,inbox_duplicates_totalcó trên Prometheus. - 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
| ID | Kịch bản | Input / điều kiện | Kỳ vọng |
|---|---|---|---|
| T5.1 | Happy path | PSP OK | CONFIRMED, seats SOLD, 3 event theo thứ tự |
| T5.2 | Payment fail | PSP trả DECLINED | CANCELLED, seats AVAILABLE |
| T5.3 | Rollback không lọt event | Ném exception sau outbox.append | Không dòng outbox, không message Kafka |
| T5.4 | Event trùng | Publish lại cùng eventId 5 lần | Inbox chặn 4 lần, hiệu ứng 1 lần |
| T5.5 | Kafka down khi confirm | Stop Kafka, gọi confirm | 202 vẫn trả, outbox PENDING, Kafka lên lại thì relay gửi |
| T5.6 | Event tới sai thứ tự | PaymentSucceeded cho booking đã EXPIRED | Không đổi state; phát yêu cầu hoàn tiền / ghi đối soát |
| T5.7 | Consumer chết trước commit offset | Kill sau khi DB commit, trước ack | Nhận lại, inbox chặn, không trùng |
| T5.8 | Confirm gọi 2 lần | Cùng Idempotency-Key | Chỉ 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ạo | Hành vi mong đợi |
|---|---|---|
| Booking chết sau commit, trước relay | Breakpoint/docker kill booking-service sau confirm | Khởi động lại, relay gửi event còn PENDING |
| Payment chết giữa lúc charge | docker 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 down | docker compose stop kafka 60s | API 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_at | Kill relay ngay sau ack Kafka | Event gửi lại lần 2; inbox ở consumer chặn trùng |
| Postgres booking chậm | Toxiproxy latency 2s trước Postgres | Consumer chậm lại, lag tăng, không mất event |
| Poison message | Publish JSON hỏng vào payment.events | Không chặn partition: đưa vào payment.events.DLT, có alert |
7. Benchmark / số liệu cần thu
- Độ trễ end-to-end confirm → CONFIRMED (p50/p99) với poll interval 100ms, 500ms, 1s.
- Throughput relay (event/s) với batch 10/100/500.
- Kết quả bài chaos (AC 5): số saga, số lần kill, số bất thường = 0.
- Kích thước bảng
outbox/inboxsau 1 giờ và chiến lược dọn (xoá/partition theo ngày). - Lưu tại
docs/benchmarks/m05-saga.md.
8. Checklist hoàn thành
- ☐ Acceptance criteria có test tự động và xanh.
- ☐ Mọi failure scenario đã chạy thật, ghi kết quả.
- ☐ Benchmark trong
docs/benchmarks/. - ☐ ADR: choreography hay orchestration; polling relay hay Debezium.
- ☐ Không hardcode secret, cấu hình qua
${ENV}. - ☐ Sơ đồ state machine booking trong
docs/. - ☐ Job dọn outbox/inbox cũ.
- ☐ Đã qua review và phỏng vấn của AI.
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:
- Xem lại
SELECT … FOR UPDATE SKIP LOCKEDtrong Postgres: mỗi instance lấy một lô khác nhau. Câu hỏi: transaction của bạn có giữ lock tới khi setpublished_atkhông, hay commit ngay sau SELECT? - Dù làm gì, relay vẫn có thể gửi trùng (chết sau ack Kafka, trước update). Vậy đừng cố đạt "không bao giờ trùng" ở relay, mà đảm bảo consumer chịu được trùng.
- Còn thứ tự: hai instance gửi xen kẽ event của cùng booking có thể đảo thứ tự. Nghĩ xem state machine của bạn có chịu được không, hay cần relay theo partition key.
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?
- Lần nhận lại, inbox nói "đã xử lý" → bỏ qua → hiệu ứng mất vĩnh viễn.
- Gợi ý: insert inbox, đổi state, ghi outbox phải nằm trong một transaction. Dùng
INSERT … ON CONFLICT DO NOTHINGvà kiểm số dòng bị ảnh hưởng. - Viết test T5.7 trước rồi mới sửa — test đỏ là bằng chứng tốt nhất.
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:
- Lựa chọn A: khi booking ở PAYMENT_REQUESTED thì job expire không nhả ghế (gia hạn hold) cho tới khi có kết quả hoặc quá timeout saga.
- Lựa chọn B: chấp nhận race, khi PaymentSucceeded tới muộn thì phát
RefundRequested(một bước compensation phía payment). - Cân nhắc: A giữ ghế lâu hơn trong flash sale (M7), B tốn phí hoàn tiền và trải nghiệm xấu. Chọn một, viết ADR, có test cho case muộn.
Mình làm xong rồi, review và phỏng vấn mình đi.
Checklist review code AI sẽ soi
OutboxWriter.appenddùngPropagation.MANDATORY; không cókafkaTemplate.sendtrong service nghiệp vụ.- Relay chỉ set
published_atsau khi Kafka ack (send().get()hoặc callback), có giới hạnattempts. - Inbox + state change + outbox trong cùng transaction; listener ack sau commit.
- State machine chặn chuyển trạng thái sai, không ném exception làm consumer retry vô hạn.
- Compensation nhả ghế dùng điều kiện
held_by = :bookingId(không nhả nhầm ghế người khác). - Key Kafka =
bookingId; có DLT cho poison message. - Có timeout saga và job đối soát; có job dọn outbox/inbox.
- Event có
version, consumer bỏ qua field lạ (tương thích tiến).
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.