Level 4: thiết kế Booking / Ticket System

4 câu hỏi~35 phútTừ khóa: double booking, race condition, atomic conditional update, pessimistic vs optimistic, temporary hold, expiry, state machine, Redis lock không phải source of truth, payment timeout ≠ failed, idempotent callback, Saga refund, Outbox

Tiếp đi: Level 4, Booking/Ticket. Giải thích tương tự các bài trước nhé.

Bài này bắt đầu có concurrency thật. Mọi thứ xoay quanh một câu: làm sao một ghế chỉ bán cho một người khi hàng nghìn request đến cùng lúc?

Seat A01 = AVAILABLE

User A ──┐
         ├── BUY A01  → ai được ghế?
User B ──┘

1. Requirements

Functional: xem sơ đồ ghế, chọn ghế, giữ ghế tạm thời, thanh toán; payment thành công → BOOKED; payment fail/hết hạn → trả ghế.

Non-functional: tuyệt đối không double booking, HA, chịu traffic spike lúc mở bán.

2. Estimate

Concert mở bán 10:00
09:59:59 →   1,000 users
10:00:00 → 500,000 users 💥   (10,000 ghế)

Read  (xem seat map): hàng trăm nghìn req/s → cache được
Write (hold ghế):     hàng chục nghìn req/s, cùng tranh vài ghế "đẹp"

Vừa là bài toán scaling, vừa là bài toán concurrency. Conflict rất cao.

3. API

GET  /events/{eventId}/seats                       → seat map (có thể hơi stale)
POST /events/{eventId}/seats/{seatId}/hold         Idempotency-Key: <uuid>
     → 200 { bookingId, heldUntil } | 409 SEAT_TAKEN
POST /bookings/{bookingId}/pay                     Idempotency-Key: <uuid>
POST /payments/webhook                             (provider callback)
GET  /bookings/{bookingId}                         → PAYMENT_PENDING / BOOKED / EXPIRED

4. Data model

CREATE TABLE seat (
    id            BIGINT PRIMARY KEY,
    event_id      BIGINT      NOT NULL,
    seat_number   VARCHAR(10) NOT NULL,
    status        VARCHAR(20) NOT NULL,   -- AVAILABLE, HELD, BOOKED
    held_by       BIGINT,
    held_until    TIMESTAMP,
    version       BIGINT      NOT NULL DEFAULT 0,
    UNIQUE (event_id, seat_number)
);

CREATE TABLE booking (
    id               UUID PRIMARY KEY,
    seat_id          BIGINT      NOT NULL,
    user_id          BIGINT      NOT NULL,
    status           VARCHAR(20) NOT NULL, -- HELD, PAYMENT_PENDING, BOOKED, EXPIRED, CANCELLED
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    created_at       TIMESTAMP   NOT NULL
);
-- một ghế tối đa một booking đang "sống": DB tự enforce invariant
CREATE UNIQUE INDEX uk_active_booking ON booking(seat_id)
    WHERE status IN ('HELD', 'PAYMENT_PENDING', 'BOOKED');

5. Problem 1: cách ngây thơ bị race condition

Seat seat = seatRepository.findById(a01);
if (seat.getStatus() == AVAILABLE) {
    seat.setStatus(BOOKED);
    seatRepository.save(seat);
}
Thread A               Thread B
READ A01 AVAILABLE     READ A01 AVAILABLE
SET BOOKED             SET BOOKED       → cả hai "mua thành công" 🔥

Check-then-act không atomic. Chi tiết ở Bài 3.5.

6. Problem 2: synchronized không đủ

Load Balancer
 /     |     \
A1     A2     A3
User A → A1 (lock trong JVM A1)
User B → A2 (lock trong JVM A2)   → KHÔNG PHẢI CÙNG LOCK

synchronized chỉ bảo vệ một JVM. Lock phải nằm ở chỗ mọi instance cùng truy cập: database.

7. Ba cách giành ghế đúng ở database

Cách 1: Atomic conditional update (đẹp nhất cho bài "claim một ghế AVAILABLE"):

public interface SeatRepository extends JpaRepository<Seat, Long> {

    @Modifying
    @Query("""
        update Seat s set s.status = 'HELD', s.heldBy = :userId, s.heldUntil = :until
        where s.id = :seatId and s.status = 'AVAILABLE'
        """)
    int hold(Long seatId, Long userId, Instant until);
}

