TungDaDev's Blog

@ManyToMany trong JPA

Many to many.png
Published on
/9 mins read/

Mối quan hệ Nhiều-Nhiều (N-N) xuất hiện trong hầu hết các mô hình nghiệp vụ: Một Sinh viên đăng ký nhiều Khóa học và một Khóa học có nhiều Sinh viên (Student <-> Course), một Người dùng có nhiều Vai trò (User <-> Role), một Sản phẩm thuộc nhiều Danh mục (Product <-> Category).

Trong JPA/Hibernate, việc thiết lập quan hệ này cực kỳ đơn giản trên bề mặt: chỉ cần gắn annotation @ManyToMany cùng @JoinTable là Hibernate sẽ tự động sinh bảng trung gian (Junction Table).

Tuy nhiên, trong các dự án phần mềm doanh nghiệp quy mô lớn, các kiến trúc sư phần mềm gần như cấm tiệt việc sử dụng @ManyToMany trực tiếp.

Tại sao một tính năng tiện lợi của JPA lại bị coi là một "món nợ kỹ thuật" (technical debt) nguy hiểm?

  • Tại sao việc xóa 1 phần tử trong @ManyToMany lại khiến Hibernate xóa sạch toàn bộ bảng nối rồi INSERT lại từ đầu (Write Amplification)?
  • Làm thế nào khi nghiệp vụ đòi hỏi lưu thêm ngày tham gia (enrolled_at), điểm số (grade), hay người phê duyệt (approved_by) trên mối quan hệ?
  • Làm sao thiết kế bảng nối chuẩn mực với khóa chính phức hợp (Composite Key) bằng @EmbeddedId và @MapsId?

Bài viết này sẽ phân tích toàn diện cơ chế hoạt động của Hibernate và đưa ra giải pháp kiến trúc chuẩn production.


# tử huyệt của direct @manytomany trong môi trường doanh nghiệp

Mô hình @ManyToMany trực tiếp ánh xạ hai Entity qua một bảng trung gian ẩn:

# bẫy mở rộng dữ liệu (the metadata trap)

Trong thực tế, không có mối quan hệ Nhiều-Nhiều nào tồn tại vĩnh viễn dưới dạng hai cột khóa ngoại thuần túy.

  • Ban đầu: Student chỉ cần liên kết với Course.
  • Sau 2 tuần lên Production: Khách hàng yêu cầu lưu enrolled_date (ngày đăng ký), status (Đang học, Hoàn thành, Hủy), final_grade (điểm tổng kết).
  • Hậu quả: Annotation @ManyToMany hoàn toàn không hỗ trợ thêm cột mới vào bảng nối! Bạn buộc phải đập bỏ toàn bộ quan hệ @ManyToMany, viết lại hàng loạt repository, service, DTO và chạy migration database phức tạp.

# thảm họa write amplification khi dùng list

Nếu bạn sử dụng java.util.List cho @ManyToMany:

@ManyToMany
@JoinTable(name = "student_course", ...)
private List<Course> courses = new ArrayList<>();

Giả sử một sinh viên đang đăng ký 50 môn học. Bạn muốn gỡ bỏ 1 môn học:

student.getCourses().remove(targetCourse);

Điều gì sẽ diễn ra dưới database? Bạn kỳ vọng Hibernate chạy: DELETE FROM student_course WHERE student_id = 1 AND course_id = 99;

Nhưng thực tế Hibernate lại chạy:

-- 1. XÓA SẠCH TOÀN BỘ 50 MÔN HỌC CỦA SINH VIÊN ĐÓ!
DELETE FROM student_course WHERE student_id = 1;
 
-- 2. INSERT LẠI 49 MÔN HỌC CÒN LẠI BẰNG 49 CÂU LỆNH INSERT RIÊNG LẺ!
INSERT INTO student_course (student_id, course_id) VALUES (1, 1);
INSERT INTO student_course (student_id, course_id) VALUES (1, 2);
...
INSERT INTO student_course (student_id, course_id) VALUES (1, 49);

