Kiểm tra trùng lịch ở tầng ứng dụng bằng câu lệnh truy vấn thông thường gần như chắc chắn sẽ dẫn đến lỗi đặt trùng khi có nhiều người dùng thao tác đồng thời. PostgreSQL cung cấp giải pháp triệt để ngay tại tầng cơ sở dữ liệu bằng cách kết hợp kiểu dữ liệu khoảng (range types) và ràng buộc loại trừ (exclusion constraints).
Điểm Yếu Của Việc Kiểm Tra Ứng Dụng Và Distributed Lock
Khi xây dựng các hệ thống đặt lịch—từ phòng khách sạn, phòng họp cho tới lịch khám bệnh—cách làm trực giác và phổ biến nhất của nhiều lập trình viên là thực hiện tuần tự hai bước:
- Chạy một câu lệnh đọc để kiểm tra xem đã có lịch nào giao nhau chưa:
SELECT COUNT(*) FROM reservations WHERE room_id = $1 AND start_time < $new_end AND end_time > $new_start. - Nếu kết quả trả về bằng 0, ứng dụng mới tiếp tục thực thi
INSERT INTO reservations ....
Khi có lưu lượng truy cập đồng thời cao, thiết kế này lập tức rơi vào lỗi Time-of-Check to Time-of-Use (TOCTOU). Giả sử hai người dùng cùng bấm nút đặt phòng trong cùng một phần nghìn giây. Cả hai luồng xử lý đều chạy Bước 1 trước khi bất kỳ luồng nào kịp chạm đến Bước 2. Cả hai cùng nhận kết quả là phòng còn trống, cả hai đều ra lệnh ghi vào cơ sở dữ liệu, và kết quả là bạn có hai bản ghi trùng giờ hợp lệ nằm song song trong bảng dữ liệu.
Nhiều nhóm kỹ sư cố gắng vá lỗi này bằng cách sử dụng distributed lock qua Redis hoặc token lạc quan. Tuy nhiên, distributed lock đem lại rủi ro vận hành không nhỏ. Nếu tiến trình xử lý gặp hiện tượng garbage collection kéo dài hoặc đứt đoạn mạng trong khi đang giữ khóa, thời gian sống (TTL) của khóa có thể hết hạn sớm. Một worker khác lập tức giành quyền và gây ra lỗi ghi đè y hệt. Thêm vào đó, distributed lock làm tăng độ trễ mạng và buộc bạn phải bảo trì thêm một cụm hạ tầng trung gian. Khóa bi quan thông thường như SELECT ... FOR UPDATE cũng bất lực trong tình huống này vì bạn không thể đặt khóa lên một dòng chưa hề tồn tại trong bảng.
Xem Khoảng Thời Gian Như Một Kiểu Dữ Liệu Thực Thụ

