Skip to content

Data Integrity: Constraints & Foreign Keys (Defense in Depth)

Dữ liệu rác vào, rác ra. "Garbage In, Garbage Out." Bài học này sẽ dạy bạn cách biến Database thành lớp phòng thủ cuối cùng cho chất lượng dữ liệu.

1️⃣ Concept: Các loại Constraints

Constraint là các quy tắc mà Database tự động kiểm tra MỖI KHI bạn INSERT hoặc UPDATE dữ liệu. Nếu vi phạm -> Database sẽ reject ngay lập tức.

PRIMARY KEY (Khóa chính)

  • Định nghĩa: Định danh duy nhất cho mỗi dòng trong bảng.
  • Quy tắc: Không được NULL, không được trùng lặp (UNIQUE).
  • Thực tế: Thường dùng id với kiểu SERIAL (PostgreSQL) hoặc AUTO_INCREMENT (MySQL).
sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,  -- PostgreSQL
    -- id INT AUTO_INCREMENT PRIMARY KEY, -- MySQL
    email VARCHAR(255) NOT NULL
);

FOREIGN KEY (Khóa ngoại) - Referential Integrity

  • Định nghĩa: Đảm bảo rằng một giá trị trong bảng này phải tồn tại ở bảng khác.
  • Mục đích: Ngăn chặn dữ liệu "mồ côi" (orphan records). Ví dụ: Đơn hàng của User không tồn tại.
sql
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INT NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Nếu INSERT order với user_id = 999 nhưng user 999 không tồn tại -> BÁO LỖI!

UNIQUE

  • Định nghĩa: Đảm bảo giá trị trong cột là duy nhất trong toàn bộ bảng.
  • Khác với PRIMARY KEY: Có thể có nhiều UNIQUE constraint, và cột UNIQUE có thể chứa NULL (tùy DB).
sql
ALTER TABLE users ADD CONSTRAINT unique_email UNIQUE (email);

CHECK

  • Định nghĩa: Kiểm tra điều kiện tùy ý trước khi chấp nhận dữ liệu.
  • Ví dụ: Tuổi phải >= 18, số lượng sản phẩm phải dương.
sql
ALTER TABLE users ADD CONSTRAINT check_adult CHECK (age >= 18);
-- INSERT INTO users (name, age) VALUES ('Kid', 10); -> LỖI!

NOT NULL

  • Định nghĩa: Bắt buộc cột phải có giá trị, không được để trống.
sql
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL, -- Tên sản phẩm bắt buộc
    description TEXT            -- Mô tả thì có thể NULL
);

2️⃣ The "Why": Tại sao validate ở Database, không chỉ Backend?

Đây là nguyên tắc Defense in Depth (Phòng thủ theo chiều sâu).

┌──────────────────────────────────────────────────────────────┐
│                      USER INPUT                              │
└──────────────────────────────────────────────────────────────┘


┌──────────────────────────────────────────────────────────────┐
│  🛡️ TẦNG 1: FRONTEND VALIDATION                              │
│  (JavaScript, Form rules)                                    │
│  → Dễ bị bypass bằng DevTools, API clients                   │
└──────────────────────────────────────────────────────────────┘


┌──────────────────────────────────────────────────────────────┐
│  🛡️🛡️ TẦNG 2: BACKEND VALIDATION                             │
│  (API, Service Layer)                                        │
│  → Có thể bị bypass nếu có bug, hoặc ai đó truy cập trực    │
│     tiếp vào DB (dev, admin, migration script)               │
└──────────────────────────────────────────────────────────────┘


┌──────────────────────────────────────────────────────────────┐
│  🛡️🛡️🛡️ TẦNG 3: DATABASE CONSTRAINTS                         │
│  (Primary Key, Foreign Key, Check, Unique, Not Null)         │
│  → Lớp phòng thủ CUỐI CÙNG. KHÔNG THỂ BYPASS. Đây là         │
│     "Source of Truth" và là hàng rào sắt cho dữ liệu.        │
└──────────────────────────────────────────────────────────────┘

Câu chuyện thực tế

Một developer tự xóa user bằng lệnh SQL trực tiếp trên production DB để "sửa lỗi". Anh ta quên rằng user đó còn 500 đơn hàng. Kết quả: 500 "ghost orders" không có thông tin khách hàng, báo cáo tài chính sai, hệ thống crash. Nếu có ON DELETE RESTRICT, Database đã ngăn chặn hành động này.


3️⃣ Production Code: Schema với Strict Constraints

Đây là schema mẫu cho hệ thống E-commerce, được thiết kế để không bao giờ nhận dữ liệu bẩn.

sql
-- ================================================================
-- BẢNG USERS: Thông tin người dùng
-- ================================================================
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    
    -- NOT NULL: Các trường bắt buộc
    email VARCHAR(255) NOT NULL,
    full_name VARCHAR(100) NOT NULL,
    age INT NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'active',
    
    -- Timestamps với default values
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- UNIQUE: Email không được trùng
    CONSTRAINT unique_user_email UNIQUE (email),
    
    -- CHECK: Tuổi phải >= 18 (theo quy định pháp luật)
    CONSTRAINT check_user_adult CHECK (age >= 18),
    
    -- CHECK: Status chỉ được là các giá trị hợp lệ
    CONSTRAINT check_user_status CHECK (status IN ('active', 'inactive', 'banned'))
);

-- Index cho tìm kiếm nhanh theo email
CREATE INDEX idx_users_email ON users(email);


