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,NULLbiể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 KEYhoặcUNIQUE, PostgreSQL tự động tạo index. Nhưng khi bạn tạoFOREIGN 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:
-
Khi bạn
JOINtừ 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ênorder_id. -
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_itemstham chiếu tới không. Nếu không có index trênorder_items(order_id), hệ thống sẽ quét toàn bộ bảngorder_itemsvà 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
CHECKvàNOT NULLconstraint ở 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,MACADDRvà 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ùngUUID v4ngẫ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ằngUUID v7.
All rights reserved