Thiết kế quan hệ Đa hình (Polymorphic) trong SQL: 3 kiến trúc từ thực tế Production

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

Giải quyết bài toán “Một thực thể, nhiều chủ thể”

Giả sử bạn đang xây dựng tính năng bình luận (Comment) cho một mạng xã hội. Người dùng có thể comment vào Bài viết (Post), Video, hoặc Sản phẩm (Product). Cách làm ngây thơ nhất là tạo 3 bảng post_comments, video_comments… hoặc nhồi 3 cột Foreign Key vào một bảng duy nhất. Cả hai cách này đều khiến Database phình to và cực kỳ khó bảo trì khi hệ thống mở rộng.

Quan hệ Polymorphic (Đa hình) là giải pháp cứu cánh. Nó cho phép một bảng liên kết linh hoạt với nhiều bảng khác qua một mối quan hệ duy nhất. Các framework nổi tiếng như Laravel hay Rails thường mặc định dùng cặp cột IDType để xử lý việc này.

-- Cách triển khai nhanh (phù hợp startup)
CREATE TABLE comments (
    id SERIAL PRIMARY KEY,
    content TEXT NOT NULL,
    commentable_id INT NOT NULL, -- ID của Post, Video hoặc Product
    commentable_type VARCHAR(50) NOT NULL -- Lưu giá trị 'Post', 'Video'...
);

Query lấy dữ liệu lúc này khá đơn giản:

SELECT * FROM comments WHERE commentable_type = 'Post' AND commentable_id = 10;

Chỉ mất 5 phút để thiết kế xong. Tuy nhiên, nếu hệ thống chạm mốc 1 triệu record, cấu trúc này sẽ bộc lộ những điểm yếu chết người về hiệu suất và tính toàn vẹn.

3 phương pháp thiết kế Polymorphic phổ biến

Sau nhiều dự án CMS thực tế, mình rút ra rằng không có kiến trúc nào là tốt nhất. Lựa chọn tùy thuộc vào việc bạn ưu tiên tốc độ code hay sự an toàn của dữ liệu.

1. Polymorphic Association (Cặp ID & Type)

Đây là cách tiếp cận linh hoạt nhất. Bạn có thể thêm bất kỳ thực thể mới nào (như Photo, Album) mà không cần sửa cấu trúc bảng comments.

  • Ưu điểm: Triển khai siêu tốc, code ở tầng Application rất gọn gàng.
  • Nhược điểm: Không thể tạo Foreign Key ràng buộc. Database không thể đảm bảo commentable_id đó thực sự tồn tại. Nếu bạn xóa một Post, bạn phải tự viết code xóa comment thủ công để tránh rác dữ liệu.

2. Exclusive Belongs To (Nhiều Foreign Key Nullable)

Thay vì dùng một cột ID chung chung, chúng ta tạo riêng các cột Foreign Key cho từng loại thực thể.

CREATE TABLE comments (
    id SERIAL PRIMARY KEY,
    content TEXT,
    post_id INT REFERENCES posts(id) ON DELETE CASCADE,
    video_id INT REFERENCES videos(id) ON DELETE CASCADE,
    CHECK (
        (post_id IS NOT NULL)::int + (video_id IS NOT NULL)::int = 1
    )
);
  • Ưu điểm: Tận dụng được sức mạnh của Foreign Key và tính năng ON DELETE CASCADE. Hiệu suất truy vấn cực cao nhờ Index chuẩn của SQL.
  • Nhược điểm: Bảng sẽ trở nên “rối mắt” nếu có quá nhiều thực thể. Mỗi lần thêm loại nội dung mới, bạn buộc phải ALTER TABLE để thêm cột.

3. Class Table Inheritance (Bảng cha trung gian)

Đây là cách làm chính thống và chuẩn hóa nhất (Normalized). Bạn tạo một bảng “giao diện” chung để quản lý ID.

-- Bảng định danh chung
CREATE TABLE commentable_entities (id SERIAL PRIMARY KEY);

-- Post kế thừa ID từ bảng trên
CREATE TABLE posts (
    id INT PRIMARY KEY REFERENCES commentable_entities(id),
    title VARCHAR(255)
);

CREATE TABLE comments (
    id SERIAL PRIMARY KEY,
    entity_id INT REFERENCES commentable_entities(id),
    content TEXT
);

Cách này biến quan hệ đa hình thành quan hệ 1-N truyền thống. Dữ liệu cực kỳ sạch và minh bạch.

Kỹ thuật tối ưu Index và Query

Sai lầm phổ biến nhất khi dùng cách 1 (ID & Type) là chỉ đánh Index cho cột id. Khi dữ liệu lớn, Database sẽ phải quét toàn bộ bảng (Full Table Scan) để lọc ra đúng type.

Giải pháp: Luôn sử dụng Composite Index (Index tổ hợp).

CREATE INDEX idx_comments_type_id ON comments (commentable_type, commentable_id);

Trong thực tế, cột type thường có ít giá trị (độ chọn lọc thấp) nên đặt trước. Điều này giúp Database thu hẹp vùng tìm kiếm nhanh hơn đáng kể.

Đôi khi bạn cần xử lý dữ liệu từ file CSV để import vào cấu trúc đa hình mới. Thay vì viết script Python phức tạp, mình thường dùng toolcraft.app/vi/tools/data/csv-to-json để convert nhanh sang JSON ngay trên trình duyệt. Công cụ này xử lý local nên khá an toàn cho dữ liệu dự án.

Triệt tiêu lỗi N+1 Query

Lỗi N+1 xảy ra khi bạn lấy 20 comment nhưng lại tốn thêm 20 query lẻ tẻ để lấy tiêu đề bài viết cha. Với quan hệ đa hình, lỗi này còn nguy hiểm hơn vì dữ liệu nằm ở nhiều bảng khác nhau.

Hãy sử dụng Eager Loading của ORM hoặc dùng UNION ALL nếu viết SQL thuần:

(SELECT c.*, p.title as parent_name FROM comments c 
 JOIN posts p ON c.commentable_id = p.id 
 WHERE c.commentable_type = 'Post' LIMIT 10)
UNION ALL
(SELECT c.*, v.name as parent_name FROM comments c 
 JOIN videos v ON c.commentable_id = v.id 
 WHERE c.commentable_type = 'Video' LIMIT 10);

Lời khuyên từ kinh nghiệm thực tế

Sau nhiều lần “trả giá” bằng việc dọn dẹp data rác, mình có vài quy tắc cho anh em:

  • Dự án Startup cần chạy nhanh: Ưu tiên ID & Type. Đừng quá ám ảnh về Foreign Key ở giai đoạn đầu, nhưng phải đánh Index tổ hợp ngay từ đầu.
  • Hệ thống tài chính, ERP: Bắt buộc dùng Base Table. Sự sai lệch dữ liệu ở đây là không thể chấp nhận được.
  • Tiết kiệm bộ nhớ: Sử dụng VARCHAR(30) hoặc ENUM cho cột type thay vì TEXT.
  • Nếu dùng PostgreSQL: Hãy thử kết hợp với JSONB để lưu các thuộc tính riêng biệt của từng loại thực thể đa hình.

Thiết kế Database là sự đánh đổi. Hãy cân nhắc kỹ giữa tính linh hoạt và sự an toàn trước khi đặt bút viết lệnh CREATE TABLE nhé.

Share: