多對多關聯是領域建模常見模式,Hibernate 通過中間關聯表實現。本文深度解析基於主鍵的雙向多對多映射:實體設計、映射配置、級聯策略、關係維護邏輯、N+1 問題優化、JPQL/HQL 查詢模式,並對比單向與雙向的工程選型建議。
@Entity
@Table(name = "t_role")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String name;
// 雙向多對多:Role 為關係維護端
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "t_role_function", // 關聯表名
joinColumns = @JoinColumn(name = "role_id"), // 本端外鍵
inverseJoinColumns = @JoinColumn(name = "function_id") // 対端外鍵
)
private Set functions = new HashSet<>();
// 業務輔助方法(關鍵:保證雙向一致性)
public void addFunction(Function function) {
this.functions.add(function);
function.getRoles().add(this);
}
public void removeFunction(Function function) {
this.functions.remove(function);
function.getRoles().remove(this);
}
// getter/setter 省略
}
@Entity
@Table(name = "t_function")
public class Function {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(length = 50)
private String code;
@Column(length = 200)
private String url;
// 非維護端:mappedBy 指向對端屬性名
@ManyToMany(mappedBy = "functions")
private Set roles = new HashSet<>();
// getter/setter 省略
}
| 註解 | 屬性 | 含義 |
|---|---|---|
| @ManyToMany | cascade | 級聯操作:PERSIST/MERGE/REMOVE/REFRESH/DETACH/ALL |
| @ManyToMany | fetch | LAZY(默認)/EAGER,強烈建議 LAZY |
| @JoinTable | name | 中間表名 |
| @JoinTable | joinColumns | 當前實體在中間表的外鍵列 |
| @JoinTable | inverseJoinColumns | 關聯實體在中間表的外鍵列 |
| @ManyToMany | mappedBy | 非維護端指定對端屬性名,放棄關係控制權 |
<!-- Role.hbm.xml -->
<class name="Role" table="t_role">
<id name="id"><generator class="native"/></id>
<property name="name" column="name"/>
<set name="functions" table="t_role_function" cascade="save-update">
<key column="role_id"/>
<many-to-many class="Function" column="function_id"/>
</set>
</class>
<!-- Function.hbm.xml -->
<class name="Function" table="t_function">
<id name="id"><generator class="native"/></id>
<property name="name"/><property name="code"/><property name="url"/>
<set name="roles" table="t_role_function" inverse="true">
<key column="function_id"/>
<many-to-many class="Role" column="role_id"/>
</set>
</class>
關鍵差異: 非維護端必須設置 inverse="true"(XML)或 mappedBy(註解),否則雙方都會維護關聯表導致重複 INSERT/DELETE。
@Transactional
public void assignFunctions(Long roleId, List functionIds) {
Role role = roleRepository.findById(roleId).orElseThrow();
Set functions = new HashSet<>(functionRepository.findAllById(functionIds));
// 清空舊關係(觸發 DELETE 中間表記錄)
role.getFunctions().clear();
// 建立新關係(雙向綁定)
for (Function f : functions) {
role.addFunction(f); // 內部自動維護反向
}
roleRepository.save(role); // 級聯 MERGE 持久化
}
List roles = roleRepository.findAll(); // 1 查詢
for (Role r : roles) {
r.getFunctions().size(); // 每個 Role 觸發 1 查詢 → N+1
}
| 方案 | HQL/JPQL | 優點 | 缺點 |
|---|---|---|---|
| Fetch Join | SELECT DISTINCT r FROM Role r LEFT JOIN FETCH r.functions | 單查詢獲取所有 | 可能產生笛卡爾積、分頁失效 |
| EntityGraph | @NamedEntityGraph(name="Role.functions", attributeNodes=@NamedAttributeNode("functions")) | 靜態/動態圖靈活 | 需 JPA 2.1+ |
| Batch Fetching | @BatchSize(size=20) 或 hibernate.default_batch_fetch_size | 保持 LAZY、自動合併 IN 查詢 | 額外查詢數 = ceil(N/batchSize) |
| Subselect Fetch | @Fetch(FetchMode.SUBSELECT) | 二次查詢獲取所有關聯 | 不適用複雜條件 |
@Entity
@BatchSize(size = 20) // 類級別批量抓取
public class Role {
@ManyToMany
@BatchSize(size = 20) // 集合級別
@JoinTable(...)
private Set functions;
}
// JPQL
@Query("SELECT r FROM Role r JOIN r.functions f WHERE f.id = :functionId")
List findRolesByFunctionId(@Param("functionId") Long functionId);
// Criteria API
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery cq = cb.createQuery(Role.class);
Root root = cq.from(Role.class);
root.join("functions").get("id").in(functionIds);
@Query("SELECT r.name, COUNT(f) FROM Role r LEFT JOIN r.functions f GROUP BY r.id, r.name")
List
Set 避免重複、@OrderColumn 需要順序時用 Listrole_id、function_id 建立聯合唯一索引 UK_ROLE_FUNC (role_id, function_id)evict 緩存@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)若業務僅單向導航(如只需「角色→功能」,無「功能→角色」),移除 mappedBy 端集合,簡化模型:
// Role 保持不變
// Function 移除 roles 集合及 @ManyToMany
// 查詢反向關係改用 JPQL
@Query("SELECT f FROM Function f JOIN f.roles r WHERE r.id = :roleId")
List findFunctionsByRoleId(@Param("roleId") Long roleId);
優勢:減少實體耦合、避免雙向維護 Bug、降低內存佔用。
@DataJpaTest
class RoleFunctionMappingTest {
@Autowired TestEntityManager em;
@Test
void testBidirectionalConsistency() {
Role admin = new Role(); admin.setName("ADMIN");
Function userMgmt = new Function(); userMgmt.setName("用戶管理");
admin.addFunction(userMgmt);
em.persist(admin);
em.flush();
em.clear();
Role loaded = em.find(Role.class, admin.getId());
assertThat(loaded.getFunctions()).hasSize(1);
Function loadedFunc = em.find(Function.class, userMgmt.getId());
assertThat(loadedFunc.getRoles()).hasSize(1);
assertThat(loadedFunc.getRoles().iterator().next().getId()).isEqualTo(admin.getId());
}
@Test
void testCascadePersist() {
Role role = new Role(); role.setName("GUEST");
Function f1 = new Function(); f1.setName("查看");
Function f2 = new Function(); f2.setName("導出");
role.addFunction(f1);
role.addFunction(f2);
em.persist(role); // 級聯持久化 f1, f2
em.flush();
assertThat(em.find(Function.class, f1.getId())).isNotNull();
}
}