Checkout end-to-end: Order – Inventory – Payment

4 câu hỏi~30 phútTừ khóa: reservation, hold expiry, payment UNKNOWN, reconciliation, idempotency key, orchestrator, failure matrix

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 đề

3. Giải pháp

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ỗiTrạng thái lúc đóXử lý
Hết hàng khi reserveOrder PENDING, chưa thu tiềnCancel order. Không cần refund.
Inventory timeout khi reserveKhông rõ đã giữ hàng chưaRetry cùng reservationId (idempotent); hold thừa sẽ tự hết hạn.
Payment bị từ chối (FAILED)Hàng HELDRelease reservation, cancel order.
Payment timeout (UNKNOWN)Có thể đã trừ tiềnKhông retry charge mù, không cancel. Reconciliation bằng idempotency key, gia hạn hold.
Gateway down lâuCircuit OPENFail 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àngThử reserve lại; không được thì refund + cancel (compensation), xin lỗi khách.
Confirm order lỗi sau khi đã thu tiềnPayment SUCCESS, order PENDINGRetry confirm (idempotent); không bao giờ release hàng đã thanh toán.
Refund lỗiSaga COMPENSATINGRetry backoff → COMPENSATION_FAILED → DLQ + alert → xử lý tay.
Order-service crash sau commitEvent nằm trong outboxRelay publish khi restart.
Event bị gửi trùngConsumer nhận 2 lầnInbox / idempotency key bỏ qua lần 2.
Client bấm checkout 2 lần2 request cùng cartHeader Idempotency-Key → trả lại saga cũ.
Khách bỏ ngang (không trả tiền)Hàng HELDExpiry worker release sau TTL, order EXPIRED.

7. Khi nào nên dùng / không nên dùng

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)

Đâ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

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

Tổng kết chương 13

BàiChủ đềÝ chính
14.1Partial failure & clockMạ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.2Timeout, retry, deadlineMọi call phải có timeout; retry có backoff + jitter, chỉ cho thao tác idempotent; deadline truyền xuyên chuỗi.
14.3Circuit breaker, bulkheadFail fast khi dependency hỏng; cô lập tài nguyên để lỗi không lan.
14.4Service boundaryTách theo business capability, mỗi service sở hữu dữ liệu riêng.
14.5Consistency & CAPCó partition thì chọn C hoặc A; nhiều chỗ chấp nhận eventual consistency.
14.6SagaLocal transaction + compensation; choreography vs orchestration; saga có state.
14.7Outbox, Inbox, CDCChống dual write; relay polling hoặc CDC; consumer idempotent; key theo aggregate.
14.8CQRS & Event SourcingTách read model; event là source of truth; snapshot, replay; không phải mặc định.
14.9Order–Inventory–PaymentReserve → 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.