@Transactional
public BookingDto holdSeat(long seatId, long userId, String idemKey) {
    Optional<Booking> existing = bookingRepo.findByIdempotencyKey(idemKey);
    if (existing.isPresent()) return BookingDto.of(existing.get());   // double click / retry

    Instant until = Instant.now().plus(Duration.ofMinutes(5));
    if (seatRepo.hold(seatId, userId, until) == 0) {
        throw new SeatAlreadyTakenException(seatId);                    // 409
    }
    return BookingDto.of(bookingRepo.save(Booking.held(seatId, userId, idemKey)));
}   // COMMIT ngay, transaction chỉ vài ms

Không cần đọc trước. affected rows = 1 → tôi thắng; 0 → người khác lấy rồi. DB row lock trong lúc UPDATE đảm bảo chỉ một request thắng.

Cách 2: Pessimistic lock: SELECT ... FOR UPDATE, B phải chờ A commit.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Seat s where s.id = :id")
Optional<Seat> findForUpdate(Long id);

Cách 3: Optimistic lock: @Version, ai ghi sau với version cũ thì nhận OptimisticLockException.

Conditional updatePessimisticOptimistic
Khi nào hợpĐiều kiện đơn giản, một câu SQLConflict cao, logic cần đọc rồi tính nhiều bướcConflict thấp (sửa profile)
NhượcLogic phức tạp khó nhét vào WHEREBlocking, contention, deadlockConflict cao → nhiều exception/retry
Với vé hotRất hợpĐược, nhưng hàng đợi lock dài9,999 request fail (business chỉ cho 1 thắng, nên chấp nhận được)

Chi tiết JPA locking ở Bài 4.3.10.

8. Problem 3: user cần 5 phút thanh toán → temporary hold

❌ BEGIN
   SELECT seat FOR UPDATE
   call Payment API ... chờ user nhập OTP 5 phút
   UPDATE seat; COMMIT

Giữ DB lock + connection + transaction trong lúc chờ network/người dùng: pool cạn sau vài trăm user. Thay vào đó, biến "lock 5 phút" thành business state:

             claim atomically (vài ms)
AVAILABLE ─────────────────────→ HELD (held_until = now + 5m)
   ▲                               │
   │                               │ payment confirmed
   │                               ▼
   └── expiry / payment FAILED ── BOOKED

Lock chỉ dùng cực ngắn để giành ghế, lưu HELD xuống DB rồi thả ngay. Payment chạy ngoài transaction.

9. Problem 4: user bỏ đi, ghế HELD mãi

@Scheduled(fixedDelay = 10_000)
@Transactional
public void releaseExpiredHolds() {
    int released = seatRepo.releaseExpired(Instant.now());
    // UPDATE seat SET status='AVAILABLE', held_by=NULL, held_until=NULL
    // WHERE status='HELD' AND held_until < :now
    //   AND id NOT IN (SELECT seat_id FROM booking WHERE status='PAYMENT_PENDING')
}

Chạy nhiều instance cũng an toàn vì bản thân câu UPDATE có điều kiện. Ghế đang PAYMENT_PENDING không được thả (xem Problem 5). Có thể thay bằng delayed message (RabbitMQ TTL + DLX) nếu cần chính xác hơn.

10. Problem 5: payment timeout ≠ payment failed

Booking → Payment → Bank: charge SUCCESS ✓
                     X   response bị timeout
Booking thấy TIMEOUT

catch (TimeoutException e) { releaseSeat(); }   ❌
→ user đã bị trừ tiền, ghế lại AVAILABLE, người khác mua mất

Timeout nghĩa là "tôi không biết kết quả". Chuyển booking sang PAYMENT_PENDING, rồi query status từ provider hoặc chờ webhook, sau đó mới quyết định BOOKED hay trả ghế. Chi tiết ở Bài 15.11.

11. Problem 6: callback đến hai lần

paymentSuccess() { bookSeat(); createTicket(); sendEmail(); }  // chạy 2 lần → 2 vé

Transition phải atomic + idempotent:

@Transactional
public void onPaymentSucceeded(UUID bookingId, String paymentId) {
    int updated = bookingRepo.transition(bookingId, "PAYMENT_PENDING", "BOOKED");
    // UPDATE booking SET status='BOOKED' WHERE id=:id AND status='PAYMENT_PENDING'
    if (updated == 0) {
        Booking b = bookingRepo.findById(bookingId).orElseThrow();
        if (b.getStatus() == BOOKED) return;                 // callback trùng → bỏ qua
        refundService.requestRefund(paymentId, bookingId);    // tiền đã trừ nhưng booking đã EXPIRED
        return;
    }
    seatRepo.markBooked(bookingRepo.seatIdOf(bookingId));
    outboxRepo.save(OutboxEvent.of("BOOKING_CONFIRMED", bookingId));  // cùng transaction
}

