Skip to content

EAV, JSON & thuộc tính động ​

Bài toán: mỗi loại sản phẩm có một bộ thuộc tính khác nhau (áo: size, màu, chất liệu; laptop: CPU, RAM, màn hình), hoặc người dùng tự định nghĩa trường riêng (custom field trong CRM, form khảo sát). Không thể biết trước mọi cột. Có năm cách lưu, mỗi cách đổi một thứ lấy một thứ khác.

1. Cột cố định cho mọi thuộc tính ​

Một bảng products có đủ size, color, cpu, ram_gb… Thuộc tính không áp dụng thì NULL.

  • Có kiểu dữ liệu, ràng buộc, index đầy đủ; query đơn giản.
  • Bảng rất rộng và thưa; thêm thuộc tính mới phải ALTER TABLE.
  • Hợp khi số thuộc tính ít, ổn định và dùng chung cho đa số dòng.

2. Bảng con theo loại ​

products giữ phần chung (tên, giá), mỗi loại một bảng con dùng chung khoá chính: product_laptops(product_id, cpu, ram_gb), product_shirts(product_id, size, color).

  • Vẫn có kiểu, ràng buộc, index đầy đủ.
  • Thêm loại mới là thêm bảng; query trộn nhiều loại phải LEFT JOIN hoặc UNION.
  • Hợp khi có vài loại, mỗi loại có bộ thuộc tính rõ ràng.

3. EAV (Entity – Attribute – Value) ​

Mỗi giá trị thuộc tính là một dòng: thực thể nào, thuộc tính nào, giá trị gì.

sql
CREATE TABLE attributes (
  id        INT PRIMARY KEY,
  code      VARCHAR(50) NOT NULL UNIQUE,   -- 'cpu', 'ram_gb', 'color'
  data_type VARCHAR(10) NOT NULL           -- 'int', 'text', 'decimal'
);

CREATE TABLE product_attribute_values (
  product_id   BIGINT NOT NULL,
  attribute_id INT NOT NULL,
  value        VARCHAR(255) NOT NULL,      -- mọi kiểu đều thành chuỗi
  PRIMARY KEY (product_id, attribute_id)
);

Ưu điểm: thêm thuộc tính chỉ là thêm một dòng vào attributes, không cần DDL; dữ liệu thưa không tốn cột NULL.

Vì sao hay bị gọi là anti-pattern:

  • Mất kiểu dữ liệu. value là chuỗi: '16' so với '8' theo chuỗi thì '8' lớn hơn. Muốn đúng kiểu phải tách nhiều cột value_int, value_text, value_decimal, hoặc nhiều bảng.
  • Mất ràng buộc. Không đặt được NOT NULL, CHECK, khoá ngoại cho từng thuộc tính. “Laptop bắt buộc có CPU” phải kiểm ở app.
  • Lọc theo nhiều thuộc tính = mỗi điều kiện một lần JOIN:
sql
-- laptop có RAM >= 16 và CPU i7 (cú pháp MySQL)
SELECT p.id, p.name
FROM products p
JOIN product_attribute_values cpu ON cpu.product_id = p.id AND cpu.attribute_id = 1
JOIN product_attribute_values ram ON ram.product_id = p.id AND ram.attribute_id = 2
WHERE cpu.value = 'i7'
  AND CAST(ram.value AS UNSIGNED) >= 16;   -- ép kiểu trên cột: không dùng được index
  • Hiển thị một sản phẩm phải “xoay” dòng thành cột (pivot):
sql
SELECT product_id,
       MAX(CASE WHEN attribute_id = 1 THEN value END) AS cpu,
       MAX(CASE WHEN attribute_id = 2 THEN value END) AS ram_gb,
       MAX(CASE WHEN attribute_id = 3 THEN value END) AS color
FROM product_attribute_values
WHERE product_id IN (…)
GROUP BY product_id;
  • Phình nhanh. 1 triệu sản phẩm × 30 thuộc tính = 30 triệu dòng, mỗi dòng còn tốn overhead riêng. Optimizer cũng khó ước lượng vì mọi thuộc tính dùng chung một cột.

