什麼是 N+1 問題
載入 1 筆 Order 後,Hibernate 額外發出 N 條 SQL 去撈每筆 Order 的 OrderItem:
1
2
3
4
5
| SELECT * FROM orders WHERE user_id = 1; -- 1 條
SELECT * FROM order_items WHERE order_id = 10; -- N 條(每筆 Order 一條)
SELECT * FROM order_items WHERE order_id = 11;
SELECT * FROM order_items WHERE order_id = 12;
...
|
根源在於 @OneToMany 預設是 LAZY 載入——存取集合屬性時才觸發查詢,在迴圈中存取就變成 N 條。
1
2
3
4
5
6
7
| @Entity
public class Order {
@Id Long id;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items; // 預設 LAZY
}
|
1
2
3
| // Service 層:觸發 N+1
List<Order> orders = orderRepository.findByUserId(userId);
orders.forEach(o -> process(o.getItems())); // 每次 getItems() 觸發一條 SQL
|
如何偵測
方式一:Hibernate Statistics
1
2
3
4
5
6
7
8
| spring:
jpa:
properties:
hibernate:
generate_statistics: true
logging:
level:
org.hibernate.stat: DEBUG
|
啟動後每次請求結束,log 會顯示:
1
2
3
4
5
6
| Session Metrics {
1234567 nanoseconds spent acquiring 1 JDBC connections;
...
20 flushes as part of pre-partial-flush operations;
52 JDBC statements executed
}
|
52 條 SQL 來自 1 個請求,明顯異常。
方式二:P6Spy / datasource-proxy
加入 datasource-proxy,攔截所有 SQL 並計數,適合在測試環境斷言 SQL 數量:
1
2
| // build.gradle
testImplementation 'net.ttddyy:datasource-proxy:1.9'
|
1
2
3
4
5
6
7
8
9
10
| @Test
void should_loadOrdersWithItems_inSingleQuery() {
QueryCountHolder.clear();
List<Order> orders = orderRepository.findWithItemsByUserId(1L);
orders.forEach(o -> assertThat(o.getItems()).isNotEmpty());
QueryCount count = QueryCountHolder.getGrandTotal();
assertThat(count.getSelect()).isEqualTo(1); // 只允許 1 條 SELECT
}
|
方式三:@DataJpaTest + SQL 斷言
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| @DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private EntityManager em;
@Test
void no_n_plus_1_on_findWithItems() {
SessionFactory sf = em.getEntityManagerFactory().unwrap(SessionFactory.class);
Statistics stats = sf.getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();
orderRepository.findWithItemsByUserId(1L);
assertThat(stats.getPrepareStatementCount()).isLessThanOrEqualTo(2);
}
}
|
三種解法
解法一:JOIN FETCH(JPQL)
1
2
3
4
5
6
7
8
9
10
11
12
13
| @Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.userId = :uid")
List<Order> findWithItemsByUserId(@Param("uid") Long uid);
// 多層關聯
@Query("SELECT DISTINCT o FROM Order o " +
"JOIN FETCH o.items i " +
"JOIN FETCH i.product " +
"WHERE o.userId = :uid")
List<Order> findWithItemsAndProducts(@Param("uid") Long uid);
}
|
注意 DISTINCT:當 Order 有多個 OrderItem 時,JOIN 會產生重複的 Order 行,DISTINCT 讓 Hibernate 在 Java 層去重(SQL 層也會加上 DISTINCT)。
限制:無法與 Pageable 分頁並用,因為分頁是在記憶體中做而非資料庫層(見下方陷阱)。
解法二:@EntityGraph
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // 方式一:在 Repository 方法上直接宣告
@EntityGraph(attributePaths = {"items", "items.product"})
List<Order> findByUserId(Long userId);
// 方式二:在 Entity 上定義具名 graph
@NamedEntityGraph(
name = "Order.withItems",
attributeNodes = @NamedAttributeNode(value = "items", subgraph = "items.product"),
subgraphs = @NamedSubgraph(name = "items.product", attributeNodes = @NamedAttributeNode("product"))
)
@Entity
public class Order { ... }
// Repository
@EntityGraph("Order.withItems")
List<Order> findByUserId(Long userId);
|
@EntityGraph 本質上等同於 JOIN FETCH,但讓 Repository 介面更乾淨,適合跨多個方法重用相同的 fetch 策略。
解法三:@BatchSize(Hibernate 專屬)
1
2
3
4
5
6
| @Entity
public class Order {
@OneToMany(mappedBy = "order")
@BatchSize(size = 30)
private List<OrderItem> items;
}
|
Hibernate 不再為每筆 Order 發一條 SQL,而是每次批次載入 30 筆 Order 的 items:
1
2
3
4
5
| -- 原本 100 條(N=100)
SELECT * FROM order_items WHERE order_id IN (1,2,...,30);
SELECT * FROM order_items WHERE order_id IN (31,32,...,60);
-- ...
-- 只剩 4 條(ceiling(100/30)=4)
|
優點:不需改動 Repository 方法,JPQL 查詢依然簡單;支援分頁。
缺點:Hibernate 專屬,換成其他 JPA 實作就失效;需要 2 次以上查詢(非 JOIN)。
Slice vs Page:COUNT Query 的代價
1
2
3
4
5
6
7
8
| // Page:Spring Data 自動追加一條 COUNT(*)
Page<Order> findByUserId(Long userId, Pageable pageable);
// 等同於:
// SELECT * FROM orders WHERE user_id = ? LIMIT ? OFFSET ?
// SELECT COUNT(*) FROM orders WHERE user_id = ? <-- 額外一條
// Slice:只多取一筆,判斷是否有下一頁,不發 COUNT
Slice<Order> findByUserId(Long userId, Pageable pageable);
|
- 需要總頁數(pagination UI)→ 用
Page - 無限滾動 / 只需「是否有更多」→ 用
Slice,省掉 COUNT 查詢
陷阱:EntityGraph + Pageable = 記憶體分頁
當 @EntityGraph 與 Pageable 並用時,Hibernate 會發出警告:
1
| HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
|
這表示 Hibernate 先把所有資料全部載入記憶體,再做分頁,資料量大時 OOM。
正確解法:
1
2
3
4
5
6
7
8
9
10
11
| // 1. 先分頁查 ID(不 JOIN 集合)
@Query("SELECT o.id FROM Order o WHERE o.userId = :uid")
Page<Long> findIdsByUserId(@Param("uid") Long uid, Pageable pageable);
// 2. 用 ID 做 JOIN FETCH
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.id IN :ids")
List<Order> findWithItemsByIds(@Param("ids") List<Long> ids);
// Service 層組合
Page<Long> idPage = orderRepository.findIdsByUserId(userId, pageable);
List<Order> orders = orderRepository.findWithItemsByIds(idPage.getContent());
|
或是設定 countQuery,讓 Spring Data 知道如何計算總數:
1
2
3
4
5
| @Query(
value = "SELECT o FROM Order o JOIN FETCH o.items WHERE o.userId = :uid",
countQuery = "SELECT COUNT(o) FROM Order o WHERE o.userId = :uid"
)
Page<Order> findWithItemsByUserId(@Param("uid") Long uid, Pageable pageable);
|
決策樹
1
2
3
4
5
6
7
8
9
10
| 需要分頁?
├── 是
│ ├── 集合關聯需要 JOIN FETCH?
│ │ ├── 是 → 兩步查詢(先查 ID,再 JOIN FETCH)
│ │ └── 否 → @BatchSize + Page/Slice
│ └── 無集合關聯 → 正常 Page
└── 否
├── 關聯層數 <= 2 → JOIN FETCH / @EntityGraph
├── 關聯層數 > 2 → 多個 JOIN FETCH + DISTINCT
└── 不想改 JPQL → @BatchSize
|
小結
| 解法 | 查詢次數 | 分頁安全 | 可讀性 | 適用場景 |
|---|
| JOIN FETCH | 1 | 有限制 | 中 | 小資料集、無分頁 |
| @EntityGraph | 1 | 同上 | 高 | 方法重用多 |
| @BatchSize | ceil(N/size) | 安全 | 高 | 有分頁、Hibernate 環境 |
| 兩步查詢 | 2 | 安全 | 低 | 大資料分頁 + JOIN FETCH |
N+1 問題不是一刀切,選解法前先確認:有沒有分頁、資料量、關聯深度。
留言討論