Làm kỹ sư phần mềm, hàng ngày chúng ta đều phải trừu tượng hóa dữ liệu. Chúng ta tìm cách hiểu những khái niệm còn mơ hồ ngoài đời rồi biểu diễn chúng thành biến, hàm, class, struct và các dạng thông tin có cấu trúc để phần mềm xử lý được. Dữ liệu cũng gần như luôn cần được lưu trữ. Một lựa chọn quen thuộc là database quan hệ, thường được gọi là database SQL. Trong bài này, mình sẽ cùng bạn tìm hiểu các dạng chuẩn của database quan hệ, lợi ích và cả những điểm bất lợi của chúng. Bài chỉ đề cập ba dạng chuẩn đầu tiên vì đây là những dạng phổ biến nhất trong kỹ thuật phần mềm hiện đại.

Dạng chuẩn thứ nhất⌗

Bắt đầu từ nền tảng: dạng chuẩn thứ nhất (1NF). Một bảng chỉ thỏa mãn 1NF khi không có cột nào chứa nhiều giá trị. Nói chặt chẽ hơn, mỗi giá trị trong cột phải là một giá trị nguyên tử, nghĩa là không thể tách một phần của nó để lấy thêm thông tin có ý nghĩa. Có một số kiểu cột không đáp ứng điều kiện này.

Nhóm lặp lại⌗

Nhóm lặp lại là cột chứa một tập hợp, danh sách hoặc mảng giá trị. Định dạng lưu trữ không quan trọng. Miễn là giá trị được hiểu như một danh sách thì đó là nhóm lặp lại. Ví dụ dưới đây có một cột như vậy, nên vi phạm 1NF.

[user_information]
------------------------------------------------
| user_id | phone_numbers        | signup_date |
------------------------------------------------
|       1 | (+84) 001, (+84) 002 |  2025-04-21 |
|       2 | (+10) 012            |  2025-04-21 |
|       3 | null                 |  2025-04-21 |
|       4 | (+11) 002            |  2025-04-21 |
|       5 |                      |  2025-04-21 |
------------------------------------------------

Cột phone_numbers chứa nhiều số điện thoại, phân cách bằng dấu phẩy. Cách lưu này vẫn ổn nếu chúng ta không cần query trên các số điện thoại, nhưng thực tế thường không như vậy. Sớm muộn chúng ta cũng muốn đếm số điện thoại bắt đầu bằng (+01), hoặc kiểm tra một số cụ thể đã được sử dụng chưa. Những query ấy không còn đơn giản vì phải so khớp hoặc phân tích chuỗi.

Cách lưu này còn có thể gây dư thừa dữ liệu. Nếu nhiều người dùng hoặc tài khoản được phép dùng chung số điện thoại, cùng một số phải được lưu ở nhiều nơi và chiếm thêm dung lượng. Vậy chuẩn hóa thế nào? Hãy xem hai bảng dưới đây.

[user_information]
-------------------------
| user_id | signup_date |
-------------------------
|       1 |  2025-04-21 |
|       2 |  2025-04-21 |
|       3 |  2025-04-21 |
|       4 |  2025-04-21 |
|       5 |  2025-04-21 |
-------------------------

[user_phone_numbers]
----------------------------
| user_id | phone_number   |
----------------------------
|       1 | (+84) 001      |
|       1 | (+84) 002      |
|       2 | (+10) 012      |
|       4 | (+11) 002      |
----------------------------

Chúng ta đã tách phone_numbers thành một bảng riêng và liên kết hai bảng bằng user_id. Cách này giúp giảm dư thừa dữ liệu và thỏa mãn 1NF. Ban đầu, khái niệm nhóm lặp lại trong 1NF chỉ các cột chứa mảng. Tuy nhiên, nó cũng có thể được dùng cho một nhóm cột cùng biểu diễn một thuộc tính, như ví dụ sau.

[user_information]
-----------------------------------------------------------
| user_id | phone_number_1 | phone_number_2 | signup_date |
-----------------------------------------------------------
|       1 | (+84) 001      | (+84) 002      |  2025-04-21 |
|       2 | (+10) 012      | null           |  2025-04-21 |
|       3 |                | null           |  2025-04-21 |
|       4 | (+11) 002      | null           |  2025-04-21 |
|       5 | null           |                |  2025-04-21 |
-----------------------------------------------------------

