Các Pattern Thiết Kế Database PostgreSQL: Polymorphic Association vs EAV vs JSONB cho Schema Linh Hoạt

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Vấn Đề Schema Ập Đến Lúc 2 Giờ Sáng

Cảnh báo bắn ra lúc 2:14 sáng. Đội product vừa ship tính năng cho phép người dùng gắn thuộc tính tùy chỉnh vào đơn hàng — kích thước, màu sắc, chất liệu, thời hạn bảo hành — và câu query xử lý việc đó đang bị timeout sau 30 giây. Bảng đã phình lên 40 triệu hàng, và chúng tôi đang dùng EAV. Sai lầm kinh điển.

Tôi đã làm việc với MySQL, PostgreSQL và MongoDB qua đủ dự án để húc vào bức tường này hơn một lần. Với PostgreSQL cụ thể, việc lựa chọn giữa Polymorphic Association, EAV và JSONB là quyết định bạn sẽ cảm nhận hậu quả trong nhiều năm. Chọn sai pattern từ đầu và bạn sẽ phải viết lại query dưới áp lực production. Chọn đúng và nó tự biến vào nền.

Mọi thứ ở đây đến trực tiếp từ sự cố lúc 2 giờ sáng đó và quá trình refactor sau đó.

Tổng Quan Ba Cách Tiếp Cận

Ba pattern. Đây là phiên bản tóm tắt:

  • Polymorphic Association: Một bảng duy nhất tham chiếu đến nhiều bảng cha bằng cột type + foreign key. Phổ biến trong các ORM kiểu Rails.
  • EAV (Entity-Attribute-Value): Lưu thuộc tính động dưới dạng hàng — mỗi thuộc tính có hàng riêng với cặp tên/giá trị. Rất linh hoạt, nổi tiếng là khó query.
  • JSONB: Lưu thuộc tính động dưới dạng blob JSON trong một cột. Cách tiếp cận native của PostgreSQL, có hỗ trợ indexing.

So Sánh Các Cách Tiếp Cận

Polymorphic Association

Pattern này xuất hiện khi một bảng duy nhất cần tham chiếu đến nhiều bảng cha. Ví dụ kinh điển: comments có thể thuộc về một post hoặc một video. Bạn thêm cột commentable_typecommentable_id rồi coi như xong.

-- Thiết lập Polymorphic Association
CREATE TABLE comments (
  id          SERIAL PRIMARY KEY,
  body        TEXT NOT NULL,
  commentable_type VARCHAR(50) NOT NULL,  -- 'post' hoặc 'video'
  commentable_id   INTEGER NOT NULL
);

-- Không có ràng buộc foreign key thực sự ở đây — đó là vấn đề
CREATE INDEX idx_comments_poly ON comments(commentable_type, commentable_id);

Không có ràng buộc foreign key thực sự — đó là dấu hiệu cảnh báo đầu tiên. PostgreSQL không thể đảm bảo referential integrity trên nhiều bảng với một cặp cột duy nhất. Bạn đang đánh đổi các đảm bảo của database để lấy sự linh hoạt.

EAV (Entity-Attribute-Value)

EAV có vẻ là câu trả lời đúng khi tập hợp thuộc tính khác nhau nhiều giữa các bản ghi. Sản phẩm vật lý có color, sản phẩm số có download_limit, subscription có billing_cycle. Thế là bạn đổ tất cả vào một bảng key-value và tiến lên.

-- Thiết lập EAV
CREATE TABLE product_attributes (
  id         SERIAL PRIMARY KEY,
  product_id INTEGER NOT NULL REFERENCES products(id),
  attr_name  VARCHAR(100) NOT NULL,
  attr_value TEXT
);

CREATE INDEX idx_product_attr ON product_attributes(product_id, attr_name);

-- Query sản phẩm với nhiều thuộc tính rất khổ
SELECT
  p.name,
  MAX(CASE WHEN pa.attr_name = 'color' THEN pa.attr_value END) AS color,
  MAX(CASE WHEN pa.attr_name = 'size'  THEN pa.attr_value END) AS size
FROM products p
LEFT JOIN product_attributes pa ON pa.product_id = p.id
GROUP BY p.id, p.name;

Câu query pivot đó đã gây ra sự cố lúc 2 giờ sáng của chúng tôi. Với 40 triệu hàng thuộc tính, tổ hợp GROUP BY + MAX(CASE WHEN) đang phá nát hiệu suất — và đây là một câu query đơn giản. Lọc theo hai thuộc tính cùng lúc còn tệ hơn.

JSONB

Kiểu JSONB của PostgreSQL lưu JSON ở định dạng nhị phân, giúp đọc nhanh hơn và cho phép GIN indexing trên các key và value. Đây là câu trả lời native của database cho schema linh hoạt.

-- Thiết lập JSONB
CREATE TABLE products (
  id         SERIAL PRIMARY KEY,
  name       VARCHAR(255) NOT NULL,
  attributes JSONB DEFAULT '{}'
);

-- GIN index giúp tra cứu key/value nhanh
CREATE INDEX idx_products_attrs ON products USING GIN(attributes);

-- Thêm sản phẩm với thuộc tính động
INSERT INTO products (name, attributes)
VALUES (
  'Mechanical Keyboard',
  '{"color": "black", "switch_type": "Cherry MX Red", "backlit": true, "warranty_years": 2}'
);

-- Query theo giá trị thuộc tính cụ thể
SELECT name, attributes->>'color' AS color
FROM products
WHERE attributes @> '{"switch_type": "Cherry MX Red"}';

Câu query cuối đó đánh vào GIN index. Với 2 triệu hàng, nó chạy dưới 5ms trên phần cứng bình thường.

Ưu và Nhược Điểm

Polymorphic Association

  • Ưu điểm: Dễ thiết lập, ánh xạ tự nhiên vào quan hệ ORM (ActiveRecord, Django GenericForeignKey)
  • Ưu điểm: Hoạt động tốt khi tập hợp loại cha nhỏ và ổn định
  • Nhược điểm: Không có referential integrity ở cấp database — hàng mồ côi là rủi ro thực sự
  • Nhược điểm: Join trở nên lộn xộn khi cần dữ liệu từ bảng cha
  • Nhược điểm: Không phù hợp với hệ thống ràng buộc của PostgreSQL — bạn đang làm việc của ORM, không phải database

EAV

  • Ưu điểm: Linh hoạt tối đa — bất kỳ thuộc tính nào, bất kỳ entity nào, không cần migration schema
  • Ưu điểm: Được dùng trong các hệ thống production như Magento và bảng meta kiểu cũ của WordPress
  • Nhược điểm: Query nhiều thuộc tính cùng lúc thực sự rất khổ — SQL trở nên xấu xí nhanh chóng
  • Nhược điểm: Không có kiểm tra kiểu — mọi thứ đều là TEXT, nên so sánh số bị sai lặng lẽ
  • Nhược điểm: Bảng phình to nghiêm trọng — một sản phẩm với 10 thuộc tính thành 10 hàng thay vì 1
  • Nhược điểm: Index giúp ích nhưng không giải quyết vấn đề pivot query ở quy mô lớn

JSONB

  • Ưu điểm: Hỗ trợ native trong PostgreSQL với GIN indexing trên các cặp key/value
  • Ưu điểm: Linh hoạt mà không bị bùng nổ hàng như EAV
  • Ưu điểm: Xử lý cấu trúc lồng nhau, mảng và giá trị boolean một cách native
  • Ưu điểm: jsonb_set(), jsonb_strip_nulls() và các hàm tích hợp khác bao phủ hầu hết tình huống cập nhật
  • Nhược điểm: Ràng buộc cấp cột không áp dụng bên trong JSON — không có NOT NULL, CHECK hay foreign key cho từng key
  • Nhược điểm: Query phức tạp trên dữ liệu lồng sâu trở nên dài dòng nhanh chóng
  • Nhược điểm: Nếu mọi query đều lọc theo một JSONB key, key đó nên nằm trong một cột thực sự

Khuyến Nghị Thiết Lập

Quy tắc của tôi sau sự cố đó: bắt đầu với JSONB, nâng lên thành cột thực khi một trường trở thành bộ lọc hạng nhất.

Cụ thể hơn:

  • Dùng JSONB khi thuộc tính thực sự động và thưa thớt — các bản ghi khác nhau có tập hợp key hoàn toàn khác nhau. Catalog sản phẩm, tùy chọn người dùng và feature flag đều phù hợp.
  • Dùng Polymorphic Association chỉ cho các tính năng quan hệ nặng (comments, attachment, activity feed) — và ghi rõ ràng các ràng buộc FK bị thiếu để kỹ sư tiếp theo không bị bất ngờ.
  • Tránh EAV trong PostgreSQL trừ khi bị ép buộc bởi một migration kế thừa. Hầu như luôn có lựa chọn tốt hơn.

Khi một JSONB key bắt đầu xuất hiện trong hơn 80% mệnh đề WHERE của bạn, hãy đưa nó ra thành cột thực:

-- Nâng JSONB key được dùng nhiều thành cột thực
ALTER TABLE products ADD COLUMN color VARCHAR(50)
  GENERATED ALWAYS AS (attributes->>'color') STORED;

CREATE INDEX idx_products_color ON products(color);

Cách này giữ được sự linh hoạt của JSONB trong khi cho query planner một index thực sự để làm việc.

Hướng Dẫn Triển Khai

