資料庫效能與維運 ch08 ORM 產生的 SQL:列表頁一次發出 200 條查詢
CH 08 回到應用層

ORM 產生的 SQL:列表頁一次發出 200 條查詢

先看到 SQLN+1 的三種來源與三種解法fetch join 的分頁陷阱OSIV 的代價批次寫入與 rewriteBatchedStatementsdirty checking何時該放棄 ORM

前面七章有一個共同的前提,而我一直到 ch04 才第一次點破它:

那些分析全部建立在「SQL 真的長你以為的那樣」之上。

在 ORM 專案裡,這個前提經常不成立。你寫的是 order.getItems(),資料庫收到的是 200 條 SELECT;你以為的分頁是 LIMIT 20,Hibernate 實際上把八萬列全撈進 JVM 再切。這些都不會出現在任何一份資料庫端的報表裡—— 上一章那個「CPU 打滿但慢查詢日誌是空的」案例,最後就是停在這裡。

所以最後一章要回到 Java 這邊,處理三件事:

  • 怎麼看到 ORM 真正發出的 SQL,以及為什麼「開 show-sql」在正式環境不夠用。
  • N+1 的三種來源與三種解法——包括最務實但最少人用的那一種。
  • 幾個會安靜地把成本放大的預設值:@ManyToOne 預設 EAGER、Spring Boot 的 OSIV 預設開啟、MySQL JDBC 的 rewriteBatchedStatements 預設關閉。
  • JOIN FETCH 配分頁:那句 HHH000104 警告背後,是一次記憶體分頁與潛在的 OOM。
  • 何時該放棄 ORM——這不是失敗,是選對工具。

最後一節會把這一整軌的三條主線收攏起來。如果你只想記住這八章的三句話,那三句話在最後一張助記卡上。

前七章的每一個工具,都要求你手上先有一句 SQL。在 ORM 專案裡,拿到那句 SQL 本身就是第一道關卡。

開發環境:看得到就好

spring:
  jpa:
    properties:
      hibernate.format_sql: true
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE   # 沒有這行就看不到參數值
參數值很重要,不是為了好看。ch02 說過:不同的參數值可以走出完全不同的計畫(範圍寬窄不同、型別不同)。拿一句帶著 ? 的 SQL 去 EXPLAIN,等於分析了一句線上根本沒跑過的查詢。

另外不要用 spring.jpa.show-sql=true:它直接寫 System.out、不經過 logger、沒有時間戳、也無法分級關閉。

正式環境:要能回答「幾句、多久、誰發的」

日誌只能告訴你 SQL 長什麼樣。真正需要的三個數字——一個請求發了幾句、各自多久、從哪一行程式發出——要靠攔截層:

  • datasource-proxy 或 p6spy:包住 DataSource,逐句記錄執行時間,並且可以按請求計數。
  • Hibernate Statistics:hibernate.generate_statistics=true,可以取得一個 session 內的查詢次數、快取命中、實體載入數。
  • SQL 註解標籤(ch07 那招):讓資料庫端的 digest 對得回程式碼的端點。
// 整合測試裡直接把 N+1 擋下來——這是最划算的一道防線
@Test
void orderList_should_not_be_n_plus_1() {
    QueryCountHolder.clear();
    mockMvc.perform(get("/api/orders?page=0"));
    assertThat(QueryCountHolder.getGrandTotal().getSelect()).isLessThanOrEqualTo(3);
}
這段測試是這一章最重要的一段程式碼。N+1 幾乎是唯一一種「可以在 CI 就被擋下來」的效能問題——因為它的訊號(SQL 條數)是確定性的,不依賴資料量、不依賴機器。而如果沒有這道防線,它只會在正式環境、在流量最大的時候被發現(ch07 的失敗場景)。

N+1 的定義很簡單:本來一句可以解決的事,變成 1 句主查詢 + N 句關聯查詢。但它的來源有三種,而且解法不完全一樣。

來源①:@ManyToOne 預設就是 EAGER

@Entity
public class Order {
    @ManyToOne                       // ← 沒寫 fetch,預設是 EAGER!
    private Member member;

    @ManyToOne
    private Merchant merchant;
}
JPA 規範裡,@ManyToOne 與 @OneToOne 的預設是 FetchType.EAGER;只有 @OneToMany/@ManyToMany 預設 LAZY。這個預設值害人無數:查 20 筆訂單、每筆帶兩個 ToOne 關聯,什麼都沒寫就是 40 句額外查詢。

