@ManyToMany trong JPA
Tung Bui Thanh (TungDaDev) (updated 6 months ago)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, nhìn bề ngoài thiết lập quan hệ này rất đơn giản: chỉ cần gắn annotation @ManyToMany cùng @JoinTable là Hibernate sẽ tự động sinh bảng trung gian (Junction Table).
Nhưng ở nhiều dự án lớn, @ManyToMany trực tiếp bị hạn chế hoặc cấm hẳn trong coding convention.
Vì sao một tính năng tiện của JPA lại bị coi là "món nợ kỹ thuật" (technical debt)?
- Vì sao xóa 1 phần tử trong
@ManyToManylại làm Hibernate xóa sạch toàn bộ bảng nối rồi INSERT lại từ đầu (Write Amplification)? - Làm sao khi nghiệp vụ cần 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ệ? - Thiết kế bảng nối thế nào với khóa chính phức hợp (Composite Key) bằng
@EmbeddedIdvà@MapsId?
Dưới đây là cách Hibernate xử lý bên dưới, và cách thiết kế dùng được trên production.
# điểm yếu của direct @manytomany
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ế, hiếm có quan hệ Nhiều-Nhiều nào cứ mãi chỉ là hai cột khóa ngoại.
- Ban đầu:
Studentchỉ cần liên kết vớiCourse. - 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
@ManyToManykhông cho thêm cột mới vào bảng nối. Bạn buộc phải bỏ quan hệ@ManyToMany, viết lại hàng loạt repository, service, DTO và chạy migration database phức tạp.
# write amplification khi dùng list
Nếu bạn 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ỡ 1 môn học:
student.getCourses().remove(targetCourse);Dưới database chuyện gì xảy ra? Bạn mong Hibernate chạy: DELETE FROM student_course WHERE student_id = 1 AND course_id = 99;
Nhưng thực tế Hibernate 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 không có @OrderColumn được Hibernate quản lý dưới dạng bag (PersistentBag): cho phép phần tử trùng lặp, và bảng nối không có khóa nào xác định duy nhất từng dòng. Hibernate không biết chính xác dòng nào ứng với phần tử vừa gỡ, nên chọn cách "đốt nhà diệt chuột": xóa hết rồi nạp lại. Kết quả là I/O và Transaction Log đội lên rất nhiều cho Database Server.
Nếu vẫn giữ @ManyToMany, ít nhất hãy đổi sang Set<Course>: phần tử trong Set là duy nhất, nên Hibernate xóa đúng một dòng DELETE ... WHERE student_id = ? AND course_id = ?. Cách này chữa được write amplification, nhưng vẫn không chữa được bẫy metadata ở trên.
# cách làm chuẩn: tách thành hai @onetomany
Cách các hệ thống lớn hay làm: biến Bảng Trung Gian thành một Entity đàng hoàng (Explicit Join Entity).
Quan hệ Nhiều-Nhiều được tách thành hai quan hệ Một-Nhiều:
# triển khai: 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 thêm cột ID tự tăng, ta kết hợp @EmbeddedId và @MapsId.
# tạo khóa phức hợp (composite key class)
Class khóa phải implement Serializable và override equals()/hashCode() đúng:
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)
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() {}
// Nên gọi khi Student và Course đã có id (đã persist); nếu id còn null,
// @MapsId sẽ điền lúc persist, nhưng equals/hashCode dựa trên id sẽ đổi giữa chừng
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
// (Course.getEnrollments() ở đây phải trả về Set thật, không phải bản unmodifiable như getter bên dưới)
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 -> {
// So theo id: e.getCourse() có thể là Hibernate proxy (LAZY), còn Course không override equals
boolean match = e.getCourse().getId().equals(course.getId());
if (match) {
course.getEnrollments().remove(e);
}
return match;
});
}
public Long getId() { return id; }
public Set<Enrollment> getEnrollments() { return Collections.unmodifiableSet(enrollments); }
}# tối ưu ở tầng database
Một lỗi nặng khác ở hệ thống 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 cách
| Tiêu chí | Direct @ManyToMany (Ngây thơ) | Explicit Join Entity (Enterprise) |
|---|---|---|
| Mở rộng thuộc tính phụ | 🔴 Không làm được; phải viết lại | 🟢 Dễ (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 có chỗ cho @CreatedDate, cột soft delete | 🟢 Dùng tốt với 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
Cái tiện trước mắt hay là mầm của rắc rối về sau. Direct @ManyToMany là một ví dụ: dùng được cho bài tập nhỏ, hoặc quan hệ chắc chắn chỉ có hai cột khóa (khi đó nhớ dùng Set), nhưng dễ thành quả bom nổ chậm khi nghiệp vụ lớn dần.
Tách rõ thành hai quan hệ @OneToMany cùng Entity Trung Gian (@EmbeddedId + @MapsId) thì:
- Dễ mở rộng khi nghiệp vụ có yêu cầu mới.
- Câu SQL chạy đúng từng dòng cần thiết.
- Kiểm soát được database tốt hơn và ổn định lâu dài.
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 😎 👍🏻 🚀 🔥.