SQL & Database — Thực chiến PostgreSQL/Normalization 1NF→3NF — refactor TaskFlow tags TEXT thành junction table
~22 phútSchema design lượt xem

Normalization 1NF→3NF — refactor TaskFlow tags TEXT thành junction table

1NF atomic value, 2NF no partial dependency, 3NF no transitive dependency. Codd 1970-1972. Pitfall over-normalize. TaskFlow refactor demo step-by-step.

TaskFlow ban đầu lưu tags trong column tags TEXT = 'urgent,bug,api'. Khi cần filter "task có tag urgent", query tự nhiên nhất là:

SELECT * FROM tasks WHERE tags LIKE '%urgent%';

Hai vấn đề ngay lập tức: thứ nhất, query này scan toàn bảng — không dùng được index trên string LIKE với leading wildcard. Thứ hai, '%urgent%' match cả 'urgentcare', 'urgently' — false positive. Sau 6 tháng, product yêu cầu thêm "top 10 tag phổ biến nhất tuần này" — bạn phải parse chuỗi từng row trong application code.

Đây là vi phạm 1NF — Codd 1970 đã cảnh báo điều này. Bài này map 1NF→3NF với lý do cơ chế, refactor TaskFlow tags step-by-step, và 1 pitfall over-normalize quan trọng không kém.

1. Analogy — Sắp tủ hồ sơ

Hình dung bạn quản lý một tủ hồ sơ nhân viên:

  • Trước normalize: mỗi ngăn chứa nhiều loại giấy tờ nhét lẫn lộn — hợp đồng, bằng cấp, lịch nghỉ phép, thông tin phòng ban — tất cả trong 1 folder. Tìm "ai có bằng thạc sĩ" phải mở từng ngăn, lật từng tờ.
  • Sau 1NF: mỗi ngăn chứa đúng 1 loại tài liệu. Mỗi "slot" trong ngăn là 1 thông tin cụ thể, không phải danh sách nhét chung.
  • Sau 2NF: mỗi ngăn phụ thuộc đúng cái mã ngăn của nó — không có tờ giấy "tên phòng ban" nằm trong ngăn nhân viên chỉ vì nhân viên đó thuộc phòng ban đó.
  • Sau 3NF: không có chuỗi A → B → C dư thừa — "nhân viên → mã phòng ban → tên phòng ban" không lặp tên phòng ban ở mọi row nhân viên.
Tủ hồ sơDatabase concept
1 ngăn, 1 loại tài liệu1 cell, 1 giá trị scalar (1NF)
Mỗi tờ phụ thuộc đúng mã ngănNon-key column phụ thuộc toàn bộ PK (2NF)
Không chuỗi A→B→C nhồi nhétKhông transitive dependency qua non-key (3NF)
Tủ quá nhỏ, chia phòng riêng cho từng tờOver-normalize — overhead JOIN không cần thiết
💡 Cách nhớ

1NF: mỗi ô một thứ. 2NF: mỗi thứ phụ thuộc đúng khoá. 3NF: không có trung gian dư thừa giữa khoá và giá trị.

2. 1NF — atomic value

Định nghĩa: mỗi cell chứa 1 giá trị scalar — không phải list, array, hay struct nhét chung trong một column.

Vi phạm kinh điển — TaskFlow tags TEXT:

-- ANTI-PATTERN: 1NF violation
CREATE TABLE tasks (
  id    BIGSERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  tags  TEXT  -- 'urgent,bug,api' - LIST trong 1 cell
);

-- Hau qua 1: Filter slow + false positive
SELECT * FROM tasks WHERE tags LIKE '%urgent%';
-- Match ca 'urgentcare', 'urgently' -- false positive
-- LIKE voi leading wildcard khong dung duoc index -- seq scan

-- Hau qua 2: Update fragile
UPDATE tasks
SET tags = REPLACE(tags, 'urgent,', '')
WHERE tags LIKE '%urgent%';
-- Pattern khong xu ly duoc truong hop 'urgent' o dau, o cuoi, hoac la tag duy nhat

-- Hau qua 3: Aggregation bat kha thi trong SQL
-- "Top 10 tag pho bien nhat" -- phai parse string moi row trong app code

Fix 1NF — junction table:

-- 1NF: atomic value qua junction table
CREATE TABLE tags (
  id   BIGSERIAL PRIMARY KEY,
  name TEXT UNIQUE NOT NULL
);

CREATE TABLE task_tags (
  task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  tag_id  BIGINT NOT NULL REFERENCES tags(id)  ON DELETE CASCADE,
  PRIMARY KEY (task_id, tag_id)
);

-- Filter accurate + index-friendly
SELECT t.* FROM tasks t
JOIN task_tags tt ON t.id = tt.task_id
JOIN tags g       ON tt.tag_id = g.id
WHERE g.name = 'urgent';

-- Top 10 tag tuan nay -- trivial voi junction table
SELECT g.name, COUNT(*) AS task_count
FROM tags g
JOIN task_tags tt ON g.id = tt.tag_id
GROUP BY g.id, g.name
ORDER BY task_count DESC
LIMIT 10;

Trade-off: 1 query với JOIN thay 1 query LIKE. Performance: index trên junction và tags.name → nhanh hơn seq scan với LIKE. Semantics rõ hơn — tag là entity, không phải fragment trong chuỗi.

PostgreSQL bonus: PG hỗ trợ ARRAY column với GIN index, cho phép WHERE 'urgent' = ANY(tags) sử dụng được index. Về mặt lý thuyết đây vẫn vi phạm 1NF, nhưng PG biến nó thành viable choice trong một số trường hợp cụ thể — Module 9 của khoá này deep dive khi ARRAY thực sự phù hợp. Mặc định: normalize về junction table.

3. 2NF — no partial dependency on composite key

Pre-condition: 1NF đã đạt + table có composite primary key (PK gồm 2 column trở lên).

Định nghĩa: mọi non-key column phải phụ thuộc toàn bộ composite PK — không được phụ thuộc chỉ một phần (partial dependency).

Anti-pattern — order_items nhét thông tin product:

-- 2NF violation: PK la (order_id, product_id)
CREATE TABLE order_items (
  order_id     BIGINT,
  product_id   BIGINT,
  quantity     INT,
  product_name TEXT,    -- phu thuoc CHI product_id, khong phu thuoc order_id
  product_price NUMERIC, -- phu thuoc CHI product_id
  PRIMARY KEY (order_id, product_id)
);

-- Hau qua:
-- 1. Update product_name -> phai update MOI row order_items co product nay
-- 2. Inconsistent: hai order khac nhau co the co product_name khac nhau (typo)
-- 3. Khong the insert product moi neu chua co order nao

Fix 2NF — tách products ra table riêng:

CREATE TABLE products (
  id    BIGSERIAL PRIMARY KEY,
  name  TEXT    NOT NULL,
  price NUMERIC NOT NULL
);

CREATE TABLE order_items (
  order_id   BIGINT  REFERENCES orders(id),
  product_id BIGINT  REFERENCES products(id),
  quantity   INT     NOT NULL,
  PRIMARY KEY (order_id, product_id)
  -- product_name va product_price da o bang products
  -- moi column o day phu thuoc CA HAI order_id va product_id
);

Lưu ý production: trong e-commerce thực tế, order_items thường giữ thêm price_at_order NUMERIC để snapshot giá lúc mua — vì giá có thể thay đổi sau này. Đây là denormalize có chủ đích, không phải vi phạm 2NF — Module 4 bài 4 của khoá này giải thích khi nào denormalize hợp lý.

4. 3NF — no transitive dependency

Pre-condition: 1NF + 2NF đã đạt.

Định nghĩa: non-key column không phụ thuộc gián tiếp qua một non-key column khác. Nếu có chain PK → A → B, thì B phải tách ra table riêng.

Anti-pattern — employees nhét thông tin department:

-- 3NF violation
CREATE TABLE employees (
  id                BIGSERIAL PRIMARY KEY,
  name              TEXT,
  department_id     BIGINT,
  department_name   TEXT,    -- phu thuoc department_id, khong phu thuoc id (transitive!)
  department_budget NUMERIC  -- phu thuoc department_id
);

-- Chain: id -> department_id -> department_name
-- Hau qua:
-- 1. Doi ten department -> phai update MOI employee cua department do
-- 2. Xoa employee cuoi cung cua department -> mat thong tin department
-- 3. Them department moi -> phai co it nhat 1 employee (insertion anomaly)

Fix 3NF — tách departments ra table riêng:

CREATE TABLE departments (
  id     BIGSERIAL PRIMARY KEY,
  name   TEXT    NOT NULL,
  budget NUMERIC
);

CREATE TABLE employees (
  id            BIGSERIAL PRIMARY KEY,
  name          TEXT   NOT NULL,
  department_id BIGINT REFERENCES departments(id)
  -- department_name va department_budget da o bang departments
);

-- Query join nhu binh thuong
SELECT e.name, d.name AS dept_name, d.budget
FROM employees e
JOIN departments d ON e.department_id = d.id;

