0

PostgreSQL Bài 7: Thiết kế bảng chuẩn hóa, Ràng buộc dữ liệu (Constraints) và Cascade Rules

Nhiều lập trình viên có thói quen đẩy toàn bộ việc kiểm tra tính đúng đắn của dữ liệu (validation) lên tầng ứng dụng backend (Laravel, Go, Node.js). Tuy nhiên, khi hệ thống mở rộng, dữ liệu không chỉ được ghi qua một API duy nhất mà còn qua các background workers, scripts migration, batch jobs, hay thao tác trực tiếp của DBA.

Nếu thiếu đi lớp bảo vệ ở tầng cơ sở dữ liệu, dữ liệu rác, mồ côi (orphaned data) hoặc xung đột logic nghiệp vụ chắc chắn sẽ xuất hiện. Bài học này sẽ giúp bạn làm chủ hệ thống ràng buộc (Constraints) và các quy tắc lan truyền (Cascade Rules) trong PostgreSQL.

1. Các cấp độ ràng buộc toàn vẹn dữ liệu

PostgreSQL cung cấp 5 loại ràng buộc chính được tích hợp trực tiếp vào schema:

┌─────────────────────────────────────────────────────────────────┐
│                    POSTGRESQL CONSTRAINTS                       │
├─────────────────┬───────────────────┬───────────────────────────┤
│ NOT NULL        │ Kiểm tra rỗng     │ Tối ưu query planner      │
├─────────────────┼───────────────────┼───────────────────────────┤
│ UNIQUE          │ Không trùng lặp   │ Tự sinh Unique B-Tree idx │
├─────────────────┼───────────────────┼───────────────────────────┤
│ PRIMARY KEY     │ NOT NULL + UNIQUE │ Định danh dòng (Tuple ID) │
├─────────────────┼───────────────────┼───────────────────────────┤
│ CHECK           │ Biểu thức logic   │ Kiểm tra miền giá trị     │
├─────────────────┼───────────────────┼───────────────────────────┤
│ FOREIGN KEY     │ Toàn vẹn tham chiếu│ Khóa liên bảng + Cascade  │
└─────────────────┴───────────────────┴───────────────────────────┘

1.1. NOT NULL và tối ưu hóa truy vấn

Cột mang ràng buộc NOT NULL giúp Query Planner loại bỏ các nhánh kiểm tra điều kiện không cần thiết (NULL scan) và cho phép engine tối ưu hóa dung lượng lưu trữ trên bit-map của page header.

1.2. UNIQUE Constraint

  • Đảm bảo giá trị của một cột (hoặc tổ hợp nhiều cột) không bao giờ trùng lặp.

  • Cơ chế ngầm: Khi bạn tạo một UNIQUE constraint, PostgreSQL sẽ tự động khởi tạo một Unique B-Tree Index trên các cột tương ứng để kiểm tra tính duy nhất. Bạn không cần tạo thêm index riêng lẻ cho cột này nữa.

  • Lưu ý với NULL: Theo chuẩn SQL truyền thống, NULL biểu thị cho "giá trị chưa biết", do đó NULL != NULL. Mặc định trong PostgreSQL, một cột UNIQUE có thể chứa nhiều dòng có giá trị NULL.

    • Từ PostgreSQL 15+, bạn có thể kiểm soát hành vi này bằng cú pháp:

      SQL

      -- Ngăn chặn cả việc trùng lặp nhiều giá trị NULL
      CREATE TABLE accounts (
          id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
          phone_number VARCHAR(20) UNIQUE NULLS NOT DISTINCT
      );
      
      

1.3. CHECK Constraint: Đưa logic nghiệp vụ cốt lõi vào Database

CHECK constraint cho phép bạn viết một biểu thức Boolean để kiểm tra dữ liệu trước khi INSERT hoặc UPDATE. Nếu biểu thức trả về FALSE, transaction bị hủy ngay lập tức.

SQL

CREATE TABLE products (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL,
    price NUMERIC(12, 2) NOT NULL,
    sale_price NUMERIC(12, 2),
    stock_quantity INT NOT NULL DEFAULT 0,
    -- Kiểm tra giá bán lẻ và giá khuyến mãi
    CONSTRAINT chk_price_positive CHECK (price > 0),
    CONSTRAINT chk_sale_price_valid CHECK (sale_price IS NULL OR (sale_price > 0 AND sale_price < price)),
    -- Kiểm tra số lượng tồn kho không âm
    CONSTRAINT chk_stock_non_negative CHECK (stock_quantity >= 0)
);

