Một bài viết có nhiều tag, một sản phẩm có nhiều nhãn, một người dùng có nhiều vai trò — dữ liệu "một-nhiều" ở khắp nơi. PostgreSQL cho hai cách lưu: một cột mảng (int[]) gói cả danh sách vào một ô, hoặc một bảng con chuẩn hóa với mỗi phần tử một dòng và khóa ngoại. Trực giác quan hệ cổ điển nói "luôn chuẩn hóa", nhưng mảng của PostgreSQL nhanh và gọn đến bất ngờ. Bài này đo thật cả hai và chỉ ra ranh giới thật sự nằm ở đâu — không phải hiệu năng, mà là toàn vẹn dữ liệu.
Hai cách lưu cùng một danh sách
Bài viết có tag, 2 triệu bài, mỗi bài 3 tag:
-- mảng: tags ngay trong bảng post
CREATE TABLE post_arr (id int8, tags int[]); -- {25, 26, 27}
-- chuẩn hóa: bảng nối post_tag (quan hệ nhiều-nhiều)
CREATE TABLE post_norm (id int8);
CREATE TABLE post_tag (post_id int8, tag_id int4); -- mỗi cặp một DÒNG
Khác biệt cốt lõi về lưu trữ: mảng gói 3 tag vào một dòng duy nhất; bảng nối tạo 3 dòng riêng, mỗi dòng gánh header 24 byte của PostgreSQL. Với 2 triệu bài × 3 tag = 6 triệu dòng trong bảng nối, phần header cộng dồn rất lớn.

Hình 1: Mảng gói danh sách vào một cột, truy vấn bằng toán tử chứa @> với index GIN, không cần JOIN. Bảng con tạo mỗi phần tử một dòng với khóa ngoại. Điểm khác biệt lớn nhất là toàn vẹn.
Đo thật: mảng gọn hơn và nhanh hơn
Trên 2 triệu bài, tìm "bài có tag 25":

Hình 2: Mảng chiếm 131 MB, chuẩn hóa 277 MB (~2,1×). Tìm tag 25: mảng @> với GIN 49 ms (index 7,5 MB, không JOIN); chuẩn hóa JOIN 117 ms (index tag_id 40 MB). Nhưng mảng nhận cả tag 999 không tồn tại, còn khóa ngoại chặn ngay.
- Dung lượng: mảng 131 MB so với chuẩn hóa 277 MB — gọn hơn ~2,1 lần.
- Tìm tag 25:
- Mảng
tags @> ARRAY[25]+ GIN: 49 ms, index GIN chỉ 7,5 MB, không cần JOIN. - Chuẩn hóa JOIN + btree: 117 ms, index
tag_id40 MB.
- Mảng
Mảng thắng rõ về cả ba: gọn hơn, nhanh hơn, index nhỏ hơn ~5 lần. Toán tử chứa @> với GIN cực hiệu quả cho câu hỏi "danh sách này có chứa giá trị X không". Nếu chỉ nhìn hiệu năng, mảng thắng.
Nhưng: toàn vẹn tham chiếu
Đây mới là ranh giới thật. Mảng không có khóa ngoại:
INSERT INTO post_arr VALUES (1, ARRAY[1, 2, 999]); -- OK dù tag 999 không tồn tại!
PostgreSQL không có cách kiểm mỗi phần tử mảng có trỏ tới một tag hợp lệ hay không. Tag 999 rác lọt vào âm thầm. Bảng con thì ngược lại — khóa ngoại chặn ngay:
-- post_tag.tag_id REFERENCES the_tag(id)
INSERT INTO post_tag VALUES (1, 999);
-- ERROR: violates foreign key constraint
-- DETAIL: Key (tag_id)=(999) is not present in table "the_tag".
Đây là điều bảng con làm được mà mảng không: đảm bảo mọi phần tử đều hợp lệ. Trong hệ thống mà tính đúng đắn quan trọng (tag phải tồn tại thật, không được trỏ sai), khóa ngoại là bảo hiểm mà mảng không có.
Đánh đổi cần cân nhắc
Mảng cho tập vô hướng đơn giản, đóng, ít cần toàn vẹn. Cờ boolean, nhãn tự do, danh sách số cố định (điểm số, tọa độ) — nơi phần tử là giá trị thuần túy, không phải tham chiếu tới thực thể khác. Ở đây mảng gọn, nhanh, và đơn giản hơn hẳn: không bảng thứ hai, không JOIN.
Bảng con khi phần tử là thực thể có thuộc tính riêng. Nếu tag có tên, màu, mô tả, số lượt dùng — nó là một thực thể, cần bảng riêng, và quan hệ cần bảng nối. Bảng con còn cho phép gắn metadata lên chính quan hệ (ví dụ post_tag.thoi_diem_gan, post_tag.nguoi_gan) — điều mảng không diễn đạt được. Và khi cần JOIN để lấy chi tiết tag, mô hình chuẩn hóa là tự nhiên.
Sửa một phần tử trong mảng tốn hơn. Thêm/xóa một tag khỏi mảng phải đọc-sửa-ghi cả mảng (rewrite toàn dòng), trong khi bảng nối chỉ INSERT/DELETE một dòng nhỏ. Với danh sách thay đổi thường xuyên và lớn, bảng con nhẹ hơn về ghi. Mảng hợp nhất với danh sách nhỏ, ít đổi.
Ba ý mang về
- Mảng gọn hơn và nhanh hơn cho truy vấn "chứa": đo thật, cột
int[]chiếm 131 MB so với 277 MB của bảng chuẩn hóa (~2,1×), vàtags @> ARRAY[25]với GIN chạy 49 ms (index 7,5 MB, không JOIN) so với 117 ms của JOIN (index 40 MB). - Nhưng mảng không có khóa ngoại: đo thật
ARRAY[1,2,999]chèn được dù tag 999 không tồn tại, trong khi bảng con với FK chặn ngay bằng lỗiviolates foreign key constraint— ranh giới thật giữa hai cách là toàn vẹn, không phải tốc độ. - Chọn theo bản chất dữ liệu: mảng cho tập vô hướng đơn giản, đóng, ít cần toàn vẹn và ít thay đổi (nhãn, cờ); bảng con khi phần tử là thực thể có thuộc tính riêng, cần FK, cần metadata trên quan hệ, hoặc danh sách thay đổi thường xuyên.
Phần sau ta xét một quyết định thiết kế nền tảng khác: Phần sau đo khóa chính bigserial so với UUID — vì sao UUID ngẫu nhiên làm phình index và chậm chèn, khi nào vẫn nên dùng, và UUIDv7 thay đổi cuộc chơi thế nào.