問題場景
三表多對多:`User` - `UserRole` - `Role`。需查詢**所有用戶及其角色**,包括**未分配角色的用戶** (右表為 NULL)。
SQL 解決方案:LEFT JOIN
-- 核心:從用戶表 LEFT JOIN 關聯表再 LEFT JOIN 角色表
SELECT u.userId, u.account, r.roleName
FROM User u
LEFT JOIN UserRole ur ON u.userId = ur.userId
LEFT JOIN Role r ON ur.roleId = r.roleId;
- 第一個 `LEFT JOIN` 保證**所有用戶**保留,無角色時 `ur` 列為 NULL。
- 第二個 `LEFT JOIN` 保證無角色用戶不被過濾,`r.roleName` 為 NULL。
- 若用 `INNER JOIN`,無角色用戶會被過濾掉 (原問題)。
Hibernate / JPA 最佳實踐
實體映射 (雙向多對多)
@Entity
public class User {
@Id @GeneratedValue Long userId;
String account;
@ManyToMany
@JoinTable(name = "UserRole",
joinColumns = @JoinColumn(name = "userId"),
inverseJoinColumns = @JoinColumn(name = "roleId"))
Set roles = new HashSet<>();
}
@Entity
public class Role {
@Id @GeneratedValue Long roleId;
String roleName;
@ManyToMany(mappedBy = "roles")
Set users = new HashSet<>();
}
查詢方式對比
| 方式 | 代碼 | 優點 | 缺點 |
| JPQL + LEFT JOIN FETCH (推薦) |
@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.roles WHERE u.account LIKE %:kw%")
List findByKeyword(@Param("kw") String kw);
| 單條 SQL、實體圖自動填充、類型安全、支持分頁 | 複雜條件構建稍繁 |
| CriteriaBuilder (動態條件) |
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery cq = cb.createQuery(User.class);
Root u = cq.from(User.class);
u.fetch("roles", JoinType.LEFT); // 關鍵:左連接抓取
cq.select(u).distinct(true);
if (keyword != null) cq.where(cb.like(u.get("account"), "%"+keyword+"%"));
List list = em.createQuery(cq).getResultList();
| 完全類型安全、動態組裝條件、IDE 重構友好 | 代碼較冗長 |
| Spring Data JPA Specification |
public static Specification byKeyword(String kw) {
return (root, query, cb) -> {
root.fetch("roles", JoinType.LEFT); // 必須在 Specification 中 fetch
query.distinct(true);
return kw == null ? null : cb.like(root.get("account"), "%"+kw+"%");
};
}
repo.findAll(byKeyword(kw), pageable);
| 業務邏輯封裝、可組合、易測試、Spring Boot 標配 | 需注意 `fetch` 在 Specification 中的使用細節 |
| @EntityGraph (靜態定義) |
@Entity
@NamedEntityGraph(name = "User.withRoles", attributeNodes = @NamedAttributeNode("roles"))
public class User { ... }
@Query("SELECT u FROM User u WHERE u.account LIKE %:kw%")
@EntityGraph("User.withRoles")
List findByKeyword(@Param("kw") String kw);
| 聲明式、復用性強、JPQL 簡潔 | 動態條件下靈活性弱 |
關鍵避坑點
- 必須 `LEFT JOIN FETCH` / `fetch(JoinType.LEFT)`:否則觸發 N+1 查詢 (每個用戶單獨查角色) 或過濾掉無角色用戶 (默認 INNER JOIN)。
- `DISTINCT` / `query.distinct(true)`:防止左連接導致用戶行重複 (一用戶多角色 → 多行)。
- 分頁陷阱:`LEFT JOIN FETCH` + `Pageable` 在 Hibernate 5.x 可能導致內存分頁 (先查所有再切頁)。Hibernate 6+ / Spring Data 3+ 已優化,或改用**子查詢分頁**:先查 ID 分頁,再 `WHERE id IN (...)` 抓取完整實體。
- 惰性加載陷阱:若不用 `FETCH`,事務關閉後訪問 `user.getRoles()` 拋 `LazyInitializationException`。解決:`@Transactional` 保持事務 / `FETCH` / DTO 投影。
最佳實踐總結
- **實體映射正確** (雙向多對多 + `@JoinTable`)。
- **查詢用 JPQL `LEFT JOIN FETCH` + `DISTINCT`** 覆蓋 90% 場景。
- **動態條件用 Specification + `root.fetch(JoinType.LEFT)`**。
- **大數據分頁用子查詢 ID 分頁策略** 避免內存溢出。
- **DTO 投影** (`SELECT NEW DTO(u.id, u.account, r.roleName) ...`) 替代實體查詢,極大提升性能、避免惰性加載問題。
最佳回答核心:「用一個左連接就可以了,你網上搜索一下左連接怎麼寫」—— 簡潔指向 SQL 核心解法。