Hai cột phone_number_1 và phone_number_2 đều biểu diễn số điện thoại. Mỗi cột chỉ chứa một giá trị, nên xét chặt chẽ thì không vi phạm 1NF. Dù vậy, đây là một anti-pattern trong thiết kế schema. Bảng chỉ cho phép lưu hai số điện thoại; nếu cần nhiều hơn thì sao? Dữ liệu cũng trở nên thưa vì chỉ ít người có hai số, còn phần lớn chỉ có một, gây lãng phí dung lượng. Vì những lý do này, một số tài liệu vẫn coi đây là vi phạm 1NF.

Bảng là một thuộc tính⌗

[user_information]
-------------------------------------------------
| user_id | name     | children                  |
-------------------------------------------------
|         |          |                          |
|         |          |  ----------------------  |
|         |          |  | user_id | name     |  |
|       1 | John Doe |  ----------------------  |
|         |          |  |       3 | Doe Doe  |  |
|         |          |  ----------------------  |
|         |          |                          |
|---------|----------|--------------------------|
|         |          |                          |
|         |          |  ----------------------  |
|         |          |  | user_id | name     |  |
|       1 | Jane Doe |  ----------------------  |
|         |          |  |       3 | Doe Doe  |  |
|         |          |  ----------------------  |
|         |          |                          |
|---------|----------|--------------------------|
|       3 | Doe Doe  |  null                    |
-------------------------------------------------

Một kiểu cột chứa nhiều giá trị khác là dùng cả một bảng, hoặc một phần của bảng, làm giá trị của cột. Nhìn ví dụ trên, có thể bạn nghĩ điều này không làm được vì hầu hết, nếu không phải tất cả, database quan hệ đều không cho phép. Điều đó từng đúng. Nhưng khi ngày càng nhiều database quan hệ hỗ trợ kiểu JSON, về mặt khái niệm, chúng ta đã có thể lưu dữ liệu dạng bảng trong một cột. Ví dụ dưới đây dùng định dạng khác nhưng biểu diễn cùng thông tin.

[user_information]
------------------------------------------------------
| user_id | name     | children                       |
------------------------------------------------------
|       1 | John Doe | {"id": 3, "name": "Doe Doe" } |
|       2 | Jane Doe | {"id": 3, "name": "Doe Doe" } |
|       3 | Doe Doe  | null                          |
------------------------------------------------------

Cách lưu này vi phạm 1NF vì children không phải cột chứa giá trị nguyên tử, đồng thời gây dư thừa dữ liệu. Khi sửa thông tin của Doe Doe, chúng ta còn phải sửa các bản sao nằm trong dữ liệu của John Doe và Jane Doe. Nếu làm không cẩn thận, dữ liệu sẽ không nhất quán. Để thỏa mãn 1NF, chúng ta có thể thiết kế lại như sau.

[user_information]
----------------------
| user_id | name     |
----------------------
|       1 | John Doe |
|       2 | Jane Doe |
|       3 | Doe Doe  |
----------------------

[user_children]
----------------------
| user_id | child_id |
----------------------
|       1 |        3 |
|       2 |        3 |
----------------------

Bảng mới biểu diễn quan hệ giữa các thực thể. Khi thông tin của Doe Doe thay đổi, chúng ta không phải cập nhật thêm các bản sao trong dữ liệu của John Doe và Jane Doe. Việc query hoặc sửa quan hệ cũng dễ hơn, không cần so khớp hay phân tích chuỗi phức tạp. Chúng ta còn có thể biểu diễn quan hệ chi tiết hơn như dưới đây. Nhưng lưu ý, chi tiết hơn không có nghĩa là tốt hơn.

[user_information]
----------------------
| user_id | name     |
----------------------
|       1 | John Doe |
|       2 | Jane Doe |
|       3 | Doe Doe  |
----------------------

[user_relationships]
---------------------------------------
| user_id | relation  | other_user_id |
---------------------------------------
|       1 | parent_of |             3 |
|       2 | parent_of |             3 |
|       3 |  child_of |             1 |
|       3 |  child_of |             2 |
---------------------------------------

