Ghế có ba trạng thái chứ không phải hai: trống → đang giữ (có hạn) → đã bán. Toàn bộ thiết kế xoay quanh việc trạng thái giữa phải tự hết hạn kể cả khi user biến mất.
Cách 1 — giữ chỗ trong DB (nên chọn khi tiền là mục tiêu chính). Bảng seat_holds(showtime_id, seat_id, user_id, expires_at) với unique index trên (showtime_id, seat_id). Chiếm ghế bằng một câu insert có điều kiện, để chính DB làm trọng tài:
INSERT INTO seat_holds (showtime_id, seat_id, user_id, expires_at)
VALUES ($1, $2, $3, now() + interval '10 minutes')
ON CONFLICT (showtime_id, seat_id) DO UPDATE
SET user_id = EXCLUDED.user_id, expires_at = EXCLUDED.expires_at
WHERE seat_holds.expires_at < now() -- chỉ cướp được chỗ đã hết hạn
RETURNING id;Không trả về dòng nào nghĩa là ghế đang bị người khác giữ. Điểm mạnh: hết hạn được suy ra từ expires_at nên không phụ thuộc job dọn chạy đúng giờ — job xoá chỉ để bảng gọn.
Cách 2 — giữ chỗ trong Redis bằng SET seat:{showtime}:{seat} {userId} NX EX 600. Nhanh và tự hết hạn, nhưng Redis mất dữ liệu là mất trạng thái giữ chỗ, nên chỉ dùng khi ghế được chốt lại trong DB ở bước thanh toán.
Điểm hay bị hỏi sâu:
- Thanh toán xong sau khi hết hạn là tình huống bắt buộc phải xử lý: khi nhận IPN vẫn kiểm tra ghế còn thuộc về user không; mất ghế thì tự động hoàn tiền và báo rõ, đừng để im.
- Giữ nhiều ghế trong một lần phải atomic — thiếu một ghế thì trả lại tất cả (một transaction, và luôn chiếm ghế theo thứ tự seat_id tăng dần để tránh deadlock giữa hai request chọn chéo nhau).
- Đồng hồ đếm ngược hiển thị cho user nên lấy expires_at từ server, không tính bằng giờ máy client.