Lần 1: 1 row → xử lý. Lần 2: 0 row → đã xử lý, bỏ qua. Email gửi qua Outbox → queue → Notification Service (Bài 15.9), email lỗi không rollback booking. Vì sao cần Outbox: Bài 14.7.

12. Redis lock có thay được DB không?

Redis lock (SET lock:seat:A01 req123 NX PX 30000) đặt lock ở hệ thống chung nên đúng là distributed lock. Nó hữu ích để giảm contention: chặn bớt request trước khi đập vào DB. Nhưng không nên là source of truth:

Redis lock               → coordination, giảm tải
DB constraint/conditional → final correctness (UNIQUE, WHERE status=..., version)

Chi tiết failure mode và fencing token ở Bài 7.5.

13. Traffic spike và seat map

Câu luôn phải hỏi: data nào cần strong consistency, data nào được stale? Seat map: stale được. "Tôi đã BOOKED chưa?": phải chính xác.

14. Full architecture

                Users
                  │
       API Gateway + Rate Limiter
                  │
            Load Balancer
        ┌─────────┼─────────┐
    Booking 1  Booking 2  Booking 3   (stateless)
        └─────────┼─────────┘
          │               │
   Redis (seat map)   Booking DB (authoritative: conditional update, UNIQUE)
                          │  outbox
                          ▼
                    Message Broker → Notification Service
        Payment Service ⇄ Provider (webhook → Booking)

 Workers: hold expiry, payment reconciliation
 Metrics: hold success rate, expired holds, DB lock wait time, p95 booking latency
Vai tròAWSAzure
Edge + rate limitAPI Gateway / WAFAPI Management / Front Door
AppECS / EKS sau ALBAKS / App Service
DBRDS / Aurora PostgreSQLAzure Database for PostgreSQL
CacheElastiCacheAzure Cache for Redis
Event + workerSQS/EventBridge + LambdaService Bus + Functions

Giải thích lại định nghĩa Pessimistic vs Optimistic giúp mình, chỗ đó mình đang hơi lủng.

Hai cái tên mô tả thái độ với conflict.

PESSIMISTIC (bi quan): "Chắc chắn sẽ có người tranh" → KHÓA TRƯỚC
A: LOCK A01 🔒 → READ → UPDATE → COMMIT → UNLOCK
B:       LOCK A01 → WAIT............... → được lock → READ (BOOKED) → thua

OPTIMISTIC (lạc quan): "Ít ai tranh đâu" → CỨ LÀM, LÚC GHI MỚI KIỂM TRA
A: READ v=1 → UPDATE ... WHERE version=1 → OK, v=2
B: READ v=1 → ................ UPDATE ... WHERE version=1 → 0 row → CONFLICT ❌

Điểm dễ nhầm: atomic conditional update là cách thứ ba, không phải optimistic. Nó không đọc version trước, mà đưa luôn điều kiện nghiệp vụ vào WHERE: WHERE status = 'AVAILABLE'. Với bài giành ghế, nó gọn nhất.

Tình huốngChọn
1000 người tranh ghế A01Conditional update hoặc pessimistic
Mỗi user sửa profile của mìnhOptimistic @Version
Đọc rồi tính toán phức tạp trên nhiều rowPessimistic (chú ý thứ tự lock để tránh deadlock)

Nếu user thanh toán thành công lúc 10:05:00 mà hold cũng hết hạn đúng 10:05:00 thì xử lý sao? Thread nào chạy trước thì thắng à?

Không nên trả lời "thread nào chạy trước thắng". Phải có business rule rõ và transition atomic.

Payment confirm ───┐
                   ├── Seat A01 / Booking #123
Expiry worker   ───┘

Ví dụ rule: "khi user bấm Pay trước held_until, booking chuyển PAYMENT_PENDING và expiry worker không được thả ghế đang PAYMENT_PENDING". Mỗi bên chỉ được chuyển từ trạng thái hợp lệ:

Pay:     UPDATE booking SET status='PAYMENT_PENDING'
         WHERE id=:id AND status='HELD' AND :now <= held_until
Expiry:  UPDATE booking SET status='EXPIRED'
         WHERE id=:id AND status='HELD' AND held_until < :now
Confirm: UPDATE booking SET status='BOOKED'
         WHERE id=:id AND status='PAYMENT_PENDING'

Hai UPDATE cùng nhắm vào row status='HELD', DB chỉ cho một cái thành công. Nếu expiry thắng nhưng tiền vẫn bị trừ (user vào trang pay từ trước), booking không thể confirm → compensation: refund. Đây chính là Saga (Bài 14.6): mỗi bước có hành động bù trừ, không có rollback xuyên service.

