Skip to content

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_idcustomerproducts
1Nguyễn Văn AiPhone, AirPods, Case
2Trần Thị BMacBook, 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_idcustomerproduct
1Nguyễn Văn AiPhone
1Nguyễn Văn AAirPods
1Nguyễn Văn ACase
2Trần Thị BMacBook
2Trần Thị BMagic Mouse

Hoặc tốt hơn: Tách thành 2 bảng ordersorder_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_idcourse_idstudent_namecourse_namegrade
1CS101Nguyễn Văn AIntro to ProgrammingA
1CS102Nguyễn Văn AData StructuresB
2CS101Trần Thị BIntro to ProgrammingA+

Primary Key: (student_id, course_id)

Vấn đề Partial Dependency:

  • student_name chỉ phụ thuộc vào student_id, KHÔNG phụ thuộc course_id.
  • course_name chỉ phụ thuộc vào course_id, KHÔNG phụ thuộc student_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_idemployee_namedepartment_iddepartment_namedepartment_location
1Nguyễn Văn AD01EngineeringTầng 5
2Trần Thị BD01EngineeringTầng 5
3Lê Văn CD02SalesTầng 3

Primary Key: employee_id

Vấn đề Transitive Dependency:

employee_id -> department_id -> department_name
employee_id -> department_id -> department_location
  • department_namedepartment_location phụ thuộc vào department_id, không trực tiếp vào employee_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àngtrá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ốngVí dụGiải pháp
Read >> WriteDashboard báo cáo, AnalyticsLưu dữ liệu đã tính toán sẵn
Tránh JOIN phức tạpProfile page cần 5 bảngLưu snapshot vào 1 bảng
Caching ở DB levelSố đơn hàng của userorder_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

  1. Data Inconsistency: Khi users.name thay đổi, order_reports.customer_name không tự cập nhật.
  2. Storage tăng: Dữ liệu trùng lặp chiếm nhiều disk.
  3. 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 FormQuy tắcGiải quyết vấn đề
1NFMỗi ô chứa 1 giá trịDữ liệu dạng list trong ô
2NFKhông partial dependencyDữ liệu phụ thuộc một phần PK
3NFKhông transitive dependencyNon-key phụ thuộc non-key

💡 HPN Pro Tip: Normalize first, Denormalize later

  1. Luôn bắt đầu với 3NF để đảm bảo data integrity.
  2. Chỉ denormalize khi có bằng chứng về performance issue (slow query logs, monitoring).
  3. Dùng Materialized Views hoặc Event-driven sync thay vì copy-paste data.