2. Toàn vẹn tham chiếu với Foreign Key & Cascade Rules

Khóa ngoại (Foreign Key) đảm bảo rằng một giá trị trong bảng con bắt buộc phải tồn tại trong bảng cha được tham chiếu.

Điểm quan trọng nhất khi thiết kế Khóa ngoại là định nghĩa hành vi: Chuyện gì sẽ xảy ra với các dòng ở bảng con khi dòng ở bảng cha bị xóa (ON DELETE) hoặc bị thay đổi mã (ON UPDATE)?

2.1. Bảng đối chiếu các hành vi ON DELETE

Quy tắc lan truyền Cơ chế hoạt động Kịch bản sử dụng thực tế
RESTRICT Chặn thao tác xóa ở bảng cha nếu còn bất kỳ bảng con nào tham chiếu tới. Ném lỗi ngay lập tức. Mặc định nếu không khai báo. Dùng cho dữ liệu quan trọng như Khách hàng -> Đơn hàng (không thể xóa khách nếu đơn hàng còn lưu).
NO ACTION Tương tự RESTRICT, nhưng cho phép trì hoãn việc kiểm tra tới cuối Transaction nếu cấu hình DEFERRABLE. Dùng khi có các trigger cập nhật trung gian trong cùng một transaction.
CASCADE Tự động xóa sạch toàn bộ các dòng liên quan ở bảng con khi dòng cha bị xóa. Bảng phụ thuộc hoàn toàn vào bảng cha (ví dụ: Xóa Đơn hàng -> Xóa Chi tiết đơn hàng order_items; Xóa Post -> Xóa comments).
SET NULL Chuyển toàn bộ giá trị cột khóa ngoại ở bảng con thành NULL. Cột con bắt buộc phải cho phép nullable. Người tạo bài viết/Người duyệt bị xóa tài khoản, bài viết chuyển về trạng thái vô danh (author_id = NULL).
SET DEFAULT Gán cột khóa ngoại ở bảng con về giá trị mặc định đã khai báo trước đó. Chuyển user bị xóa về tài khoản mặc định system_user.

3. Thực hành: Thiết kế hệ thống Đơn hàng chuẩn hóa

Dưới đây là một mô hình thực tế mô phỏng quan hệ giữa Khách hàng, Đơn hàng và Chi tiết đơn hàng, kết hợp đầy đủ các loại ràng buộc:

SQL

CREATE SCHEMA IF NOT EXISTS sales;

-- 1. Bảng Khách hàng
CREATE TABLE sales.customers (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT uq_customer_email UNIQUE (email),
    CONSTRAINT chk_customer_status CHECK (status IN ('ACTIVE', 'BANNED', 'SUSPENDED'))
);

-- 2. Bảng Đơn hàng (Orders)
CREATE TABLE sales.orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_number VARCHAR(32) NOT NULL UNIQUE,
    customer_id BIGINT NOT NULL,
    total_amount NUMERIC(14, 2) NOT NULL DEFAULT 0.00,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    -- Khóa ngoại: Chặn xóa khách hàng nếu khách hàng đó đã phát sinh đơn hàng
    CONSTRAINT fk_orders_customer 
        FOREIGN KEY (customer_id) 
        REFERENCES sales.customers(id) 
        ON DELETE RESTRICT 
        ON UPDATE CASCADE,
    CONSTRAINT chk_order_amount_non_negative CHECK (total_amount >= 0)
);

-- 3. Bảng Chi tiết đơn hàng (Order Items)
CREATE TABLE sales.order_items (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id BIGINT NOT NULL,
    product_sku VARCHAR(50) NOT NULL,
    unit_price NUMERIC(12, 2) NOT NULL,
    quantity INT NOT NULL,
    -- Khóa ngoại: Nếu Đơn hàng bị xóa, toàn bộ Chi tiết đơn hàng sẽ tự động bị xóa theo
    CONSTRAINT fk_items_order 
        FOREIGN KEY (order_id) 
        REFERENCES sales.orders(id) 
        ON DELETE CASCADE 
        ON UPDATE CASCADE,
    CONSTRAINT chk_item_quantity_positive CHECK (quantity > 0),
    CONSTRAINT chk_unit_price_positive CHECK (unit_price >= 0),
    -- Đảm bảo một sản phẩm chỉ xuất hiện 1 lần trong 1 đơn hàng (tránh trùng record)
    CONSTRAINT uq_order_product UNIQUE (order_id, product_sku)
);