Khi EAV vẫn hợp lý: thuộc tính do người dùng hoặc từng tenant tự định nghĩa, cần quản lý metadata cho từng thuộc tính (kiểu, nhãn, bắt buộc, danh sách lựa chọn), và hiếm khi lọc theo nhiều thuộc tính cùng lúc. Hệ thống y tế, khoa học với hàng nghìn thuộc tính rất thưa cũng dùng EAV. Magento là ví dụ nổi tiếng: dùng EAV cho sản phẩm và phải dựng thêm bảng “phẳng” cùng index riêng để trang danh mục chạy đủ nhanh.

4. Cột JSON ​

Phần chung là cột thường, phần thay đổi theo loại nằm trong một cột JSON. Một sản phẩm vẫn là một dòng, đọc một lần là đủ.

MySQL: lọc theo trường trong JSON qua cột sinh có index; mảng dùng multi-valued index (8.0.17+); kiểm tra cấu trúc bằng JSON_SCHEMA_VALID trong CHECK.

sql
CREATE TABLE products (
  id     BIGINT PRIMARY KEY,
  type   VARCHAR(20)  NOT NULL,
  name   VARCHAR(200) NOT NULL,
  price  BIGINT       NOT NULL,
  attrs  JSON         NOT NULL,
  ram_gb INT AS (CAST(attrs->>'$.ram_gb' AS UNSIGNED)) VIRTUAL,
  INDEX idx_type_ram (type, ram_gb),
  INDEX idx_tags ((CAST(attrs->'$.tags' AS CHAR(30) ARRAY)))
);

SELECT id, name FROM products WHERE type = 'laptop' AND ram_gb >= 16;
SELECT id, name FROM products WHERE 'gaming' MEMBER OF (attrs->'$.tags');

PostgreSQL: JSONB kèm index GIN cho phép lọc theo bất kỳ cặp key–value nào; trường hay lọc theo khoảng thì dùng expression index.

sql
CREATE INDEX idx_products_attrs ON products USING GIN (attrs jsonb_path_ops);
SELECT id, name FROM products WHERE attrs @> '{"cpu": "i7"}';

CREATE INDEX idx_laptop_ram ON products (((attrs->>'ram_gb')::int)) WHERE type = 'laptop';
SELECT id, name FROM products WHERE type = 'laptop' AND (attrs->>'ram_gb')::int >= 16;

Giới hạn: không có khoá ngoại bên trong JSON; tên key lặp lại ở mọi dòng nên tốn chỗ hơn cột thường; sửa một trường thường ghi lại cả document. Trường nào được lọc, sort, join thường xuyên thì nên tách thành cột thật.

5. Kết hợp: định nghĩa thuộc tính trong bảng, giá trị trong JSON ​

Giữ bảng attribute_definitions(tenant_id, code, label, data_type, required, options) để app biết vẽ form và kiểm tra dữ liệu, còn giá trị lưu trong cột JSON của thực thể. Cách này có được metadata của EAV mà không phải chịu cảnh mỗi giá trị một dòng.

Trang danh mục có bộ lọc theo nhiều thuộc tính (faceted search: hãng, giá, RAM, màu…) thường không lọc trực tiếp trên database mà đẩy dữ liệu qua CDC sang Elasticsearch hoặc OpenSearch.

So sánh ​

CáchKiểu & ràng buộcLọc, indexThêm thuộc tínhHợp khi
Cột cố địnhĐầy đủTốtALTER TABLEThuộc tính ít, ổn định
Bảng con theo loạiĐầy đủTốtThêm bảngVài loại, mỗi loại rõ ràng
EAVMấtKém, mỗi điều kiện một JOINThêm dòngThuộc tính người dùng tự định nghĩa, rất thưa, ít lọc
JSONMột phần (JSON schema)Được, qua cột sinh hoặc GINKhông cần DDLThuộc tính linh hoạt, thường đọc cả object
Search engine (bổ sung)—Lọc nhiều thuộc tính, faceted searchMappingTrang danh mục có bộ lọc

Ghi nhớ

  1. EAV: mỗi giá trị một dòng; mất kiểu, mất ràng buộc, lọc nhiều thuộc tính phải JOIN nhiều lần, phình nhanh.
  2. Mặc định hiện nay: cột thường cho phần lõi + JSON cho phần linh hoạt; tách trường hay lọc thành cột sinh có index.
  3. EAV hợp lý khi thuộc tính do người dùng định nghĩa và cần metadata từng thuộc tính.