Cột JSON trong ví dụ trên vi phạm 1NF, nhưng cách lưu ấy không phải lúc nào cũng xấu. Với một số nhu cầu, phi chuẩn hóa để tăng tốc độ đọc lại phù hợp hơn. Kỹ thuật này gọi là embedding: lưu một snapshot của dữ liệu liên quan cùng dữ liệu chính để tránh JOIN hoặc nhiều query. Đổi lại, snapshot có thể lỗi thời và làm dữ liệu không nhất quán. Chúng ta cần chọn cách phù hợp với công việc.

Số lượng thuộc tính không cố định⌗

JSON còn cho phép một cách khác để vi phạm 1NF: lưu dữ liệu có số lượng thuộc tính không cố định.

[user_information]
--------------------------------------------------------------------------------------
| user_id | name     | misc_info                                                     |
--------------------------------------------------------------------------------------
|       1 | John Doe | {"date_of_birth": "2000-01-01"}                               |
|       2 | Jane Doe | {"date_of_birth": "2000-01-01", "hobbies": ["read", "write"]} |
|       3 | Doe Doe  | {"hobbies": ["running"]}                                      |
--------------------------------------------------------------------------------------

Trong ví dụ trên, liệu cả John Doe, Jane Doe và Doe Doe đều có ba thuộc tính là user_id, name, misc_info? Hay John Doe có ba thuộc tính user_id, name, date_of_birth, còn Jane Doe có bốn thuộc tính user_id, name, date_of_birth, hobbies? Nếu hiểu theo cách đầu, misc_info là một giá trị nguyên tử nên không vi phạm 1NF. Nếu hiểu theo cách sau thì rõ ràng có vi phạm. Khi ấy, bảng khó query, khó quản lý hơn và dễ xảy ra sai lệch dữ liệu.

Đọc đến đây, có thể bạn thấy JSON thật rắc rối. Nhưng như đã nói, vấn đề nằm ở cách sử dụng. Nếu coi cả giá trị JSON là một đơn vị nguyên tử thì nhìn chung vẫn ổn. Nếu bắt đầu coi từng cặp khóa/giá trị là một thuộc tính độc lập, chẳng hạn dùng date_of_birth trong misc_info như một cột date_of_birth thông thường của người dùng, vấn đề có thể xuất hiện về lâu dài. Mô hình dữ liệu càng phát triển và hệ thống càng lớn thì sự phức tạp ấy càng tăng.

Lợi ích⌗

Mục tiêu của 1NF là loại bỏ nhóm lặp lại và cột chứa nhiều giá trị. Hy vọng các ví dụ đã cho thấy những cấu trúc ấy có thể làm mô hình dữ liệu khó hiểu và khiến query phức tạp hơn về sau. 1NF cũng giúp cải thiện tính nhất quán bằng cách giảm dư thừa. Cùng một thông tin càng ít nơi cần cập nhật thì càng dễ giữ nhất quán trong toàn database. Chẳng hạn, thông tin của Doe Doe sẽ giống nhau dù chúng ta tra từ bảng nào.

Lợi ích này sẽ còn được nhắc lại nhiều lần trong bài. Suy cho cùng, các dạng chuẩn được tạo ra để cải thiện tính nhất quán bằng cách giảm dư thừa và các bất thường khi thao tác dữ liệu, gồm insert anomaly, update anomaly và delete anomaly.

Hạn chế⌗

Dù giúp giảm dữ liệu không nhất quán và đơn giản hóa mô hình, 1NF vẫn có điểm bất lợi. Như đã nói, đôi khi embedding hiệu quả hơn cách dùng tham chiếu mà 1NF ngầm khuyến khích. Một số database NoSQL còn ưu tiên embedding hơn referencing, chẳng hạn MongoDB. 1NF cũng không thuận tiện với dữ liệu dạng cây. Chúng ta thường phải dùng self-join phức tạp cùng các kỹ thuật như path enumeration, nested set, adjacency list, hoặc chạy nhiều query rồi JOIN ở tầng ứng dụng.

Những hạn chế này cũng áp dụng cho các dạng chuẩn cao hơn. Các dạng chuẩn khuyến khích tách dữ liệu thành những bảng chứa giá trị nguyên tử khi có thể, nên query thường cần nhiều JOIN và có thể đọc chậm hơn. Việc dùng khóa tham chiếu cũng làm sharding phức tạp: chúng ta phải chia dữ liệu sang nhiều vị trí vật lý hoặc logic mà vẫn đảm bảo tính toàn vẹn của các khóa.

