Level 4: thiết kế Booking / Ticket System
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 update | Pessimistic | Optimistic | |
|---|---|---|---|
| Khi nào hợp | Điều kiện đơn giản, một câu SQL | Conflict cao, logic cần đọc rồi tính nhiều bước | Conflict thấp (sửa profile) |
| Nhược | Logic phức tạp khó nhét vào WHERE | Blocking, contention, deadlock | Conflict cao → nhiều exception/retry |
| Với vé hot | Rất hợp | Được, nhưng hàng đợi lock dài | 9,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:
- TTL hết trong lúc process bị GC pause → hai bên cùng nghĩ mình giữ lock.
- Redis failover, replica chưa kịp nhận lock → lock "biến mất".
- Redis nói "locked", DB nói "AVAILABLE": tin ai?
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
- Booking Service stateless, scale ngang sau LB. DB vẫn là bottleneck.
- Rate limit theo user/IP ở Gateway chặn bot. Đợt bán cực hot cần waiting room/admission (xem Bài 15.12).
- Seat map cache trong Redis, chấp nhận stale vài trăm ms. User thấy A01 AVAILABLE, bấm mua, DB trả "đã bị giữ": hoàn toàn bình thường.
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ò | AWS | Azure |
|---|---|---|
| Edge + rate limit | API Gateway / WAF | API Management / Front Door |
| App | ECS / EKS sau ALB | AKS / App Service |
| DB | RDS / Aurora PostgreSQL | Azure Database for PostgreSQL |
| Cache | ElastiCache | Azure Cache for Redis |
| Event + worker | SQS/EventBridge + Lambda | Service 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 ❌
- Pessimistic = ngăn conflict bằng lock. B bị chặn ngay từ đầu.
- Optimistic = cho chạy song song rồi phát hiện conflict bằng version. Không ai bị chặn khi đọc.
Đ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ống | Chọn |
|---|---|
| 1000 người tranh ghế A01 | Conditional update hoặc pessimistic |
| Mỗi user sửa profile của mình | Optimistic @Version |
| Đọc rồi tính toán phức tạp trên nhiều row | Pessimistic (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
- synchronized = 1 JVM; correctness nằm ở DB chung.
- Giành ghế:
UPDATE ... WHERE status='AVAILABLE', check affected rows. - Lock vài ms, HELD 5 phút: biến lock dài thành business state + expiry.
- Timeout ≠ failed → PAYMENT_PENDING; callback idempotent; không confirm được thì refund.
- Redis lock giảm tải, DB constraint quyết định.
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.