5. BCNF — một dòng

Boyce-Codd Normal Form là phiên bản strict hơn của 3NF: mọi functional dependency phải có vế trái là superkey. Vi phạm chỉ xảy ra khi table có overlapping candidate keys — rất hiếm trong schema thực tế. 99% trường hợp 3NF là đủ. BCNF tồn tại và có tên để bạn biết khi đọc tài liệu, không phải mục tiêu thường ngày.

6. 4 anomaly khi không normalize

#AnomalyMô tảVí dụ TaskFlow
1InsertionKhông thể insert data mới nếu thiếu data liên quanKhông thể thêm product mới khi chưa có order nào
2UpdateThay đổi 1 fact phải update nhiều rowĐổi tên department → update mọi employee row
3DeletionXoá data này vô tình xoá luôn data khácXoá order cuối → mất thông tin product
4RedundancyCùng 1 fact lặp lại ở nhiều rowTên product lặp ở mọi order_item

Mỗi anomaly là cost ẩn của denormalize ngẫu nhiên. Pattern cốt lõi: normalize đến 3NF → integrity tự nhiên, query cần JOIN thêm một chút nhưng đổi lại bảo đảm được toàn vẹn dữ liệu mà không cần trigger hay application logic bù đắp.

7. Pitfall — over-normalize

Pitfall — over-normalize: tách bảng không mang lại lợi ích

Normalize đến mức không cần thiết sinh ra JOIN overhead và schema phức tạp mà không cải thiện integrity. Quy tắc: chỉ tách khi có lợi ích thực sự — cardinality M:N, reuse, hoặc concern khác biệt rõ. Quan hệ 1:1 luôn đi cùng nhau thường nên giữ inline.

Ví dụ over-normalize — tách address thành bảng riêng cho users:

-- OVER-NORMALIZE: 1:1 relationship, address khong bao gio reuse
CREATE TABLE addresses (
  id      BIGSERIAL PRIMARY KEY,
  street  TEXT,
  city    TEXT,
  country TEXT
);

CREATE TABLE users (
  id         BIGSERIAL PRIMARY KEY,
  email      TEXT,
  address_id BIGINT REFERENCES addresses(id)  -- luon 1:1, khong co reuse
);

-- Hau qua:
-- 1. Moi query user phai JOIN addresses (overhead khong can thiet)
-- 2. Quan he 1:1, luon di cung nhau -> tach bang khong gain integrity
-- 3. Schema phuc tap hon can thiet -- onboarding kho hon
-- 4. DELETE user phai cleanup addresses rieng (hoac cascade)
-- BETTER: embed address inline khi 1:1 va always-together
CREATE TABLE users (
  id      BIGSERIAL PRIMARY KEY,
  email   TEXT,
  street  TEXT,
  city    TEXT,
  country TEXT
);

Quy tắc thực chiến — chỉ tách bảng khi:

  • Cardinality M:N hoặc 1:N có khả năng reuse: tags dùng chung cho nhiều task → junction table.
  • Tách concern: dữ liệu nhạy cảm (payment info, PII) vào table riêng với permission khác.
  • Query pattern khác biệt rõ: bảng hot (đọc nhiều) vs bảng cold (historical) → tách để tune index riêng.

Giữ inline khi quan hệ 1:1 và data luôn được đọc cùng nhau. Pattern này gọi là embedded value object trong DDD — không phải lười normalize, mà là normalize đúng mức.

8. Applied — TaskFlow refactor tags TEXT sang junction table

Migration step-by-step — không downtime, không mất data:

-- Step 1: tao bang tags + task_tags (CHUA drop column tags cu)
CREATE TABLE tags (
  id   BIGSERIAL PRIMARY KEY,
  name TEXT UNIQUE NOT NULL
);

CREATE TABLE task_tags (
  task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  tag_id  BIGINT NOT NULL REFERENCES tags(id)  ON DELETE CASCADE,
  PRIMARY KEY (task_id, tag_id)
);

-- Step 2: backfill du lieu tu tags TEXT
-- unnest(string_to_array(...)) tach chuoi thanh rows
INSERT INTO tags(name)
SELECT DISTINCT TRIM(tag_name)
FROM tasks,
     unnest(string_to_array(tags, ',')) AS tag_name
WHERE tags IS NOT NULL AND tags <> ''
ON CONFLICT (name) DO NOTHING;

INSERT INTO task_tags(task_id, tag_id)
SELECT t.id, g.id
FROM tasks t,
     unnest(string_to_array(t.tags, ',')) AS tag_name