做法:所有關聯一律明確寫 fetch = FetchType.LAZY,需要的時候再明確載入。

來源②:LAZY,但在迴圈裡碰到了

List<Order> orders = repo.findByStatus(1);      // 1 句
for (Order o : orders) {
    dto.setMemberName(o.getMember().getName()); // 每一圈觸發一次 SELECT
}

這是最常見的一種,而且在 DTO 轉換層特別容易發生——因為那段程式碼看起來只是在搬欄位,完全沒有「查詢」的樣子。ch07 那個 820 萬次的案例正是這種。

來源③:集合的逐一初始化

for (Order o : orders) {
    total += o.getItems().size();   // 每一圈初始化一個集合 = 一句 SELECT
}

更糟的是巢狀:訂單 → 品項 → 商品,就是 20 → 200 → 2000。

怎麼一眼認出它

  • 日誌裡連續出現一模一樣的 SQL,只有參數不同。
  • 資料庫端:ch07 說的那個簽名——QPS 暴增但前端流量沒變,而且每句都很快。
  • Hibernate Statistics 的 queryExecutionCount 與回傳筆數呈正比。

解法① JOIN FETCH / EntityGraph

@Query("SELECT o FROM Order o JOIN FETCH o.member JOIN FETCH o.merchant WHERE o.status = :s")
List<Order> findWithRefs(int s);

// 或用 EntityGraph,不必改 JPQL
@EntityGraph(attributePaths = {"member", "merchant"})
List<Order> findByStatus(int status);
  • 適合 ToOne 關聯:一句 JOIN 就解決,資料不會重複。
  • 對 ToMany 要小心:一對多的 JOIN 會讓主表欄位重複 N 次(笛卡爾積);兩個以上的集合同時 fetch 會直接拋 MultipleBagFetchException,或產生列數相乘的災難。
  • 配分頁會出事——下一節專講。

解法② @BatchSize(最務實,但最少人用)

@Entity
public class Order {
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @BatchSize(size = 50)          // 或全域:hibernate.default_batch_fetch_size: 50
    private List<OrderItem> items;
}

Hibernate 會把 N 次單筆查詢合併成 WHERE order_id IN (?, ?, ... ):200 句變成 4 句。

它的優點是「不必猜哪裡會用到」——設定 hibernate.default_batch_fetch_size 之後全域生效,即使某個沒被注意到的路徑觸發了 lazy 載入,也只會是 IN 查詢而不是 N 次往返。這是投入產出比最高的一個設定,而且大部分專案沒開。

解法③ DTO 投影(列表頁的最佳解)

@Query("""
    SELECT new com.shop.dto.OrderRow(o.id, o.amount, m.name, mc.name)
    FROM Order o JOIN o.member m JOIN o.merchant mc
    WHERE o.status = :s
    """)
List<OrderRow> findRows(int s);
  • 一句 SQL 取齊需要的欄位,而且只取需要的欄位(回扣 ch04/ch06 的 SELECT * 三個代價)。
  • 不進 persistence context:沒有 dirty checking 成本、沒有 lazy 載入的可能、記憶體用量小得多。

怎麼選

場景選它理由
列表頁(只讀、只顯示)DTO 投影一句解決、欄位最少、不進 session
要修改實體EntityGraph / JOIN FETCH需要受管理的實體才能 dirty checking
集合關聯、或路徑不確定@BatchSize全域生效,把 N 次變成 N/size 次
要分頁又要集合兩段式查詢(見下節)JOIN FETCH 配分頁會在記憶體裡分頁

把 JOIN FETCH 和分頁放在一起,Hibernate 會照做,但會在日誌裡留下一行警告:

HHH000104: firstResult/maxResults specified with collection fetch;
           applying in memory

它實際做了什麼

1
因為一對多 JOIN 之後,「一筆訂單」會變成多列,資料庫的 LIMIT 20 會切到品項而不是訂單——分頁結果會是錯的。
2
Hibernate 為了保證分頁語意正確,選擇不加 LIMIT:把符合條件的資料全部撈進 JVM,在記憶體裡去重、組裝,然後才切出那 20 筆。
所以「分頁」這件事完全沒有發生在資料庫端。八萬筆訂單、每筆十個品項,就是把八十萬列拉進 JVM 記憶體,只為了回傳 20 筆。症狀是應用端 OOM 或長時間 GC 停頓,而不是慢查詢——資料庫那句 SQL 甚至可能只跑了 300 毫秒。