4. Bẫy hiệu năng kinh điển: Khóa ngoại KHÔNG tự động tạo Index!

Đây là một trong những sai lầm phổ biến nhất của các backend engineer khi làm việc với PostgreSQL.

  • Thực tế: Khi bạn tạo PRIMARY KEY hoặc UNIQUE, PostgreSQL tự động tạo index. Nhưng khi bạn tạo FOREIGN KEY, PostgreSQL HOÀN TOÀN KHÔNG TỰ ĐỘNG TẠO INDEX trên cột khóa ngoại của bảng con.

  • Hậu quả nghiêm trọng:

    1. Khi bạn JOIN từ bảng cha sang bảng con (orders JOIN order_items ON orders.id = order_items.order_id), engine buộc phải quét toàn bộ bảng con (Sequential Scan) nếu bảng con không có index trên order_id.

    2. Khi bạn xóa hoặc cập nhật một dòng ở bảng cha (ví dụ xóa 1 dòng trên orders), PostgreSQL phải kiểm tra xem có dòng nào ở order_items tham chiếu tới không. Nếu không có index trên order_items(order_id), hệ thống sẽ quét toàn bộ bảng order_items và thậm chí có thể gây khóa bảng (Table Lock), làm tê liệt hệ thống production.

Quy tắc thực chiến: Luôn luôn tạo index thủ công cho mọi cột Foreign Key ở bảng con!

SQL

-- Bắt buộc phải thêm index cho các cột khóa ngoại:
CREATE INDEX idx_orders_customer_id ON sales.orders(customer_id);
CREATE INDEX idx_order_items_order_id ON sales.order_items(order_id);

5. Kỹ thuật nâng cao: Deferred Constraints (Ràng buộc trì hoãn)

Thông thường, khi bạn chạy một câu lệnh SQL, constraint sẽ được kiểm tra ngay lập tức. Tuy nhiên, có những bài toán nghiệp vụ yêu cầu hoán đổi dữ liệu giữa hai dòng liên kết vòng (Circular dependency), ví dụ: Hai người dùng đổi chỗ danh vị cho nhau trong một danh sách có ràng buộc UNIQUE.

PostgreSQL cho phép khai báo DEFERRABLE để hoãn việc kiểm tra tới thời điểm chạy lệnh COMMIT:

SQL

CREATE TABLE priority_queue (
    task_id BIGINT PRIMARY KEY,
    priority_order INT NOT NULL,
    -- Cho phép hoãn kiểm tra tính duy nhất tới cuối Transaction
    CONSTRAINT uq_priority UNIQUE (priority_order) DEFERRABLE INITIALLY IMMEDIATE
);

-- Khi thực thi transaction phức tạp:
BEGIN;
  -- Đổi sang chế độ trì hoãn cho transaction hiện tại
  SET CONSTRAINTS uq_priority DEFERRED;
  
  -- Hoán đổi thứ tự ưu tiên (tạm thời có thể trùng nhau ở bước trung gian)
  UPDATE priority_queue SET priority_order = 2 WHERE task_id = 100;
  UPDATE priority_queue SET priority_order = 1 WHERE task_id = 200;
  
COMMIT; -- Engine chỉ kiểm tra tính duy nhất tại thời điểm này!

6. Tóm tắt & Bài tiếp theo

  • Đưa validation cốt lõi vào CHECK và NOT NULL constraint ở database giúp bảo vệ hệ thống đa tầng trước các lỗi ứng dụng.

  • Hiểu rõ sự khác biệt giữa ON DELETE CASCADE (xóa lan truyền phụ thuộc) và ON DELETE RESTRICT (bảo vệ dữ liệu lịch sử).

  • Tuyệt đối không quên: PostgreSQL không tự tạo index cho Foreign Key; bạn phải chủ động tạo index cho mọi cột khóa ngoại ở bảng con để tránh Table Lock và Full Table Scan.

Bài 8 xem tiếp: Kiểu dữ liệu nâng cao: UUID, INET, MACADDR và khi nào nên dùng — chúng ta sẽ phân tích lý do tại sao không nên dùng UUID v4 ngẫu nhiên làm Primary Key trên bảng dữ liệu lớn (bệnh Cache Churn và phân mảnh B-Tree) và cách giải quyết bằng UUID v7.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí