1. Vì sao normalize?
Hãy xem xét một bảng không normalize:
orders (chưa normalize):
┌────┬─────────┬──────────────┬────────────┬─────────────────────┐
│ id │ user_id │ user_name │ user_email │ products │
├────┼─────────┼──────────────┼────────────┼─────────────────────┤
│ 1 │ 42 │ Alice │ a@x.com │ "iPhone, Charger" │
│ 2 │ 42 │ Alice │ a@x.com │ "Cable" │
│ 3 │ 43 │ Bob │ b@x.com │ "iPhone" │
│ 4 │ 42 │ Alice Smith │ a@x.com │ "Adapter" │
└────┴─────────┴──────────────┴────────────┴─────────────────────┘
Hai vấn đề lớn:
- Trùng lặp dữ liệu — Alice xuất hiện 3 lần với 3 thông tin user. Khi cô ấy đổi email → phải update 3 dòng. Quên 1 dòng → dữ liệu không nhất quán.
- Cột "products" chứa nhiều giá trị — không thể query "đếm số lần bán iPhone" mà không parse string.
Normalization là quá trình tách bảng thành các bảng nhỏ hơn để: (1) loại bỏ trùng lặp, (2) đảm bảo mỗi cột phụ thuộc vào toàn bộ PK, (3) tránh các anomaly khi insert/update/delete.
2. Ba loại Anomaly
2.1. Update anomaly
Khi cùng một dữ liệu lặp ở nhiều dòng, update phải đụng nhiều nơi. Quên 1 nơi = bug nhất quán.
Trong ví dụ trên: Alice đổi tên thành "Alice Smith" ở order #4, nhưng order #1, #2 vẫn ghi "Alice". → Hệ thống có 2 sự thật.
2.2. Insert anomaly
Không thể thêm dữ liệu mà không có dữ liệu khác đi kèm. Ví dụ: muốn thêm 1 user mới chưa có order — bảng trên không cho phép (vì PK là id của order).
2.3. Delete anomaly
Xóa dữ liệu lại làm mất dữ liệu khác không liên quan. Xóa order cuối cùng của Alice → mất luôn thông tin Alice (email, name).
Normalization ra đời để giải quyết 3 vấn đề này.
3. 1NF — First Normal Form: nguyên tử hóa
Một bảng thuộc 1NF khi:
- Mỗi cột chứa giá trị nguyên tử (không phải list, không phải nested object).
- Mỗi dòng có giá trị unique (có PK).
- Thứ tự dòng và cột không quan trọng.
Bảng vi phạm 1NF:
orders:
| id | products |
|----|----------------------|
| 1 | ["iPhone","Charger"] | ← list trong cột!
| 2 | "Cable, Cable" | ← string nhiều giá trị
Tuân thủ 1NF — tách bảng:
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT
);
CREATE TABLE order_items (
order_id BIGINT REFERENCES orders(id),
product_id BIGINT REFERENCES products(id),
qty INT,
PRIMARY KEY (order_id, product_id)
);
4. Functional Dependency (FD)
Định nghĩa: A → B (đọc: "A xác định B") nếu mọi tuple có cùng giá trị A đều có cùng giá trị B.
Ví dụ:
user_id → email— biết user_id biết email (mỗi user có 1 email).order_id → user_id— mỗi order có 1 user.{order_id, product_id} → qty— composite determinant.
FD là nền tảng để định nghĩa các normal form. Khi bạn nói "cột X phụ thuộc vào PK", thực ra bạn đang nói "PK → X".
4.1. Loại FD đặc biệt
- Trivial FD — A → A (luôn đúng, vô nghĩa).
- Partial FD — chỉ một phần của composite key xác định attribute. Vi phạm 2NF.
- Transitive FD — A → B → C (vi phạm 3NF).
5. 2NF — Second Normal Form: full FD on PK
Bảng 2NF khi:
- Đã ở 1NF.
- Mọi cột không thuộc PK đều phụ thuộc vào TOÀN BỘ PK, không phụ thuộc một phần.
2NF chỉ quan trọng khi PK là composite. Nếu PK là 1 cột (id) thì 2NF tự động thỏa.
Ví dụ vi phạm 2NF:
order_items (PK = order_id + product_id):
| order_id | product_id | qty | product_name | product_price |
|----------|------------|-----|--------------|---------------|
| 1 | 99 | 2 | iPhone | 999 |
| 2 | 99 | 1 | iPhone | 999 |
| 3 | 50 | 5 | Cable | 10 |
product_name, product_price phụ thuộc vào product_id (chỉ 1 phần PK), không phụ thuộc order_id. → vi phạm 2NF.
Tuân thủ 2NF — tách products ra bảng riêng:
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255),
price DECIMAL(10,2)
);
CREATE TABLE order_items (
order_id BIGINT, product_id BIGINT,
qty INT,
PRIMARY KEY (order_id, product_id),
FOREIGN KEY (product_id) REFERENCES products(id)
);
6. 3NF — Third Normal Form: no transitive
Bảng 3NF khi:
- Đã ở 2NF.
- Không có transitive dependency: nếu PK → A → B thì B phải phụ thuộc trực tiếp vào PK, không gián tiếp qua A.
Ví dụ vi phạm 3NF:
employees:
| id (PK) | name | dept_id | dept_name | dept_location |
|---------|-------|---------|-----------|---------------|
| 1 | Anh | 10 | HR | HCM |
| 2 | Bình | 10 | HR | HCM |
| 3 | Cường | 20 | IT | HN |
Phụ thuộc: id → dept_id → dept_name, dept_location. dept_name, dept_location phụ thuộc vào id qua trung gian dept_id → vi phạm 3NF.
Tuân thủ 3NF:
CREATE TABLE departments (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100),
location VARCHAR(100)
);
CREATE TABLE employees (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100),
dept_id BIGINT REFERENCES departments(id)
);
7. BCNF — Boyce-Codd Normal Form
BCNF strict hơn 3NF. Yêu cầu: mọi FD nontrivial X → Y, X phải là superkey.
Ví dụ thường gặp khi 3NF nhưng KHÔNG BCNF:
course_teaches (PK = student_id + course):
| student_id | course | teacher |
|------------|--------|---------|
| 1 | DB | Smith |
| 2 | DB | Smith |
| 3 | OS | Jones |
Giả sử ràng buộc: mỗi teacher dạy đúng 1 course → có FD: teacher → course. Nhưng teacher không phải superkey → vi phạm BCNF.
Tuân thủ BCNF:
CREATE TABLE teachers_courses (
teacher VARCHAR(100) PRIMARY KEY,
course VARCHAR(100)
);
CREATE TABLE student_teachers (
student_id BIGINT,
teacher VARCHAR(100),
PRIMARY KEY (student_id, teacher)
);
Trong thực hành: hầu hết DB tốt chỉ cần đến 3NF. BCNF strict, đôi khi đánh đổi tính đơn giản — chỉ cần thiết cho domain phức tạp hoặc khi 3NF rõ ràng còn anomaly.
8. 4NF & 5NF — nói qua
4NF giải quyết Multi-valued Dependency (MVD) — khi 1 thuộc tính có thể có nhiều giá trị độc lập với 1 thuộc tính khác. Ví dụ: 1 sinh viên có nhiều môn học và nhiều câu lạc bộ — không nên gộp 2 list vào 1 bảng.
5NF (Project-join NF) liên quan join dependency — rất hiếm gặp trong thực hành.
99% projects không cần đụng đến 4NF/5NF. Hiểu 1NF–3NF + biết khi nào denormalize là đủ.
9. Khi nào DENORMALIZE?
Normalize đầy đủ → schema đẹp nhưng có thể chậm. Khi đó denormalize có chọn lọc. Quy tắc:
- Bắt đầu bằng normalize. Đẹp, dễ bảo trì, ít bug.
- Đo lường trước khi optimize. Đừng denormalize vì cảm tính.
- Khi read-heavy + JOIN nóng quá nhiều → denormalize cụ thể.
- Đảm bảo cách đồng bộ dữ liệu trùng (trigger, app, queue).
9.1. Các pattern denormalize phổ biến
Cached counters
-- Thay vì JOIN + COUNT mỗi lần render:
ALTER TABLE posts ADD COLUMN comment_count INT DEFAULT 0;
-- Update qua trigger hoặc app:
CREATE TRIGGER ... AFTER INSERT ON comments ...
UPDATE posts SET comment_count = comment_count + 1 WHERE id = NEW.post_id;
Materialized aggregate
CREATE MATERIALIZED VIEW user_stats AS
SELECT user_id, COUNT(*) AS orders, SUM(total) AS spent
FROM orders GROUP BY user_id;
-- Refresh định kỳ (cron) hoặc concurrent:
REFRESH MATERIALIZED VIEW CONCURRENTLY user_stats;
Wide table cho read
-- Thay vì JOIN 5 bảng cho dashboard, copy snapshot:
CREATE TABLE order_summary (
order_id BIGINT PRIMARY KEY,
user_email VARCHAR, -- denormalized
user_country VARCHAR, -- denormalized
product_names TEXT[],
total DECIMAL,
created_at TIMESTAMPTZ
);
Tiền-tính sort key
-- Để dùng index cho ORDER BY phức tạp:
ALTER TABLE posts ADD COLUMN hot_score DECIMAL;
-- Update mỗi vote/comment:
UPDATE posts SET hot_score = (votes - 1) / EXTRACT(EPOCH FROM (NOW() - created_at)) ...;
CREATE INDEX idx_hot ON posts(hot_score DESC);
10. ERD — Entity Relationship Diagram
10.1. Các thành phần
- Entity — đối tượng nghiệp vụ → một bảng (User, Order, Product).
- Attribute — thuộc tính → cột.
- Relationship — quan hệ giữa entity (User places Order).
- Cardinality — số lượng đối ứng (1-1, 1-N, N-N).
10.2. Cardinality
| Loại | Mô tả | Ví dụ | Cách triển khai |
|---|---|---|---|
| 1-1 | Mỗi A có đúng 1 B, mỗi B có đúng 1 A | User ↔ UserProfile | FK + UNIQUE constraint |
| 1-N | Mỗi A có nhiều B, mỗi B thuộc về 1 A | User → Orders | FK ở phía N (orders.user_id) |
| N-N | Mỗi A có nhiều B và ngược lại | Students ↔ Courses | Bảng nối (junction): enrollments |
10.3. Quan hệ tham gia (participation)
- Total (mandatory) — mọi instance bên A bắt buộc có B → FK NOT NULL.
- Partial (optional) — không bắt buộc → FK nullable.
10.4. Ví dụ ERD ASCII
11. Naming conventions & Anti-patterns
11.1. Naming
- snake_case cho table và column. SQL không phân biệt hoa thường mặc định, snake tránh nhầm lẫn.
- Tên bảng số nhiều:
users,orders,order_items. (Hoặc số ít — nhất quán cả project.) - Tên cột số ít:
email,created_at. - FK:
user_id,category_id— luôn có suffix_id. - Boolean:
is_active,has_avatar,can_edit. - Timestamp:
created_at,updated_at,deleted_at. - Index:
idx_users_email(idx_table_col). - Constraint:
users_email_uniq,orders_user_fk.
11.2. Anti-patterns
EAV (Entity-Attribute-Value)
attributes:
| entity_id | key | value |
| 1 | "name" | "iPhone" |
| 1 | "price" | "999" |
| 1 | "color" | "black" |
"Linh hoạt vô hạn" nhưng cực chậm, không có type safety, JOIN dày đặc. Chỉ dùng khi schema thực sự dynamic (form builder, custom field). Cân nhắc JSONB thay thế.
Magic strings
status VARCHAR(20) -- 'paid' | 'pending' | ?? typo 'pendng' không ai catch
Sửa: dùng CHECK (status IN (...)), ENUM, hoặc bảng status riêng có FK.
Chiếc bảng "kitchen sink"
1 bảng 50+ cột vì "tất cả thông tin về user". Khi vài cột chỉ liên quan với nhau (preferences, billing, profile) — tách bảng theo concern.
Polymorphic FK
comments(commentable_id, commentable_type) -- 'post' | 'video' | 'product'
FK constraint không enforce được, JOIN khó. Cách thay thế: bảng riêng cho từng (post_comments, video_comments) hoặc bảng polymorphic + UNION view.
UUID v4 làm clustered PK
UUID v4 random → INSERT chèn random vào B-tree → fragmentation, page split, ghi chậm. Dùng UUID v7 (time-ordered) hoặc BIGSERIAL nếu OK.
Soft delete bừa bãi
Thêm deleted_at vào mọi bảng → mọi query phải WHERE deleted_at IS NULL. Quên 1 chỗ = bug bảo mật. Cân nhắc archive table riêng.
12. Walkthrough: thiết kế schema cho Twitter mini
Bước 1 — Liệt kê entity nghiệp vụ:
- User
- Tweet (post)
- Follow (quan hệ N-N giữa user)
- Like
- Retweet
- Reply (thread)
- Hashtag
- Direct Message
Bước 2 — Top truy vấn cần phục vụ:
- Timeline của 1 user (tweets từ những người họ follow, sắp theo created_at).
- Trang user X — tweets của X + thông tin profile.
- Search hashtag #abc.
- Đếm follower / following.
- Like + retweet count cho mỗi tweet.
Bước 3 — Schema:
-- Users
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100),
bio TEXT,
avatar_url VARCHAR(500),
follower_count INT NOT NULL DEFAULT 0, -- denormalized
following_count INT NOT NULL DEFAULT 0, -- denormalized
tweet_count INT NOT NULL DEFAULT 0, -- denormalized
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Tweets
CREATE TABLE tweets (
id BIGSERIAL PRIMARY KEY,
author_id BIGINT NOT NULL REFERENCES users(id),
content VARCHAR(280) NOT NULL,
reply_to_id BIGINT REFERENCES tweets(id), -- thread
retweet_of_id BIGINT REFERENCES tweets(id), -- retweet
like_count INT NOT NULL DEFAULT 0, -- denormalized
retweet_count INT NOT NULL DEFAULT 0, -- denormalized
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tweets_author_created ON tweets(author_id, created_at DESC);
-- Follows (N-N self join)
CREATE TABLE follows (
follower_id BIGINT NOT NULL REFERENCES users(id),
followee_id BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (follower_id, followee_id),
CHECK (follower_id != followee_id)
);
CREATE INDEX idx_follows_followee ON follows(followee_id); -- "ai follow X?"
-- Likes
CREATE TABLE likes (
user_id BIGINT NOT NULL REFERENCES users(id),
tweet_id BIGINT NOT NULL REFERENCES tweets(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, tweet_id)
);
-- Hashtags (N-N)
CREATE TABLE hashtags (
id BIGSERIAL PRIMARY KEY,
tag VARCHAR(100) UNIQUE NOT NULL
);
CREATE TABLE tweet_hashtags (
tweet_id BIGINT NOT NULL REFERENCES tweets(id),
hashtag_id BIGINT NOT NULL REFERENCES hashtags(id),
PRIMARY KEY (tweet_id, hashtag_id)
);
CREATE INDEX idx_th_hashtag ON tweet_hashtags(hashtag_id);
-- Direct Messages (1-N giữa users; phòng chat đơn giản hóa)
CREATE TABLE conversations (
id BIGSERIAL PRIMARY KEY,
user_a_id BIGINT NOT NULL REFERENCES users(id),
user_b_id BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (user_a_id < user_b_id),
UNIQUE (user_a_id, user_b_id)
);
CREATE TABLE messages (
id BIGSERIAL PRIMARY KEY,
conversation_id BIGINT NOT NULL REFERENCES conversations(id),
sender_id BIGINT NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_messages_conv_created ON messages(conversation_id, created_at DESC);
Phân tích:
- Counter denormalize (follower_count, like_count) — read-heavy, cập nhật qua trigger hoặc app.
- Bảng nối follows, likes, tweet_hashtags — composite PK = (a_id, b_id).
- Index theo top query: timeline → idx tweets(author_id, created_at DESC).
- CHECK trên follows ngăn user follow chính mình.
- conversations dùng
user_a_id < user_b_idđể chuẩn hóa cặp, tránh 2 hàng (A,B) và (B,A) cho cùng 1 cuộc.
13. Bài tập
Bài 1. Bảng sau vi phạm normal form nào? Sửa lại đúng 3NF:
course_offering:
| id | course_code | course_name | semester | teacher_id | teacher_name | teacher_dept |
|----|-------------|-------------|----------|------------|--------------|--------------|
| 1 | CS101 | DB Intro | Fall26 | 7 | Smith | CS |
| 2 | CS101 | DB Intro | Spring27 | 7 | Smith | CS |
Bài 2. Thiết kế schema cho 1 hệ E-commerce mini với: user, product, category (cây phân cấp), cart, order, order_item, payment, address. Vẽ ERD và viết CREATE TABLE.
Bài 3. Bạn cần lưu "preferences" của user — tập key-value động (theme, language, notifications…). Cách nào tốt hơn:
- (a) bảng
user_preferences(user_id, key, value)EAV - (b) cột
preferences JSONBtrên users - (c) các cột boolean cố định:
theme, lang, ...
Đưa ra phân tích trade-off và chọn.
Bài 4. Xác định cardinality giữa các cặp:
- User ↔ Email (1 user có nhiều email được lưu)
- Order ↔ Address (mỗi order có 1 shipping + 1 billing)
- Movie ↔ Actor
- Book ↔ Author
- User ↔ Profile
Bài 5. Khi nào denormalize follower_count là hợp lý? Khi nào thì NÊN tính lại từ SELECT COUNT(*) FROM follows?
14. Quiz
Quiz cuối Chương 4
Bảng orders(id PK, user_id, user_email, total) vi phạm:
2NF chỉ liên quan đến:
Câu mantra Codd về normal forms:
Khi nào denormalize HỢP LÝ?
EAV (Entity-Attribute-Value) là pattern:
Quan hệ N-N giữa Students và Courses thường được triển khai bằng:
UUID v4 (random) làm clustered PK trên B-tree:
Khi cần query "tất cả con cháu của node X" trong cây phân cấp, lựa chọn tốt là:
Hoàn thành Chương 4. Tiếp theo: Chương 5 — Indexes (B-tree, Hash, GIN, Composite) →