這正是 ch04 說的:「LIMIT 根本不會出現在 SQL 裡」。你在資料庫端怎麼找都找不到深分頁的痕跡,因為分頁是在 Java 這邊做的。

正確做法:兩段式查詢

// 第一段:只查主表的 id,分頁在資料庫端完成(可以用 ch04 的 keyset)
@Query("SELECT o.id FROM Order o WHERE o.status = :s ORDER BY o.createdAt DESC")
List<Long> findIds(int s, Pageable page);

// 第二段:用 id 一次把集合抓齊,沒有分頁就不會觸發記憶體分頁
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.id IN :ids")
List<Order> findWithItems(List<Long> ids);

或者更簡單:第一段照常分頁,集合關聯交給 @BatchSize——效果幾乎一樣,而且不必寫兩個方法。

順帶一提:Page 與 Slice

ch04 提過:Page 會多發一句 COUNT(*)。在 ORM 專案裡它還有一個額外的代價——那句 count 會把 JOIN FETCH 一起帶進去(Hibernate 通常會移除 fetch,但複雜查詢下不一定),變成一句昂貴又無用的統計。不需要總頁數時一律用 Slice。

Open Session In View(spring.jpa.open-in-view)預設是 true。Spring Boot 啟動時甚至會印一行 WARN 提醒你,但幾乎沒有人去讀它。

它做了什麼

它讓 Hibernate 的 session 一直開到 HTTP 回應寫完為止。好處很直接:controller 或序列化階段存取 lazy 關聯時不會拋 LazyInitializationException——它讓開發變順,代價藏在後面。

代價一:連線佔用時間 = 整個請求時間

session 開著就代表資料庫連線被佔著——包括 JSON 序列化、視圖渲染、甚至回應寫回客戶端變慢的那段時間。

於是連線池的有效容量被大幅稀釋:一個請求真正用到資料庫可能只有 20 毫秒,卻佔住連線 300 毫秒。連線池滿的時候,這往往是隱藏的原因之一(回扣 backend ch03 與 ch07 的分流表)。

代價二:把 N+1 藏起來

沒有 OSIV 的話,在 controller 碰 lazy 關聯會直接拋例外——問題在開發階段就暴露了。有了 OSIV,它不會拋例外,只會安靜地多發幾百句 SQL。ch07 那個案例能活到正式環境,OSIV 是共犯。

要不要關

spring:
  jpa:
    open-in-view: false
  • 關掉的代價:所有需要的關聯都必須在 service 層(交易範圍內)明確載入完,否則序列化時就是 LazyInitializationException。
  • 而這個代價其實是好事:它強迫你決定「這個 API 到底需要哪些資料」,而不是讓序列化過程去隨機觸發查詢。
  • 務實建議:新專案一開始就關;既有專案先關在測試環境跑一輪,把爆出來的地方逐一改成 DTO 投影或明確 fetch。
OSIV 是一個典型的「代價守恆」案例:它買到的是開發便利,付出的是連線佔用時間與一個會隱藏 N+1 的環境。而付款時間點被延後到了正式環境的尖峰時段。

寫入端有兩個預設值會讓「明明設定了批次」的努力白費。

設定批次的正確組合

spring:
  jpa:
    properties:
      hibernate.jdbc.batch_size: 500
      hibernate.order_inserts: true    # 把同型別的 INSERT 排在一起才批得起來
      hibernate.order_updates: true

陷阱①:JDBC URL 沒開 rewriteBatchedStatements

MySQL 的 JDBC driver 預設 rewriteBatchedStatements=false。在這個預設值下,JDBC batch 只是「少幾次 API 呼叫」,送到網路上的仍然是 500 條獨立的 INSERT。

打開它,driver 才會把它們改寫成一句 INSERT INTO t VALUES (...), (...), (...):
jdbc:mysql://host:3306/db?rewriteBatchedStatements=true
差距通常是五到十倍,而它只是連線字串上的一個參數。

陷阱②:GenerationType.IDENTITY 會直接停用批次

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)   // MySQL 自增
private Long id;