Thiết Lập Schema JSONB Kết Hợp

Các trường ổn định xuất hiện trong mọi mệnh đề WHERE được đặt vào cột thực. Mọi thứ còn lại đi vào JSONB. Đó là toàn bộ pattern.

CREATE TABLE products (
  id           SERIAL PRIMARY KEY,
  name         VARCHAR(255) NOT NULL,
  category_id  INTEGER NOT NULL REFERENCES categories(id),
  price        NUMERIC(10, 2) NOT NULL,
  status       VARCHAR(20) NOT NULL DEFAULT 'active',
  attributes   JSONB NOT NULL DEFAULT '{}',
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- GIN index cho việc tra cứu key tùy ý
CREATE INDEX idx_products_attrs_gin ON products USING GIN(attributes);

-- Index thông thường cho các key được dùng nhiều
CREATE INDEX idx_products_attr_brand
  ON products ((attributes->>'brand'));

Cập Nhật JSONB An Toàn trong Python

Một lỗi tôi thấy liên tục: ghi đè toàn bộ cột JSONB khi chỉ một key cần thay đổi — trong môi trường concurrent, điều này dễ gây mất dữ liệu. Dùng jsonb_set() thay vào đó:

import psycopg2
import json

conn = psycopg2.connect("dbname=mydb user=postgres")
cur = conn.cursor()

product_id = 42
new_color = "midnight blue"

# An toàn: chỉ cập nhật key 'color' bên trong attributes
cur.execute("""
    UPDATE products
    SET attributes = jsonb_set(attributes, '{color}', %s::jsonb)
    WHERE id = %s
""", (json.dumps(new_color), product_id))

conn.commit()
cur.close()
conn.close()

Xác Thực Schema JSONB với Ràng Buộc CHECK

Kiểm tra kiểu ở cấp cột không hoạt động bên trong JSONB, nhưng bạn có thể bắt buộc các key cần thiết bằng ràng buộc CHECK:

ALTER TABLE products
  ADD CONSTRAINT chk_attributes_required_keys
  CHECK (
    (attributes ? 'brand') AND
    (jsonb_typeof(attributes->'price_override') IN ('number', 'null'))
  );

Không chặt chẽ bằng cột có kiểu, nhưng tốt hơn là một blob hoàn toàn không bị ràng buộc. Hữu ích khi bạn cần một hợp đồng giữa các team mà không cần khóa cứng toàn bộ schema.

Di Chuyển từ EAV sang JSONB

Kế thừa một bảng EAV? Đây là con đường migration chính xác chúng tôi đã chạy sau sự cố lúc 2 giờ sáng:

-- Bước 1: Tổng hợp các hàng EAV thành JSON theo entity
CREATE TABLE product_attributes_jsonb AS
SELECT
  product_id,
  jsonb_object_agg(attr_name, attr_value) AS attributes
FROM product_attributes
GROUP BY product_id;

-- Bước 2: Thêm cột JSONB vào products
ALTER TABLE products ADD COLUMN attributes JSONB DEFAULT '{}';

-- Bước 3: Backfill dữ liệu
UPDATE products p
SET attributes = paj.attributes
FROM product_attributes_jsonb paj
WHERE paj.product_id = p.id;

-- Bước 4: Thêm GIN index
CREATE INDEX idx_products_attrs ON products USING GIN(attributes);

-- Bước 5: Xác minh, rồi xóa bảng EAV cũ
-- DROP TABLE product_attributes;

Sau khi migration, câu query chậm nhất của chúng tôi giảm từ 30 giây xuống còn 180ms. Không còn cảnh báo lúc 2 giờ sáng từ bảng đó nữa.

Khung Quyết Định Cuối Cùng

Khi thiết kế tính năng mới, tôi chạy qua danh sách kiểm tra này:

  1. Thuộc tính đã biết trước và được query thường xuyên? → Dùng cột thực.
  2. Thuộc tính động nhưng luôn gắn với một loại entity? → Dùng JSONB.
  3. Bạn cần quan hệ giữa một entity và nhiều bảng cha? → Dùng Polymorphic Association, nhưng ghi rõ ràng các ràng buộc FK bị thiếu.
  4. Bạn đang bị cám dỗ dùng EAV? → Dùng JSONB thay vào đó. Thật đấy.

Hỗ trợ JSONB của PostgreSQL đã trưởng thành. EAV hầu như không bao giờ là lựa chọn đúng cho các hệ thống mới. Indexing hoạt động tốt. Cú pháp query hợp lý. Các hàm tích hợp xử lý được các tình huống phổ biến. JSONB cho bạn sự linh hoạt cần thiết mà không từ bỏ các đảm bảo quan hệ khiến PostgreSQL đáng để chạy ngay từ đầu.

Share: