Giao diện
Database Design: Normalization (1NF, 2NF, 3NF)
Normalization là nghệ thuật tổ chức dữ liệu để loại bỏ dư thừa và đảm bảo tính toàn vẹn. Bài này sẽ dẫn bạn qua 3 Normal Forms cơ bản và khi nào nên "phá luật".
1️⃣ The Forms: Ba cấp độ chuẩn hóa
First Normal Form (1NF): Atomic Values
Mỗi ô trong bảng chỉ chứa MỘT giá trị duy nhất, không phải danh sách.
❌ Vi phạm 1NF:
| order_id | customer | products |
|---|---|---|
| 1 | Nguyễn Văn A | iPhone, AirPods, Case |
| 2 | Trần Thị B | MacBook, Magic Mouse |
Vấn đề:
- Làm sao lọc đơn hàng có "AirPods"? Phải dùng
LIKE '%AirPods%'- chậm, không dùng được index. - Làm sao đếm số lượng từng sản phẩm? Không thể.
✅ Đạt 1NF:
| order_id | customer | product |
|---|---|---|
| 1 | Nguyễn Văn A | iPhone |
| 1 | Nguyễn Văn A | AirPods |
| 1 | Nguyễn Văn A | Case |
| 2 | Trần Thị B | MacBook |
| 2 | Trần Thị B | Magic Mouse |
Hoặc tốt hơn: Tách thành 2 bảng orders và order_items.
sql
-- 1NF: Tách data thành atomic values
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_name VARCHAR(100) NOT NULL
);
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INT REFERENCES orders(id),
product_name VARCHAR(100) NOT NULL
);Second Normal Form (2NF): No Partial Dependency
Điều kiện tiên quyết: Đã đạt 1NF.
Mọi non-key column phải phụ thuộc vào TOÀN BỘ Primary Key, không chỉ một phần.
❌ Vi phạm 2NF: (Composite Key + Partial Dependency)
| student_id | course_id | student_name | course_name | grade |
|---|---|---|---|---|
| 1 | CS101 | Nguyễn Văn A | Intro to Programming | A |
| 1 | CS102 | Nguyễn Văn A | Data Structures | B |
| 2 | CS101 | Trần Thị B | Intro to Programming | A+ |
Primary Key: (student_id, course_id)
Vấn đề Partial Dependency:
student_namechỉ phụ thuộc vàostudent_id, KHÔNG phụ thuộccourse_id.course_namechỉ phụ thuộc vàocourse_id, KHÔNG phụ thuộcstudent_id.
Hậu quả:
- Dư thừa: "Nguyễn Văn A" lặp lại mỗi lần đăng ký môn mới.
- Update Anomaly: Đổi tên sinh viên -> Phải update nhiều dòng.
✅ Đạt 2NF: Tách ra 3 bảng
sql
-- Bảng Students: student_name chỉ phụ thuộc student_id
CREATE TABLE students (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
-- Bảng Courses: course_name chỉ phụ thuộc course_id
CREATE TABLE courses (
id VARCHAR(10) PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
-- Bảng Enrollments: grade phụ thuộc cả (student_id, course_id)
CREATE TABLE enrollments (
student_id INT REFERENCES students(id),
course_id VARCHAR(10) REFERENCES courses(id),
grade CHAR(2),
PRIMARY KEY (student_id, course_id)
);Third Normal Form (3NF): No Transitive Dependency
Điều kiện tiên quyết: Đã đạt 2NF.
Non-key column không được phụ thuộc vào non-key column khác.
❌ Vi phạm 3NF: (Transitive Dependency)
| employee_id | employee_name | department_id | department_name | department_location |
|---|---|---|---|---|
| 1 | Nguyễn Văn A | D01 | Engineering | Tầng 5 |
| 2 | Trần Thị B | D01 | Engineering | Tầng 5 |
| 3 | Lê Văn C | D02 | Sales | Tầng 3 |
Primary Key: employee_id
Vấn đề Transitive Dependency:
employee_id -> department_id -> department_name
employee_id -> department_id -> department_locationdepartment_namevàdepartment_locationphụ thuộc vàodepartment_id, không trực tiếp vàoemployee_id.
Hậu quả:
- Dư thừa: "Engineering, Tầng 5" lặp lại nhiều lần.
- Delete Anomaly: Xóa nhân viên cuối cùng của phòng ban -> Mất thông tin phòng ban.
✅ Đạt 3NF: Tách bảng Departments
sql
CREATE TABLE departments (
id VARCHAR(10) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
location VARCHAR(100)
);
CREATE TABLE employees (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
department_id VARCHAR(10) REFERENCES departments(id)
);2️⃣ Denormalization: Khi nào "phá luật"?
Normalization giúp ghi dữ liệu dễ dàng và tránh anomaly. Nhưng nó có trade-off: đọc dữ liệu chậm hơn vì phải JOIN nhiều bảng.
Khi nào Denormalize?
| Tình huống | Ví dụ | Giải pháp |
|---|---|---|
| Read >> Write | Dashboard báo cáo, Analytics | Lưu dữ liệu đã tính toán sẵn |
| Tránh JOIN phức tạp | Profile page cần 5 bảng | Lưu snapshot vào 1 bảng |
| Caching ở DB level | Số đơn hàng của user | order_count column trong users |
Ví dụ Denormalization thực tế
Normalized (3NF):
sql
-- Để hiển thị thông tin đơn hàng, cần JOIN 4 bảng
SELECT
o.id,
u.name AS customer_name,
u.email,
p.name AS product_name,
p.price
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON oi.product_id = p.id
WHERE o.id = 123;Denormalized (cho Reporting):
sql
-- Bảng order_reports lưu sẵn dữ liệu cần thiết
CREATE TABLE order_reports (
order_id INT PRIMARY KEY,
customer_name VARCHAR(100),
customer_email VARCHAR(255),
products JSONB, -- Lưu array sản phẩm
total_items INT,
total_amount DECIMAL(15,2),
created_at TIMESTAMPTZ
);
-- Query cực nhanh, không cần JOIN
SELECT * FROM order_reports WHERE order_id = 123;⚠️ Trade-off của Denormalization
- Data Inconsistency: Khi
users.namethay đổi,order_reports.customer_namekhông tự cập nhật. - Storage tăng: Dữ liệu trùng lặp chiếm nhiều disk.
- Write complexity: Phải update nhiều nơi khi dữ liệu gốc thay đổi.
Giải pháp: Dùng Triggers hoặc Materialized Views để tự động sync.
3️⃣ Case Study: Thiết kế E-commerce Schema
Yêu cầu
- Quản lý Users, Products, Orders
- Một đơn hàng có nhiều sản phẩm
- Sản phẩm thuộc category (category có thể lồng nhau)
- Lưu lịch sử giá sản phẩm
Schema Design (3NF)
sql
-- ================================================================
-- USERS
-- ================================================================
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
full_name VARCHAR(100) NOT NULL,
phone VARCHAR(20),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ================================================================
-- CATEGORIES (Self-referencing for hierarchy)
-- ================================================================
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_id INT REFERENCES categories(id) ON DELETE SET NULL,
slug VARCHAR(100) NOT NULL UNIQUE
);
-- Ví dụ:
-- id=1: Electronics, parent_id=NULL
-- id=2: Laptops, parent_id=1
-- id=3: MacBook, parent_id=2
-- ================================================================
-- PRODUCTS
-- ================================================================
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
sku VARCHAR(50) NOT NULL UNIQUE,
category_id INT REFERENCES categories(id),
current_price DECIMAL(15,2) NOT NULL CHECK (current_price > 0),
stock INT NOT NULL DEFAULT 0 CHECK (stock >= 0),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ================================================================
-- PRICE HISTORY (Audit trail)
-- ================================================================
CREATE TABLE product_price_history (
id SERIAL PRIMARY KEY,
product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
old_price DECIMAL(15,2) NOT NULL,
new_price DECIMAL(15,2) NOT NULL,
changed_at TIMESTAMPTZ DEFAULT NOW(),
changed_by INT REFERENCES users(id)
);
-- Trigger để tự động log thay đổi giá
CREATE OR REPLACE FUNCTION log_price_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.current_price <> NEW.current_price THEN
INSERT INTO product_price_history (product_id, old_price, new_price)
VALUES (OLD.id, OLD.current_price, NEW.current_price);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_log_price_change
AFTER UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION log_price_change();
-- ================================================================
-- ORDERS
-- ================================================================
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id),
order_code VARCHAR(20) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed', 'shipping', 'completed', 'cancelled')),
subtotal DECIMAL(15,2) NOT NULL,
discount_amount DECIMAL(15,2) DEFAULT 0,
total_amount DECIMAL(15,2) NOT NULL,
shipping_address TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ================================================================
-- ORDER ITEMS (Many-to-Many: Orders <-> Products)
-- ================================================================
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id INT NOT NULL REFERENCES products(id),
quantity INT NOT NULL CHECK (quantity > 0),
unit_price DECIMAL(15,2) NOT NULL, -- Snapshot giá tại thời điểm mua
subtotal DECIMAL(15,2) NOT NULL
);
-- Index để query nhanh
CREATE INDEX idx_orders_user ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_order_items_order ON order_items(order_id);
CREATE INDEX idx_products_category ON products(category_id);ERD Diagram
Tổng kết
| Normal Form | Quy tắc | Giải quyết vấn đề |
|---|---|---|
| 1NF | Mỗi ô chứa 1 giá trị | Dữ liệu dạng list trong ô |
| 2NF | Không partial dependency | Dữ liệu phụ thuộc một phần PK |
| 3NF | Không transitive dependency | Non-key phụ thuộc non-key |
💡 HPN Pro Tip: Normalize first, Denormalize later
- Luôn bắt đầu với 3NF để đảm bảo data integrity.
- Chỉ denormalize khi có bằng chứng về performance issue (slow query logs, monitoring).
- Dùng Materialized Views hoặc Event-driven sync thay vì copy-paste data.