推導一次為什麼:Hibernate 必須在 persist() 當下就拿到主鍵值(實體要進 persistence context 就得有 id),而自增值只有在 INSERT 真的執行之後才拿得到。於是它只能一句一句送,批次完全失效。

  • MySQL 沒有 sequence,所以 JPA 這邊能做的有限(TABLE 策略有自己的競爭問題)。
  • 實務解法:大量寫入直接繞過 ORM——用 JdbcTemplate.batchUpdate() 或原生的多值 INSERT。這不是妥協,是選對工具(見下一節)。

順帶:dirty checking 的成本

Hibernate 在 flush 時會逐一比對 session 裡每個實體的每個欄位與載入時的快照。一次載入五萬個實體,即使只改了一個,flush 也要比對五萬次。

@Transactional(readOnly = true)   // 讓 Hibernate 跳過 dirty checking
public List<OrderRow> list() { ... }

唯讀查詢一律加 readOnly = true:省下快照與比對成本,而且它同時是「這個方法不會寫資料」的宣告(Spring 也可以據此路由到唯讀複本)。DTO 投影則根本不進 persistence context,連快照都不會建。

這是 ch07 那場故障的另一半——同一件事,從應用端看。

徵狀

  • 訂單列表 API 回應 800 毫秒(改版前 60 毫秒)。
  • 資料庫端每一句都是 1~3 毫秒,慢查詢日誌完全是空的。
  • 壓測時應用端 CPU 先滿,資料庫 CPU 隨後跟上。
  • 本機開發完全感覺不到——因為本機資料庫在同一台機器,每次往返只要 0.1 毫秒。

診斷

先量最基本的數字:一個請求發了幾句 SQL。

// datasource-proxy 的計數結果
GET /api/orders?page=0  →  SELECT: 213, total time: 640ms

213 句。拆解來源:

1
主查詢 1 句:20 筆訂單。
2
@ManyToOne Member 與 @ManyToOne Merchant 都沒寫 fetch,預設 EAGER → 40 句。
3
DTO 轉換時碰到 order.getItems() → 20 句。
4
每個品項再碰 item.getProduct()(平均 8 個品項)→ 約 152 句。
總計 213 句,每句 3 毫秒——本機是 20 毫秒,正式環境(跨網路 3 毫秒往返)是 640 毫秒。N+1 的傷害與網路延遲成正比,這就是「本機完全正常」的原因。它不是被測試環境的資料量掩蓋,是被網路距離掩蓋。

修法(依序)

1
立刻:全域加上 hibernate.default_batch_fetch_size: 50。213 句 → 約 8 句,一行設定、不改任何程式碼。這是止血的最佳投報比。
2
當天:把兩個 @ManyToOne 改成 fetch = LAZY,列表查詢改用 @EntityGraph 明確指定要載入什麼。
3
治本:列表頁改成 DTO 投影——一句 SQL 取齊需要的欄位。回應時間回到 25 毫秒(比改版前還快,因為欄位更少了)。
4
防再犯:整合測試斷言「這個端點的 SELECT 不得超過 3 句」;並關掉 OSIV,讓未來的 lazy 誤用在開發階段就拋例外。

教訓

沒有任何一句 SQL 是錯的,沒有任何一個索引缺失,資料庫從頭到尾都很健康。問題完全在「要求資料庫做了幾次」——而這個數字,前七章的所有工具都量不到。這就是為什麼這一軌的最後一章要回到應用層。

把 ORM 用在它不擅長的地方,是這一章大部分問題的共同起點。知道邊界在哪,比熟練任何技巧都有用。

ORM 擅長的

  • 以聚合根為單位的 CRUD:載入一個訂單、改幾個欄位、存回去。dirty checking、樂觀鎖、關聯維護都在幫你。
  • 領域邏輯集中在實體上的場景。

ORM 不擅長的(換工具)

場景問題換成
報表與複雜聚合JPQL 表達力不足,寫得出來也難以優化原生 SQL、jOOQ、MyBatis
大量寫入/匯入IDENTITY 停用批次、dirty checking 成本JdbcTemplate.batchUpdate + rewriteBatchedStatements
只讀列表頁載入整個實體圖太貴DTO 投影(仍可用 JPA)
需要 hint/特殊語法JPQL 不支援(ch02 的 optimizer hint)native query,但注意型別(ch01 的失敗場景)
批次更新/刪除逐一載入再刪=N+1 的寫入版一句 UPDATE/DELETE,但記得清 persistence context
混用不是妥協。同一個專案裡 JPA 管交易與 CRUD、JdbcTemplate 管批次與報表,是非常正常的架構。「全部都用 ORM」才是那個要被質疑的預設。

混用時的兩個注意事項

  • 原生 SQL 繞過了 persistence context。用 @Modifying 的批次 UPDATE 之後,session 裡的實體還是舊值——要加 clearAutomatically = true,或明確 flush/clear。
  • 交易邊界仍然由 Spring 管:JdbcTemplate 與 JPA 在同一個 @Transactional 裡會共用同一條連線與同一個交易(前提是用同一個 DataSource),這點是安全的。

一個判斷句

要不要用 ORM 處理某段邏輯,問這個問題就好:

「我需要的是『物件』,還是『資料』?」
需要物件(要改它、要它的行為、要交易保護)→ ORM。
需要資料(顯示、統計、匯出、匯入)→ 直接查資料,不要繞道物件。

這一軌從 EXPLAIN 一路走到 ORM。表面上是八個不同的主題,但它們一直在講同樣的三件事。

① 估算 vs 實測

優化器看的是估算,你該看的是實測。

  • ch01:rows/filtered 是估算,EXPLAIN ANALYZE 才是實測。
  • ch02:估算來自 20 頁的採樣——它失準的時候,計畫就會很有邏輯地走錯。
  • ch03:JOIN 把估算誤差乘上被驅動表的查詢次數。
  • ch07:「感覺很慢」也是一種估算。先量再說——而且要量對東西(總耗時,不是最慢那句)。

② 代價守恆

每一個加速手段,都在別的地方付錢。沒有例外,只有「你知不知道帳單寄到哪裡」。

  • 索引付寫入與空間(ch06);hint 付未來的維護(ch02)。
  • keyset 分頁付「不能跳頁」這個功能(ch04);反範式付一致性維護(ch06)。
  • 調大 buffer 付的是 Buffer Pool 的記憶體(ch03、ch04);RC 隔離等級付的是幻讀(ch05)。
  • OSIV 付的是連線佔用時間,而且帳單延後到正式環境的尖峰才寄到(本章)。

③ 先量再調

照「症狀 → 計畫 → 實測 → 根因」的順序走,不要一上來就調旋鈕。

  • ch01:先看它打算做什麼,再問它為什麼慢。
  • ch05:1205 找長交易、1213 找加鎖順序——症狀決定方向。
  • ch07:症狀決定工具。用錯工具比不查更糟,因為它會給你「一切正常」這個最誤導的結論。
  • 本章:而最後一站永遠要問——這句 SQL 為什麼會被要求執行?被要求了幾次?

還有一條沒有明說的線

最危險的問題,是不會報錯的那一種。
ch01 的隱式轉換不只慢,還會撈到錯的人;ch03 的 NOT IN 遇到 NULL 安靜地回傳空集合;ch04 的 GROUP BY 在 8.0 之後不再隱式排序;ch06 的 collation 變更會讓比較結果改變。

這些全都不拋例外、不進錯誤日誌,只是安靜地給出錯的答案。效能問題最終會被發現,正確性問題不會——它會變成一張三個月後的客訴單。

如果只帶走一句

