Có một cám dỗ quen thuộc khi viết ứng dụng: "để mã nguồn kiểm dữ liệu, cơ sở dữ liệu chỉ là chỗ lưu". Kiểm email trùng chưa, kiểm tuổi hợp lệ chưa, kiểm mã khách có thật chưa — tất cả trong tầng ứng dụng, rồi mới ghi. Nghe gọn gàng. Nhưng ràng buộc (constraint) ở tầng CSDL không phải thứ thừa: nó là lằn ranh cuối mà dữ liệu bẩn không vượt qua được, kể cả khi mã ứng dụng có lỗ hổng. Bài này đo cả hai: các ràng buộc từ chối dữ liệu sai thế nào, và vì sao tầng ứng dụng một mình không đủ.

Ràng buộc toàn vẹn

Ràng buộc là lời hứa CSDL tự giữ

Một ràng buộc là điều kiện mà mọi hàng trong bảng buộc phải thỏa; CSDL kiểm nó ngay lúc ghi và từ chối bất kỳ INSERT hay UPDATE nào vi phạm. PostgreSQL có vài loại chính. PRIMARY KEY (khóa chính) vừa là duy nhất vừa không NULL, và tự tạo một index để tra nhanh. UNIQUE cấm giá trị trùng. NOT NULL (đã gặp ở bài NULL) bắt cột phải có giá trị. CHECK đòi một biểu thức phải đúng. FOREIGN KEY (khóa ngoại) buộc giá trị phải trỏ tới một hàng có thật ở bảng khác. DEFAULT điền giá trị mặc định khi không khai.

Điểm chung: chúng sống trong CSDL, không phụ thuộc mã nào gọi tới. Dù request đến từ ứng dụng web, một script psql gõ tay, hay một job nền quên kiểm tra — ràng buộc vẫn chặn. Đó là khác biệt cốt lõi với kiểm ở tầng ứng dụng.

Đo: vi phạm bị từ chối ngay, với lỗi rõ

Tôi tạo bảng khach với khóa chính, email UNIQUE NOT NULL, và CHECK (tuoi >= 0 AND tuoi <= 150), rồi thử chèn từng loại dữ liệu sai:

INSERT trùng id=1      -> ERROR: duplicate key ... "khach_pkey"
INSERT trùng email     -> ERROR: duplicate key ... "khach_email_key"
INSERT tuoi=200        -> ERROR: violates check constraint "khach_tuoi_check"
INSERT email=NULL      -> ERROR: null value ... violates not-null constraint

Cả bốn đều bị chặn, mỗi lỗi nêu đúng tên ràng buộc bị vi phạm và hàng gây lỗi. Quan trọng hơn: khi một câu lệnh trong giao dịch vấp ràng buộc, cả giao dịch cuộn lại — không có chuyện nửa hàng lọt vào. Dữ liệu bẩn không bao giờ chạm mặt bảng. Một chi tiết đáng nhớ: PRIMARY KEYUNIQUE mỗi cái tự tạo một index ngầm (liệt kê index của bảng thấy khach_pkeykhach_email_key, mỗi cái 16 kB) — CSDL cần index đó để kiểm trùng cho nhanh, và bạn được dùng ké nó khi truy vấn theo cột ấy.

Một lần tôi đo hớ: kiểm ở tầng ứng dụng không chặn được đua tranh

Đây là chỗ tôi tự sập bẫy. Tôi lập luận: "cần email không trùng thì trước khi chèn, cứ SELECT xem đã có chưa, chưa có mới INSERT — khỏi cần UNIQUE, ứng dụng lo được". Nghe kín kẽ. Tôi dựng đúng logic đó trên bảng không có ràng buộc UNIQUE:

IF NOT EXISTS (SELECT 1 FROM acct WHERE email='dup@x.vn') THEN
    -- (cửa sổ thời gian ở đây)
    INSERT INTO acct(email) VALUES ('dup@x.vn');
END IF;

Chạy một mình thì đúng. Nhưng đời thật có nhiều request đồng thời. Tôi cho hai request chạy song song, mỗi cái làm đúng trình tự kiểm-rồi-ghi:

hai request song song, chỉ kiểm ở tầng ứng dụng:
  -> số hàng 'dup@x.vn' = 2   (trùng lọt vào!)

Cả hai cùng SELECT gần như đồng thời, cả hai đều thấy "chưa có", rồi cả hai INSERT. Kết quả: hai dòng trùng nằm chình ình trong bảng — đúng cái tôi định ngăn. Lỗi của tôi là quên rằng giữa lúc kiểm và lúc ghi có một cửa sổ thời gian, và dưới đồng thời, request khác chen vào đúng cửa sổ đó. Kiểm-rồi-ghi ở tầng ứng dụng có một lỗ đua tranh (race condition) mà chạy thử một mình không bao giờ lộ ra.

