StackTraces
Spring Boot已驗證

JPA N+1 查詢:一次列表打 101 條 SQL

@debug_master32 天前更新閱讀 6 min00

一句話先說結論:N+1 不是 bug,是懶加載的副作用——1 條查詢拿到 N 個父實體,遍歷時訪問每個父的懶加載關聯又各觸發 1 條查詢,合計 N+1 條。根因是 FetchMode.SELECT(預設每實體單查),不是 LAZY 本身;改成 EAGER 只是把 N+1 從“按需觸發”變成“自動觸發”,查詢數不變。正確解法是按場景用 JOIN FETCH、@EntityGraph 或 @BatchSize。

背景

JPA 用物件關聯(member.getOrders())代替了 SQL 的顯式 JOIN。好處是程式碼自然,代價是“物件圖導航”在數據庫側並不便宜。當你查一批父實體、又逐個訪問它們的關聯集合時,每個集合訪問都會單獨發一條 SQL——這在小數據量下毫無感知,數據一上去就線性爆炸。

現象

一個“查部門列表並統計每個部門人數”的接口,控制台刷出大量重複 SQL:

-- 第 1 条:查所有部门
select d.id, d.nombre from departamento d;

-- 第 2..N+1 条:每个部门各查一次员工
select e.id from empleado e where e.departamento_id = ?;
select e.id from empleado e where e.departamento_id = ?;
select e.id from empleado e where e.departamento_id = ?;
-- ... 每有一个部门就多一条

50 個部門就是 51 條查詢,500 個就是 501 條。開發環境只有幾條數據看不出問題,一上生產就慢——一個列表接口因為 101 條 SQL 拖成 12 秒、CPU 95% 的案例很常見。

Hibernate 的 N+1 警告日誌

開啟 spring.jpa.show-sql=true 後,同一時刻刷屏的日誌:

Hibernate: select d1_0.id, d1_0.nombre from departamento d1_0
Hibernate: select e1_0.id, e1_0.departamento_id from empleado e1_0 where e1_0.departamento_id=?
Hibernate: select e1_0.id, e1_0.departamento_id from empleado e1_0 where e1_0.departamento_id=?
Hibernate: select e1_0.id, e1_0.departamento_id from empleado e1_0 where e1_0.departamento_id=?
... (重复 100 次)
Hibernate: select e1_0.id, e1_0.departamento_id from empleado e1_0 where e1_0.departamento_id=?

JOIN FETCH + 分頁觸發的 OOM 風險警告

WARN  12345 --- [main] o.h.h.internal.ASTQueryTranslatorFactory : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory

如果用 JOIN FETCH 配合 Pageable 分頁,Hibernate 會把整張表查到記憶體裡再切頁。數據一大直接 OutOfMemoryError:

java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3512)
    at java.util.ArrayList.grow(ArrayList.java:237)
    at java.util.ArrayList.add(ArrayList.java:464)
    at org.hibernate.collection.internal.PersistentBag.add(PersistentBag.java:337)
    at org.hibernate.loader.ast.internal.LoaderSelectBuilder$CollectionFetchableEntityInitializer.initializeInstance(LoaderSelectBuilder.java:1495)
    at org.hibernate.sql.results.graph.entity.internal.EntityInitializer.processKeyResult(EntityInitializer.java:438)
    at org.hibernate.sql.results.internal.RowProcessingStateStandardImpl.nextRow(RowProcessingStateStandardImpl.java:104)
    at org.hibernate.sql.results.spi.ListResultsProducer.lambda$performQuery$0(ListResultsProducer.java:63)

根因分析

先把兩個容易混的概念分開:

  • FetchType 決定“何時”加載:LAZY 是訪問時才加載,EAGER 是父實體加載時立即加載。
  • FetchMode 決定“如何”加載:預設 SELECT 是每個實體單獨一條 SELECT;此外還有 JOIN(一次性 join)、SUBSELECT(一條子查詢裝下所有集合)、BATCH(用 IN 分批)。

N+1 的根子是 FetchMode.SELECT,不是 LAZY。 很多人以為“改成 EAGER 就好了”,但如果還是 SELECT 模式,查詢數一模一樣,只不過從“用到才查”變成“一進來就查”。典型的錯誤修復就是這樣:把 LAZY 改 EAGER 消滅了 LazyInitializationException,卻把 N+1 藏得更深。

預設加載策略也要記住:@ManyToOne / @OneToOne 預設 EAGER,@OneToMany / @ManyToMany 預設 LAZY。

解決方案

方案 A:JOIN FETCH(最直接)

用 JPQL 顯式把關聯一起 fetch,一條 SQL 搞定:

public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query("SELECT o FROM Order o JOIN FETCH o.member")
    List<Order> findAllWithMember();
}

對 @OneToMany 集合,要用 DISTINCT 去重(父實體因 join 會產生重複行):

@Query("SELECT DISTINCT m FROM Member m JOIN FETCH m.orders")
List<Member> findAllWithOrders();

JOIN FETCH 有兩個坑:

  1. 不能和分頁 Pageable 一起用。Hibernate 會打日誌 HHH000104: firstResult/maxResults specified with collection fetch; applying in memory,意思是它把整張表查到記憶體裡再切頁,數據一大直接 OOM。
  2. 一條查詢別 join 兩個集合。同時 JOIN FETCH 兩個集合會拋 MultipleBagFetchException 或產生笛卡爾積。

方案 B:@EntityGraph(聲明式 fetch join)

不想手寫 JPQL 時,用 Spring Data 的註解聲明要 fetch 的路徑:

public interface OrderRepository extends JpaRepository<Order, Long> {
    @EntityGraph(attributePaths = {"member"})
    List<Order> findAll();
}

內部走 LEFT OUTER JOIN,效果等同 fetch join,程式碼更簡潔。適合簡單查詢、快速止漏;複雜動態條件還是直接用 fetch join 更靈活。

方案 C:@BatchSize / default_batch_fetch_size(分頁場景首選)

把 N 次單條查詢,壓成 ceil(N/batchSize) 次帶 IN 的批量查詢。可以在實體上:

@BatchSize(size = 25)
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();

也可以全局設定,省得每個實體都標:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 25

它和分頁兼容,是“深度嵌套物件圖”最省心的安全網。代價是它不是“1 條查詢”,而是把 N+1 降到 N/25 + 1 條,對“一定要單查詢”的場景仍不夠。

方案 D:DTO Projection(只讀列表直接丟掉實體)

如果列表頁只展示幾個字段,根本不用加載整個實體圖,直接投影到 DTO:

@Query("SELECT new com.example.ProjectionDto(d.id, d.name, size(d.employees)) " +
       "FROM Department d")
List<ProjectionDto> findAllSummary();

不碰關聯集合,N+1 自然無從發生。這是隻讀接口的最優解。

怎麼發現 N+1

  • 本地:開 spring.jpa.show-sql=true 或 logging.level.org.hibernate.SQL=DEBUG,看到同一條 select ... where ...=? 反覆出現就是 N+1。
  • CI 防迴歸:用 datasource-proxy 或 assertj-db 在集成測試裡斷言查詢次數,一旦哪次改動重新引入 N+1,測試直接紅。

小結

  • N+1 來自 FetchMode.SELECT,不是 LAZY,改 EAGER 不是解法。
  • 決策口訣:單條不復雜查詢用 @EntityGraph;條件複雜用 JOIN FETCH;要分頁或嵌套物件圖用 default_batch_fetch_size;只讀列表直接 DTO 投影。
  • JOIN FETCH + 分頁 = 記憶體分頁 + OOM 風險,務必避開。
  • 在 CI 里加查詢次數斷言,比事後看 APM 更可靠。

References(參考來源)

最後更新於 2026-09-09

這篇幫到你了嗎?

剛解決了一個棘手的報錯?花兩分鐘記錄下來,幫助下一個遇到同樣問題的開發者。

貢獻一條解法