資料庫的每一個行為都是可以解釋的:它讀了多少頁、加了什麼鎖、被要求了幾次。
你的工作不是猜它為什麼慢,是去把那些數字讀出來。
N+1 的三種來源與對應解法
來源怎麼發生第一順位解法備註
@ManyToOne 預設 EAGER沒寫 fetch,JPA 規範預設就是 EAGER一律明確寫 fetch = LAZY只有 ToMany 預設 LAZY,這個預設值害人無數
迴圈裡碰 lazy 關聯DTO 轉換時逐筆 getMember()EntityGraph 明確載入,或 DTO 投影最常見;程式碼看起來只是在搬欄位
集合逐一初始化迴圈裡 getItems(),巢狀時 20→200→2000@BatchSize / default_batch_fetch_size一行全域設定就能救,投報比最高
四個會安靜放大成本的預設值
設定預設代價建議
@ManyToOne fetchEAGER每筆主資料多發 1 句/關聯一律改 LAZY,需要時明確載入
spring.jpa.open-in-viewtrue連線佔用 = 整個請求時間;還會把 N+1 藏起來關掉;新專案一開始就關
rewriteBatchedStatementsfalseJDBC batch 仍送出 N 條獨立 INSERTJDBC URL 加上,通常快 5~10 倍
hibernate.default_batch_fetch_size未設定lazy 載入一律逐筆往返設 50;大部分專案沒開
@Transactional 的 readOnlyfalse唯讀查詢也付 dirty checking 與快照成本唯讀方法一律加 readOnly = true
需要「物件」還是「資料」:工具選擇
場景需要什麼用什麼注意
載入聚合根、改欄位、存回物件JPA 實體ORM 的主場
只讀列表頁資料DTO 投影一句 SQL、欄位最少、不進 persistence context
報表與複雜聚合資料原生 SQL / jOOQ / MyBatisJPQL 表達力不足
大量寫入/匯入資料JdbcTemplate.batchUpdateIDENTITY 主鍵會停用 JPA 批次
批次 UPDATE/DELETE資料@Modifying 的一句 SQL記得 clearAutomatically,session 裡還是舊值

練習題 點選選項查看解析

