Ràng buộc (Constraints)

← Quản lý bảng - CREATE, ALTER, SERIAL, IDENTITY, sequence | → Kiểu dữ liệu chuyên sâu

Bạn sẽ học được

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) <> '')

Trên bảng lớn, SET NOT NULL khóa bảng lâu — xem mẹo tại Quản lý bảng - CREATE, ALTER, SERIAL, IDENTITY, sequence.


2. DEFAULT

Không hẳn là ràng buộc, nhưng thường đi kèm:

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àmTrả về
now(), current_timestampThờ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ột
CREATE 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ướcThường lớn hơnNhỏ (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);   -- OK
INSERT 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

4.2. UNIQUE nhiều cột

CREATE TABLE dang_ky_lop (
   sv_id INT,
   lop_id INT,
   hoc_ky VARCHAR(10),
   UNIQUE (sv_id, lop_id, hoc_ky)
);

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 định
CREATE UNIQUE INDEX uq_dia_chi_mac_dinh
ON 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_active
ON 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ức
CREATE UNIQUE INDEX uq_email_ci ON nguoi_dung (lower(email));
 
-- Cách 2: kiểu citext
CREATE 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ản
CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
 
-- Số điện thoại Việt Nam
CHECK (sdt ~ '^0[35789][0-9]{8}$')
 
-- Chuỗi không rỗng sau khi cắt khoảng trắng
CHECK (length(trim(ten)) > 0)
 
-- Giá trị trong tập cho phép
CHECK (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

CHECK khô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, conname
FROM pg_constraint
WHERE 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 độngKhi xóa bản ghi cha
NO ACTION (mặc định)Báo lỗi — kiểm tra ở cuối câu lệnh
RESTRICTBáo lỗi — kiểm tra ngay lập tức
CASCADEXó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_buoc
FROM pg_constraint c
JOIN unnest(c.conkey) AS k(attnum) ON true
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE 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 sau
ALTER 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ết
SELECT 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_thuc
FROM pg_constraint
WHERE conrelid = 'don_hang'::regclass
ORDER 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:

pk_<bang>              → pk_don_hang
fk_<bang>_<bang_cha>   → fk_don_hang_khach_hang
uq_<bang>_<cot>        → uq_nguoi_dung_email
chk_<bang>_<mo_ta>     → chk_san_pham_gia_duong

8. Bài tập

Thiết kế CSDL cho một cửa hàng sách:

  1. Bảng tac_gia (id, ho_ten bắt buộc, ngay_sinh hợp lệ).
  2. Bảng sach (id, tieu_de bắt buộc, isbn duy nhất, gia dương, tac_gia_id tham chiếu tac_gia, xóa tác giả thì SET NULL).
  3. Bảng don_hang và chi_tiet_don, xóa đơn hàng thì xóa luôn chi tiết.
  4. Thêm ràng buộc: mỗi sách chỉ xuất hiện một lần trong một đơn hàng.
  5. Thêm CHECK đảm bảo so_luong > 0 và don_gia >= 0 trong chi_tiet_don.
  6. Tạo index cho tất cả cột khóa ngoại.
  7. Thử chèn dữ liệu vi phạm từng ràng buộc và ghi lại thông báo lỗi.
  8. Bảng dia_chi với ràng buộc: mỗi khách hàng chỉ có một địa chỉ mặc định.
  9. Vì sao câu sau vẫn chèn được dù có CHECK (gia > 0)?
    CREATE TABLE t (gia NUMERIC CHECK (gia > 0));
    INSERT INTO t VALUES (NULL);

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.

← Quản lý bảng - CREATE, ALTER, SERIAL, IDENTITY, sequence | → Kiểu dữ liệu chuyên sâu