Checkout end-to-end: Order – Inventory – Payment
Tiếp đi: ghép lại toàn bộ checkout Order – Inventory – Payment.
Bài này ráp tất cả: timeout, retry, circuit breaker, Saga, Outbox, idempotency. Giống phỏng vấn system design: có yêu cầu → phân tích → thêm từng thành phần khi có lý do.
1. Bài toán
POST /checkout {"cartId":"CART-123"}
Functional: reserve hàng → thanh toán → xác nhận đơn → ship, gửi email
Non-functional: không trừ tiền 2 lần, không oversell,
không giữ hàng mãi, checkout trả lời nhanh
2. Vấn đề
- Thứ tự: bài 14.6 charge trước rồi reserve sau → hết hàng thì phải refund. Refund tốn phí, khách thấy trừ rồi hoàn tiền.
- Payment timeout: gọi bank 30s không trả lời. Không biết đã trừ tiền hay chưa.
- Khách bỏ ngang: hàng bị giữ, người khác không mua được.
- Hai người mua chiếc iPhone cuối: race condition ở inventory.
3. Giải pháp
- Reserve trước, charge sau: giữ hàng (reservation có hạn) rồi mới thu tiền. Fail thì chỉ cần release, không phải refund.
- Orchestrator (checkout-saga trong order-service) giữ saga state, vì flow nhiều bước và nhiều nhánh lỗi.
- Payment có 3 kết quả: SUCCESS, FAILED, UNKNOWN. Timeout = UNKNOWN, không phải FAILED.
- Reconciliation: job hỏi lại payment provider bằng idempotency key để chốt UNKNOWN.
- Expiry worker: reservation và order PENDING quá hạn thì release/cancel.
- Mỗi bước chuyển state ghi kèm Outbox; mỗi consumer có Inbox.
4. Code từng bước
Bước 1: Inventory reserve chống oversell (atomic conditional update)
@Transactional
public ReservationResult reserve(String reservationId, String sku, int qty, Duration ttl) {
if (reservationRepo.existsById(reservationId)) { // idempotent
return ReservationResult.from(reservationRepo.getReferenceById(reservationId));
}
int updated = jdbc.update("""
UPDATE stock SET available = available - ?, reserved = reserved + ?
WHERE sku = ? AND available >= ?
""", qty, qty, sku, qty);
if (updated == 0) return ReservationResult.outOfStock();
reservationRepo.save(new Reservation(reservationId, sku, qty,
ReservationStatus.HELD, Instant.now().plus(ttl)));
outbox.save("INVENTORY_RESERVED", reservationId);
return ReservationResult.held(reservationId);
}
Câu WHERE available >= ? làm phép kiểm tra và trừ trong một lệnh, hai request đồng thời không thể cùng lấy chiếc cuối.
Bước 2: Payment với idempotency key và trạng thái UNKNOWN
public PaymentResult charge(String sagaId, BigDecimal amount) {
String key = sagaId + "-PAYMENT"; // CHECKOUT-123-PAYMENT
Payment p = paymentRepo.findByIdempotencyKey(key)
.orElseGet(() -> paymentRepo.save(Payment.pending(key, amount)));
if (p.isFinal()) return PaymentResult.from(p); // gọi lại → trả kết quả cũ
try {
GatewayResponse r = gateway.charge(key, amount); // timeout 3s + circuit breaker
p.complete(r.success() ? PaymentStatus.SUCCESS : PaymentStatus.FAILED);
} catch (TimeoutException | CallNotPermittedException e) {
p.markUnknown(); // KHÔNG đánh FAILED
}
paymentRepo.save(p); // + outbox PAYMENT_xxx
return PaymentResult.from(p);
}
Bước 3: Orchestrator xử lý theo kết quả
@Transactional
public void onPaymentResult(PaymentResultEvent e) { // từ Kafka, qua inbox
CheckoutSaga saga = sagaRepo.findById(e.sagaId()).orElseThrow();
switch (e.status()) {
case SUCCESS -> {
saga.moveTo(SagaStatus.CONFIRMING);
outbox.save("CONFIRM_RESERVATION", saga.getReservationId());
outbox.save("CONFIRM_ORDER", saga.getOrderId());
}
case FAILED -> {
saga.moveTo(SagaStatus.COMPENSATING);
outbox.save("RELEASE_RESERVATION", saga.getReservationId());
outbox.save("CANCEL_ORDER", saga.getOrderId());
}
case UNKNOWN -> saga.moveTo(SagaStatus.PAYMENT_UNKNOWN); // chờ reconciliation
}
}
Bước 4: Reconciliation và expiry worker
@Scheduled(fixedDelay = 30_000)
public void reconcileUnknownPayments() {
paymentRepo.findByStatusAndUpdatedAtBefore(PaymentStatus.UNKNOWN, Instant.now().minusSeconds(30))
.forEach(p -> {
GatewayStatus s = gateway.queryStatus(p.getIdempotencyKey()); // hỏi lại, không charge lại
switch (s) {
case CAPTURED -> p.complete(PaymentStatus.SUCCESS);
case DECLINED, NOT_FOUND -> p.complete(PaymentStatus.FAILED);
case PROCESSING -> { return; } // để lần sau
}
paymentRepo.save(p); // + outbox PAYMENT_SUCCESS/FAILED → orchestrator đi tiếp
});
}
@Scheduled(fixedDelay = 60_000)
public void expireHolds() {
reservationRepo.findByStatusAndExpiresAtBefore(ReservationStatus.HELD, Instant.now())
.forEach(r -> inventoryService.release(r.getId())); // idempotent, trả hàng về available
}
Quan trọng: reservation chỉ hết hạn khi saga không ở PAYMENT_UNKNOWN. Nếu tiền có thể đã bị trừ, nên gia hạn hold tới khi reconciliation có kết quả; nếu đã release mà tiền về SUCCESS thì phải refund.
Test
curl -X POST localhost:8080/checkout -H 'Content-Type: application/json' \
-H 'Idempotency-Key: CHECKOUT-123' -d '{"cartId":"CART-123"}'
# 202 Accepted {"orderId":"ORDER-123","status":"PENDING"}
curl localhost:8080/orders/ORDER-123
# PENDING → CONFIRMED (hoặc CANCELLED); client poll hoặc nhận push
5. Cách hoạt động (sequence)
Client Order/Saga Inventory Payment Gateway
│ POST /checkout │ │ │ │
│───────────────►│ order PENDING │ │ │
│◄── 202 ────────│ + outbox │ │ │
│ │── RESERVE ───►│ available-1 │ │
│ │ │ HELD (ttl 15m) │ │
│ │◄── RESERVED ──│ │ │
│ │── CHARGE ───────────────────────►│── charge(key) ►│
│ │ │◄── OK / timeout│
│ │◄── SUCCESS / FAILED / UNKNOWN ───│ │
│ │ │ │
│ SUCCESS: │── CONFIRM ───►│ HELD → SOLD │ │
│ │ order CONFIRMED + outbox ORDER_CONFIRMED → shipping, email
│ FAILED: │── RELEASE ───►│ HELD → RELEASED │ │
│ │ order CANCELLED │ │
│ UNKNOWN: │ chờ ... │ hold gia hạn │ reconcile ────►│ queryStatus(key)
│ │◄──────── SUCCESS / FAILED (sau reconciliation) ───│
6. Failure matrix
| Lỗi | Trạng thái lúc đó | Xử lý |
|---|---|---|
| Hết hàng khi reserve | Order PENDING, chưa thu tiền | Cancel order. Không cần refund. |
| Inventory timeout khi reserve | Không rõ đã giữ hàng chưa | Retry cùng reservationId (idempotent); hold thừa sẽ tự hết hạn. |
| Payment bị từ chối (FAILED) | Hàng HELD | Release reservation, cancel order. |
| Payment timeout (UNKNOWN) | Có thể đã trừ tiền | Không retry charge mù, không cancel. Reconciliation bằng idempotency key, gia hạn hold. |
| Gateway down lâu | Circuit OPEN | Fail fast → UNKNOWN/FAILED tùy việc request đã gửi chưa; báo client thử lại sau. |
| SUCCESS nhưng hold đã hết hạn và hàng đã bán cho người khác | Đã thu tiền, không có hàng | Thử reserve lại; không được thì refund + cancel (compensation), xin lỗi khách. |
| Confirm order lỗi sau khi đã thu tiền | Payment SUCCESS, order PENDING | Retry confirm (idempotent); không bao giờ release hàng đã thanh toán. |
| Refund lỗi | Saga COMPENSATING | Retry backoff → COMPENSATION_FAILED → DLQ + alert → xử lý tay. |
| Order-service crash sau commit | Event nằm trong outbox | Relay publish khi restart. |
| Event bị gửi trùng | Consumer nhận 2 lần | Inbox / idempotency key bỏ qua lần 2. |
| Client bấm checkout 2 lần | 2 request cùng cart | Header Idempotency-Key → trả lại saga cũ. |
| Khách bỏ ngang (không trả tiền) | Hàng HELD | Expiry worker release sau TTL, order EXPIRED. |
7. Khi nào nên dùng / không nên dùng
- Nên: đã tách order/inventory/payment thành service riêng với DB riêng, traffic thật sự cần.
- Không nên: internal tool 100 user. Một Spring Boot + một PostgreSQL +
@Transactionallà đủ, và đó mới là câu trả lời engineering. Thêm Saga/Outbox khi có lý do. - Email/notification không nằm trên critical path: đẩy async sau ORDER_CONFIRMED.
Gọi Payment bị timeout thì coi luôn là thất bại, release hàng rồi báo khách thử lại được không? Cho đơn giản.
Không nên. Timeout chỉ nói "mình không biết", không nói "thất bại".
Charge $100 → Bank SUCCESS → response lost → Payment TIMEOUT
→ coi là FAILED → release hàng, cancel order
→ khách thử lại → Charge $100 lần nữa 💀 (bị trừ 2 lần, đơn đầu không có hàng)
- Ghi UNKNOWN, giữ hàng thêm, trả client "đang xử lý".
- Retry charge chỉ an toàn khi gateway hỗ trợ idempotency key: cùng key thì trả kết quả cũ, không trừ lần hai.
- Reconciliation hỏi trạng thái bằng key; cuối ngày còn đối soát file settlement với bank để bắt những ca lọt.
Đây là cùng ý với fallback của Circuit Breaker: fallback thanh toán không được trả FAILED cứng (bài 14.3).
Khách giữ hàng mà không bao giờ thanh toán thì sao? Hold bao lâu là hợp lý?
Bạn hiểu đúng hướng: hold phải có hạn, nếu không chiếc iPhone cuối bị "giữ chỗ" mãi.
Reservation HELD (expires_at = now + 15m)
│
├── payment SUCCESS → SOLD
├── payment FAILED → RELEASED
└── hết hạn, saga không ở PAYMENT_UNKNOWN
↓
expiry worker: RELEASED, available + qty
↓
outbox RESERVATION_EXPIRED → orchestrator: order EXPIRED
- TTL tùy nghiệp vụ: vài phút cho flash sale, 15–30 phút cho checkout thường, dài hơn cho chuyển khoản ngân hàng.
- Release phải idempotent và có điều kiện:
UPDATE ... WHERE status = 'HELD', để không release một reservation vừa chuyển SOLD. - Race "payment SUCCESS đúng lúc hold hết hạn": điều kiện trên giải quyết một bên thắng; nếu release thắng thì nhánh "đã thu tiền nhưng hết hàng" trong failure matrix xử lý.
- Thời gian dùng clock của DB (
now()) cho đồng nhất, đừng dựa vào clock từng instance (bài 14.1).
Phỏng vấn yêu cầu thiết kế checkout cho e-commerce thì trả lời sao?
Q1. Thiết kế luồng checkout qua Order, Inventory, Payment.
🇻🇳 Tiếng Việt:
Em dùng orchestration saga. Order tạo đơn PENDING, inventory giữ hàng có thời hạn bằng atomic conditional update để không oversell, rồi mới thu tiền với idempotency key. Thành công thì confirm reservation và đơn; thất bại thì release hàng và cancel đơn. Mỗi bước chuyển state đi qua outbox, consumer idempotent. Email đi async sau khi đơn được xác nhận.
🇬🇧 English:
I'd use an orchestrated saga. Order creates a PENDING order, inventory places a time-limited hold using an atomic conditional update to avoid overselling, and only then we charge with an idempotency key. On success we confirm the reservation and the order; on failure we release the stock and cancel the order. Each state change is published through an outbox and consumers are idempotent. Notifications are sent asynchronously after confirmation.
Q2. Payment timeout thì xử lý thế nào?
🇻🇳 Tiếng Việt:
Timeout không có nghĩa là thất bại, tiền có thể đã bị trừ. Em ghi trạng thái UNKNOWN, giữ hàng thêm, và chạy reconciliation hỏi provider theo idempotency key. Chỉ retry charge khi provider đảm bảo idempotency. Cuối ngày đối soát với settlement của bank.
🇬🇧 English:
A timeout doesn't mean failure; the money may already be captured. I record the payment as UNKNOWN, keep the stock on hold, and run a reconciliation job that queries the provider by idempotency key. We only retry a charge if the provider guarantees idempotency, and we reconcile against the bank's settlement report daily.
Q3. Vì sao reserve hàng trước rồi mới charge?
🇻🇳 Tiếng Việt:
Vì compensation của reserve chỉ là release, rẻ và nội bộ. Compensation của charge là refund, tốn phí, chậm và khách nhìn thấy. Nên đặt bước dễ fail và dễ undo lên trước, bước khó undo về sau.
🇬🇧 English:
Because compensating a reservation is just a release, which is cheap and internal, while compensating a charge is a refund, which costs money, is slow and is visible to the customer. Put steps that are likely to fail and easy to undo first, and hard-to-undo steps last.
Cách nhớ ngắn gọn
- Reserve (có TTL) → Charge (idempotency key) → Confirm / Release.
- Payment có 3 kết quả: SUCCESS, FAILED, UNKNOWN. UNKNOWN → reconciliation.
- Outbox ở mọi bước chuyển state, Inbox ở mọi consumer.
- Expiry worker dọn hold; release có điều kiện
WHERE status='HELD'.
Tổng kết chương 13
| Bài | Chủ đề | Ý chính |
|---|---|---|
| 14.1 | Partial failure & clock | Mạng có thể mất/chậm, không phân biệt được chậm và chết; clock các máy lệch nhau. |
| 14.2 | Timeout, retry, deadline | Mọi call phải có timeout; retry có backoff + jitter, chỉ cho thao tác idempotent; deadline truyền xuyên chuỗi. |
| 14.3 | Circuit breaker, bulkhead | Fail fast khi dependency hỏng; cô lập tài nguyên để lỗi không lan. |
| 14.4 | Service boundary | Tách theo business capability, mỗi service sở hữu dữ liệu riêng. |
| 14.5 | Consistency & CAP | Có partition thì chọn C hoặc A; nhiều chỗ chấp nhận eventual consistency. |
| 14.6 | Saga | Local transaction + compensation; choreography vs orchestration; saga có state. |
| 14.7 | Outbox, Inbox, CDC | Chống dual write; relay polling hoặc CDC; consumer idempotent; key theo aggregate. |
| 14.8 | CQRS & Event Sourcing | Tách read model; event là source of truth; snapshot, replay; không phải mặc định. |
| 14.9 | Order–Inventory–Payment | Reserve → charge → confirm/release; UNKNOWN + reconciliation; expiry; failure matrix. |
Tự kiểm tra
Câu SQL nào ngăn oversell khi hai người mua chiếc cuối?
UPDATE stock SET available = available - ? WHERE sku = ? AND available >= ?; kiểm tra rows updated = 0 là hết hàng.
Payment timeout thì saga chuyển sang trạng thái nào và ai chốt kết quả?
PAYMENT_UNKNOWN. Reconciliation job hỏi provider theo idempotency key rồi phát SUCCESS/FAILED.
Vì sao không release hold khi saga đang PAYMENT_UNKNOWN?
Tiền có thể đã bị trừ; release thì có thể đã thu tiền mà không còn hàng.
Client bấm checkout 2 lần, làm sao không tạo 2 đơn?
Header Idempotency-Key gắn với saga; request trùng trả lại saga/đơn cũ.
Email xác nhận có nên nằm trong saga đồng bộ không?
Không. Không phải critical path và không undo được; gửi async từ event ORDER_CONFIRMED qua outbox.