0 / 10
01 / 10
為什麼說前七章的分析都建立在一個前提上?那個前提是什麼?
A 前提是資料庫版本必須是 MySQL 8.0
B 前提是「SQL 真的長你以為的那樣」——而在 ORM 專案裡這經常不成立
C 前提是必須開啟慢查詢日誌
D 前提是資料量足夠大
解析
你寫的是 order.getItems(),資料庫收到的是 200 條 SELECT;你以為的分頁是 LIMIT 20,Hibernate 卻把八萬列撈進 JVM 再切。這些都不會出現在資料庫端的任何報表裡——這正是最後一章要回到應用層的原因。
02 / 10
JPA 規範中,哪些關聯的預設 fetch 策略是 EAGER?
A 全部都是 LAZY
B @ManyToOne 與 @OneToOne 預設 EAGER;@OneToMany 與 @ManyToMany 預設 LAZY
C 全部都是 EAGER
D 由 Hibernate 自動判斷
解析
這個預設值害人無數:查 20 筆訂單、每筆帶兩個 ToOne 關聯,什麼都沒寫就是 40 句額外查詢。正確做法是所有關聯一律明確寫 fetch = FetchType.LAZY,需要時再用 EntityGraph 或 JOIN FETCH 明確載入。
03 / 10
解決 N+1 時,投報比最高、且「不必猜哪裡會用到」的做法是什麼?
A 所有查詢都加 JOIN FETCH
B 設定 hibernate.default_batch_fetch_size(或 @BatchSize),把 N 次單筆查詢合併成 IN 查詢
C 把所有關聯改成 EAGER
D 增加連線池大小
解析
它是全域生效的:即使某個沒被注意到的路徑觸發了 lazy 載入,也只會變成 IN 查詢而不是 N 次往返(213 句 → 約 8 句)。一行設定、不改程式碼,是止血的最佳選擇——而大部分專案沒開。
04 / 10
日誌出現 HHH000104: firstResult/maxResults specified with collection fetch; applying in memory,代表什麼?
A 查詢語法有錯誤
B Hibernate 不加 LIMIT,把符合條件的資料全部撈進 JVM 再於記憶體分頁——可能 OOM,而資料庫端看不到深分頁的痕跡
C 分頁參數被忽略了,結果會是全部資料
D 需要增加 fetch size
解析
因為一對多 JOIN 後「一筆訂單」變成多列,資料庫的 LIMIT 會切到品項而不是訂單,分頁語意會錯。Hibernate 為了正確性選擇在記憶體分頁。症狀是應用端 OOM 或 GC 停頓,而不是慢查詢——正是 ch04 說的「LIMIT 根本不會出現在 SQL 裡」。
05 / 10
承上題,要同時做到「分頁」與「載入集合」的正確做法是什麼?
A 把 JOIN FETCH 改成兩個以上
B 兩段式查詢:先在資料庫端分頁查出主表 id,再用 id IN (...) 把集合抓齊;或分頁照常、集合交給 @BatchSize
C 改用 EAGER 載入
D 把 Pageable 換成手寫的 LIMIT
解析
第一段只查 id,分頁真正發生在資料庫端(也可以用 ch04 的 keyset);第二段沒有分頁參數,就不會觸發記憶體分頁。或者更簡單:集合關聯交給 @BatchSize,效果幾乎一樣且不用寫兩個方法。
06 / 10
Spring Boot 的 spring.jpa.open-in-view 預設開啟,它的兩個代價是什麼?
A 增加記憶體用量與 CPU 使用率
B 資料庫連線被佔到 HTTP 回應寫完為止(連線池有效容量被稀釋),以及把 N+1 藏起來——不拋例外只是安靜地多發幾百句 SQL
C 會導致交易無法回滾
D 會停用二級快取
解析
一個請求真正用到資料庫可能只有 20 毫秒,卻佔住連線 300 毫秒。而關掉之後,在 controller 碰 lazy 關聯會直接拋 LazyInitializationException——問題在開發階段就暴露,這個「代價」其實是好事。
07 / 10
設定了 hibernate.jdbc.batch_size=500,但寫入效能沒有明顯改善。最可能的原因是什麼?
A batch_size 設得太大
B MySQL JDBC 的 rewriteBatchedStatements 預設是 false,batch 只是少幾次 API 呼叫,送到網路上的仍是 500 條獨立 INSERT
C 資料庫不支援批次寫入
D 需要同時關閉交易
解析
打開它之後 driver 才會改寫成一句多值 INSERT,差距通常五到十倍——而它只是連線字串上的一個參數。另一個常見原因是 GenerationType.IDENTITY:Hibernate 必須在 persist() 當下拿到主鍵,只能一句一句送,批次完全失效。
08 / 10
為什麼 GenerationType.IDENTITY 會讓 JPA 的批次寫入失效?
A 因為自增主鍵不支援交易
B 因為實體要進 persistence context 就必須有 id,而自增值只有在 INSERT 真的執行後才拿得到——所以只能一句一句送
C 因為 IDENTITY 會鎖表
D 因為 Hibernate 不支援 MySQL 的自增欄位
解析
MySQL 沒有 sequence,JPA 這邊能做的有限(TABLE 策略有自己的競爭問題)。實務解法是大量寫入直接繞過 ORM,用 JdbcTemplate.batchUpdate 或原生多值 INSERT——這不是妥協,是選對工具。
09 / 10
一個 N+1 的列表頁在本機完全感覺不到問題,正式環境卻要 800 毫秒。為什麼?
A 本機的資料量比較小
B N+1 的傷害與網路往返延遲成正比:本機往返 0.1 毫秒 × 213 句是 20 毫秒,正式環境 3 毫秒 × 213 句就是 640 毫秒
C 本機使用了快取
D 正式環境的資料庫設定不同
解析
它不是被測試環境的資料量掩蓋,是被網路距離掩蓋——所以連「用正式資料量測試」都擋不住它。唯一可靠的防線是計算「一個請求發了幾句 SQL」,並在整合測試裡斷言上限。
10 / 10
決定某段邏輯要不要用 ORM,最簡單的判斷句是什麼?
A 看資料量大不大
B 問「我需要的是『物件』還是『資料』」——要改它、要它的行為、要交易保護就用 ORM;顯示、統計、匯出、匯入就直接查資料
C 看團隊熟不熟悉 SQL
D 一律使用 ORM 以保持一致性
解析
混用不是妥協:JPA 管交易與 CRUD、JdbcTemplate 管批次與報表,是非常正常的架構——「全部都用 ORM」才是那個該被質疑的預設。混用時要注意原生 SQL 繞過 persistence context(@Modifying 記得 clearAutomatically)。

點擊卡片翻面查看答案,共 13 張。

QUESTION
前七章的分析建立在什麼前提上?為什麼最後一章要回到應用層?
點擊翻面
ANSWER
前提是「SQL 真的長你以為的那樣」。在 ORM 專案裡經常不成立:一句 getItems() 可能是 200 條 SELECT,一次分頁可能是把八萬列撈進 JVM。這些在資料庫端的報表裡完全看不到。
點擊翻回
QUESTION
在正式環境要能回答關於 ORM 的哪三個問題?靠什麼?
點擊翻面
ANSWER
①一個請求發了幾句 ②各自多久 ③從哪一行程式發出。靠 datasource-proxy / p6spy 攔截、Hibernate Statistics、以及 SQL 註解標籤(讓 digest 對得回端點)。日誌只能回答「SQL 長什麼樣」。
點擊翻回
QUESTION
為什麼說 N+1 是少數能在 CI 擋住的效能問題?
點擊翻面
ANSWER
它的訊號(SQL 條數)是確定性的,不依賴資料量與機器。整合測試裡斷言「這個端點的 SELECT 不得超過 N 句」就能擋下來——沒有這道防線,它只會在正式環境流量最大時被發現。
點擊翻回
QUESTION
JPA 各種關聯的預設 fetch 策略是什麼?
點擊翻面
ANSWER
@ManyToOne 與 @OneToOne 預設 EAGER;@OneToMany 與 @ManyToMany 預設 LAZY。所以什麼都不寫的兩個 ToOne 關聯,查 20 筆就是 40 句額外查詢。一律明確寫 fetch = LAZY。
點擊翻回
QUESTION
N+1 的三種解法各自適合什麼場景?
點擊翻面
ANSWER
JOIN FETCH/EntityGraph 適合 ToOne 與「要修改實體」;@BatchSize 適合集合關聯與路徑不確定的情況(全域生效、投報比最高);DTO 投影適合只讀列表頁(一句解決、不進 persistence context)。
點擊翻回
QUESTION
HHH000104 警告背後發生了什麼?
點擊翻面
ANSWER
JOIN FETCH 集合配分頁時,Hibernate 不加 LIMIT,把全部資料撈進 JVM 在記憶體分頁(因為一對多 JOIN 後 LIMIT 會切到品項而非訂單)。症狀是 OOM 或 GC 停頓,資料庫端看不到深分頁痕跡。
點擊翻回
QUESTION
要同時做到分頁與載入集合,正確做法是什麼?
點擊翻面
ANSWER
兩段式:先在資料庫端分頁查出主表 id,再用 id IN (...) 抓集合;或分頁照常、集合交給 @BatchSize(更簡單,效果幾乎一樣)。
點擊翻回
QUESTION
OSIV(open-in-view)預設開啟的兩個代價?
點擊翻面
ANSWER
①連線被佔到 HTTP 回應寫完,連線池有效容量被稀釋(真正用 DB 20ms 卻佔住 300ms)②把 N+1 藏起來——關掉後在 controller 碰 lazy 會直接拋例外,問題在開發階段就暴露。
點擊翻回
QUESTION
設了 hibernate.jdbc.batch_size 卻沒效果,兩個常見原因?
點擊翻面
ANSWER
①MySQL JDBC 的 rewriteBatchedStatements 預設 false,送出的仍是 N 條獨立 INSERT(打開通常快 5~10 倍)②GenerationType.IDENTITY:必須在 persist() 當下取得主鍵,只能逐句送。
點擊翻回
QUESTION
dirty checking 的成本是什麼?怎麼避免?
點擊翻面
ANSWER
flush 時逐一比對 session 內每個實體的每個欄位與載入快照——載入五萬個實體就比對五萬次。唯讀方法一律加 @Transactional(readOnly = true) 跳過;DTO 投影根本不進 persistence context。
點擊翻回
QUESTION
為什麼 N+1 在本機完全測不出來?
點擊翻面
ANSWER
它的傷害與網路往返延遲成正比:本機 0.1ms × 213 句 = 20ms,正式環境 3ms × 213 句 = 640ms。它不是被資料量掩蓋,是被網路距離掩蓋——所以連「用正式資料量測試」也擋不住。
點擊翻回
QUESTION
決定要不要用 ORM 的判斷句?
點擊翻面
ANSWER
「我需要的是物件,還是資料?」需要物件(要改它、要交易保護)用 ORM;需要資料(顯示、統計、匯出、匯入)就直接查資料。混用不是妥協,「全部都用 ORM」才是該被質疑的預設。
點擊翻回
QUESTION
這一軌八章的三條主線是什麼?
點擊翻面
ANSWER
①估算 vs 實測——優化器看估算,你要看實測 ②代價守恆——每個加速手段都在別處付錢,差別只在你知不知道帳單寄到哪 ③先量再調——症狀決定工具,用錯工具會得到「一切正常」這個最誤導的結論。還有一條沒明說的:最危險的問題是不會報錯的那一種。
點擊翻回