Kiểm tra kiểu SELECT xem có trùng không rồi mới INSERT là sai ở mức concurrency: hai request cùng chạy đều thấy trống rồi cùng ghi. Ràng buộc phải nằm ở tầng dữ liệu.
Cách chắc chắn nhất trong Postgres: exclusion constraint trên range.
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD COLUMN period tstzrange
GENERATED ALWAYS AS (tstzrange(starts_at, ends_at, '[)')) STORED;
ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
EXCLUDE USING gist (room_id WITH =, period WITH &&)
WHERE (status <> 'cancelled');DB tự từ chối bản ghi thứ hai giao nhau (&&) trên cùng room_id; ứng dụng chỉ cần bắt lỗi vi phạm ràng buộc và trả 409. btree_gist cần thiết để trộn so sánh bằng (room_id) với so sánh range trong một index GiST. Mệnh đề WHERE giúp booking đã huỷ không chiếm chỗ.
Chọn nửa mở [) cho khoảng thời gian: phòng trả lúc 10:00 và phòng nhận lúc 10:00 không bị coi là trùng. Đây là chi tiết interviewer hay hỏi lại.
Nếu DB không hỗ trợ exclusion constraint (MySQL), hai phương án thay thế:
- Chia lịch thành ô rời rạc (slot 30 phút, hoặc từng đêm với khách sạn) rồi đặt unique index trên (room_id, slot). Đơn giản, chống trùng tuyệt đối, đổi lại kém linh hoạt với khoảng thời gian tuỳ ý.
- Khoá theo tài nguyên trước khi kiểm tra: SELECT ... FROM rooms WHERE id = $1 FOR UPDATE để serialize mọi booking của phòng đó, rồi mới kiểm tra và ghi trong cùng transaction.
Bổ sung thường được hỏi: đệm thời gian dọn phòng thì cộng vào ends_at khi tạo range; và mọi mốc thời gian lưu timestamptz để lịch nhiều chi nhánh khác múi giờ vẫn so sánh đúng.