JOIN tags g ON g.name = TRIM(tag_name)
WHERE t.tags IS NOT NULL AND t.tags <> '';

-- Step 3: verify count match
SELECT COUNT(*) FROM task_tags;
-- So sanh voi: SELECT SUM(array_length(string_to_array(tags, ','), 1)) FROM tasks WHERE tags IS NOT NULL;

-- Step 4: switch app code sang doc/ghi tu task_tags
-- (deploy app version moi truoc khi drop column)

-- Step 5: drop column cu (sau khi app code da switch hoan toan)
-- ALTER TABLE tasks DROP COLUMN tags;
-- (production: deferred theo expand-contract pattern -- Module 11 cua khoa nay)

Bước nào cần cẩn thận nhất: Step 5 — DROP COLUMN là irreversible. Module 11 của khoá này (production migration safe pattern) giải thích expand-contract: giữ column cũ song song với junction table, double-write trong thời gian chuyển tiếp, chỉ drop sau khi verify đủ lâu không có rollback. Module 5 của khoá này đi sâu về composite index trên junction table để query hiệu quả.

9. Deep Dive — Normalization

📚 Deep Dive — Normalization

Ghi chú: DSC Ch.7 cho rigor và practice problems — nên đọc khi cần hiểu formal proof của decomposition lossless. Codd 1972 cho lịch sử và intuition gốc — ngắn, đọc được trong 1 buổi, cho thấy tác giả suy nghĩ thế nào khi đặt ra định nghĩa ban đầu.

10. Tóm tắt

  • 1NF: mỗi cell 1 giá trị scalar — không nhét list, array, hay struct. Vi phạm điển hình: tags TEXT = 'urgent,bug'.
  • 2NF: pre-1NF + mọi non-key column phụ thuộc toàn bộ composite PK — không phụ thuộc partial.
  • 3NF: pre-2NF + không có transitive dependency qua non-key column (PK → A → B chain).
  • BCNF: strict 3NF — vi phạm chỉ khi có overlapping candidate keys, hiếm gặp trong practice.
  • 4 anomaly khi không normalize: insertion, update, deletion, redundancy — mỗi cái là cost ẩn phải trả bằng application logic hoặc inconsistent data.
  • Pragmatic: 3NF + chấp nhận embed 1:1 always-together (DDD embedded value object) — không phải lười, mà tránh over-normalize vô ích.
  • Forward: Module 4 bài 4 của khoá này (denormalization tradeoff — khi nào break normalize có chủ đích), Module 11 của khoá này (production migration safe pattern — expand-contract).

11. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao tags TEXT = 'urgent,bug' vi phạm 1NF? Hậu quả thực tế khi filter và aggregate?

1NF yêu cầu mỗi cell chứa đúng 1 giá trị scalar — không phải tập hợp hay danh sách. tags = 'urgent,bug' nhét nhiều giá trị vào 1 cell, vi phạm nguyên tắc atomic value.

Hậu quả filter: WHERE tags LIKE '%urgent%' phải scan toàn bảng (không dùng được index với leading wildcard), và gây false positive — urgently, urgentcare đều match. Không có cách chính xác để filter "chỉ tag urgent" mà không parse chuỗi phía application.

Hậu quả aggregate: "top 10 tag phổ biến" không thể làm bằng SQL thuần — phải đọc tất cả row, parse từng chuỗi trong application, đếm thủ công. Khi bảng có triệu row, không scale được.

Q2
Phân biệt 2NF vs 3NF. Cho ví dụ vi phạm mỗi cái với schema TaskFlow và cách fix.

2NF vi phạm khi: có composite PK và non-key column chỉ phụ thuộc một phần PK.

Ví dụ TaskFlow: nếu task_tags có thêm column tag_color — màu này chỉ phụ thuộc tag_id, không phụ thuộc task_id. PK là (task_id, tag_id) nhưng tag_color chỉ cần tag_id → partial dependency → vi phạm 2NF. Fix: chuyển tag_color vào bảng tags.

3NF vi phạm khi: có chain PK → A → B qua non-key column.

Ví dụ TaskFlow: nếu tasks có thêm project_id, project_name, project_owner_email. Chain: tasks.id → project_id → project_nameproject_id → project_owner_email. project_name không phụ thuộc trực tiếp tasks.id mà qua trung gian project_id → transitive dependency → vi phạm 3NF. Fix: tách projects table riêng với project_nameproject_owner_email.

Q3
PostgreSQL hỗ trợ tags TEXT[] array với GIN index — vi phạm 1NF không? Khi nào ARRAY thực sự phù hợp trong PG?

