SQL 5 — Thiết kế CSDL & Chuẩn hoá
Vì sao thiết kế CSDL lại quan trọng
Một truy vấn chậm thường có thể vá bằng index. Nhưng một lược đồ (schema) sai thì khó sửa hơn nhiều: dữ liệu đã dư thừa, đã mâu thuẫn, và mọi ứng dụng phía trên đều phụ thuộc vào cấu trúc bảng đó. Vì vậy, thiết kế đúng từ đầu là khoản đầu tư rẻ nhất bạn có thể làm.
Bài này đi từ những viên gạch nền — khoá và ràng buộc — đến cách mô hình hoá quan hệ giữa các thực thể, rồi tới chuẩn hoá (1NF, 2NF, 3NF) bằng ví dụ "trước/sau" cụ thể. Cuối cùng là khi nào nên cố tình phi chuẩn hoá để đọc nhanh hơn.
Bối cảnh xuyên suốt: một hệ thống ngân hàng đơn giản gồm khách hàng — tài khoản — giao dịch.
Khoá: định danh và liên kết dữ liệu
Khoá chính (Primary Key)
Khoá chính là cột (hoặc tập cột) định danh duy nhất mỗi dòng trong bảng. Quy tắc:
- Không bao giờ NULL.
- Không bao giờ trùng.
- Lý tưởng là không bao giờ thay đổi giá trị.
-- (minh hoạ, không chạy trong sandbox)
CREATE TABLE customer (
customer_id BIGINT PRIMARY KEY,
full_name VARCHAR(120) NOT NULL,
national_id VARCHAR(20) NOT NULL
);
Khoá tự nhiên vs khoá thay thế
Đây là một quyết định thiết kế cốt lõi.
- Khoá tự nhiên (natural key): dùng dữ liệu nghiệp vụ vốn có làm khoá, ví dụ
national_id(số CMND/CCCD),email, hay mã ISO quốc gia. - Khoá thay thế (surrogate key): một giá trị nhân tạo không mang ý nghĩa nghiệp vụ, thường là số tự tăng (
BIGINT IDENTITY/SERIAL) hoặcUUID.
Vì sao surrogate key thường được ưa chuộng trong thực tế:
- Ổn định: số CCCD có thể bị cấp lại, email có thể đổi — nhưng
customer_id = 1024thì không bao giờ đổi. - Gọn và đồng nhất: khoá ngoại trỏ tới một
BIGINTnhanh và nhẹ hơn trỏ tới chuỗi dài. - Tránh lộ thông tin: dùng số nội bộ thay vì để national_id rải khắp các bảng.
Nhược điểm: khoá thay thế không tự ngăn trùng lặp nghiệp vụ. Vì vậy ngay cả khi đã có surrogate key, ta vẫn nên đặt UNIQUE lên khoá tự nhiên (xem phần ràng buộc).
Khoá tổng hợp (Composite Key)
Khi không một cột đơn lẻ nào định danh được dòng, ta ghép nhiều cột. Thường gặp ở bảng trung gian của quan hệ n-n.
-- (minh hoạ, không chạy trong sandbox)
CREATE TABLE account_holder (
account_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
role VARCHAR(20) NOT NULL, -- 'owner', 'authorized'...
PRIMARY KEY (account_id, customer_id)
);
Cặp (account_id, customer_id) là duy nhất: một khách hàng chỉ xuất hiện một lần cho mỗi tài khoản.
Khoá ngoại (Foreign Key)
Khoá ngoại là cột tham chiếu tới khoá chính của bảng khác, tạo nên liên kết và toàn vẹn tham chiếu (referential integrity): không thể có giao dịch trỏ tới một tài khoản không tồn tại.
-- (minh hoạ, không chạy trong sandbox)
CREATE TABLE account (
account_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
balance NUMERIC(18,2) NOT NULL DEFAULT 0,
FOREIGN KEY (customer_id) REFERENCES customer(customer_id)
);
Bạn có thể điều khiển hành vi khi dòng cha bị xoá/sửa:
ON DELETE CASCADE— xoá luôn các dòng con.ON DELETE RESTRICT(mặc định ở nhiều hệ) — chặn xoá nếu còn con.ON DELETE SET NULL— đặt khoá ngoại về NULL.
Hãy chọn có chủ đích. Với dữ liệu tài chính, RESTRICT thường an toàn hơn CASCADE vì bạn không muốn xoá khách hàng lại làm bay sạch lịch sử giao dịch.
Ràng buộc (Constraints): để CSDL tự bảo vệ dữ liệu
Ràng buộc giúp dữ liệu luôn hợp lệ bất kể ứng dụng nào ghi vào. Đừng phó mặc toàn bộ việc kiểm tra cho tầng ứng dụng — CSDL là tuyến phòng thủ cuối cùng.
| Ràng buộc | Ý nghĩa |
|---|---|
NOT NULL | Cột bắt buộc có giá trị |
UNIQUE | Giá trị không được trùng |
CHECK | Giá trị phải thoả điều kiện |
DEFAULT | Giá trị mặc định khi không nhập |
FOREIGN KEY | Phải trỏ tới dòng tồn tại ở bảng cha |
PRIMARY KEY | NOT NULL + UNIQUE, định danh dòng |
Ví dụ tổng hợp:
-- (minh hoạ, không chạy trong sandbox)
CREATE TABLE account (
account_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
account_no VARCHAR(20) NOT NULL UNIQUE, -- khoá tự nhiên
currency CHAR(3) NOT NULL DEFAULT 'VND',
balance NUMERIC(18,2) NOT NULL DEFAULT 0
CHECK (balance >= 0), -- không cho âm
status VARCHAR(10) NOT NULL DEFAULT 'active'
CHECK (status IN ('active','frozen','closed')),
FOREIGN KEY (customer_id) REFERENCES customer(customer_id)
);
Lưu ý vài điều tinh tế:
- UNIQUE và NULL: trong chuẩn SQL, nhiều NULL không bị coi là trùng nhau, nên một cột UNIQUE vẫn cho phép nhiều dòng NULL.
- CHECK chỉ nên dùng cho điều kiện trong phạm vi một dòng. Quy tắc liên dòng (ví dụ "tổng dư nợ ≤ hạn mức") thường cần trigger hoặc xử lý ở tầng nghiệp vụ.
- Đặt
status IN (...)bằng CHECK là một dạng "enum nghèo" — đơn giản và đủ dùng; với danh mục hay thay đổi thì nên tách thành bảng tham chiếu (lookup table).
Quan hệ giữa các thực thể
Một-một (1:1)
Mỗi dòng A ứng với tối đa một dòng B và ngược lại. Thường dùng để tách cột ít dùng/nhạy cảm ra bảng riêng, ví dụ tách thông tin định danh KYC nhạy cảm khỏi bảng khách hàng chính.
-- (minh hoạ, không chạy trong sandbox)
CREATE TABLE customer_kyc (
customer_id BIGINT PRIMARY KEY, -- vừa PK vừa FK
id_document VARCHAR(40) NOT NULL,
verified_at TIMESTAMP,
FOREIGN KEY (customer_id) REFERENCES customer(customer_id)
);
Mẹo: cho khoá chính của bảng phụ trùng luôn với khoá ngoại — đó là cách áp 1:1 chặt chẽ.
Một-nhiều (1:n)
Phổ biến nhất. Một khách hàng có nhiều tài khoản; một tài khoản có nhiều giao dịch. Cài đặt bằng cách đặt khoá ngoại ở bên "nhiều":
-- (minh hoạ, không chạy trong sandbox)
CREATE TABLE transaction (
txn_id BIGINT PRIMARY KEY,
account_id BIGINT NOT NULL, -- bên "nhiều" giữ FK
amount NUMERIC(18,2) NOT NULL,
txn_type VARCHAR(10) NOT NULL CHECK (txn_type IN ('debit','credit')),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES account(account_id)
);
Nhiều-nhiều (n:n)
Một khách hàng có thể đồng sở hữu nhiều tài khoản, và một tài khoản có thể có nhiều đồng sở hữu. SQL không biểu diễn trực tiếp n:n; ta tách thành hai quan hệ 1:n qua một bảng trung gian (junction/bridge table) — chính là bảng account_holder ở trên, với khoá tổng hợp (account_id, customer_id).
Bảng trung gian còn là nơi tự nhiên để lưu thuộc tính của chính mối quan hệ, ví dụ cột role ('owner' / 'authorized').
Sơ đồ ERD
Đọc sơ đồ: ||--o{ nghĩa là "một và chỉ một" phía bên trái ứng với "không hoặc nhiều" phía bên phải (1:n). CUSTOMER và ACCOUNT nối với nhau gián tiếp qua ACCOUNT_HOLDER, thể hiện quan hệ n:n.
Chuẩn hoá: loại bỏ dư thừa một cách có hệ thống
Chuẩn hoá là quá trình tổ chức lại các bảng để giảm dư thừa và tránh các dị thường (anomaly) khi thêm/sửa/xoá. Ta sẽ đi qua ba bậc đầu — đủ dùng cho hầu hết hệ thống nghiệp vụ.
Hãy bắt đầu từ một bảng "tất cả trong một" tệ hại, ghi lại từng giao dịch kèm mọi thông tin liên quan:
Trước chuẩn hoá (dư thừa)
| txn_id | cust_name | cust_phone | account_no | currency | products | amount |
|---|---|---|---|---|---|---|
| 1 | Lan Nguyen | 0901, 0902 | ACC-001 | VND | Phí thẻ; Lãi tiết kiệm | 500000 |
| 2 | Lan Nguyen | 0901, 0902 | ACC-001 | VND | Phí SMS | 11000 |
| 3 | Minh Tran | 0987 | ACC-002 | VND | Phí thẻ | 500000 |
Bảng này mắc đủ loại lỗi:
- Dị thường khi sửa: nếu Lan đổi tên, phải sửa nhiều dòng; bỏ sót một dòng là dữ liệu mâu thuẫn.
- Dị thường khi xoá: xoá giao dịch cuối của một khách có thể xoá luôn thông tin khách hàng.
- Dị thường khi thêm: không thể lưu một khách hàng mới chưa có giao dịch nào.
1NF — Mỗi ô một giá trị nguyên tử
Quy tắc 1NF: mỗi ô chứa đúng một giá trị (không danh sách, không lặp nhóm), và mỗi dòng là duy nhất.
Vấn đề ở bảng trên: cust_phone chứa "0901, 0902" và products chứa "Phí thẻ; Lãi tiết kiệm" — đây là các ô đa trị, vi phạm 1NF.
Sau 1NF
Tách giá trị đa trị thành nhiều dòng / bảng riêng. Ví dụ, số điện thoại tách ra bảng riêng:
customer_phone(customer_id, phone)
(101, '0901')
(101, '0902')
(102, '0987')
Và mỗi giao dịch chỉ gắn với một sản phẩm; nếu một giao dịch thật sự có nhiều dòng sản phẩm thì đó là quan hệ 1:n cần bảng transaction_line riêng. Sau bước này, không còn ô nào chứa danh sách.
2NF — Loại phụ thuộc bộ phận vào khoá
Quy tắc 2NF: đã ở 1NF, và mọi cột không khoá phải phụ thuộc vào TOÀN BỘ khoá chính, không phụ thuộc vào chỉ một phần.
2NF chỉ có ý nghĩa khi bảng có khoá tổng hợp. Xét một bảng dòng giao dịch với khoá (txn_id, product_id):
Trước 2NF
| txn_id | product_id | product_name | txn_date | line_amount |
|---|---|---|---|---|
| 1 | P-10 | Phí thẻ | 2026-06-01 | 500000 |
| 1 | P-20 | Lãi tiết kiệm | 2026-06-01 | 30000 |
| 2 | P-30 | Phí SMS | 2026-06-02 | 11000 |
Phân tích phụ thuộc:
product_namephụ thuộc chỉ vàoproduct_id(một phần của khoá) → phụ thuộc bộ phận, vi phạm 2NF. Hệ quả:product_namecủa P-10 bị lặp ở mọi giao dịch dùng nó.txn_datephụ thuộc chỉ vàotxn_id→ cũng phụ thuộc bộ phận.
Sau 2NF
Tách các phần phụ thuộc bộ phận ra bảng riêng:
product(product_id PK, product_name)
transaction(txn_id PK, txn_date, ...)
transaction_line(txn_id, product_id, line_amount) -- PK (txn_id, product_id)
Giờ tên sản phẩm chỉ lưu một chỗ trong product; ngày giao dịch chỉ lưu một chỗ trong transaction.
3NF — Loại phụ thuộc bắc cầu
Quy tắc 3NF: đã ở 2NF, và không cột không khoá nào phụ thuộc vào một cột không khoá khác (loại bỏ phụ thuộc bắc cầu — transitive dependency).
Xét bảng tài khoản nhúng cả thông tin chi nhánh:
Trước 3NF
| account_id | branch_id | branch_name | branch_city |
|---|---|---|---|
| 1 | B-01 | CN Hà Nội | Hà Nội |
| 2 | B-01 | CN Hà Nội | Hà Nội |
| 3 | B-02 | CN Đà Nẵng | Đà Nẵng |
Ở đây account_id → branch_id → branch_name, branch_city. Tức branch_name phụ thuộc vào branch_id (cột không khoá), rồi mới gián tiếp gắn với khoá. Đây là phụ thuộc bắc cầu: tên và thành phố chi nhánh bị lặp ở mọi tài khoản cùng chi nhánh, và đổi tên chi nhánh phải sửa nhiều dòng.
Sau 3NF
-- (minh hoạ, không chạy trong sandbox)
CREATE TABLE branch (
branch_id VARCHAR(10) PRIMARY KEY,
branch_name VARCHAR(60) NOT NULL,
branch_city VARCHAR(40) NOT NULL
);
CREATE TABLE account (
account_id BIGINT PRIMARY KEY,
branch_id VARCHAR(10) NOT NULL,
-- ... các cột khác
FOREIGN KEY (branch_id) REFERENCES branch(branch_id)
);
Bây giờ thuộc tính của chi nhánh chỉ tồn tại đúng một lần trong bảng branch.
Câu thần chú ghi nhớ: mỗi cột không khoá phải phụ thuộc vào khoá (1NF gom dữ liệu nguyên tử), vào toàn bộ khoá (2NF), và không gì ngoài khoá (3NF) — "the key, the whole key, and nothing but the key".
Khi nào nên CỐ TÌNH phi chuẩn hoá
Chuẩn hoá tối ưu cho toàn vẹn dữ liệu và ghi (write): mỗi sự thật lưu đúng một nơi. Nhưng nó đánh đổi bằng việc đọc phải JOIN nhiều bảng. Trong một số tình huống, ta cố tình đi ngược lại — phi chuẩn hoá (denormalization) — để đọc nhanh hơn.
Các trường hợp hợp lý:
- Báo cáo & BI / data warehouse: mô hình sao (star schema) cố tình gom thuộc tính vào bảng dimension rộng để tránh JOIN sâu khi tổng hợp. Đọc nhiều, ghi theo mẻ — đánh đổi này đáng giá.
- Cột tổng hợp được tính sẵn (precomputed): lưu
account.balancethay vì luônSUMtoàn bộ giao dịch; hay lưuorder.total_amountthay vì cộng lại các dòng hàng mỗi lần đọc. - Sao chép dữ liệu ít đổi: nhúng
currencyhay tên danh mục để tránh JOIN bảng tra cứu trong truy vấn nóng.
Cái giá phải trả luôn là nguy cơ mâu thuẫn: dữ liệu trùng lặp phải được giữ đồng bộ. Khi phi chuẩn hoá, hãy:
- Có cơ chế cập nhật rõ ràng (trigger, job nền, hoặc logic ứng dụng) để các bản sao luôn khớp.
- Ghi lại lý do trong tài liệu để người sau không tưởng nhầm là lỗi thiết kế.
- Đo đạc trước: chỉ phi chuẩn hoá khi có bằng chứng JOIN thật sự là nút cổ chai.
Nguyên tắc thực dụng: chuẩn hoá tới 3NF trước, rồi mới phi chuẩn hoá có chủ đích ở những điểm nóng đã đo được — đừng làm ngược lại.
Tóm tắt
- Khoá chính định danh dòng; ưu tiên khoá thay thế (surrogate) cho sự ổn định, nhưng vẫn đặt UNIQUE lên khoá tự nhiên.
- Khoá tổng hợp thường xuất hiện ở bảng trung gian của quan hệ n:n; khoá ngoại đảm bảo toàn vẹn tham chiếu.
- Ràng buộc (NOT NULL, UNIQUE, CHECK, DEFAULT, FK) để CSDL tự bảo vệ dữ liệu, không phó mặc tầng ứng dụng.
- Quan hệ: 1:1 (tách bảng), 1:n (FK ở bên "nhiều"), n:n (bảng trung gian).
- Chuẩn hoá: 1NF (giá trị nguyên tử), 2NF (bỏ phụ thuộc bộ phận), 3NF (bỏ phụ thuộc bắc cầu) — diệt dị thường thêm/sửa/xoá.
- Phi chuẩn hoá là công cụ có chủ đích cho đọc nhanh/BI, chấp nhận giữ đồng bộ — chuẩn hoá trước, phi chuẩn hoá sau.
Tự kiểm tra
- Khác biệt giữa khoá tự nhiên và khoá thay thế là gì, và vì sao thực tế thường dùng khoá thay thế?
- Quan hệ n:n được cài đặt trong SQL như thế nào, và khoá chính của bảng trung gian thường gồm những cột nào?
- Cho bảng
(txn_id, product_id, product_name)với khoá(txn_id, product_id)— bảng này vi phạm bậc chuẩn hoá nào và sửa ra sao? - "Phụ thuộc bắc cầu" trong 3NF nghĩa là gì? Cho một ví dụ.
- Nêu hai tình huống mà phi chuẩn hoá là lựa chọn hợp lý, kèm cái giá phải trả.
- Vì sao nên đặt ràng buộc CHECK/FK ở CSDL thay vì chỉ kiểm tra ở tầng ứng dụng?
Đọc tiếp
Bài viết liên quan
date_trunc/extract/age, cộng trừ khoảng thời gian, hàm chuỗi, ép kiểu CAST, COALESCE/NULLIF và làm tròn.
Đưa logic vào trong CSDL: viết function và procedure bằng PL/pgSQL (biến, điều khiển luồng, vòng lặp, xử lý ngoại lệ), sự khác nhau function vs procedure, trigger & trigger function, và khi nào NÊN/KHÔNG NÊN đặt logic ở tầng DB.
Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.
Kết nhiều bảng đúng cách: các loại JOIN, bẫy thường gặp và phép hợp tập.