Phỏng Vấn System Design: Thiết Kế Hệ Thống Đặt Vé Du Lịch Chịu Tải Cao
Bài này có bản tiếng Anh. Read in English →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Đề bài system design mình gặp nhiều nhất khi phỏng vấn — cũng là bài toán mình đụng hằng ngày khi làm backend cho mảng du lịch — là: “Thiết kế một hệ thống đặt vé (tour, máy bay, khách sạn) có thể chịu được một đợt flash-sale, nơi hàng chục nghìn người cùng cố gắng giữ vài trăm chỗ còn lại.”
Bài này hay ở chỗ thuật toán không khó. Cái khó là mấy khoảng trống tư duy, và chúng chỉ lộ ra khi bạn từng vận hành một hệ thống có ràng buộc tồn kho thật.
1. Clarify Requirements — hỏi cho rõ rồi hãy vẽ
Trước khi vẽ ô vuông nào lên bảng, mình luôn hỏi lại ba nhóm câu hỏi:
- Ràng buộc nghiệp vụ: Chỗ ngồi/tour có giới hạn cứng không (ví dụ 50 chỗ trên 1 chuyến tour)? Có cho phép overselling có kiểm soát (như ngành hàng không) hay tuyệt đối không được bán vượt (như tour riêng)?
- Yêu cầu trải nghiệm: Khi người dùng bấm “Giữ chỗ”, họ có bao nhiêu thời gian để hoàn tất thanh toán trước khi chỗ được nhả lại cho người khác?
- Quy mô tải: Flash-sale dự kiến bao nhiêu request/giây tại đỉnh điểm? Tỷ lệ request thành công so với tổng request thường rất thấp trong flash-sale (phần lớn là “đến trễ”), đây là điểm quan trọng ảnh hưởng đến thiết kế.
Bỏ bước này mà nhảy thẳng vào “dùng Kafka, dùng Redis, dùng microservices” là tự bắn vào chân. Interviewer nghe ra ngay bạn đang ráp công thức có sẵn chứ không tư duy theo bài toán.
2. Back-of-envelope Estimation — bóc số trước đã
Giả sử: 50,000 người cùng truy cập trong 60 giây đầu của đợt mở bán, tranh nhau 500 chỗ.
- Peak QPS cho API xem chỗ trống: có thể lên tới hàng nghìn request/giây, tức đọc nhiều hơn ghi rất nhiều lần.
- Peak QPS cho API giữ chỗ (write có tranh chấp): thấp hơn nhiều. Nhưng đây mới là phần nhạy cảm, vì mọi request đều cố ghi vào cùng một tập dữ liệu nhỏ — 500 chỗ.
- Đọc nhiều thì cache với CDN lo được. Chỗ đau nằm ở ghi: tất cả cùng đổ vào một tài nguyên giới hạn, trong thời gian ngắn.
3. High-level Design — vẽ luồng chính
Luồng chính gồm ba giai đoạn:
- Browse & Search — đọc nhiều, cache mạnh (CDN, Redis cache tầng đọc), không cần consistency tuyệt đối vì chỉ hiển thị thông tin tham khảo.
- Hold/Lock chỗ — đây là phần lõi, cần consistency mạnh trên tài nguyên tồn kho.
- Thanh toán & Xác nhận — luồng bất đồng bộ, có thể dùng queue, có compensation nếu thanh toán thất bại (nhả chỗ lại).
Kiến trúc tổng quan đi theo một đường: Gateway, rate limiter, service xem tồn kho, service giữ chỗ, queue thanh toán, worker. Vẽ ra thì nó như thế này:
flowchart LR GW["Gateway"] --> RL["Rate limiter<br/>chặn bot/spam request"] RL --> INV["Service xem tồn kho<br/>đọc cache"] INV --> LCK["Service giữ chỗ<br/>ghi vào kho dữ liệu nguồn sự thật, có khoá"] LCK --> Q["Queue xử lý thanh toán bất đồng bộ"] Q --> W["Worker<br/>xác nhận hoặc rollback"]
4. Deep Dive: seat-lock có TTL, chặn overselling
Phần này mình luôn dành nhiều thời gian nhất khi phỏng vấn. Người từng vận hành hệ thống thật với người chỉ học lý thuyết khác nhau rõ nhất ở đây.
Cốt lõi nằm ở chỗ này: nhiều request cùng đọc số chỗ còn lại rồi mới ghi giảm đi, tức read-then-write, là có race condition ngay, và thế nào cũng bán vượt tồn kho. Cách chặn là một thao tác nguyên tử để giữ chỗ, kèm TTL để chỗ tự nhả nếu người dùng không thanh toán kịp:
// Redis SETNX (set-if-not-exists) với TTL để khoá một chỗ cụ thể
// Trả về true nếu giữ chỗ thành công, false nếu chỗ đã bị người khác giữ
FUNCTION tryLockSeat(seatId, userId, ttlSeconds):
key = "seat_lock:" + seatId
success = REDIS.SET(key, userId, NX=true, EX=ttlSeconds)
RETURN success
Vài điểm quan trọng cần nói rõ khi trình bày giải pháp này:
- NX (not-exists) đảm bảo chỉ một request duy nhất thắng được khoá, các request đến sau fail ngay lập tức thay vì chờ. Race condition được loại bỏ mà không cần transaction phức tạp ở tầng database.
- TTL đảm bảo nếu người dùng bỏ ngang (đóng tab, mất mạng), chỗ sẽ tự động được nhả lại sau vài phút thay vì bị khoá vĩnh viễn.
- Redis chỉ là nguồn giữ chỗ tạm thời. Database quan hệ (Postgres) vẫn là nguồn sự thật cuối cùng khi xác nhận thanh toán thành công, nên Redis restart cũng không mất dữ liệu.
5. Idempotency Key — chặn double-booking khi client retry
Chỗ này hay bị bỏ sót: mạng chập chờn trong lúc flash-sale thì client (web/app) rất hay tự động retry request giữ chỗ hoặc thanh toán. Server xử lý mỗi request retry như một giao dịch mới là khách bị giữ chỗ 2 lần, hoặc bị trừ tiền 2 lần.
Cách làm: mỗi request có khả năng retry mang theo một Idempotency-Key do client sinh ra. Server lưu kết quả xử lý gắn với key đó. Nhận lại cùng key thì trả về kết quả cũ, không xử lý lại từ đầu. API nào có side-effect tài chính cũng cần thứ này, không riêng gì booking.
Đi hết một vòng retry thì nó ra thế này:
sequenceDiagram participant C as Client participant S as Server C->>S: POST giữ chỗ (Idempotency-Key: K1) S->>S: Xử lý, lưu kết quả theo K1 S-->>C: 200, giữ chỗ thành công Note over C,S: Mạng chập chờn, client không nhận được response C->>S: Retry, vẫn Idempotency-Key: K1 S-->>C: Trả lại kết quả đã xử lý trước đó
6. Traffic vượt ngưỡng: cho người dùng xếp hàng
Traffic đỉnh điểm vượt khả năng xử lý đồng thời của service giữ chỗ thì thêm server mãi cũng không xong — tốn kém mà vẫn có giới hạn. Cách tụi mình chọn: đưa người dùng vào một hàng đợi ảo.
- Request giữ chỗ được xếp hàng theo thứ tự đến trước.
- Hệ thống xử lý theo khả năng thực tế của tầng lock, thay vì để tất cả cùng tranh chấp trực tiếp.
- Trải nghiệm người dùng: thấy một màn hình “đang xếp hàng, còn khoảng X giây” thay vì gặp lỗi 500 hoặc timeout.
Hệ thống xuống cấp có kiểm soát thay vì sập hẳn khi vượt ngưỡng. Trong phỏng vấn, chỗ này ghi điểm vì nó cho thấy bạn nghĩ đến trải nghiệm thật, chứ không chỉ thông số kỹ thuật.
7. Cấu trúc trình bày: Clarify, Estimate, High-level, Deep Dive
Đề bài là gì đi nữa, đi đủ bốn bước Clarify, Estimate, High-level Design rồi Deep Dive thì interviewer theo kịp mạch tư duy của bạn. Riêng bài booking thì có ba từ khoá nên chủ động nhắc: atomic lock với TTL, idempotency key, và hàng đợi xử lý có kiểm soát. Ba thứ đó tách một câu trả lời lý thuyết suông khỏi câu trả lời của người từng vận hành hệ thống có tồn kho giới hạn trong production.
Đọc tiếp
Cần người cho dự án contract hoặc freelance?
Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.