Nguyên nhân: List (được Hibernate quản lý dưới dạng PersistentBag) cho phép phần tử trùng lặp và không có primary key định danh duy nhất trên bảng nối. Để đảm bảo tính toàn vẹn, Hibernate chọn giải pháp "đốt nhà diệt chuột": xóa hết rồi nạp lại! Điều này tạo ra gánh nặng I/O và Transaction Log khủng khiếp lên Database Server.


# kiến trúc chuẩn enterprise: phân rã thành hai @onetomany

Giải pháp chuẩn mực được áp dụng tại các hệ thống lớn là: Biến Bảng Trung Gian thành một First-Class Entity thực sự (Explicit Join Entity).

Quan hệ Nhiều-Nhiều được phân rã thành hai quan hệ Một-Nhiều độc lập:


# triển khai thực chiến: composite key với @embeddedid & @mapsid

Để bảng trung gian có khóa chính là cặp (student_id, course_id) mà không cần sinh thêm cột ID tự tăng thừa thãi, chúng ta kết hợp @EmbeddedId và @MapsId.

# tạo khóa phức hợp (composite key class)

Class khóa bắt buộc phải implement Serializable và ghi đè equals()/hashCode() chính xác:

package com.company.academic.domain;
 
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;
 
@Embeddable
public class EnrollmentId implements Serializable {
 
    @Column(name = "student_id")
    private Long studentId;
 
    @Column(name = "course_id")
    private Long courseId;
 
    public EnrollmentId() {}
 
    public EnrollmentId(Long studentId, Long courseId) {
        this.studentId = studentId;
        this.courseId = courseId;
    }
 
    public Long getStudentId() { return studentId; }
    public Long getCourseId() { return courseId; }
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof EnrollmentId that)) return false;
        return Objects.equals(studentId, that.studentId) &&
               Objects.equals(courseId, that.courseId);
    }
 
    @Override
    public int hashCode() {
        return Objects.hash(studentId, courseId);
    }
}

# tạo Entity trung gian (explicit Join Entity)

Sử dụng @MapsId để gắn trực tiếp khóa chính của Student và Course vào EnrollmentId:

package com.company.academic.domain;
 
import jakarta.persistence.*;
import java.time.Instant;
import java.util.Objects;
 
@Entity
@Table(name = "student_course_enrollments")
public class Enrollment {
 
    @EmbeddedId
    private EnrollmentId id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("studentId") // Map trực tiếp với field studentId trong EnrollmentId
    @JoinColumn(name = "student_id")
    private Student student;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("courseId") // Map trực tiếp với field courseId trong EnrollmentId
    @JoinColumn(name = "course_id")
    private Course course;
 
    // Các thuộc tính nghiệp vụ mở rộng (Metadata)
    @Column(name = "enrolled_at", nullable = false)
    private Instant enrolledAt = Instant.now();
 
    @Column(name = "grade")
    private String grade;
 
    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    private EnrollmentStatus status = EnrollmentStatus.ACTIVE;
 
    protected Enrollment() {}
 
    public Enrollment(Student student, Course course) {
        this.student = student;
        this.course = course;
        this.id = new EnrollmentId(student.getId(), course.getId());
    }
 
    // Getters & Setters
    public EnrollmentId getId() { return id; }
    public Student getStudent() { return student; }
    public Course getCourse() { return course; }
    public Instant getEnrolledAt() { return enrolledAt; }
    public String getGrade() { return grade; }
    public void setGrade(String grade) { this.grade = grade; }
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Enrollment that)) return false;
        return id != null && id.equals(that.id);
    }
 
    @Override
    public int hashCode() {
        return Objects.hashCode(id);
    }
}

# đồng bộ ở hai đầu Entity cha (student & course)

