Giao diện
Polymorphic association
Bài toán: một loại dữ liệu gắn được vào nhiều loại bảng cha. Bình luận có thể nằm dưới bài viết, video hoặc sản phẩm. Lượt thích, file đính kèm, thông báo, nhật ký hoạt động, tag cũng hay gặp cảnh này.
Quan hệ một – nhiều thông thường chỉ cần một cột khoá ngoại: comments.post_id REFERENCES posts(id). Khi cha có thể là posts, videos hoặc products, một cột khoá ngoại không đủ, vì khoá ngoại chỉ trỏ được tới một bảng. Có bốn cách giải.
Polymorphic có phải là đánh tag?
Tag là một ví dụ hay gặp, nhưng hai khái niệm khác nhau:
- Tag là quan hệ nhiều – nhiều: một bài có nhiều tag, một tag có ở nhiều bài. Nếu chỉ bài viết có tag thì một bảng nối
post_tags(post_id, tag_id)là đủ, không có gì polymorphic (Quan hệ). - Polymorphic là phía cha có nhiều loại. Tag trở thành polymorphic khi cùng một bộ tag dùng cho cả bài viết, video và sản phẩm: bảng nối giờ phải trỏ tới nhiều loại bảng, kiểu
taggings(tag_id, taggable_type, taggable_id).
| Một loại cha | Nhiều loại cha (polymorphic) | |
|---|---|---|
| Một – nhiều | comments.post_id | Bình luận cho bài viết, video, sản phẩm |
| Nhiều – nhiều | post_tags(post_id, tag_id) | Tag dùng chung cho bài viết, video, sản phẩm |
Bốn cách bên dưới dùng được cho cả hai dòng của bảng. Ví dụ dùng bình luận.
1. Cặp cột loại + id
Cách Rails và Laravel làm mặc định: một cột ghi loại cha, một cột ghi id cha.
sql
CREATE TABLE comments (
id BIGINT PRIMARY KEY,
commentable_type VARCHAR(20) NOT NULL, -- 'post' | 'video' | 'product'
commentable_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
CHECK (commentable_type IN ('post', 'video', 'product'))
);
CREATE INDEX idx_comments_target ON comments (commentable_type, commentable_id, created_at);
-- comment của bài #1
SELECT * FROM comments
WHERE commentable_type = 'post' AND commentable_id = 1
ORDER BY created_at;Được: một bảng duy nhất, thêm loại cha mới không phải đổi schema, ORM hỗ trợ sẵn (Rails polymorphic: true, Laravel morphTo; Sequelize làm được bằng scope kèm constraints: false; Prisma không hỗ trợ trực tiếp).
Mất: không có khoá ngoại, nên database không bảo vệ gì cả:
- Xoá bài viết thì comment ở lại thành mồ côi. Ứng dụng phải tự xoá trong cùng transaction.
- Ghi
('post', 999)với bài 999 không tồn tại vẫn thành công. - Lấy tên của nơi comment phải
LEFT JOINtừng bảng cha kèm điều kiện type, hoặc chạy một query cho mỗi loại. - Mọi bảng cha phải dùng cùng kiểu id (cùng
BIGINT, hoặc cùng UUID).
Nếu chọn cách này, giảm rủi ro bằng:
- Giá trị type ngắn và ổn định (
'post'), đừng lưu tên class ('App\Models\Post'): đổi tên class là dữ liệu cũ hỏng. Laravel cóRelation::enforceMorphMapcho việc này. CHECKgiới hạn danh sách type (MySQL thực thiCHECKtừ 8.0.16).- Index bắt đầu bằng
(commentable_type, commentable_id). - Job định kỳ tìm và dọn mồ côi:
sql
SELECT c.id FROM comments c
WHERE c.commentable_type = 'post'
AND NOT EXISTS (SELECT 1 FROM posts p WHERE p.id = c.commentable_id);2. Nhiều cột khoá ngoại
Mỗi loại cha một cột, chỉ đúng một cột được khác NULL. Tên gọi khác: exclusive arc.
sql
CREATE TABLE comments (
id BIGINT PRIMARY KEY,
post_id BIGINT NULL,
video_id BIGINT NULL,
product_id BIGINT NULL,
user_id BIGINT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (post_id) REFERENCES posts (id),
FOREIGN KEY (video_id) REFERENCES videos (id),
FOREIGN KEY (product_id) REFERENCES products (id),
CHECK (
(CASE WHEN post_id IS NOT NULL THEN 1 ELSE 0 END)
+ (CASE WHEN video_id IS NOT NULL THEN 1 ELSE 0 END)
+ (CASE WHEN product_id IS NOT NULL THEN 1 ELSE 0 END) = 1
)
);
CREATE INDEX idx_comments_post ON comments (post_id);
CREATE INDEX idx_comments_video ON comments (video_id);
CREATE INDEX idx_comments_product ON comments (product_id);Được: khoá ngoại thật, không có mồ côi, JOIN theo từng cột rõ ràng.
Mất: thêm loại cha là ALTER TABLE thêm cột, khoá ngoại, index và sửa CHECK. Nhiều cột NULL. Hợp khi chỉ có vài loại cha và ít khi thêm.
Lưu ý theo engine:
- PostgreSQL viết
CHECKgọn hơn:CHECK (num_nonnulls(post_id, video_id, product_id) = 1). - MySQL không cho dùng
ON DELETE CASCADEhaySET NULLtrên cột có mặt trongCHECK. Vì vậy ví dụ trên để mặc định: muốn xoá bài thì xoá comment trước. - PostgreSQL không tự tạo index cho cột khoá ngoại; MySQL InnoDB thì tự tạo nếu chưa có. Viết rõ
CREATE INDEXnhư trên chạy được cả hai.
3. Bảng riêng cho từng loại
post_comments, video_comments, product_comments, mỗi bảng có khoá ngoại tới đúng bảng cha.
sql
CREATE TABLE post_comments (
id BIGINT PRIMARY KEY,
post_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE
);
-- video_comments, product_comments giống hệt, chỉ khác cột chaĐược: khoá ngoại đầy đủ, ON DELETE CASCADE dùng thoải mái, mỗi bảng có thể có cột và quy tắc riêng (bình luận sản phẩm có số sao).
Mất: câu hỏi xuyên loại (mọi comment của một user, mọi comment chờ kiểm duyệt) phải UNION ALL tất cả các bảng; code và migration lặp lại theo số loại.
Với tag, đây thường là lựa chọn đúng: post_tags(post_id, tag_id), video_tags(video_id, tag_id) dùng chung bảng tags. Mỗi bảng nối có khoá ngoại hai đầu, và câu hỏi hay gặp nhất (tag của bài này, bài có tag này) chỉ chạm một bảng nối.
4. Bảng cha chung
Tạo một bảng đại diện cho mọi thứ có thể được bình luận. Bài viết, video, sản phẩm mỗi cái có một dòng ở đó; comment trỏ vào bảng chung.
sql
CREATE TABLE commentables (
id BIGINT PRIMARY KEY,
kind VARCHAR(20) NOT NULL, -- 'post' | 'video' | 'product'
title VARCHAR(200) NOT NULL -- tuỳ chọn: tên hiển thị, để khỏi JOIN xuống bảng con
);
CREATE TABLE posts (
id BIGINT PRIMARY KEY,
commentable_id BIGINT NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL,
FOREIGN KEY (commentable_id) REFERENCES commentables (id) ON DELETE CASCADE
);
-- videos, products cũng có commentable_id như vậy
CREATE TABLE comments (
id BIGINT PRIMARY KEY,
commentable_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (commentable_id) REFERENCES commentables (id) ON DELETE CASCADE
);
CREATE INDEX idx_comments_target ON comments (commentable_id, created_at);Được: khoá ngoại đầy đủ, thêm loại cha không đổi bảng comments, và nhiều tính năng dùng chung một bảng cha: likes, attachments, taggings đều trỏ vào commentables (lúc đó nên đặt tên chung hơn, ví dụ entities).
Mất: thêm một tầng gián tiếp. Tạo bài viết phải tạo dòng commentables trước, trong cùng transaction. Xoá thì xoá dòng commentables để CASCADE dọn cả bài lẫn comment. Lấy comment của bài #1 phải đi qua posts.commentable_id.
Biến thể: bảng con dùng luôn id của bảng cha làm khoá chính (posts.id REFERENCES commentables(id)), giống bảng con theo loại. Bớt được một cột, nhưng khó áp vào bảng đã có sẵn dữ liệu.
So sánh
Thử: cùng một tình huống trên bốn thiết kế
1. Cặp cột loại + id
Có vấn đề
Bài bị xoá, 3 comment ở lại thành mồ côi. Ứng dụng phải tự xoá comment trong cùng transaction, và chỗ nào quên là có rác.
DELETE FROM posts WHERE id = 1;
-- comments vẫn còn 3 dòng
-- ('post', 1, …)Ổn ở 2/5 tình huống2. Nhiều cột khoá ngoại
Ổn
Khoá ngoại chặn xoá bài khi còn comment. Xoá comment trước rồi mới xoá bài. Không thể có mồ côi.
DELETE FROM comments WHERE post_id = 1; DELETE FROM posts WHERE id = 1;Ổn ở 4/5 tình huống
3. Bảng riêng cho từng loại
Ổn
post_comments có khoá ngoại ON DELETE CASCADE: comment đi theo bài.
DELETE FROM posts WHERE id = 1; -- post_comments tự xoá theo CASCADEỔn ở 3/5 tình huống
4. Bảng cha chung
Ổn
Xoá dòng ở bảng cha chung: CASCADE xoá cả bài lẫn comment.
DELETE FROM commentables WHERE id = 101; -- posts và comments trỏ tới 101 -- tự xoá theo CASCADEỔn ở 4/5 tình huống
| 1. Loại + id | 2. Nhiều cột FK | 3. Bảng riêng | 4. Bảng cha chung | |
|---|---|---|---|---|
| Khoá ngoại | Không | Có | Có | Có |
| Thêm loại cha mới | Không đổi schema | Thêm cột vào comments | Thêm bảng, sửa UNION | Thêm cột vào bảng cha mới |
| Comment của một cha | Index (type, id) | Index từng cột | Một bảng | Qua commentable_id |
| Câu hỏi xuyên loại | Dễ | Dễ | UNION ALL | Dễ |
| Lấy thông tin cha | LEFT JOIN từng loại có điều kiện type | LEFT JOIN từng cột | Có sẵn | Một JOIN nếu bảng chung lưu tên |
| Hỗ trợ ORM | Rails, Laravel có sẵn | Như quan hệ thường | Như quan hệ thường | Như quan hệ thường |
Chọn cách nào
Ba câu hỏi:
- Có cần database đảm bảo toàn vẹn không? Tiền, đơn hàng, quyền truy cập: cần. Nhật ký hoạt động, thông báo: thường không, và nhật ký còn phải giữ lại sau khi cha bị xoá.
- Số loại cha có tăng thường xuyên không?
- Có hay hỏi xuyên mọi loại không? (mọi comment của một user, hàng chờ kiểm duyệt)
| Tình huống | Chọn |
|---|---|
| Vài loại cha cố định, cần toàn vẹn | 2. Nhiều cột khoá ngoại |
| Nhiều tính năng (comment, like, file, tag) cùng gắn vào nhiều loại | 4. Bảng cha chung |
| Tag cho vài loại đối tượng | 3. Bảng nối riêng mỗi loại, dùng chung bảng tags |
| Mỗi loại có cột, quy tắc riêng; ít hỏi xuyên loại | 3. Bảng riêng |
| Nhật ký, thông báo, audit; loại tăng liên tục | 1. Loại + id, có CHECK và job dọn |
| Dự án Rails/Laravel, dữ liệu không quan trọng | 1. Loại + id (mặc định của framework) |
Ghi nhớ
- Polymorphic là khi phía cha có nhiều loại bảng; tag chỉ polymorphic khi một bộ tag dùng cho nhiều loại đối tượng.
- Cặp cột loại + id dễ làm nhất nhưng không có khoá ngoại: có mồ côi, có id trỏ vào hư không.
- Nhiều cột khoá ngoại +
CHECKđúng một cột khác NULL: toàn vẹn đầy đủ, hợp khi ít loại. - Bảng riêng mỗi loại: toàn vẹn đầy đủ, nhưng câu hỏi xuyên loại phải UNION.
- Bảng cha chung: toàn vẹn đầy đủ, dễ mở rộng, đáng dùng khi nhiều tính năng cùng gắn vào nhiều loại.