多對多關聯是領域模型中常見關係 (學生-課程、用戶-角色、訂單-商品)。Hibernate 通過中間表 (關聯表) 實現雙向多對多映射。本文以「學生-課程」為例,系統講解映射配置、inverse 反向控制、級聯操作、關聯維護最佳實踐與常見陷阱。
// Student.java
public class Student {
private Long id;
private String name;
private Set<Course> courses = new HashSet<>(); // 多對多關聯集合
// getter/setter 省略
}
// Course.java
public class Course {
private Long id;
private String name;
private Set<Student> students = new HashSet<>(); // 反向關聯集合
// getter/setter 省略
}
-- 學生表
CREATE TABLE students (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL
);
-- 課程表
CREATE TABLE courses (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL
);
-- 關聯表 (中間表) - 聯合主鍵
CREATE TABLE students_courses (
student_id BIGINT NOT NULL,
course_id BIGINT NOT NULL,
PRIMARY KEY (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES students(id),
FOREIGN KEY (course_id) REFERENCES courses(id)
);
<hibernate-mapping>
<class name="com.example.Student" table="students">
<id name="id" column="id">
<generator class="native"/>
</id>
<property name="name" column="name"/>
<!-- 雙向多對多:Student 為關係維護端 (inverse=false 默認) -->
<set name="courses" table="students_courses" cascade="save-update">
<key column="student_id"/>
<many-to-many class="com.example.Course" column="course_id"/>
</set>
</class>
</hibernate-mapping>
<hibernate-mapping>
<class name="com.example.Course" table="courses">
<id name="id" column="id">
<generator class="native"/>
</id>
<property name="name" column="name"/>
<!-- 反向端:inverse=true 放棄關係維護權 -->
<set name="students" table="students_courses" cascade="save-update" inverse="true">
<key column="course_id"/>
<many-to-many class="com.example.Student" column="student_id"/>
</set>
</class>
</hibernate-mapping>
| 屬性 | 位置 | 作用 | 關鍵值 |
|---|---|---|---|
table | <set> | 指定中間表名 | students_courses |
key/column | <key> | 中間表外鍵列,關聯本端主鍵 | student_id / course_id |
many-to-many/class | <many-to-many> | 關聯對象實體類全限定名 | com.example.Course |
many-to-many/column | <many-to-many> | 中間表外鍵列,關聯對端主鍵 | course_id / student_id |
cascade | <set> | 級聯操作傳播 | save-update / all / delete-orphan |
inverse | <set> | 關係維護權控制 (核心!) | true / false (默認) |
inverse="false" (默認):該端負責維護關係,會對中間表執行 INSERT/UPDATE/DELETE
inverse="true":該端放棄維護關係,不會操作中間表,僅作為查詢導航
Student s = new Student(); s.setName("李四");
Course c1 = new Course(); c1.setName("英語");
Course c2 = new Course(); c2.setName("數學");
// 建立雙向關聯 (雙邊都添加,保證對象圖一致性)
s.getCourses().add(c1); s.getCourses().add(c2);
c1.getStudents().add(s); c2.getStudents().add(s);
session.save(c1); session.save(c2); session.save(s);
| 配置組合 | SQL 執行情況 | 結果 |
|---|---|---|
| 雙方 inverse=false | 雙方都向中間表 INSERT → 主鍵衝突 | 異常: Duplicate entry |
| 雙方 inverse=true | 雙方都不操作中間表 | 關係丟失 (中間表無數據) |
| Student false, Course true | Student 維護,Course 忽略 | 正確 (推薦: 集合較小端維護) |
| Student true, Course false | Course 維護,Student 忽略 | 正確 (但 Course 學生多,維護開銷大) |
// 正確姿勢:雙邊添加,由維護端持久化
Student s = session.get(Student.class, 1L);
Course c = session.get(Course.class, 2L);
s.getCourses().add(c); // 維護端操作會同步到中間表
c.getStudents().add(s); // 反向端僅為對象圖一致性
session.saveOrUpdate(s); // 或 save(c) 視級聯配置
// 僅移除關聯,不刪除實體
Student s = session.get(Student.class, 1L);
Course c = session.get(Course.class, 2L);
s.getCourses().remove(c); // 維護端移除 → 中間表 DELETE
c.getStudents().remove(s); // 反向端同步
// 級聯刪除實體 (慎用 cascade="delete" 或 "all")
session.delete(s); // 若 cascade 包含 delete,會刪除中間表記錄,但不會刪除 Course 實體
// 查詢學生的所有課程 (延遲加載默認)
Student s = session.get(Student.class, 1L);
Set<Course> courses = s.getCourses(); // 觸發查詢中間表 JOIN courses
// 查詢課程的所有學生
Course c = session.get(Course.class, 2L);
Set<Student> students = c.getStudents();
// Student.java (維護端)
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "students_courses",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>();
// Course.java (反向端)
@ManyToMany(mappedBy = "courses") // mappedBy 指向維護端屬性名
private Set<Student> students = new HashSet<>();
若需在中間表存儲額外屬性 (如選課時間、成績),必須拆解為兩個一對多:
// 中間實體
@Entity
@Table(name = "students_courses")
public class StudentCourse {
@EmbeddedId
private StudentCourseId id; // 聯合主鍵
@ManyToOne
@MapsId("studentId")
@JoinColumn(name = "student_id")
private Student student;
@ManyToOne
@MapsId("courseId")
@JoinColumn(name = "course_id")
private Course course;
private LocalDateTime enrollTime;
private Double score;
// ...
}
| 陷阱 | 現象 | 原因 | 解決 |
|---|---|---|---|
| 雙方 inverse=false | 主鍵衝突異常 | 雙方都嘗試 INSERT 中間表 | 一方設 true |
| 雙方 inverse=true | 關係不持久化 | 雙方都放棄維護 | 一方設 false |
| 單邊添加關聯 | 關係丟失或不一致 | 對象圖未同步,維護端未感知變化 | 雙邊同步添加/移除 |
| cascade="delete" 刪除實體 | 誤刪關聯實體 | delete 會傳播到關聯對象 | 用 save-update 或手動管理 |
| Set 未初始化 | NPE | 聲明未實例化 | new HashSet<>() |
| equals/hashCode 未實現 | Set 去重失效、異常 | 實體作集合元素必須實現 | 基於 ID 實現 |
Set 去重語義符合多對多,List 需 @OrderColumn 維護索引開銷大lazy=true,避免載入大量關聯數據@BatchSize(size=20) 或 hibernate.default_batch_fetch_size 減少 N+1 查詢@LazyCollection(LazyCollectionOption.EXTRA)核心心法:雙向多對多的靈魂是
inverse屬性。「誰維護,誰負責中間表增刪改」。維護端 (inverse=false) 操作集合會同步到數據庫;反向端 (inverse=true) 僅為導航查詢。雙邊在內存中同步添加/移除,保證對象圖一致性。選擇集合較小端作維護端是效能優化鐵律。
掌握雙向多對多映射,不僅能正確持久化複雜關係,更能在排查關聯數據丟失、重複插入、級聯刪除異常時直指本質。