Rồi tôi thêm UNIQUE ở CSDL và chạy lại đúng kịch bản hai request song song đó:

hai request song song, có UNIQUE ở CSDL:
  -> một request báo ERROR: duplicate key ... "acct2_email_key"
  -> số hàng 'dup@x.vn' = 1

Lần này CSDL giữ lời: một request ghi được, request kia đâm vào ràng buộc và nhận lỗi duplicate key, chỉ đúng một dòng tồn tại. UNIQUE không kiểm "rồi mới ghi" — nó kiểm ngay tại điểm ghi, dưới khóa, nên không có cửa sổ nào để chen. Bài học: chỉ ràng buộc ở tầng CSDL mới đảm bảo tính duy nhất dưới đồng thời; kiểm ở tầng ứng dụng là tối ưu trải nghiệm (báo lỗi đẹp trước khi thử ghi), nhưng không thay được lằn ranh cuối.

Khóa ngoại: nhớ đánh index bên bảng con

Khóa ngoại cũng có một cái bẫy hiệu năng, lần này về tốc độ chứ không phải tính đúng. Khi bạn xóa hay sửa một hàng cha, CSDL phải kiểm bảng con xem còn hàng nào trỏ tới nó không (để giữ toàn vẹn tham chiếu). Nếu cột khóa ngoại bên con không có index, cuộc kiểm đó là một lần quét tuần tự cả bảng con.

Tôi dựng hai cặp cha–con, mỗi bảng con một triệu hàng, một cặp có index trên cột FK, một cặp không, rồi đo thời gian xóa một hàng cha:

xóa 1 hàng cha, bảng con 1 triệu hàng:
  FK con KHÔNG index  -> ~20 ms   (quét tuần tự cả bảng con)
  FK con CÓ index     -> ~0,2 ms  (tra index)

Chênh khoảng 80 lần cho một lần xóa. Trên một bảng cha bị xóa/cập nhật thường xuyên, khoảng cách này cộng dồn thành nghẽn thật sự. PostgreSQL tự tạo index cho PRIMARY KEYUNIQUE, nhưng không tự tạo index cho cột khóa ngoại bên con — đó là việc bạn phải tự làm. Như bài quét chỉ mục đã đo, khác biệt giữa tra index và quét tuần tự chính là khác biệt giữa đọc vài trang và đọc cả bảng.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: đặt bất biến dữ liệu ở tầng CSDL, không chỉ ở ứng dụng. Email duy nhất, tuổi trong khoảng, mã khách phải có thật — đây là những điều luôn đúng bất kể ai ghi. Ràng buộc biến chúng thành lời hứa CSDL tự giữ, sống sót qua lỗi ứng dụng, script tay, và migration cẩu thả. Kiểm ở ứng dụng vẫn nên có, nhưng để báo lỗi thân thiện sớm, không phải để thay ràng buộc.

Hệ quả thứ hai: bắt lỗi lúc ghi rẻ hơn dọn dữ liệu bẩn về sau. Một CHECK hay NOT NULL chặn hàng sai ngay tại cửa; không có nó, dữ liệu vô lý (tuổi 200, email rỗng, đơn hàng trỏ tới khách đã xóa) lặng lẽ tích lại, và một ngày nào đó bạn phải viết script dò tìm rồi sửa — thường là không thể khôi phục ý định gốc. Con số mang theo: ràng buộc chặn dữ liệu sai ngay lúc ghi với lỗi rõ (duplicate key, check, not-null); chỉ UNIQUE ở CSDL mới chống được đua tranh — kiểm-rồi-ghi ở ứng dụng cho 2 dòng trùng khi hai request song song, còn UNIQUE cho đúng 1 dòng; và khóa ngoại phải đánh index bên con, nếu không xóa cha quét cả bảng con (20ms so với 0,2ms). Toàn vẹn dữ liệu là việc của CSDL; đừng giao trọn cho tầng ứng dụng vốn không thấy được các request khác đang chạy cùng lúc.

Thử ba mươi giây

Trong psql, tạo CREATE TABLE t (email text UNIQUE); rồi chạy INSERT INTO t VALUES ('a@x.vn'); hai lần. Lần đầu vào được; lần hai trả ERROR: duplicate key value violates unique constraint — CSDL vừa chặn một dòng trùng mà bạn không phải viết một dòng kiểm nào. Rồi thử INSERT INTO t VALUES (NULL), (NULL); và xem PostgreSQL cho phép nhiều NULL trong cột UNIQUE (vì hai "không biết" không được coi là trùng nhau — đúng logic ba trạng thái). Hai thử nghiệm đó, trong nửa phút, cho bạn thấy ràng buộc canh cửa chặt tới đâu và ranh giới của nó nằm ở đâu.