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.

Ảnh chụp đoạn mã SQL nền tối minh hoạ mảng vs bảng con gọn nhẹ đổi lấy toàn vẹn, hai cách lưu danh sách bài viết có nhiều tag mảng tags ngay trong bảng post CREATE TABLE post_arr id int8 tags int mảng 25 26 27 một cột không cần bảng thứ hai, chuẩn hóa bảng nối post_tag nhiều nhiều CREATE TABLE post_norm id int8 CREATE TABLE post_tag post_id int8 tag_id int4 mỗi cặp post tag một dòng riêng, truy vấn bài có tag 25 mảng toán tử chứa cộng index GIN không JOIN CREATE INDEX ON post_arr USING gin tags SELECT sao FROM post_arr WHERE tags chứa ARRAY 25, chuẩn hóa index btree cộng JOIN SELECT p.sao FROM post_norm p JOIN post_tag pt ON pt.post_id bằng p.id WHERE pt.tag_id bằng 25, toàn vẹn 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 mảng không kiểm được, bảng nối có FK chặn ngay tag_id int4 REFERENCES the_tag id ERROR violates foreign key constraint Key tag_id bằng 999 not present, khi nào chọn cái nào mảng tập vô hướng đơn giản đóng ít cần toàn vẹn nhãn cờ tag bảng con tag là thực thể có thuộc tính riêng cần FK cần metadata quan hệ

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":

Ảnh chụp bảng kết quả đo thật nền tối mảng vs bảng con 2 triệu bài mỗi bài 3 tag PostgreSQL 16 pg_relation_size EXPLAIN ANALYZE shared_buffers 128MB, kích thước lưu trữ mảng int post_arr 131 MB 3 tag gói trong 1 dòng chuẩn hóa post cộng post_tag 277 MB khoảng 2,1 lần mỗi tag một dòng header 24 byte, tìm bài có tag 25 mảng tags chứa ARRAY 25 GIN Bitmap Index Scan không JOIN 49 mili giây index 7,5 MB chuẩn hóa JOIN post_tag btree Hash Join 117 mili giây index 40 MB mảng gọn hơn nhanh hơn cho truy vấn chứa index GIN nhỏ hơn khoảng 5 lần không cần JOIN, nhưng toàn vẹn tham chiếu mảng INSERT ARRAY 1 2 999 OK tag 999 không tồn tại vẫn lọt bảng nối tag_id REFERENCES the_tag ERROR violates foreign key constraint Key tag_id bằng 999 is not present in table the_tag mảng không kiểm được khóa ngoại bảng con đảm bảo mọi tag đều hợp lệ, cốt lõi mảng gọn 2 lần nhỏ hơn nhanh cho truy vấn chứa index GIN tí hon khỏi JOIN hợp cho tập vô hướng đơn giản đóng nhãn cờ bảng con tốn hơn nhưng cho FK toàn vẹn metadata trên tag và JOIN chuẩn hợp khi tag là thực thể có thuộc tính riêng chọn theo nhu cầu toàn vẹn không theo tốc độ

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_id 40 MB.

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ề

  1. 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).
  2. 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ỗi violates foreign key constraint — ranh giới thật giữa hai cách là toàn vẹn, không phải tốc độ.
  3. 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.