Về mặt lý thuyết, TEXT[] array vẫn vi phạm 1NF — một cell chứa nhiều giá trị, không phải scalar. Định nghĩa 1NF không thay đổi vì database hỗ trợ kiểu dữ liệu đó.

Tuy nhiên, PG GIN index làm cho WHERE 'urgent' = ANY(tags) chạy nhanh, tránh được false positive, và tránh JOIN overhead. Về mặt practical, ARRAY có thể acceptable khi: (1) tags là attribute của object, không phải entity độc lập cần query riêng; (2) không cần count tần suất tag global; (3) write pattern đơn giản (luôn replace cả mảng, không update từng element).

Mặc định: junction table khi tags cần aggregate, filter phức tạp, hoặc có thể trở thành entity (tag có màu, category, description). ARRAY khi tags chỉ là label đơn giản, không bao giờ query độc lập. Module 9 của khoá này deep dive JSON/ARRAY trong PG.

Q4
4 anomaly (insertion, update, deletion, redundancy) — cái nào nguy hiểm nhất trong production? Cho ví dụ cụ thể.

Không có anomaly nào "tệ nhất" tuyệt đối — phụ thuộc vào business context. Nhưng trong production, update anomaly thường gây hậu quả âm thầm nhất vì dễ tạo ra inconsistent data mà không có lỗi rõ ràng.

Ví dụ: employees nhét department_name. Team HR đổi tên "Engineering" thành "Product Engineering" qua UI, chỉ update 70 trong 100 employee của department đó (bug trong batch update). Hệ quả: 70 employee thấy "Product Engineering", 30 thấy "Engineering" — cùng một department, hai tên khác nhau. Báo cáo headcount sai, export CSV cho payroll sai, dashboard bị split thành 2 department.

Insertion anomaly dễ thấy ngay (không insert được), deletion anomaly thường được phát hiện khi data đã mất. Redundancy tốn storage nhưng không gây sai data ngay. Update anomaly sinh ra inconsistent state tồn tại lâu dài mà không báo lỗi — nguy hiểm vì silent.

Q5
"Always normalize to 3NF" vs "pragmatic — denormalize khi cần read performance". Quan điểm nào đúng? Tradeoff thực sự là gì?

Cả hai đều đúng trong context riêng. Mâu thuẫn chỉ xuất hiện khi áp dụng cứng nhắc không đúng hoàn cảnh.

3NF làm baseline: normalize đến 3NF trước — đây là trạng thái ít bug nhất, integrity tự nhiên nhất, dễ hiểu nhất. Hầu hết schema không cần vượt qua 3NF để hoạt động tốt.

Denormalize có chủ đích: khi profiling cho thấy JOIN cụ thể là bottleneck (đo được, không đoán), và denormalize giải quyết được vấn đề đó mà không sinh ra consistency bug mới. Ví dụ: giữ price_at_order trong order_items là denormalize có lý do — snapshot giá lúc mua, không phải lazy design.

Tradeoff thực sự: normalize tốt cho write integrity, denormalize tốt cho read performance. Không phải binary — thường có điểm cân bằng phụ thuộc read/write ratio, scale, và query pattern. Module 4 bài 4 của khoá này đi sâu vào quyết định này với ví dụ cụ thể.

Q6
TaskFlow refactor tags TEXT sang junction table: 4 bước migration. Bước nào nguy hiểm nhất và cách phòng ngừa downtime?

4 bước: (1) Tạo bảng tagstask_tags. (2) Backfill data từ tags TEXT sang junction. (3) Switch app code để đọc/ghi từ junction table. (4) Drop column tags cũ.

Bước nguy hiểm nhất: bước 4 — DROP COLUMN. Đây là thao tác không thể hoàn tác (không có down migration trong Prisma 7, và ngay cả nếu có, data trong column đã xoá). Nếu app code vẫn còn đường code nào đọc tasks.tags, sẽ lỗi ngay sau DROP.

Cách phòng ngừa: dùng expand-contract pattern — giữ column cũ song song với junction trong ít nhất 1 deploy cycle (hoặc 1 tuần nếu có rollback window). Trong giai đoạn chuyển tiếp: double-write vào cả column cũ và junction, đọc từ junction. Sau khi verify monitoring không có error từ column cũ trong N ngày, mới DROP. Module 11 của khoá này chi tiết pattern này với migration tooling.

Bài tiếp theo: Denormalization tradeoff — khi nào break normalize có chủ đích

Bài này có giúp bạn hiểu bản chất không?