-- ================================================================
-- BẢNG ORDERS: Đơn hàng của người dùng
-- ================================================================
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    
    -- NOT NULL: Đơn hàng phải thuộc về một user
    user_id INT NOT NULL,
    
    -- Thông tin đơn hàng
    order_code VARCHAR(50) NOT NULL,
    total_amount DECIMAL(15, 2) NOT NULL,
    status VARCHAR(30) NOT NULL DEFAULT 'pending',
    
    -- Timestamps
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- FOREIGN KEY: Đảm bảo user_id phải tồn tại trong bảng users
    -- ON DELETE RESTRICT: Không cho xóa user nếu còn đơn hàng
    CONSTRAINT fk_orders_user FOREIGN KEY (user_id) 
        REFERENCES users(id) 
        ON DELETE RESTRICT 
        ON UPDATE CASCADE,
    
    -- UNIQUE: Mã đơn hàng không được trùng
    CONSTRAINT unique_order_code UNIQUE (order_code),
    
    -- CHECK: Số tiền phải dương
    CONSTRAINT check_positive_amount CHECK (total_amount > 0),
    
    -- CHECK: Status hợp lệ
    CONSTRAINT check_order_status CHECK (
        status IN ('pending', 'confirmed', 'shipping', 'completed', 'cancelled')
    )
);

-- Index cho query theo user
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_created_at ON orders(created_at);

4️⃣ Deep Dive: ON DELETE CASCADE vs ON DELETE SET NULL

Khi bạn xóa một dòng ở bảng cha (ví dụ: users), database sẽ làm gì với các dòng con liên quan (ví dụ: orders)?

Các tùy chọn ON DELETE:

Hành độngMô tảVí dụ thực tế
RESTRICT (Default)Chặn xóa. Nếu còn dòng con -> Lỗi.User còn đơn hàng -> Không cho xóa user.
CASCADEXóa theo. Xóa cha -> Tự động xóa hết tất cả con.Xóa user -> Tự động xóa tất cả đơn hàng của user đó.
SET NULLĐặt NULL. Xóa cha -> user_id của con thành NULL.Xóa user -> Đơn hàng vẫn còn, nhưng user_id = NULL.
SET DEFAULTĐặt giá trị mặc định.Ít dùng trong thực tế.
NO ACTIONTương tự RESTRICT, nhưng kiểm tra trì hoãn.Dùng cho deferred constraints.

So sánh CASCADE vs SET NULL:

sql
-- Option 1: ON DELETE CASCADE
-- Xóa user_id = 1 => TỰ ĐỘNG xóa tất cả orders có user_id = 1
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) 
    REFERENCES users(id) ON DELETE CASCADE;

-- Option 2: ON DELETE SET NULL (cột phải cho phép NULL)
-- Xóa user_id = 1 => Tất cả orders có user_id = 1 sẽ thành user_id = NULL
ALTER TABLE orders ALTER COLUMN user_id DROP NOT NULL; -- Cho phép NULL trước
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) 
    REFERENCES users(id) ON DELETE SET NULL;

⚠️ Rủi ro chết người của ON DELETE CASCADE

CẢNH BÁO: Historical Data Loss

CASCADE cực kỳ nguy hiểm trong các hệ thống cần lưu trữ lịch sử (Auditing, Compliance).

Ví dụ thảm họa:

  1. Bạn có bảng usersorders.
  2. Một user yêu cầu xóa tài khoản (GDPR).
  3. Bạn DELETE FROM users WHERE id = 1.
  4. CASCADE tự động xóa TẤT CẢ 500 đơn hàng của user đó.
  5. Kết quả: Dữ liệu tài chính sai, báo cáo doanh thu tháng đó lệch 50 triệu, bộ phận kế toán gọi điện hỏi thăm.

Giải pháp:

  • Dùng RESTRICT để ngăn xóa.
  • Thay vì xóa cứng (Hard Delete), hãy Soft Delete bằng cách đặt cờ is_deleted = true hoặc deleted_at = NOW().

💡 HPN Golden Rule cho Production

Nguyên tắc:

  1. ON DELETE RESTRICT là mặc định an toàn nhất cho mọi hệ thống.
  2. ON DELETE CASCADE chỉ nên dùng cho các bảng phụ trợ (Config, Temp, Log tạm thời), KHÔNG BAO GIỜ cho dữ liệu core (Users, Orders, Transactions).
  3. ON DELETE SET NULL hữu ích khi bạn muốn giữ lại record con nhưng chấp nhận mất liên kết (ví dụ: user rời đi nhưng đơn hàng vẫn cần cho báo cáo).

Tổng kết

ConstraintMục đíchVí dụ
PRIMARY KEYĐịnh danh duy nhấtid SERIAL PRIMARY KEY
FOREIGN KEYĐảm bảo tham chiếu hợp lệREFERENCES users(id)
UNIQUEKhông trùng lặpUNIQUE (email)
CHECKĐiều kiện tùy chỉnhCHECK (age >= 18)
NOT NULLBắt buộc có giá trịname VARCHAR NOT NULL

💡 HPN Pro Tip: Đừng sợ "strict"

Nhiều junior developer ngại đặt quá nhiều constraints vì "sợ lỗi". Nhưng thực tế, lỗi sớm tốt hơn lỗi muộn. Một lỗi CHECK constraint violation lúc INSERT còn dễ debug hơn việc phát hiện dữ liệu bẩn sau 6 tháng trong production và phải viết script migration để sửa.