Quan hệ Entity & Ánh xạ nâng cao

Table of Contents

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

  1. Thêm chức năng bookmark: @ManyToMany giữa User và Post.
  2. 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.
  3. 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.
  4. Profile vấn đề N+1: tạm bỏ JOIN FETCH, truy cập post.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.ALL cho quan hệ cha-con mạnh.
  • @OneToMany / @ManyToOne: @ManyToOne là owning side. Helper method giữ hai bên đồng bộ.
  • @ManyToMany: @JoinTable ở owning side. Luôn dùng Set cho hiệu suất.
  • Cascade: ALL cho 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, @BatchSize cho 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

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *