SQL & Database — Thực chiến PostgreSQL/Constraints — DB last line of defense, không trust app validation
~18 phútSchema design lượt xem

Constraints — DB last line of defense, không trust app validation

NOT NULL, UNIQUE, PK, FK, CHECK, EXCLUDE, deferred constraint. Cascading. Vì sao app-level validate race-prone, DB-level enforce safe.

App đã validate email duplicate trước khi insert. Chạy 6 tháng ổn. Black Friday traffic spike — 2 request cùng email pass validate trong cùng millisecond, cả hai insert thành công, cả hai row tồn tại trong database. Bug được phát hiện 3 tháng sau khi support team báo "user X có 2 account".

Đây là race condition kinh điển của app-level validation: khoảng thời gian giữa SELECT (check) và INSERT (write) không được bảo vệ. Constraint là last line of defense — không thể trust app validation cho integrity. Bài này map 5 loại constraint cốt lõi, EXCLUDE (PG-specific), deferred constraint cho circular FK, cascading behavior, và 2 pitfall phổ biến.

1. Analogy — Constraint là khoá vault

Hãy hình dung app validation là bảo vệ đứng trước cổng tòa nhà. Database constraint là khoá vault bên trong. Bảo vệ có thể nhầm — hai người cùng xuất trình giấy tờ đúng trong cùng lúc, cả hai đều được thả vào. Khoá vault không nhầm — chỉ một người mở được, người thứ hai nhận lỗi.

Đời thườngDatabase concept
Bảo vệ kiểm tra giấy tờ trước cổngApp-level validation (có thể pass đồng thời)
Khoá vault — chỉ mở được 1 lầnDB constraint — enforce tại write time, atomic
Quy định "ô trống phải điền" trong formNOT NULL constraint
Mã số nhân viên duy nhất toàn công tyUNIQUE constraint
Số thẻ nhân viên là định danh chính thứcPRIMARY KEY
Phòng ban phải tồn tại trước khi assign nhân viênFOREIGN KEY
Tuổi nhân viên phải trong khoảng hợp lệCHECK constraint
💡 Cách nhớ

App validate = guard có thể nhầm. Constraint = vault không thể nhầm. Luôn cần cả hai, nhưng integrity đến từ constraint — guard chỉ để UX tốt hơn.

2. 5 loại constraint cốt lõi

-- 1. NOT NULL: column khong duoc NULL
email TEXT NOT NULL

-- 2. UNIQUE: khong duplicate value trong column
email TEXT UNIQUE NOT NULL
-- Multi-column UNIQUE: cap (user_id, project_id) phai duy nhat
UNIQUE (user_id, project_id)

-- 3. PRIMARY KEY: NOT NULL + UNIQUE + tao B-tree index mac dinh
id BIGSERIAL PRIMARY KEY
-- Composite PK cho junction table M:N
PRIMARY KEY (user_id, project_id)

-- 4. FOREIGN KEY: tham chieu den PK bang khac
project_id BIGINT NOT NULL REFERENCES projects(id)
-- Voi cascading (giai thich o Section 5)
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE

-- 5. CHECK: dieu kien bat buoc per-row
status TEXT NOT NULL CHECK (status IN ('todo', 'doing', 'done'))
salary NUMERIC NOT NULL CHECK (salary >= 0)

Quirks quan trọng:

  • PostgreSQL tự tạo B-tree index cho mọi PRIMARY KEY và UNIQUE constraint — không cần CREATE INDEX riêng.
  • FOREIGN KEY không tự tạo index trên cột FK. JOIN trên task.project_id mà thiếu index sẽ scan toàn bảng khi bảng lớn. Module 5 của khoá này đi sâu về index strategy cho FK.
  • NOT NULL không cần index — NULL check xảy ra tại write time, không phải query time.

3. EXCLUDE constraint — PG-specific cho range non-overlap

UNIQUE chỉ check operator = (hai giá trị có bằng nhau không). EXCLUDE check bất kỳ operator nào — thường dùng cho range non-overlap, geo non-intersect.

Use case: booking phòng — không thể có 2 booking cùng phòng overlap thời gian.

-- Can extension btree_gist de dung GIST index voi kieu du lieu scalar
CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE bookings (
  id      BIGSERIAL PRIMARY KEY,
  room_id INT NOT NULL,
  period  TSTZRANGE NOT NULL,
  EXCLUDE USING GIST (room_id WITH =, period WITH &&)
);
-- room_id WITH =  : cung room_id...
-- period WITH &&  : ...khong cho phep period overlap (&&: range overlap operator)

Thử insert hai booking overlap:

INSERT INTO bookings(room_id, period)
VALUES (1, '[2026-05-04 14:00, 2026-05-04 16:00)');
-- OK

INSERT INTO bookings(room_id, period)
VALUES (1, '[2026-05-04 15:00, 2026-05-04 17:00)');
-- ERROR: conflicting key value violates exclusion constraint "bookings_room_id_period_excl"

Không thể làm điều này bằng UNIQUE thông thường — UNIQUE chỉ block hai booking có period bằng nhau chính xác, không block overlap. EXCLUDE là generalized UNIQUE cho operator tùy ý.

4. Deferred constraint — giải quyết circular FK

Mặc định, PostgreSQL check constraint sau mỗi statement. DEFERRABLE INITIALLY DEFERRED dời check xuống cuối transaction.

Use case điển hình: circular FK — companies tham chiếu employees (CEO), employees tham chiếu companies. Insert bên nào trước cũng vi phạm FK.

CREATE TABLE companies (
  id     BIGSERIAL PRIMARY KEY,
  ceo_id BIGINT REFERENCES employees(id) DEFERRABLE INITIALLY DEFERRED
);

CREATE TABLE employees (
  id         BIGSERIAL PRIMARY KEY,
  company_id BIGINT NOT NULL REFERENCES companies(id) DEFERRABLE INITIALLY DEFERRED
);

BEGIN;
  -- Insert company truoc, ceo_id tro den employee chua ton tai
  -- OK vi constraint deferred den COMMIT
  INSERT INTO companies(id, ceo_id) VALUES (1, 1);

  -- Insert employee, company_id tro den company vua insert
  INSERT INTO employees(id, company_id) VALUES (1, 1);

  -- Ca hai check tai COMMIT -- luc nay ca hai row da co
COMMIT;

Nếu không có DEFERRABLE, INSERT đầu tiên sẽ báo lỗi FK ngay lập tức.

Deferred constraint hiếm dùng trong app thông thường — chủ yếu xuất hiện khi schema có circular FK hoặc khi migration tool (Atlas, Flyway) cần tái cấu trúc dữ liệu trong transaction.

5. Cascading — ON DELETE / ON UPDATE behavior

Khi xoá/cập nhật row cha, FK quyết định hành vi với row con:

-- 4 option ON DELETE
ON DELETE NO ACTION   -- mac dinh: block delete neu co row con reference
ON DELETE RESTRICT    -- giong NO ACTION nhung check ngay, khong defer duoc
ON DELETE CASCADE     -- xoa row con theo khi xoa row cha
ON DELETE SET NULL    -- set column FK thanh NULL (column phai nullable)
ON DELETE SET DEFAULT -- set column FK ve gia tri DEFAULT

-- TaskFlow: comments la weak entity, ton tai phu thuoc task
CREATE TABLE comments (
  id      BIGSERIAL PRIMARY KEY,
  task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  user_id BIGINT       REFERENCES users(id)    ON DELETE SET NULL,
  body    TEXT NOT NULL
);
-- Xoa task -> tu dong xoa comment (weak entity, khong co y nghia doc lap)
-- Xoa user -> set comment.user_id NULL (preserve comment, lose author link)

Decision matrix:

Tình huốngNên dùngLý do
Child không tồn tại được nếu parent xoáCASCADEComments không có task là vô nghĩa
Muốn giữ child, chấp nhận mất linkSET NULLComment vẫn có giá trị dù user xoá tài khoản
Phải cleanup explicit trướcRESTRICTOrders của customer — xoá customer khi còn order là nguy hiểm
Child có default fallbackSET DEFAULTAssign task về "unassigned" user khi assignee rời đi

NO ACTIONRESTRICT khác nhau chỉ khi dùng với deferred constraint: NO ACTION có thể defer đến cuối transaction, RESTRICT luôn check ngay.

6. Pitfall — app validation race condition

// SAI: race condition giua check va insert
const existing = await db.query(
  'SELECT id FROM users WHERE email = $1',
  [email]
);
if (existing.rows.length > 0) {
  throw new Error('Email already exists');
}
// Khoang trong o day: 2 request cung pass check, ca 2 tien den INSERT
await db.query('INSERT INTO users(email) VALUES ($1)', [email]);

Fix: tin tưởng DB UNIQUE constraint, catch error code:

try {
  await db.query('INSERT INTO users(email) VALUES ($1)', [email]);
} catch (e) {
  if (e.code === '23505') {  // PG unique_violation error code
    throw new Error('Email already exists');
  }
  throw e;  // re-throw loi khac
}
Pitfall — app validation không thay thế được DB constraint

Khoảng thời gian giữa SELECT (check) và INSERT (write) không được bảo vệ bởi bất kỳ lock nào trong default isolation level. Hai transaction đồng thời đều thấy "email chưa tồn tại" và đều insert thành công. UNIQUE constraint enforce tại tầng storage — atomic, không có race condition. Catch error code 23505 (unique_violation) từ PostgreSQL để trả user-facing message phù hợp.

7. Pitfall — CHECK không thay thế validation phức tạp

-- OK: check don gian, immutable, per-row
CHECK (age >= 0 AND age <= 150)
CHECK (status IN ('todo', 'doing', 'done'))
CHECK (start_at < end_at)

-- KHONG OK: check cross-row (phai dung trigger hoac app logic)
-- "User chi tao toi da 5 project" -- khong the dung CHECK
-- CHECK chi nhin thay row hien tai, khong nhin thay row khac

-- NGUY HIEM: check goi function khong immutable
CHECK (verified_at <= now())
-- now() thay doi theo thoi gian -- PostgreSQL se reject CHECK nay
-- hoac neu accept, se fail random tuy thoi diem query
Pitfall — CHECK chỉ cho immutable per-row predicate

CHECK expression phải là immutable per-row predicate — không gọi function thay đổi theo thời gian (now(), random()), không tham chiếu row khác. Cross-row rule (vd giới hạn số lượng) cần trigger PL/pgSQL hoặc app logic kết hợp DB unique constraint. Time-dependent check cần xử lý ở app layer hoặc trigger với BEFORE INSERT/UPDATE.

8. Applied — TaskFlow constraint review

Đối chiếu TaskFlow schema (DDL đầy đủ ở Module 1 bài 5 của khoá này) với constraint rationale:

-- users.email TEXT UNIQUE NOT NULL
--   App validate unique? Race-prone. DB UNIQUE enforce: safe.
--   Catch error code 23505 de tra user-facing message.

-- project_members.role TEXT NOT NULL CHECK (role IN ('owner','member','viewer'))
--   Enum-like validation bang CHECK. Alternative: CREATE TYPE role_enum AS ENUM(...)
--   Tradeoff ENUM: type-safe + doc ro rang, nhung ALTER TYPE ADD VALUE can migration
--   CHECK linh hoat hon cho value set co the thay doi.

-- tasks.project_id BIGINT NOT NULL REFERENCES projects(id)
--   Mandatory parent: task khong the ton tai khong co project.
--   Khong ON DELETE CASCADE -- xoa project KHONG tu xoa task.
--   Default RESTRICT: phai cleanup task truoc khi xoa project.
--   Design choice: preserve task history, require explicit cleanup.

-- tasks.assignee_id BIGINT REFERENCES users(id)
--   Nullable: task chua assign la hop le.
--   Khong ON DELETE CASCADE: xoa user KHONG xoa task.
--   Nen them ON DELETE SET NULL? Design choice -- task con gia tri
--   doc lap voi assignee, set NULL preserve task, lose assignment.

-- comments.task_id BIGINT NOT NULL REFERENCES tasks(id)
--   Hien tai: khong co ON DELETE CASCADE trong schema goc.
--   Nen them? Comment la weak entity -- xoa task nen xoa comment.
--   Nhung neu can preserve comment history (audit)? Thi RESTRICT + soft delete.
--   Module 11 cua khoa nay (production migration) se chi cach them
--   constraint nay an toan cho bang co du lieu.

Module 4 bài 6 của khoá này (Atlas migration) và Module 11 (production constraint changes) sẽ đi sâu về cách thêm/sửa constraint trên bảng production đang có data.

9. Deep Dive — Constraints

📚 Deep Dive — Constraints

Ghi chú: PG docs cho rules chính xác — đặc biệt section EXCLUDE cho range non-overlap pattern với btree_gist extension, và section FK cho cascade behavior chi tiết.

Liên kết khoá học khác

  • Spring — bài 3.6 Validation — Bean Validation (@NotNull, @Email) là layer thứ nhất ở app, constraint DB là layer cuối — vì sao cần cả hai.

10. Tóm tắt

  • 5 constraint core: NOT NULL, UNIQUE, PRIMARY KEY, FOREIGN KEY, CHECK — mỗi loại enforce một tầng khác nhau của data integrity.
  • PostgreSQL tự tạo B-tree index cho PK và UNIQUE. FK không tự có index — phải tạo thủ công (Module 5 của khoá này).
  • EXCLUDE (PG-specific) là generalized UNIQUE — check bất kỳ operator, dùng GIST index, cần extension btree_gist. Use case điển hình: range non-overlap (booking).
  • Deferred constraint dời check xuống cuối transaction — dùng khi có circular FK. Hiếm gặp trong app thường, phổ biến trong migration tool.
  • Cascading (CASCADE / SET NULL / RESTRICT / SET DEFAULT) là design decision: CASCADE cho weak entity, SET NULL để preserve child mất link, RESTRICT để force explicit cleanup.
  • App validate email unique race-prone — trust DB UNIQUE constraint, catch error code 23505.
  • CHECK chỉ cho immutable per-row predicate — không cross-row, không time-dependent function.
  • Forward: Module 4 bài 6 của khoá này (Atlas migration — thêm constraint an toàn), Module 6 của khoá này (transaction + retry on unique violation), Module 11 của khoá này (production constraint changes không downtime).

11. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao app validate 'email unique' race-prone dù check trước insert? Cơ chế nào của DB enforce safe?

Giữa bước SELECT (check) và INSERT (write) có một khoảng thời gian không được lock. Trong default isolation level (READ COMMITTED), hai transaction đồng thời đều thấy snapshot chưa có email — cả hai pass check. Khi đến INSERT, cả hai write thành công → duplicate.

UNIQUE constraint enforce tại tầng storage engine với locking cấp index entry — atomic. Khi transaction thứ nhất insert, nó lock index entry cho email đó. Transaction thứ hai muốn insert cùng email sẽ phải chờ (hoặc nhận conflict ngay). Không có race condition vì lock ở tầng thấp hơn app code có thể reach.

Pattern đúng: bỏ SELECT check, dùng INSERT trực tiếp, catch error.code === "23505" (unique_violation) để trả user-facing message.

Q2
Phân biệt khi nào dùng CASCADE, SET NULL, RESTRICT cho FK. Cho 2 ví dụ TaskFlow cụ thể cho mỗi option.

CASCADE — khi child không có ý nghĩa độc lập nếu parent bị xoá:

  • Xoá task → xoá comments của task (comment không tồn tại được không có task).
  • Xoá project → xoá project_members rows của project đó (membership không tồn tại được không có project).

SET NULL — khi muốn preserve child, chấp nhận mất link:

  • Xoá user → set comments.user_id = NULL (giữ comment, mất thông tin tác giả).
  • Xoá user → set tasks.assignee_id = NULL (task về trạng thái unassigned, không mất task).

RESTRICT — khi phải cleanup explicit trước khi xoá parent:

  • Xoá project khi còn task → lỗi, phải archive/delete task trước (preserve intent).
  • Xoá user khi còn project owned → lỗi, phải transfer ownership hoặc delete project trước.
Q3
EXCLUDE constraint vs UNIQUE — cho 1 use case mỗi loại. Vì sao UNIQUE không đủ cho non-overlap booking?

UNIQUE phù hợp khi cần block hai row có giá trị bằng nhau: email TEXT UNIQUE — hai user không được có cùng email. UNIQUE dùng operator =.

EXCLUDE phù hợp khi cần block hai row có giá trị conflict theo operator tùy ý: booking phòng không được overlap thời gian. EXCLUDE dùng operator && (range overlap).

UNIQUE không đủ cho booking vì nó chỉ check hai period có bằng nhau chính xác không. Booking [14:00, 16:00)[15:00, 17:00) khác nhau (UNIQUE không block), nhưng overlap (EXCLUDE block). Không có cách nào express "không overlap" bằng UNIQUE.

Q4
CHECK constraint với now() function — vì sao fail random? Pattern thay thế là gì?

now() là volatile function — giá trị thay đổi theo thời gian. PostgreSQL yêu cầu CHECK expression phải immutable: cùng input luôn cho cùng output. Predicate verified_at <= now() đúng tại thời điểm insert nhưng có thể sai khi PostgreSQL re-evaluate constraint (vd sau VACUUM, REINDEX, hoặc sau khi row được update).

PostgreSQL thực ra reject hoặc warn khi định nghĩa CHECK với non-immutable function. Ngay cả khi accept được, behavior undefined và có thể gây lỗi lạ.

Pattern thay thế:

  • Validation time-dependent → app layer trước khi insert.
  • Nếu cần enforce ở DB: dùng BEFORE INSERT OR UPDATE trigger với PL/pgSQL — trigger có thể gọi now() vì nó chạy tại execution time, không phải definition time.
Q5
PG báo unique_violation với error code 23505. App nên catch + retry hay throw user-facing error? Tradeoff của mỗi approach.

Throw user-facing error (phổ biến hơn cho unique constraint trên business key như email):

  • Ưu: rõ ràng với user, không retry vô nghĩa — email đã tồn tại là trạng thái terminal, không thể resolve bằng retry.
  • Cách: catch e.code === "23505", parse e.constraint để biết constraint nào bị vi phạm, trả message cụ thể.

Retry (phù hợp cho unique constraint trên generated key hoặc idempotent insert):

  • Ưu: tự động resolve khi conflict là tạm thời — vd ULID/UUID collision cực hiếm nhưng có thể xảy ra.
  • Cách: retry với exponential backoff, giới hạn số lần, log nếu vượt ngưỡng.
  • Cũng dùng khi cần upsert: INSERT ... ON CONFLICT DO NOTHING hoặc ON CONFLICT DO UPDATE — tránh round-trip select + retry.

Module 6 của khoá này (transactions) đi sâu về retry strategy và isolation level liên quan đến serialization failure.

Bài tiếp theo: Normalization 1NF→3NF — refactor TaskFlow tags TEXT thành tags table

Bài này có giúp bạn hiểu bản chất không?