Các kiểu quan hệ trong Cơ sở dữ liệu quan hệ
Trong thực tế, dữ liệu luôn có liên kết. Người dùng viết bài viết. Bài viết có bình luận. Bài viết thuộc danh mục và có nhiều tag. Mô hình hóa các liên kết này đúng cách trong JPA là một trong những kỹ năng quan trọng nhất.
Ba kiểu quan hệ
One-to-One (1:1) — Một bản ghi trong bảng A quan hệ với đúng một bản ghi trong bảng B. Ví dụ: Một user có một profile.
One-to-Many (1:N) — Một bản ghi trong bảng A quan hệ với nhiều bản ghi trong bảng B. Ví dụ: Một user viết nhiều bài viết.
Many-to-Many (M:N) — Nhiều bản ghi trong bảng A quan hệ với nhiều bản ghi trong bảng B. Luôn cần bảng nối (junction table). Ví dụ: Bài viết có nhiều tag, tag thuộc nhiều bài viết.
JPA ánh xạ quan hệ như thế nào
| Annotation | Quan hệ | Vị trí khóa ngoại |
|---|---|---|
@OneToOne |
1:1 | Bên phụ thuộc |
@ManyToOne |
N:1 | Bên “nhiều” (entity có khóa ngoại) |
@OneToMany |
1:N | Bên “một” (nghịch đảo của @ManyToOne) |
@ManyToMany |
M:N | Bảng nối (không entity nào có khóa ngoại) |
Ánh xạ @OneToOne — User và Profile
Entity Mapping
// UserProfile — bên sở hữu (owning side, có khóa ngoại)
@Entity
@Table(name = "user_profiles")
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "avatar_url", length = 500)
private String avatarUrl;
@Column(length = 500)
private String website;
// @OneToOne — profile này thuộc đúng một user.
// @JoinColumn chỉ định cột khóa ngoại trong bảng user_profiles.
// Đây là "owning side" vì nó chứa khóa ngoại.
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false, unique = true)
private User user;
}
// User — bên nghịch đảo (inverse side, không có khóa ngoại)
@Entity
@Table(name = "users")
public class User {
// "mappedBy = user" nghĩa là: entity UserProfile sở hữu quan hệ
// thông qua trường "user". Không có cột khóa ngoại trong bảng users.
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL,
fetch = FetchType.LAZY, orphanRemoval = true)
private UserProfile profile;
// Phương thức helper — giữ cả hai bên đồng bộ
public void setProfile(UserProfile profile) {
this.profile = profile;
if (profile != null) {
profile.setUser(this);
}
}
}
Owning Side vs Inverse Side
- Owning side là entity có
@JoinColumn(khóa ngoại). Thay đổi ở đây mới được lưu vào database. - Inverse side là entity có
mappedBy. Thay đổi ở đây KHÔNG được lưu trực tiếp.
@OneToMany và @ManyToOne — Post và Comments
Đây là kiểu quan hệ phổ biến nhất trong ứng dụng web.
Bên @ManyToOne (Comment → Post) — Owning side
@Entity
@Table(name = "comments")
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
// Nhiều bình luận thuộc một bài viết.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private Post post;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id", nullable = false)
private User author;
// Quan hệ tự tham chiếu: bình luận có thể trả lời bình luận khác
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Comment parent;
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> replies = new ArrayList<>();
public void addReply(Comment reply) {
replies.add(reply);
reply.setParent(this);
}
}
Bên @OneToMany (Post → Comments) — Inverse side
@Entity
@Table(name = "posts")
public class Post {
// Một bài viết có nhiều bình luận.
// "mappedBy = post" nghĩa là: entity Comment sở hữu quan hệ qua trường "post".
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
// Phương thức helper — giữ cả hai bên đồng bộ
public void addComment(Comment comment) {
comments.add(comment);
comment.setPost(this);
}
public void removeComment(Comment comment) {
comments.remove(comment);
comment.setPost(null);
}
}
Tại sao khởi tạo Collection?
private List<Comment> comments = new ArrayList<>();
Ngăn NullPointerException khi gọi post.getComments() trên bài viết chưa có bình luận.
@ManyToMany — Posts và Tags
Entity Mapping
// Tag entity
@Entity
@Table(name = "tags")
public class Tag {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String name;
// Bên nghịch đảo
@ManyToMany(mappedBy = "tags")
private Set<Post> posts = new HashSet<>();
}
// Post entity — thêm trường tags
@Entity
@Table(name = "posts")
public class Post {
// Bên SỞ HỮU — kiểm soát bảng nối
@ManyToMany
@JoinTable(
name = "post_tags",
joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private Set<Tag> tags = new HashSet<>();
// Helper — LUÔN cập nhật cả hai bên
public void addTag(Tag tag) {
this.tags.add(tag);
tag.getPosts().add(this);
}
public void removeTag(Tag tag) {
this.tags.remove(tag);
tag.getPosts().remove(this);
}
}
Tại sao dùng Set thay vì List?
// ❌ XẤU — List với @ManyToMany
// Khi xóa tag, Hibernate: XÓA TẤT CẢ hàng rồi INSERT LẠI tất cả còn lại. Cực kỳ kém hiệu quả!
@ManyToMany
private List<Tag> tags = new ArrayList<>();
// ✅ TỐT — Set với @ManyToMany
// Khi xóa tag, Hibernate chỉ XÓA đúng một hàng. Hiệu quả hơn nhiều!
@ManyToMany
private Set<Tag> tags = new HashSet<>();
Cascade Type và Orphan Removal
Cascade Type
| Cascade Type | Tác dụng |
|---|---|
PERSIST |
Khi cha được lưu, cũng lưu entity con mới |
MERGE |
Khi cha được cập nhật, cũng cập nhật entity con |
REMOVE |
Khi cha bị xóa, cũng xóa entity con |
ALL |
Tất cả ở trên kết hợp |
Chọn Cascade đúng
// CascadeType.ALL — cho quan hệ cha-con mạnh
// (bình luận không có ý nghĩa nếu không có bài viết)
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
private List<Comment> comments;
// Không cascade — cho tham chiếu lỏng
// (category tồn tại độc lập với bài viết nào)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category; // Không cascade
Orphan Removal
orphanRemoval = true tự động xóa entity con khi chúng bị loại khỏi collection của cha:
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
// Sử dụng:
post.getComments().removeIf(c -> c.getId().equals(commentId));
// Vì orphanRemoval = true, Hibernate tự sinh: DELETE FROM comments WHERE id = ?
Chiến lược Fetch — LAZY vs EAGER Loading
Quy tắc vàng: Luôn dùng LAZY
// Mọi quan hệ nên là LAZY
@ManyToOne(fetch = FetchType.LAZY) // Tải khi gọi getAuthor()
@OneToOne(fetch = FetchType.LAZY)
@OneToMany(fetch = FetchType.LAZY) // mặc định, nhưng rõ ràng
@ManyToMany(fetch = FetchType.LAZY) // mặc định, nhưng rõ ràng
EAGER loading là cài đặt toàn cục — một khi đặt, nó áp dụng cho MỌI truy vấn. Với LAZY, bạn tải entity liên quan chỉ khi và nơi bạn cần, sử dụng JOIN FETCH hoặc @EntityGraph.
Vấn đề N+1 và cách giải quyết
Vấn đề N+1 là gì?
Tưởng tượng bạn muốn hiển thị danh sách 10 bài viết với tên tác giả:
List<Post> posts = postRepository.findAll(); // Truy vấn 1: SELECT * FROM posts
for (Post post : posts) {
String authorName = post.getAuthor().getUsername(); // N truy vấn thêm!
}
Hibernate sinh: 1 truy vấn cho bài viết + 10 truy vấn cho tác giả = 11 truy vấn. Với 1000 bài viết, đó là 1001 truy vấn!
Giải pháp 1: JOIN FETCH (Khuyến nghị cho truy vấn cụ thể)
@Query("SELECT p FROM Post p JOIN FETCH p.author")
List<Post> findAllWithAuthors();
// Một truy vấn duy nhất tải tất cả!
Giải pháp 2: @EntityGraph (Khuyến nghị cho Derived Query)
@EntityGraph(attributePaths = {"author", "category"})
List<Post> findByStatus(String status);
Giải pháp 3: Batch Fetching (Tốt cho Collection)
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
@BatchSize(size = 25) // Tải bình luận theo lô 25, không phải từng cái
private List<Comment> comments = new ArrayList<>();
Hoặc cấu hình toàn cục:
spring.jpa.properties.hibernate.default_batch_fetch_size=25
Giải pháp 4: DTO Projection (Tốt nhất cho read-only)
Bỏ qua entity hoàn toàn và tải chính xác dữ liệu cần:
@Query("SELECT new com.example.blogapi.dto.PostWithAuthorDto(" +
"p.id, p.title, p.status, p.author.username) " +
"FROM Post p WHERE p.status = :status")
List<PostWithAuthorDto> findPostsWithAuthorInfo(@Param("status") String status);
Chọn giải pháp nào?
| Tình huống | Giải pháp tốt nhất |
|---|---|
| Tải entity đơn với quan hệ | JOIN FETCH hoặc @EntityGraph |
| Tải danh sách với một entity liên quan | JOIN FETCH hoặc @EntityGraph |
| Tải danh sách với collection (comments, tags) | @BatchSize hoặc DTO projection |
| API response, view chỉ đọc | DTO projection |
Bidirectional vs Unidirectional Relationship
Unidirectional — Chỉ một bên biết quan hệ
// Comment biết Post, nhưng Post KHÔNG biết Comments
@Entity
public class Comment {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
}
@Entity
public class Post {
// Không có trường comments
}
// Lấy bình luận qua: commentRepository.findByPostId(postId);
Bidirectional — Cả hai bên biết quan hệ
@Entity
public class Comment {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
}
@Entity
public class Post {
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
private List<Comment> comments;
}
Khi nào dùng cái nào?
| Dùng Unidirectional khi | Dùng Bidirectional khi |
|---|---|
| Chỉ điều hướng một chiều | Điều hướng cả hai chiều thường xuyên |
| Collection có thể rất lớn | Collection có kích thước hợp lý |
| Ưu tiên model đơn giản | Cần thao tác cascade |
Nguy hiểm của Bidirectional @OneToMany trên Collection lớn:
// ⚠️ NGUY HIỂM — nếu user có 10,000 bài viết, gọi user.getPosts()
// tải tất cả 10,000 vào bộ nhớ!
@OneToMany(mappedBy = "author")
private List<Post> posts; // Có thể RẤT LỚN
// ✅ AN TOÀN — dùng repository query với phân trang
postRepository.findByAuthorId(authorId, pageable);
Quy tắc: Nếu collection có thể tăng không giới hạn, KHÔNG ánh xạ nó là @OneToMany. Dùng repository query thay thế.
Best Practice cho thiết kế Entity
Luôn dùng LAZY Fetching
Dùng Set cho @ManyToMany, List cho @OneToMany
Luôn khởi tạo Collection
private List<Comment> comments = new ArrayList<>(); // ✅
private List<Comment> comments; // ❌ NullPointerException
Triển khai equals() và hashCode() đúng
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Post post = (Post) o;
return id != null && id.equals(post.id);
}
@Override
public int hashCode() {
return getClass().hashCode(); // Hằng số — an toàn khi id chưa được gán
}
Viết phương thức helper cho quan hệ Bidirectional
public void addComment(Comment comment) {
comments.add(comment);
comment.setPost(this); // Đồng bộ cả hai bên
}
Dùng @JsonIgnore để ngăn vòng lặp vô hạn
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
@JsonIgnore // Không serialize post khi serialize comment
private Post post;
Giải pháp thực sự là DTO (Bài 8) — ánh xạ entity sang DTO trước khi serialize.
Tránh Cascade trên @ManyToOne
// ❌ NGUY HIỂM — lưu comment KHÔNG nên cascade thay đổi đến post
@ManyToOne(cascade = CascadeType.ALL)
// ✅ ĐÚNG — không cascade trên @ManyToOne
@ManyToOne(fetch = FetchType.LAZY)
Không ánh xạ Collection không giới hạn
Dùng repository query thay vì @OneToMany cho collection lớn.
Thực hành: Triển khai đầy đủ Schema Blog với Quan hệ
Repository với truy vấn nhận biết quan hệ
public interface PostRepository extends JpaRepository<Post, Long> {
// Tải bài viết với tác giả, danh mục, và tags (một truy vấn)
@Query("SELECT DISTINCT p FROM Post p " +
"JOIN FETCH p.author " +
"LEFT JOIN FETCH p.category " +
"LEFT JOIN FETCH p.tags " +
"WHERE p.id = :id")
Optional<Post> findByIdWithAllDetails(@Param("id") Long id);
// Bài viết theo tag
@Query("SELECT p FROM Post p JOIN p.tags t WHERE t.slug = :tagSlug AND p.status = 'PUBLISHED'")
Page<Post> findByTagSlug(@Param("tagSlug") String tagSlug, Pageable pageable);
}
public interface CommentRepository extends JpaRepository<Comment, Long> {
// Bình luận cấp cao nhất (parent IS NULL) với phân trang
@Query("SELECT c FROM Comment c JOIN FETCH c.author " +
"WHERE c.post.id = :postId AND c.parent IS NULL ORDER BY c.createdAt DESC")
Page<Comment> findTopLevelByPostId(@Param("postId") Long postId, Pageable pageable);
long countByPostId(Long postId);
}
Bài tập
- Thêm chức năng bookmark:
@ManyToManygiữa User và Post. - Triển khai đếm bình luận hiệu quả bằng
countByPostId()thay vì tải tất cả bình luận. - Kiểm thử cascade delete: xóa bài viết có bình luận và tag — xác nhận bình luận bị xóa nhưng tag còn lại.
- Profile vấn đề N+1: tạm bỏ
JOIN FETCH, truy cậppost.getCategory().getName()trong vòng lặp, đếm truy vấn trong console.
Tổng kết
- @OneToOne:
@JoinColumnở owning side,mappedByở inverse side.CascadeType.ALLcho quan hệ cha-con mạnh. - @OneToMany / @ManyToOne:
@ManyToOnelà owning side. Helper method giữ hai bên đồng bộ. - @ManyToMany:
@JoinTableở owning side. Luôn dùngSetcho hiệu suất. - Cascade:
ALLcho cha-con mạnh, không cascade cho tham chiếu lỏng, không bao giờ cascade trên@ManyToOne. - Fetch: Luôn LAZY. Override bằng
JOIN FETCH,@EntityGraph,@BatchSizecho truy vấn cụ thể. - N+1: 1 truy vấn cha + N truy vấn con = chậm. Sửa bằng JOIN FETCH, @EntityGraph, @BatchSize, hoặc DTO.
- Bidirectional vs Unidirectional: Bidirectional khi cần điều hướng hai chiều và cascade. Unidirectional cho tham chiếu lỏng và collection lớn.
Tham chiếu nhanh
| Khái niệm | Mô tả |
|---|---|
@OneToOne |
Một entity quan hệ với đúng một entity khác |
@ManyToOne |
Nhiều entity quan hệ với một entity (sở hữu khóa ngoại) |
@OneToMany |
Một entity quan hệ với nhiều entity (nghịch đảo @ManyToOne) |
@ManyToMany |
Nhiều-nhiều (bảng nối) |
mappedBy |
Khai báo bên nghịch đảo của quan hệ |
| Owning side | Entity chứa khóa ngoại — thay đổi ở đây mới được lưu |
CascadeType.ALL |
Lan truyền tất cả thao tác đến entity con |
orphanRemoval |
Xóa entity con khi bị loại khỏi collection cha |
FetchType.LAZY |
Tải entity liên quan chỉ khi truy cập (khuyến nghị mặc định) |
| N+1 Problem | 1 truy vấn cha + N truy vấn con = chậm |
JOIN FETCH |
Từ khóa JPQL tải entity liên quan trong một truy vấn |
@EntityGraph |
Tải eager khai báo cho truy vấn cụ thể |
@BatchSize |
Tải entity liên quan theo lô thay vì từng cái |
