Bảo vệ tính toàn vẹn dữ liệu ngay tại tầng CSDL bằng các ràng buộc — lớp phòng thủ mà không ứng dụng nào vượt qua được.
0. Vì sao ràng buộc quan trọng hơn kiểm tra ở ứng dụng?
Ứng dụng có thể có bug. Có thể có nhiều ứng dụng cùng ghi vào một CSDL. Có script chạy thủ công, có công cụ ETL, có DBA gõ SQL trực tiếp lúc 2 giờ sáng.
Ràng buộc CSDL là nơi duy nhất mà không gì đi vòng qua được. Kiểm tra ở ứng dụng cho phản hồi thân thiện; ràng buộc CSDL đảm bảo tính đúng đắn. Bạn cần cả hai.
1. NOT NULL
CREATE TABLE nhan_vien ( id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ho_ten VARCHAR(100) NOT NULL, email VARCHAR(255) NOT NULL, ghi_chu TEXT -- cho phép NULL);INSERT INTO nhan_vien (ho_ten, email) VALUES (NULL, 'a@b.com');-- ERROR: null value in column "ho_ten" of relation "nhan_vien"-- violates not-null constraint
Thêm/xóa sau khi bảng đã tồn tại:
ALTER TABLE nhan_vien ALTER COLUMN ghi_chu SET NOT NULL;ALTER TABLE nhan_vien ALTER COLUMN ghi_chu DROP NOT NULL;
NOT NULL không chặn chuỗi rỗng
'' là một giá trị hợp lệ, khác NULL. Muốn chặn cả hai:
ho_ten VARCHAR(100) NOT NULL CHECK (trim(ho_ten) <> '')
CREATE TABLE bai_viet ( id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, tieu_de TEXT NOT NULL, luot_xem INT NOT NULL DEFAULT 0, trang_thai VARCHAR(20) NOT NULL DEFAULT 'nhap', tao_luc TIMESTAMPTZ NOT NULL DEFAULT now(), ma_uuid UUID NOT NULL DEFAULT gen_random_uuid());
DEFAULT chỉ áp dụng khi bạn không nhắc tới cột trong INSERT:
INSERT INTO bai_viet (tieu_de) VALUES ('A');-- luot_xem = 0, trang_thai = 'nhap'INSERT INTO bai_viet (tieu_de, luot_xem) VALUES ('B', NULL);-- ❌ ERROR: vi phạm NOT NULL — DEFAULT không thay thế NULL tường minh
Quản lý:
ALTER TABLE bai_viet ALTER COLUMN luot_xem SET DEFAULT 100;ALTER TABLE bai_viet ALTER COLUMN luot_xem DROP DEFAULT;
now() vs current_timestamp vs clock_timestamp()
Hàm
Trả về
now(), current_timestamp
Thời điểm bắt đầu giao dịch — không đổi trong suốt giao dịch
statement_timestamp()
Thời điểm bắt đầu câu lệnh
clock_timestamp()
Thời gian thực tại lúc gọi — thay đổi trong cùng một câu lệnh
Dùng now() cho DEFAULT để mọi dòng chèn trong cùng giao dịch có cùng dấu thời gian.
3. PRIMARY KEY
Khóa chính = UNIQUE + NOT NULL. Mỗi bảng có tối đa một khóa chính.
-- Cách 1: ràng buộc cộtCREATE TABLE sinh_vien ( ma_sv VARCHAR(10) PRIMARY KEY, ho_ten VARCHAR(100) NOT NULL);-- Cách 2: ràng buộc bảng (bắt buộc khi khóa gồm nhiều cột)CREATE TABLE dang_ky ( ma_sv VARCHAR(10), ma_mon VARCHAR(10), diem NUMERIC(4,2), PRIMARY KEY (ma_sv, ma_mon) -- khóa chính tổng hợp);
PostgreSQL tự tạo index B-tree cho khóa chính.
3.1. Thêm/xóa
ALTER TABLE t ADD PRIMARY KEY (id);ALTER TABLE t ADD CONSTRAINT pk_t PRIMARY KEY (id);ALTER TABLE t DROP CONSTRAINT pk_t;
Trên bảng lớn, tránh khóa lâu bằng cách tạo unique index trước:
CREATE UNIQUE INDEX CONCURRENTLY idx_t_pk ON t (id);ALTER TABLE t ADD CONSTRAINT pk_t PRIMARY KEY USING INDEX idx_t_pk;
3.2. Khóa tự nhiên vs khóa thay thế
Khóa tự nhiên (CMND, email, ISBN)
Khóa thay thế (id tự tăng, UUID)
Có nghĩa nghiệp vụ
✅
❌
Có thể thay đổi
⚠️ Có — gây cập nhật lan truyền
✅ Không bao giờ
Kích thước
Thường lớn hơn
Nhỏ (4/8/16 byte)
Lộ thông tin qua URL
⚠️ Có
Chỉ với UUID thì không
Thực hành phổ biến: dùng khóa thay thế làm PRIMARY KEY, và thêm UNIQUE cho khóa tự nhiên:
CREATE TABLE nguoi_dung ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, -- khóa tự nhiên ho_ten VARCHAR(100) NOT NULL);
3.3. BIGINT hay INT?
INT tối đa ~2,1 tỷ. Nghe có vẻ nhiều, nhưng:
Sequence không rollback → id tiêu hao nhanh hơn số dòng thực tế.
Đổi từ INT sang BIGINT sau này đòi hỏi ghi lại toàn bộ bảng và mọi khóa ngoại trỏ tới nó — có thể mất hàng giờ downtime.
Dùng BIGINT cho khóa chính ngay từ đầu
Chênh lệch 4 byte mỗi dòng là không đáng kể so với rủi ro phải migrate sau này.
4. UNIQUE
Đảm bảo giá trị không trùng lặp.
CREATE TABLE nguoi_dung ( id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email VARCHAR(255) UNIQUE, sdt VARCHAR(20), CONSTRAINT uq_sdt UNIQUE (sdt));
4.1. UNIQUE và NULL
Nhiều NULL được phép trong cột UNIQUE
INSERT INTO nguoi_dung (email) VALUES (NULL); -- OKINSERT INTO nguoi_dung (email) VALUES (NULL); -- OK — vẫn được!
Vì NULL nghĩa là “không biết”, hai giá trị không biết không thể khẳng định là trùng nhau.
Từ PostgreSQL 15, có thể đổi hành vi:
CREATE TABLE t (email TEXT UNIQUE NULLS NOT DISTINCT);-- Bây giờ chỉ cho phép MỘT dòng có email = NULL
Ràng buộc áp dụng cho tổ hợp — cùng sinh viên có thể đăng ký cùng lớp ở học kỳ khác.
4.3. Unique một phần (partial unique index)
Đây là tính năng mạnh mà nhiều người không biết:
-- Mỗi khách chỉ có MỘT địa chỉ mặc địnhCREATE UNIQUE INDEX uq_dia_chi_mac_dinhON dia_chi (khach_id)WHERE la_mac_dinh = true;
Cho phép nhiều địa chỉ không mặc định, nhưng chỉ một mặc định.
-- Email duy nhất, nhưng chỉ với tài khoản chưa xóa (soft delete)CREATE UNIQUE INDEX uq_email_activeON nguoi_dung (lower(email))WHERE deleted_at IS NULL;
4.4. Unique không phân biệt hoa thường
-- Cách 1: index trên biểu thứcCREATE UNIQUE INDEX uq_email_ci ON nguoi_dung (lower(email));-- Cách 2: kiểu citextCREATE EXTENSION IF NOT EXISTS citext;ALTER TABLE nguoi_dung ALTER COLUMN email TYPE citext;
citext tự động so sánh không phân biệt hoa thường ở mọi nơi — tiện hơn nhưng ít linh hoạt.
4.5. EXCLUDE — ràng buộc loại trừ
Tổng quát hóa của UNIQUE. Ví dụ kinh điển: không cho phép hai lượt đặt phòng chồng thời gian:
CREATE EXTENSION IF NOT EXISTS btree_gist;CREATE TABLE dat_phong ( id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, phong_id INT NOT NULL, khoang_tg TSTZRANGE NOT NULL, EXCLUDE USING GIST (phong_id WITH =, khoang_tg WITH &&));INSERT INTO dat_phong (phong_id, khoang_tg)VALUES (1, '[2026-08-01, 2026-08-05)');INSERT INTO dat_phong (phong_id, khoang_tg)VALUES (1, '[2026-08-03, 2026-08-07)');-- ERROR: conflicting key value violates exclusion constraint
Toán tử && nghĩa là “giao nhau”. Không hệ CSDL phổ biến nào khác làm được điều này ở tầng ràng buộc.
5. CHECK
Kiểm tra giá trị theo biểu thức boolean.
CREATE TABLE san_pham ( id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ten VARCHAR(255) NOT NULL, gia NUMERIC(12,2) NOT NULL CHECK (gia > 0), giam_gia NUMERIC(12,2) NOT NULL DEFAULT 0, so_luong INT NOT NULL CHECK (so_luong >= 0), -- CHECK cấp bảng: so sánh nhiều cột CONSTRAINT chk_giam_gia CHECK (giam_gia >= 0 AND giam_gia < gia));
5.1. Các mẫu CHECK hữu ích
-- Email cơ bảnCHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')-- Số điện thoại Việt NamCHECK (sdt ~ '^0[35789][0-9]{8}$')-- Chuỗi không rỗng sau khi cắt khoảng trắngCHECK (length(trim(ten)) > 0)-- Giá trị trong tập cho phépCHECK (trang_thai IN ('cho', 'dang_xu_ly', 'hoan_thanh', 'huy'))-- Khoảng ngày hợp lệCHECK (ngay_ket_thuc IS NULL OR ngay_ket_thuc >= ngay_bat_dau)-- Chỉ một trong hai cột được có giá trịCHECK ((ca_nhan_id IS NULL) <> (cong_ty_id IS NULL))-- Tuổi hợp lýCHECK (ngay_sinh > '1900-01-01' AND ngay_sinh <= current_date)
5.2. CHECK với NULL — cạm bẫy
CHECK cho qua khi biểu thức trả về NULL
CREATE TABLE t (gia NUMERIC CHECK (gia > 0));INSERT INTO t (gia) VALUES (NULL); -- ✅ THÀNH CÔNG!
NULL > 0 cho ra NULL, không phải false. CHECK chỉ từ chối khi biểu thức là false.
Muốn chặn NULL, thêm NOT NULL:
gia NUMERIC NOT NULL CHECK (gia > 0)
5.3. Giới hạn của CHECK
CHECKkhông được chứa:
Subquery — không thể CHECK (x IN (SELECT ...))
Tham chiếu tới cột của bảng khác
Hàm biến thiên (now(), random()) — vì CHECK được kiểm tra lại khi ALTER TABLE VALIDATE
Cần kiểm tra liên bảng → dùng FOREIGN KEY hoặc trigger.
CHECK (ngay <= current_date) là bẫy
current_date là hàm biến thiên. Ràng buộc này có thể chặn các thao tác trên dòng cũ hợp lệ, và pg_dump/pg_restore có thể thất bại. Hãy kiểm tra loại này ở tầng ứng dụng hoặc trigger.
5.4. NOT VALID — thêm ràng buộc không khóa bảng
-- ① Thêm nhưng không kiểm tra dữ liệu cũ (nhanh, khóa nhẹ)ALTER TABLE bang_lon ADD CONSTRAINT chk_gia CHECK (gia > 0) NOT VALID;-- ② Kiểm tra dữ liệu cũ sau (khóa nhẹ, cho phép đọc ghi song song)ALTER TABLE bang_lon VALIDATE CONSTRAINT chk_gia;
Ngay sau bước ①, mọi dòng mới đều bị kiểm tra. Đây là mẫu chuẩn khi thêm ràng buộc trên production.
Tìm các ràng buộc chưa xác thực:
SELECT conrelid::regclass AS bang, connameFROM pg_constraintWHERE NOT convalidated;
6. FOREIGN KEY
Đảm bảo giá trị ở bảng con phải tồn tại ở bảng cha.
CREATE TABLE khach_hang ( id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ten VARCHAR(255) NOT NULL);CREATE TABLE don_hang ( id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, khach_id INT NOT NULL, tong_tien NUMERIC(12,2), CONSTRAINT fk_khach FOREIGN KEY (khach_id) REFERENCES khach_hang (id));INSERT INTO don_hang (khach_id, tong_tien) VALUES (999, 100);-- ERROR: insert or update on table "don_hang" violates foreign key-- constraint "fk_khach"-- DETAIL: Key (khach_id)=(999) is not present in table "khach_hang".
Cột được tham chiếu phải có PRIMARY KEY hoặc UNIQUE.
6.1. Hành động khi xóa/sửa bản ghi cha
FOREIGN KEY (khach_id) REFERENCES khach_hang (id) ON DELETE hanh_dong ON UPDATE hanh_dong
Hành động
Khi xóa bản ghi cha
NO ACTION (mặc định)
Báo lỗi — kiểm tra ở cuối câu lệnh
RESTRICT
Báo lỗi — kiểm tra ngay lập tức
CASCADE
Xóa luôn các bản ghi con
SET NULL
Đặt khóa ngoại thành NULL
SET DEFAULT
Đặt về giá trị DEFAULT
6.2. ON DELETE CASCADE
CREATE TABLE chi_tiet_don ( id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, don_id INT NOT NULL, san_pham VARCHAR(255), CONSTRAINT fk_don FOREIGN KEY (don_id) REFERENCES don_hang (id) ON DELETE CASCADE);DELETE FROM don_hang WHERE id = 1;-- Tự động xóa mọi chi_tiet_don có don_id = 1
CASCADE lan truyền theo dây chuyền
Nếu A ← B ← C đều có ON DELETE CASCADE, xóa một dòng của A có thể xóa hàng nghìn dòng ở C mà bạn không hề biết.
Nên dùng CASCADE khi: bản ghi con không có ý nghĩa độc lập (chi tiết đơn hàng không tồn tại nếu không có đơn hàng).
Không nên khi: bản ghi con có giá trị lịch sử (đừng xóa toàn bộ lịch sử thanh toán chỉ vì xóa một khách hàng — hãy dùng soft delete).
6.3. ON DELETE SET NULL
CREATE TABLE nhan_vien ( id INT PRIMARY KEY, ten VARCHAR(100), phong_id INT REFERENCES phong_ban(id) ON DELETE SET NULL);
Xóa phòng ban → nhân viên vẫn còn, chỉ mất liên kết phòng ban. Cột phải cho phép NULL.
6.4. DEFERRABLE — hoãn kiểm tra tới cuối giao dịch
Giải quyết bài toán tham chiếu vòng tròn:
ALTER TABLE don_hang ADD CONSTRAINT fk_khach FOREIGN KEY (khach_id) REFERENCES khach_hang (id) DEFERRABLE INITIALLY DEFERRED;BEGIN; -- Chèn đơn hàng trước khi có khách — tạm thời vi phạm INSERT INTO don_hang (id, khach_id) VALUES (1, 100); INSERT INTO khach_hang (id, ten) VALUES (100, 'Nguyễn A');COMMIT; -- ← kiểm tra ở đây, và mọi thứ hợp lệ
Hoặc bật tạm thời:
BEGIN;SET CONSTRAINTS ALL DEFERRED; ...COMMIT;
6.5. ⚠️ Luôn tạo index cho cột khóa ngoại
Đây là lỗi hiệu năng phổ biến nhất trong thiết kế CSDL
PostgreSQL tự tạo index cho cột được tham chiếu (bảng cha, vì nó là PRIMARY KEY), nhưng KHÔNG tạo index cho cột khóa ngoại ở bảng con.
Hậu quả: mỗi lần DELETE hoặc UPDATE khóa chính ở bảng cha, PostgreSQL phải quét toàn bộ bảng con để kiểm tra tham chiếu.
CREATE INDEX ON don_hang (khach_id);
Tìm tất cả khóa ngoại thiếu index:
SELECT c.conrelid::regclass AS bang, a.attname AS cot_thieu_index, c.conname AS ten_rang_buocFROM pg_constraint cJOIN unnest(c.conkey) AS k(attnum) ON trueJOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnumWHERE c.contype = 'f' AND NOT EXISTS ( SELECT 1 FROM pg_index i WHERE i.indrelid = c.conrelid AND a.attnum = i.indkey[0] )ORDER BY 1;
6.6. Thêm khóa ngoại trên bảng lớn
-- ① Không kiểm tra dữ liệu cũALTER TABLE don_hang ADD CONSTRAINT fk_khach FOREIGN KEY (khach_id) REFERENCES khach_hang(id) NOT VALID;-- ② Xác thực sauALTER TABLE don_hang VALIDATE CONSTRAINT fk_khach;
7. Xem và quản lý ràng buộc
-- Trong psql\d ten_bang-- Liệt kê chi tiếtSELECT conname AS ten, CASE contype WHEN 'p' THEN 'PRIMARY KEY' WHEN 'f' THEN 'FOREIGN KEY' WHEN 'u' THEN 'UNIQUE' WHEN 'c' THEN 'CHECK' WHEN 'x' THEN 'EXCLUDE' END AS loai, pg_get_constraintdef(oid) AS dinh_nghia, convalidated AS da_xac_thucFROM pg_constraintWHERE conrelid = 'don_hang'::regclassORDER BY contype;
Thêm/xóa/đổi tên:
ALTER TABLE t ADD CONSTRAINT ten CHECK (...);ALTER TABLE t DROP CONSTRAINT ten;ALTER TABLE t RENAME CONSTRAINT ten_cu TO ten_moi;
7.1. Quy ước đặt tên
PostgreSQL tự sinh tên như don_hang_khach_id_fkey. Đặt tên rõ ràng sẽ tốt hơn:
CREATE TABLE dia_chi ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, khach_id BIGINT NOT NULL, dia_chi TEXT NOT NULL, la_mac_dinh BOOLEAN NOT NULL DEFAULT false);CREATE UNIQUE INDEX uq_dia_chi_mac_dinhON dia_chi (khach_id)WHERE la_mac_dinh = true;
Đây là partial unique index — không có ràng buộc UNIQUE nào làm được điều này.
9.NULL > 0 cho ra NULL, và CHECK chỉ từ chối khi biểu thức là false. Sửa bằng cách thêm NOT NULL:
CREATE TABLE t (gia NUMERIC NOT NULL CHECK (gia > 0));
9. Tóm tắt
Ràng buộc CSDL là lớp bảo vệ duy nhất không ứng dụng nào đi vòng qua được.
NOT NULL không chặn chuỗi rỗng — thêm CHECK (trim(x) <> '').
DEFAULT chỉ áp dụng khi không nhắc tới cột trong INSERT.
Khóa chính nên là BIGINT GENERATED ALWAYS AS IDENTITY; khóa tự nhiên đặt UNIQUE.
UNIQUE cho phép nhiều NULL (trừ khi dùng NULLS NOT DISTINCT từ PG 15).
Partial unique index giải quyết “chỉ một dòng thỏa điều kiện X” — rất mạnh.
CHECK cho qua khi biểu thức trả về NULL — luôn kèm NOT NULL khi cần.
Luôn tạo index cho cột khóa ngoại — PostgreSQL không tự làm.
Thêm ràng buộc trên bảng lớn: NOT VALID trước, VALIDATE CONSTRAINT sau.
ON DELETE CASCADE lan truyền theo dây chuyền — cân nhắc kỹ trước khi dùng.