@Entity
@Table(name = "students")
public class Student {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, unique = true)
    private String email;
 
    @OneToMany(mappedBy = "student", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<Enrollment> enrollments = new HashSet<>();
 
    // Helper methods đảm bảo toàn vẹn dữ liệu
    public void enrollInCourse(Course course) {
        Enrollment enrollment = new Enrollment(this, course);
        enrollments.add(enrollment);
        course.getEnrollments().add(enrollment);
    }
 
    public void dropCourse(Course course) {
        enrollments.removeIf(e -> {
            boolean match = e.getCourse().equals(course);
            if (match) {
                course.getEnrollments().remove(e);
            }
            return match;
        });
    }
 
    public Long getId() { return id; }
    public Set<Enrollment> getEnrollments() { return Collections.unmodifiableSet(enrollments); }
}

# tối ưu hóa hiệu năng ở tầng cơ sở dữ liệu

Một lỗi chí mạng khác trong các hệ thống quy mô lớn là thiếu Index chiều ngược trên bảng nối:

-- DDL tạo bảng trung gian chuẩn production
CREATE TABLE student_course_enrollments (
    student_id BIGINT NOT NULL,
    course_id BIGINT NOT NULL,
    enrolled_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    grade VARCHAR(10),
    status VARCHAR(20) DEFAULT 'ACTIVE' NOT NULL,
 
    -- Composite Primary Key: Tự động tạo B-Tree Index trên (student_id, course_id)
    PRIMARY KEY (student_id, course_id),
 
    CONSTRAINT fk_enrollment_student FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
    CONSTRAINT fk_enrollment_course FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
);
 
-- BẮT BUỘC: Tạo Index trên chiều ngược lại (course_id)!
-- Nếu không có index này, query: "Tìm tất cả sinh viên học môn Toán"
-- sẽ gây FULL TABLE SCAN trên bảng hàng triệu bản ghi!
CREATE INDEX idx_enrollment_course_id ON student_course_enrollments(course_id);

# so sánh hai hướng tiếp cận

Tiêu chíDirect @ManyToMany (Ngây thơ)Explicit Join Entity (Enterprise)
Mở rộng thuộc tính phụ🔴 Bất khả thi; phải đập bỏ viết lại toàn bộ🟢 Cực kỳ dễ dàng (chỉ cần thêm field vào Join Entity)
Hành vi Xóa (Delete)🔴 Xóa toàn bộ rồi INSERT lại nếu dùng List🟢 Chỉ xóa đúng 1 dòng mục tiêu: DELETE WHERE s_id=? AND c_id=?
Audit Log / Soft Delete🔴 Không thể gán @CreatedDate, @DeletedAt🟢 Hỗ trợ hoàn hảo Spring Data JPA Auditing
Kiểm soát Truy vấn🟡 Khó tối ưu fetch; dễ dính N+1 query🟢 Linh hoạt query qua EnrollmentRepository độc lập
Chi phí viết mã ban đầu🟢 Rất ít dòng code🟡 Cần tạo thêm 2 class (Enrollment, EnrollmentId)

# tổng kết

Trong kiến trúc phần mềm chuyên nghiệp, sự "tiện lợi ngắn hạn" thường là khởi đầu của "thảm họa dài hạn". Direct @ManyToMany là một tính năng như vậy: nó phù hợp cho các bài tập nhỏ hoặc đồ án sinh viên, nhưng là một quả bom nổ chậm trong môi trường Enterprise.

Việc phân rã tường minh thành hai quan hệ @OneToMany kết hợp Entity Trung Gian (@EmbeddedId + @MapsId):

  • Đảm bảo hệ thống luôn sẵn sàng mở rộng bất kỳ yêu cầu nghiệp vụ mới nào.
  • Tối ưu hóa triệt để các câu lệnh SQL ở mức độ chính xác từng dòng.
  • Đem lại sự kiểm soát toàn diện và tính ổn định dài hạn cho hạ tầng cơ sở dữ liệu.

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.