Phụ thuộc hàm⌗

Mình thấy khó hiểu các dạng chuẩn tiếp theo nếu chưa nắm được phụ thuộc hàm. Đây là khái niệm cốt lõi của dạng chuẩn thứ hai và thứ ba. Hãy tìm hiểu nó trước.

Một thuộc tính A phụ thuộc hàm vào B, ký hiệu B -> A, khi tại bất kỳ thời điểm nào, mỗi giá trị B chỉ tương ứng với một giá trị A. Nói cách khác, biết B thì luôn xác định được A.

[books]
----------------------------------------------------------------
| isbn   | author   | title                                    |
----------------------------------------------------------------
| 000001 | John Doe | John Doe's Introduction to Life          |
| 000002 | John Doe | John Doe's Introduction to Life (Vol. 2) |
| 000003 | Jane Doe | How to Laugh!                            |
| 000004 | Jane Doe | The Memory.                              |
----------------------------------------------------------------

Ở ví dụ trên, title phụ thuộc hàm vào isbn (isbn -> title), vì mỗi isbn xác định một title. Tương tự, author cũng phụ thuộc hàm vào isbn (isbn -> author). Nhưng title không phụ thuộc hàm vào author, vì biết tác giả chưa đủ để xác định duy nhất một tựa sách. Với Jane Doe, tựa sách có thể là How to Laugh! hoặc The Memory. Vì vậy, có thể viết gọn isbn -> [author, title]: biết isbn thì xác định được cả tác giả lẫn tựa sách.

Phụ thuộc hàm đầy đủ⌗

Giả sử A đã phụ thuộc hàm vào một thuộc tính hoặc tập thuộc tính B (B -> A). A chỉ phụ thuộc hàm đầy đủ vào B khi không có tập con thực sự nào của B đủ để xác định A.

Khái niệm này thường dùng khi bảng có khóa ghép. Muốn một thuộc tính phụ thuộc hàm đầy đủ vào khóa ghép, phải cần toàn bộ khóa để xác định giá trị của thuộc tính đó. Chỉ một phần khóa thì không đủ.

[ratings]
-----------------------------------------
| isbn   | user_id | user_name | rating |
-----------------------------------------
| 000001 |   00002 | A. Readr  |      4 |
| 000001 |   00001 | Avid R.   |      5 |
| 000002 |   00002 | A. Readr  |      3 |
-----------------------------------------

Giả sử khóa ghép của bảng là isbn và user_id. rating phụ thuộc hàm vào khóa này (isbn, user_id -> rating) vì mỗi cặp sách và người dùng chỉ cho một giá trị rating. Đây còn là phụ thuộc hàm đầy đủ, vì chỉ biết isbn hoặc user_id thì có thể nhận được nhiều giá trị rating. Sách 000001 được hai người đánh giá lần lượt là 4 và 5. Người dùng 00002 cũng đánh giá hai sách khác nhau với điểm 4 và 3.

Phụ thuộc hàm riêng phần⌗

Giả sử A đã phụ thuộc hàm vào một thuộc tính hoặc tập thuộc tính B (B -> A). Nếu phụ thuộc này không đầy đủ thì đó là phụ thuộc hàm riêng phần.

[ratings]
-----------------------------------------
| isbn   | user_id | user_name | rating |
-----------------------------------------
| 000001 |   00002 | A. Readr  |      4 |
| 000001 |   00001 | Avid R.   |      5 |
| 000002 |   00002 | A. Readr  |      3 |
-----------------------------------------

Vẫn dùng ví dụ ở phần phụ thuộc hàm đầy đủ, với khóa ghép là isbn và user_id, nhưng lần này xét user_name. Cũng như rating, user_name phụ thuộc hàm vào khóa ghép (isbn, user_id -> user_name), vì mỗi cặp giá trị chỉ xác định một tên người dùng. Tuy nhiên, phụ thuộc này không đầy đủ. Chỉ cần user_id, tức một tập con thực sự của khóa ghép, đã xác định được user_name. Đây là phụ thuộc hàm riêng phần.