PostgreSQL giải quyết bài toán này từ gốc bằng cách cung cấp sẵn các kiểu dữ liệu khoảng chuyên dụng như tsrange, tstzrange và daterange. Thay vì lưu tách biệt hai cột start_time và end_time rồi tự xử lý các phép so sánh toán học phức tạp, bạn lưu toàn bộ khung thời gian vào một cột duy nhất.
Theo mặc định, PostgreSQL sử dụng định dạng nửa đóng nửa mở [start, end), tức là bao gồm mốc bắt đầu nhưng không bao gồm mốc kết thúc. Đây chính là cách mô hình hóa chuẩn xác nhất cho lịch thực tế: một khách trả phòng lúc 12:00 trưa sẽ không bị coi là xung đột với khách mới nhận phòng đúng vào 12:00 trưa.
-- Khởi tạo một khoảng thời gian nửa đóng nửa mở từ 14:00 đến 18:00
SELECT tstzrange(
'2026-10-02 14:00:00+07',
'2026-10-02 18:00:00+07',
'[)'
);
PostgreSQL tích hợp sẵn toán tử && để kiểm tra sự giao nhau giữa hai khoảng thời gian. Toán tử này trả về TRUE nếu hai khoảng trùng nhau dù chỉ một phần triệu giây:
-- Trả về true vì hai khoảng thời gian giao nhau
SELECT tstzrange('2026-10-02 10:00:00+07', '2026-10-02 12:00:00+07')
&& tstzrange('2026-10-02 11:30:00+07', '2026-10-02 13:00:00+07');
Khóa Chặt Bằng GiST Exclusion Constraint
Ràng buộc UNIQUE truyền thống trong SQL chỉ có thể so sánh bằng tuyệt đối giữa các giá trị rời rạc (=). Nó bất lực khi cần kiểm tra xem hai khoảng thời gian có chèn lấn lên nhau hay không. Ràng buộc loại trừ (EXCLUDE) của PostgreSQL là sự tổng quát hóa của tính duy nhất, cho phép áp dụng bất kỳ toán tử chỉ mục nào trên nhiều cột.
Để đảm bảo không có hai đơn đặt chỗ nào của cùng một phòng bị đè lên nhau, chúng ta kết hợp toán tử bằng thông thường (=) trên cột room_id với toán tử giao khoảng (&&) trên cột khung thời gian booking_period.
Do chỉ mục B-tree mặc định chỉ xử lý các toán tử so sánh thứ tự vô hướng, chúng ta cần kích hoạt extension btree_gist. Tiện ích này bổ sung các lớp toán tử GiST cho các kiểu dữ liệu cơ bản như UUID, chuỗi hay số nguyên.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE reservations (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id UUID NOT NULL,
booking_period TSTZRANGE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'confirmed',
CONSTRAINT no_overlapping_room_bookings
EXCLUDE USING gist (
room_id WITH =,
booking_period WITH &&
)
WHERE (status != 'cancelled')
);
Điểm đáng chú ý trong định nghĩa trên là mệnh đề điều kiện WHERE (status != 'cancelled'). Khi khách hàng hủy đơn, ứng dụng chỉ cần cập nhật trạng thái đơn sang đã hủy, khung giờ đó sẽ lập tức được giải phóng cho người khác đặt mà không cần xóa dòng hay tìm cách lách ràng buộc dữ liệu.
Cơ Chế Phản Ứng Khi Xảy Ra Xung Đột
Khi có hai giao dịch đồng thời cố gắng chèn hai khoảng thời gian trùng nhau cho cùng một room_id, cơ sở dữ liệu sẽ kiểm soát ngay tại tầng chỉ mục GiST.
Nếu Giao dịch A thực thi lệnh ghi trước, Giao dịch B sẽ bị tạm dừng ở trạng thái chờ cho đến khi Giao dịch A commit thành công. Ngay khi Giao dịch A ghi nhận dữ liệu hoàn tất, câu lệnh của Giao dịch B lập tức bị cơ sở dữ liệu từ chối với mã lỗi tiêu chuẩn 23P01 (exclusion_violation):
ERROR: conflicting key value violates exclusion constraint "no_overlapping_room_bookings"
DETAIL: Key (room_id, booking_period)=(d3b07384..., ["2026-10-02 14:00:00+07","2026-10-02 16:00:00+07")) conflicts with existing key (room_id, booking_period)=(d3b07384..., ["2026-10-02 13:00:00+07","2026-10-02 15:00:00+07")).
Ở tầng backend, bạn chỉ cần bắt lỗi vi phạm toàn vẹn dữ liệu có mã 23P01 và chuyển đổi thành phản hồi HTTP 409 Conflict gửi về cho client. Bạn không cần đến distributed lock phức tạp bằng Redis, không cần mutex trong bộ nhớ, và không sợ rò rỉ dữ liệu sai lệch.
Những Điểm Cần Lưu Ý Khi Triển Khai Thực Tế
Trước khi áp dụng rộng rãi cơ chế này vào production, hãy cân nhắc một số yếu tố vận hành sau:
- Chi Phí Ghi Chỉ Mục (Write Overhead): Cấu trúc cây GiST đòi hỏi nhiều tài nguyên CPU và bộ nhớ hơn B-tree khi thực hiện thao tác chèn hoặc cập nhật. Với các hệ thống đặt lịch vốn có tỷ lệ đọc áp đảo tỷ lệ ghi, chi phí này hoàn toàn không đáng ngại, nhưng cần lưu ý nếu bảng phải hứng hàng chục nghìn lượt ghi mỗi giây.
- Độ Phình Của Index (Index Bloat): Các bảng có mật độ cập nhật và xóa liên tục có thể khiến chỉ mục GiST bị phân mảnh và phình to theo thời gian. Bạn cần đảm bảo tiến trình
autovacuumcủa PostgreSQL được cấu hình đủ nhạy để dọn dẹp các trang chết định kỳ. - Nạp Dữ Liệu Lớn (Bulk Insert): Khi cần nạp một lượng lớn bản ghi lịch sử vào hệ thống, việc tắt ràng buộc hoặc tạo chỉ mục loại trừ sau khi nạp xong sẽ giúp tốc độ import nhanh hơn đáng kể.
Đưa trách nhiệm kiểm soát bất biến thời gian về đúng nơi mạnh nhất là PostgreSQL giúp loại bỏ hẳn một lớp lỗi tiềm ẩn, giữ cho mã nguồn backend tinh gọn mà vẫn bảo toàn tính toàn vẹn tuyệt đối.



Đang tải bình luận…