Nếu bạn từng tự hỏi "Shopee, Tiki, Lazada làm sao quản lý được hàng triệu sản phẩm thuộc đủ loại ngành hàng khác nhau — từ áo thun có màu/size, đến điện thoại có RAM/ROM, đến sách chỉ có tác giả/NXB — mà không cần sửa database mỗi khi thêm ngành hàng mới?" — câu trả lời nằm ở mô hình SPU/SKU kết hợp EAV. Đây là bài toán thiết kế database kinh điển và cũng dễ làm sai nhất khi mới bắt đầu xây hệ thống e-commerce.
SPU (Standard Product Unit) — sản phẩm ở mức khái niệm, đại diện cho thứ khách hàng nhìn thấy đầu tiên trên trang danh sách.
SKU (Stock Keeping Unit) — biến thể cụ thể có thể bán, có giá riêng, tồn kho riêng, mã vạch riêng.
SPU: "Áo thun basic cotton"
├── SKU: Đen - Size M (giá 150.000đ, còn 20)
├── SKU: Đen - Size L (giá 150.000đ, còn 15)
├── SKU: Trắng - Size M (giá 150.000đ, còn 30)
└── SKU: Trắng - Size L (giá 155.000đ, còn 8)
💡 Một SPU luôn có ít nhất 1 SKU. Sản phẩm không có biến thể (VD: 1 cuốn sách) vẫn có đúng 1 SKU tương ứng — SPU và SKU không loại trừ nhau, SKU luôn là "con" của SPU.
products?Đây là câu hỏi đầu tiên ai cũng đặt ra. Nếu gộp chung, mỗi biến thể là 1 dòng độc lập trong bảng products — nghe đơn giản, nhưng gây ra hàng loạt vấn đề thực tế:
| Vấn đề | Gộp chung 1 bảng | Tách SPU/SKU |
|---|---|---|
| Trang danh sách sản phẩm | Bị trùng lặp — mỗi biến thể hiện 1 dòng | Chỉ hiện 1 SPU, chọn biến thể sau |
| Giá / tồn kho riêng từng biến thể | Khó tách bạch | SKU độc lập hoàn toàn |
| Review, Q&A, SEO | Bị phân tán theo từng biến thể | Gom về đúng 1 SPU |
| Thêm ngành hàng mới với thuộc tính khác | Phải sửa cấu trúc bảng | Chỉ cần thêm dữ liệu, không đổi schema |
Đây là lúc bài toán trở nên thú vị. Thử hình dung cách tiếp cận "ngây thơ" — thiết kế bảng cứng với cột cố định:
CREATE TABLE products (
id INT,
name VARCHAR(255),
color VARCHAR(50), -- chỉ thời trang mới cần
size VARCHAR(50), -- chỉ thời trang, giày dép mới cần
ram VARCHAR(50), -- chỉ điện thoại, laptop mới cần
material VARCHAR(50), -- chỉ nội thất, thời trang mới cần
author VARCHAR(255) -- chỉ sách mới cần
);
Vấn đề: bảng phình to với hàng trăm cột, đa số NULL tuỳ ngành hàng. Tệ hơn, mỗi lần sàn mở thêm 1 ngành hàng mới (VD: "Giày dép" cần thêm thuộc tính "Size chân"), bạn phải ALTER TABLE — rủi ro downtime, không thể mở rộng vô hạn theo kiểu này. Đây chính là lúc mô hình EAV phát huy tác dụng.
Thay vì mỗi thuộc tính là 1 cột cố định, EAV lưu mỗi thuộc tính là 1 dòng với 3 thành phần:
sku_idsku_id | attribute_id | attribute_value_id
101 | 1 (Màu) | 5 (Đen)
101 | 2 (Size) | 12 (M)
102 | 1 (Màu) | 5 (Đen)
102 | 2 (Size) | 13 (L)
Với cấu trúc này, thêm 1 thuộc tính mới cho ngành hàng mới (VD: "Dung lượng pin" cho điện thoại) chỉ cần INSERT thêm 1 dòng vào bảng attributes — không cần đổi schema database. Đây là lý do mọi sàn TMĐT lớn đều dùng biến thể của EAV cho phần thuộc tính sản phẩm.
-- 1. Danh mục sản phẩm (cây phân cấp)
CREATE TABLE categories (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
parent_id BIGINT NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
FOREIGN KEY (parent_id) REFERENCES categories(id)
);
-- 2. SPU - sản phẩm gốc, chứa thông tin dùng chung
CREATE TABLE spus (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
category_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
status ENUM('draft','active','inactive') DEFAULT 'draft',
FOREIGN KEY (category_id) REFERENCES categories(id)
);
-- 3. Định nghĩa các "trục" thuộc tính có thể tồn tại
CREATE TABLE attributes (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL, -- "Màu sắc", "Kích cỡ"
type ENUM('variant','specification') DEFAULT 'variant'
);
-- 4. Giá trị cụ thể của từng thuộc tính (tái sử dụng được)
CREATE TABLE attribute_values (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
attribute_id BIGINT NOT NULL,
value VARCHAR(255) NOT NULL, -- "Đen", "M", "128GB"
FOREIGN KEY (attribute_id) REFERENCES attributes(id)
);
-- 5. SPU này dùng những attribute nào
CREATE TABLE spu_attributes (
spu_id BIGINT NOT NULL,
attribute_id BIGINT NOT NULL,
PRIMARY KEY (spu_id, attribute_id)
);
-- 6. SKU - biến thể cụ thể, đơn vị giao dịch thực tế
CREATE TABLE skus (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
spu_id BIGINT NOT NULL,
sku_code VARCHAR(100) UNIQUE NOT NULL,
price DECIMAL(15,2) NOT NULL,
stock_quantity INT DEFAULT 0,
FOREIGN KEY (spu_id) REFERENCES spus(id)
);
-- 7. Tổ hợp thuộc tính của từng SKU — trái tim của EAV
CREATE TABLE sku_attribute_values (
sku_id BIGINT NOT NULL,
attribute_value_id BIGINT NOT NULL,
PRIMARY KEY (sku_id, attribute_value_id)
);
erDiagram
CATEGORIES ||--o{ CATEGORIES : "danh mục cha - con"
CATEGORIES ||--o{ PRODUCTS : "chứa"
PRODUCTS ||--o{ PRODUCT_VARIANTS : "có nhiều biến thể"
PRODUCTS ||--o{ PRODUCT_ATTRIBUTES : "dung thuoc tinh nao"
PRODUCTS ||--o{ PRODUCT_SPECIFICATIONS : "thong so"
ATTRIBUTES ||--o{ PRODUCT_ATTRIBUTES : "duoc gan vao SPU"
ATTRIBUTES ||--o{ ATTRIBUTE_VALUES : "co cac gia tri cu the"
ATTRIBUTES ||--o{ PRODUCT_SPECIFICATIONS : "dung lam loai thong so"
ATTRIBUTE_VALUES ||--o{ VARIANT_ATTRIBUTE_VALUES : "gia tri cu the (Den, M...) duoc bien the chon"
PRODUCT_VARIANTS ||--o{ VARIANT_ATTRIBUTE_VALUES : "co to hop gia tri thuoc tinh"
CATEGORIES {
bigint id PK
bigint parent_id FK
varchar name
varchar slug
}
PRODUCTS {
bigint id PK
bigint category_id FK
varchar name
varchar slug
text description
enum status
}
ATTRIBUTES {
bigint id PK
varchar name
enum type "variant hoac specification"
}
ATTRIBUTE_VALUES {
bigint id PK
bigint attribute_id FK
varchar value
}
PRODUCT_ATTRIBUTES {
bigint product_id FK
bigint attribute_id FK
}
PRODUCT_VARIANTS {
bigint id PK
bigint product_id FK
varchar variant_code
decimal price
decimal sale_price
int stock_quantity
varchar barcode
varchar image_url
enum status
}
VARIANT_ATTRIBUTE_VALUES {
bigint product_variant_id FK
bigint attribute_value_id FK
}
PRODUCT_SPECIFICATIONS {
bigint product_id FK
bigint attribute_id FK
varchar value
}
attributes và attribute_values thành 2 bảng riêng?Vì 1 attribute (Màu sắc) có nhiều giá trị (Đen, Trắng, Xanh...) dùng lại được cho hàng nghìn SPU khác nhau. Nếu không tách, mỗi SPU phải tự định nghĩa lại "Đen" là gì — vừa trùng lặp dữ liệu, vừa không filter được kiểu "lọc tất cả sản phẩm màu Đen trên toàn sàn".
type trong bảng attributes để làm gì?Đây là điểm rất dễ bị bỏ sót nhưng cực kỳ quan trọng về nghiệp vụ. Có 2 loại thuộc tính khác nhau hoàn toàn:
Nếu gộp chung 2 loại này, hệ thống sẽ tự sinh SKU cho cả "Chất liệu" — sai hoàn toàn về nghiệp vụ, vì áo thun không nên có 3 SKU chỉ vì có 3 dòng mô tả chất liệu khác nhau.
INSERT| Nhược điểm | Chi tiết |
|---|---|
| Query phức tạp | Tìm "SKU màu Đen VÀ size M" cần self-join nhiều lần vào cùng 1 bảng trung gian |
| Không có type-safety ở DB | Giá trị lưu dạng string nên mất khả năng so sánh số (128GB > 64GB) |
| Khó validate ở DB level | Không dùng được CHECK constraint để giới hạn giá trị hợp lệ |
| Filter nhiều điều kiện chậm dần | Lọc theo 4-5 thuộc tính cùng lúc cần join 4-5 lần, hiệu năng giảm theo số điều kiện |
| Khó làm báo cáo/BI | Dữ liệu dạng dọc (long format) khó pivot so với dạng ngang (wide format) |
Các hệ thống lớn hiếm khi dùng EAV thuần túy. Cách tiếp cận thực dụng nhất là EAV lai (Hybrid EAV), kết hợp 3 kỹ thuật sau:
Giữ bảng sku_attribute_values làm nguồn dữ liệu chuẩn (để admin quản lý, validate), đồng thời đồng bộ sang cột JSON để đọc nhanh, không cần JOIN:
ALTER TABLE skus ADD COLUMN attributes_snapshot JSON;
-- {"color": "Đen", "size": "M"}
-- PostgreSQL: index GIN cho phép filter nhanh không cần JOIN
CREATE INDEX idx_sku_attrs ON skus USING GIN (attributes_snapshot);
SELECT * FROM skus WHERE attributes_snapshot @> '{"color": "Đen"}';
Các sàn lớn không filter trực tiếp trên EAV trong SQL, mà đẩy dữ liệu sang Elasticsearch — nơi mỗi SKU được "làm phẳng" (denormalize) thành 1 document với đầy đủ thuộc tính dạng field, filter nhanh nhờ inverted index. Database quan hệ chỉ đóng vai trò nguồn sự thật, còn filter/search thực tế chạy trên search engine.
Các trường mọi SKU đều có và cần type-safety (price, stock_quantity) nên giữ là cột cứng trong bảng skus, không đưa vào EAV. Chỉ những thuộc tính biến đổi theo ngành hàng (màu, size, RAM...) mới cần EAV. Đây là nguyên tắc quan trọng nhất: EAV không phải giải pháp cho mọi trường dữ liệu, chỉ dành cho phần thực sự động.
attributes_snapshot hoặc bảng trung gian) → hiển thị giá/tồn/ảnh của SKU đóThiết kế SPU/SKU với EAV giải quyết đúng bài toán cốt lõi của e-commerce: làm sao 1 schema database phục vụ được vô số ngành hàng với bộ thuộc tính hoàn toàn khác nhau, mà không phải sửa cấu trúc bảng mỗi khi mở rộng. Nhưng EAV không miễn phí — cái giá phải trả là độ phức tạp khi query và performance khi filter nhiều điều kiện.
Nguyên tắc thực chiến quan trọng nhất khi áp dụng:
sku_idNắm vững những nguyên tắc này, bạn sẽ tự tin thiết kế được hệ thống catalog sản phẩm mở rộng tốt, không phải "đập đi làm lại" khi sàn phát triển thêm ngành hàng mới.
Bạn đang thiết kế catalog cho ngành hàng nào? Để lại comment, mình cùng bàn cách áp dụng EAV cho case cụ thể của bạn! 🚀