Dạng chuẩn thứ hai⌗

Một bảng chỉ thỏa mãn dạng chuẩn thứ hai (2NF) khi:

  • Bảng đã thỏa mãn dạng chuẩn thứ nhất.
  • Không có cột ngoài khóa nào phụ thuộc hàm riêng phần vào khóa.

Tại sao 2NF không cho phép phụ thuộc hàm riêng phần? Vì nó gây dư thừa, tức lặp lại dữ liệu. Hãy xem lại ví dụ ở phần phụ thuộc hàm.

[ratings]
-----------------------------------------
| isbn   | user_id | user_name | rating |
-----------------------------------------
| 000001 |   00002 | A. Readr  |      4 |
| 000001 |   00001 | Avid R.   |      5 |
| 000002 |   00002 | A. Readr  |      3 |
-----------------------------------------

Như đã phân tích, user_name chỉ cần user_id để xác định, nên nó phụ thuộc riêng phần vào khóa ghép (isbn, user_id). Bảng vì vậy vi phạm 2NF và lưu lặp lại tên người dùng. Nếu người dùng 00002 đổi tên, chúng ta phải cập nhật nhiều bản ghi. Đây là update anomaly. Có thể lập luận rằng việc lặp dữ liệu chưa gây vấn đề nhất quán trong trường hợp này, vì query SQL cập nhật tên được đảm bảo atomic.

UPDATE 'ratings'
SET
    'user_name' = "A whole new name"
WHERE
    'user_id' = '00002';

Nhưng vấn đề không dừng ở tính nhất quán mà còn có thể dẫn đến mất thông tin. Người dùng 00099 chưa đánh giá sách nào, nên với bảng chưa chuẩn hóa, chúng ta không có cách lưu và biết user_name của họ. Đây là insert anomaly. Tương tự, nếu người dùng 00001 xóa đánh giá duy nhất, chúng ta mất luôn thông tin tên của họ là Avid R.. Đây là delete anomaly.

Về mặt hiệu năng, update anomaly còn có thể làm chậm tốc độ ghi. Khi phải cập nhật nhiều bản ghi, database có thể phải tải và sửa nhiều file vật lý, giữ lock trên nhiều dòng hơn, quét nhiều page hoặc index hơn, đồng thời dùng thêm CPU và RAM. Vậy làm sao để bảng này thỏa mãn 2NF?

[ratings]
-----------------------------
| isbn   | user_id | rating |
-----------------------------
| 000001 |   00002 |      4 |
| 000001 |   00001 |      5 |
| 000002 |   00002 |      3 |
-----------------------------

[user_information]
-----------------------
| user_id | user_name |
-----------------------
|   00001 | Avid R.   |
|   00002 | A. Readr  |
|   00099 | Anon U.   |
-----------------------

Chúng ta tách bảng cũ thành hai bảng: một bảng lưu đánh giá, một bảng lưu thông tin người dùng. Khi user_name được đưa ra khỏi bảng đánh giá, phụ thuộc riêng phần vào khóa ghép cũng được loại bỏ và hai bảng thỏa mãn 2NF. Muốn sửa thông tin người dùng, chỉ cần cập nhật một bản ghi, nên không còn update anomaly. Người dùng 00099 chưa có đánh giá vẫn có thông tin riêng, nên không còn insert anomaly. Người dùng 00001 cũng có thể xóa đánh giá mà không mất thông tin cá nhân, loại bỏ delete anomaly.

Tách bảng để loại bỏ phụ thuộc riêng phần có thể ảnh hưởng đến tốc độ đọc. Giả sử mỗi khi đọc đánh giá, chúng ta đều cần tên người đánh giá. Nếu dữ liệu này được query thường xuyên, lưu user_name ngay cùng đánh giá có thể hợp lý vì tránh phải JOIN liên tục. Chúng ta có thể thiết kế như sau.

[ratings_with_user_name]
-----------------------------------------
| isbn   | user_id | user_name | rating |
-----------------------------------------
| 000001 |   00002 | A. Readr  |      4 |
| 000001 |   00001 | Avid R.   |      5 |
| 000002 |   00002 | A. Readr  |      3 |
-----------------------------------------

[user_information]
-----------------------
| user_id | user_name |
-----------------------
|   00001 | Avid R.   |
|   00002 | A. Readr  |
|   00099 | Anon U.   |
-----------------------

Cấu trúc này tránh được insert anomaly và delete anomaly, nhưng lại đưa update anomaly trở lại. Phi chuẩn hóa có thể tăng tốc độ đọc, đổi lại có thể làm chậm tốc độ ghi và gây dữ liệu không nhất quán. Lựa chọn cuối cùng phụ thuộc vào nhu cầu sử dụng cụ thể.

Dạng chuẩn thứ ba⌗

Một bảng thỏa mãn dạng chuẩn thứ ba (3NF) khi:

  • Bảng đã thỏa mãn dạng chuẩn thứ hai.
  • Không có cột ngoài khóa nào phụ thuộc bắc cầu vào cột khóa.

Phụ thuộc bắc cầu xảy ra khi một cột ngoài khóa phụ thuộc hàm vào một cột ngoài khóa khác, và cột thứ hai lại phụ thuộc hàm vào khóa. Cụ thể, xét bảng có các thuộc tính A, B, C, với C là khóa:

  • Cột ngoài khóa A phụ thuộc hàm vào cột ngoài khóa B (B -> A).
  • Cột ngoài khóa B phụ thuộc hàm vào khóa C (C -> B).
  • Khi đó, A phụ thuộc bắc cầu vào C (C -> B -> A).

Nói đơn giản, 3NF yêu cầu không có cột ngoài khóa nào phụ thuộc hàm vào một cột ngoài khóa khác trong cùng bảng. Những vấn đề mà 3NF muốn giải quyết cũng giống 2NF. Hãy xem ví dụ sau.

[book_with_publisher]
--------------------------------------------------------------------------------------------
| isbn   | title                                    | publisher_id | publisher_name        |
--------------------------------------------------------------------------------------------
| 000001 | John Doe's Introduction to Life          |        P0001 | First Time Publishing |
| 000002 | John Doe's Introduction to Life (Vol. 2) |        P0002 | Pub. Ltd.             |
| 000003 | How to Laugh!                            |        P0001 | First Time Publishing |
| 000004 | The Memory.                              |        P0003 | Yet Another Publisher |
--------------------------------------------------------------------------------------------

Bảng book_with_publisher có khóa là isbn. Bảng vi phạm 3NF vì cột ngoài khóa publisher_name phụ thuộc hàm vào một cột ngoài khóa khác là publisher_id. Nó gặp những vấn đề giống ví dụ 2NF:

  • Insert anomaly: Không thể lưu thông tin của nhà xuất bản chưa xuất bản sách nào.
  • Delete anomaly: Xóa sách có isbn là 000002 sẽ làm mất thông tin nhà xuất bản P0002.
  • Update anomaly: Đổi publisher_name phải cập nhật nhiều bản ghi.
  • Dư thừa dữ liệu: Tên nhà xuất bản được lưu lặp lại ở nhiều nơi.

Để thỏa mãn 3NF, chúng ta loại bỏ phụ thuộc bắc cầu isbn -> publisher_id -> publisher_name bằng cách tách thành hai bảng sau:

[books]
--------------------------------------------------------------------
| isbn   | title                                    | publisher_id |
--------------------------------------------------------------------
| 000001 | John Doe's Introduction to Life          |        P0001 |
| 000002 | John Doe's Introduction to Life (Vol. 2) |        P0002 |
| 000003 | How to Laugh!                            |        P0001 |
| 000004 | The Memory.                              |        P0003 |
--------------------------------------------------------------------

[publishers]
----------------------------------------
| publisher_id | publisher_name        |
----------------------------------------
|        P0001 | First Time Publishing |
|        P0002 | Pub. Ltd.             |
|        P0003 | Yet Another Publisher |
|        P0004 | Anon Publishing       |
----------------------------------------

Cũng như 1NF và 2NF, cách chuẩn hóa này cải thiện tính toàn vẹn và giảm dư thừa dữ liệu. Đổi lại, nhiều bảng hơn có thể khiến một số query cần nhiều JOIN hơn và đọc chậm hơn. Việc chuẩn hóa lúc nào, đến mức nào phụ thuộc vào cách cân bằng những yếu tố này cho nhu cầu cụ thể.

Tài liệu tham khảo⌗