Ngoài ra, PAYMENT_PENDING cũng cần timeout riêng: quá lâu không có kết quả thì job reconciliation hỏi provider rồi quyết định BOOKED hoặc trả ghế + refund nếu cần.

Phỏng vấn hỏi "How would you prevent double booking?" thì trả lời sao?

Q1. How do you prevent double booking?

🇻🇳 Tiếng Việt:

Em không dựa vào synchronized vì booking service có nhiều instance. Em enforce việc chuyển trạng thái ghế ngay ở database: UPDATE có điều kiện WHERE status = 'AVAILABLE', kiểm tra affected rows, kèm unique index cho booking đang hoạt động. Khi checkout, ghế chuyển AVAILABLE → HELD có hạn 5 phút thay vì giữ transaction trong lúc thanh toán. Payment thành công thì BOOKED, thất bại hoặc hết hạn thì trả ghế.

🇬🇧 English:

I wouldn't rely on application-level synchronization because the booking service runs multiple instances. I enforce the seat transition atomically in the database with a conditional update such as WHERE status = 'AVAILABLE', check the affected rows, and back it with a unique index on active bookings. At checkout the seat moves from AVAILABLE to HELD with a five-minute expiry instead of holding a transaction during payment. Successful payment makes it BOOKED; failure or expiry releases it.

Q2. Redis lock or database lock?

🇻🇳 Tiếng Việt:

Tính đúng đắn cuối cùng em đặt ở data store có thẩm quyền: transaction, conditional update, optimistic hoặc pessimistic lock, constraint. Redis lock có thể giúp điều phối hoặc giảm contention, nhưng em không dùng nó làm lớp bảo vệ duy nhất, vì TTL hết hạn, GC pause hay failover có thể làm hai bên cùng nghĩ mình giữ lock.

🇬🇧 English:

For final correctness I rely on the authoritative data store: transactions, conditional updates, optimistic or pessimistic locking, and constraints. A Redis lock can help with coordination or reduce contention, but I wouldn't use it as the only protection, because TTL expiry, GC pauses or failover can let two clients believe they hold the lock.

Q3. Pessimistic or optimistic locking?

🇻🇳 Tiếng Việt:

Tùy mức contention. Optimistic hợp khi conflict ít vì không giữ lock, nhưng update xung đột phải fail hoặc retry. Pessimistic hợp khi conflict nhiều và cần serialize, đổi lại là blocking, contention, có thể deadlock. Với việc chuyển trạng thái một ghế, em chọn atomic conditional update.

🇬🇧 English:

It depends on contention. Optimistic locking works well when conflicts are rare because it holds no locks, but conflicting updates must fail or retry. Pessimistic locking fits high contention when we need serialization, at the cost of blocking, contention and possible deadlocks. For a simple seat transition I would use an atomic conditional update.

Q4. The payment call times out. Do you release the seat?

🇻🇳 Tiếng Việt:

Không. Timeout là kết quả chưa biết, không phải thất bại. Booking chuyển PAYMENT_PENDING, em hỏi lại provider hoặc chờ webhook, callback xử lý idempotent bằng conditional update. Nếu tiền đã trừ mà booking không thể confirm thì refund như một bước bù trừ của Saga.

🇬🇧 English:

No. A timeout means the outcome is unknown, not failed. The booking moves to PAYMENT_PENDING, and I query the provider or wait for the webhook, handling callbacks idempotently with a conditional update. If the money was taken but the booking can't be confirmed, a refund runs as a Saga compensation step.

Cách nhớ ngắn gọn


Tự kiểm tra

Vì sao synchronized không chống được double booking?

Nó chỉ khóa trong một JVM. Hai request vào hai instance khác nhau giữ hai lock khác nhau.

Atomic conditional update khác optimistic lock chỗ nào?

Optimistic đọc version rồi ghi WHERE version = ?. Conditional update không cần đọc trước, đặt điều kiện nghiệp vụ vào WHERE (status='AVAILABLE') và xem affected rows.

Sao không giữ SELECT FOR UPDATE trong lúc user thanh toán?

Giữ lock, connection, transaction hàng phút chờ network/người dùng sẽ cạn pool và chặn người khác. Dùng trạng thái HELD có hạn.

Callback payment-success đến 2 lần, làm sao không tạo 2 vé?

UPDATE booking SET status='BOOKED' WHERE id=? AND status='PAYMENT_PENDING'; lần 2 được 0 row nên bỏ qua.

Seat map lấy từ cache bị stale có sao không?

Không sao, vì thao tác hold ở DB mới quyết định ai thắng. User có thể nhận 409 dù vừa thấy AVAILABLE.