fetchSize 與 Stream 的關係分析報告

環境資訊

項目
DB Driver mssql-jdbc (SQL Server)
ORM Hibernate / Spring Data JPA
當前 fetchSize 12000
當前 chunkSize 50000 rows

一、fetchSize 作用在哪一層?

┌──────────────────────────────────────────────────────────────────┐
│  Java 應用層                                                       │
│                                                                    │
│  stream.forEach(entity -> process(entity))                        │
│  └─ 你的程式看到的:每次只取「1 筆」entity                            │
│                                                                    │
├──────────────────────────────────────────────────────────────────┤
│  Hibernate Session(L1 Cache)                                     │
│                                                                    │
│  每 forEach 一筆 entity → 自動放入 Session cache                    │
│  ❌ 不會自動清除,直到 Transaction 結束                              │
│  → 50K 行跑完 = 50K entities 全部在記憶體                           │
│                                                                    │
├──────────────────────────────────────────────────────────────────┤
│  JDBC ResultSet Buffer  ← fetchSize 作用在這裡                     │
│                                                                    │
│  fetchSize = 12000                                                 │
│  → 每次網路往返從 DB 取 12,000 筆 raw rows 到這個 buffer           │
│  → 下一批:再去 DB 取下一個 12,000 筆                               │
│                                                                    │
├──────────────────────────────────────────────────────────────────┤
│  SQL Server(DB 層)                                               │
│                                                                    │
│  開啟 server-side cursor(adaptive response buffering)            │
│  按批次回應 Driver 的請求                                            │
└──────────────────────────────────────────────────────────────────┘

二、SQL Server JDBC 的特殊行為

SQL Server JDBC Driver(mssql-jdbc)有一個特殊的預設行為,與其他 Driver 不同:

預設模式:responseBuffering=adaptive

沒有設定 fetchSize 時(SQL Server JDBC 預設):
  → Driver 採用 "adaptive" 模式
  → ResultSet 資料從 Server「按需」流入 Client
  → 看起來像 streaming,但實際上是 Driver 控制的

設定 fetchSize = 12000 後:
  → Driver 強制開啟 Server-Side Cursor
  → 每次網路往返取 12,000 筆 rows 到 JDBC Buffer
  → Hibernate 再從 Buffer 逐筆轉成 Entity

兩種模式對比

模式 JDBC Buffer 大小 DB Round-trips Server-Side Cursor
不設 fetchSize(adaptive) Driver 自動決定 少(可能 1 次) ❌ 無
fetchSize = 12000 最多 12,000 rows 多(每 12K 1 次) ✅ 有

三、Stream<T> 的真實行為

// 你以為的行為(理想):
stream.forEach(entity -> process(entity));
// 每次只有 1 筆在記憶體 ✓

// 實際發生的事:
// ① JDBC Buffer:
//    持有 12,000 筆 raw ResultSet rows(在 native 記憶體 / byte buffer)
//    應用程式碼無法直接觸碰這一層

// ② Hibernate L1 Cache(問題所在):
//    第 1 筆 forEach  → L1 Cache: [entity_1]
//    第 5000 筆 forEach → L1 Cache: [entity_1 ... entity_5000]
//    第 12001 筆 forEach → JDBC 去 DB 再取 12,000 筆(新一批)
//                        → L1 Cache: [entity_1 ... entity_12001] ← 持續累積!
//    第 50000 筆 forEach → L1 Cache: [entity_1 ... entity_50000] ← 全部留著!

// ③ Transaction 結束前,這 50,000 個 entity 都無法被 GC

四、記憶體占用計算(本專案)

ApiLogOldEntity 的高記憶體欄位:

@Column(name="RequestContext")   // 無 length 限制 = TEXT,可能 5KB ~ 100KB
private String requestContext;

@Column(name="ResponseContext")  // 無 length 限制 = TEXT,可能 5KB ~ 100KB
private String responseContext;

單個 Chunk 的記憶體估算

假設每筆大小 50,000 rows(L1 Cache) JDBC Buffer(12,000 rows) 合計
樂觀:5KB/row 250 MB 60 MB ~310 MB
保守:20KB/row 1,000 MB 240 MB ~1.2 GB
悲觀:40KB/row 2,000 MB 480 MB ~2.5 GB ← OOM

結論:OOM 的主因是 L1 Cache 累積了大型 Entity,fetchSize 只影響 JDBC 這一層,無法阻止 L1 Cache 增長。


五、各個 QueryHint 的作用分析

@QueryHints(value = {
    @QueryHint(name = "org.hibernate.fetchSize", value = "12000"),   // [A]
    @QueryHint(name = "org.hibernate.readOnly", value = "true"),     // [B]
    @QueryHint(name = "org.hibernate.fetchDirection", value = "1")   // [C]
})

[A] org.hibernate.fetchSize = 12000

  • 作用層:JDBC ResultSet Buffer
  • 效果:每次網路往返取 12,000 筆 raw rows
  • 對記憶體:控制 JDBC 層的瞬時大小,不影響 L1 Cache
  • 副作用:開啟 Server-Side Cursor,增加 DB 資源消耗(cursor 需要 tempdb 空間)

[B] org.hibernate.readOnly = true

  • 作用層:Hibernate Session
  • 效果:Entity 被標記為 read-only,Hibernate 不追蹤髒值(dirty check)
  • 對記憶體:稍微減少 snapshot 記憶體(不用儲存原始值供 dirty check 對比)
  • 重要:❌ 並**不**阻止 entity 被放進 L1 Cache!只是不追蹤修改

[C] org.hibernate.fetchDirection = 1

  • 作用層:JDBC ResultSet
  • 效果:告訴 JDBC 這是 forward-only cursor(ResultSet.FETCH_FORWARD
  • 對記憶體:允許 Driver 釋放已讀的 rows(不需要支援往回滾動)
  • 對 SQL Server:大多數情況 SQL Server 自動選擇最佳 cursor 類型

六、問題根源總結

fetchSize = 12000 解決了什麼:
  ✅ 控制網路往返批次大小(減少 round-trips)
  ✅ 告訴 Driver 使用 server-side cursor

fetchSize = 12000 沒有解決的:
  ❌ Hibernate L1 Cache 累積(50K entities 全部留在記憶體)
  ❌ Entity 中 requestContext / responseContext 的大字串占用
  ❌ Transaction 結束前 entity 無法被 GC

七、修復建議

修復 1:在 forEach 中定期清理 L1 Cache(最直接)

CsvExportChunkProcessor 中,注入 EntityManager 並每 N 筆清一次:

chunk.forEach(model -> {
    rowBuilder[0].setLength(0);
    appendCsvRow(rowBuilder[0], model);
    rowBuilder[0].append('\n');
    writer.append(rowBuilder[0]);
    rowCount[0]++;

    // 每 1,000 筆清一次 L1 Cache,讓 GC 可以回收 entity
    if (rowCount[0] % 1000 == 0) {
        entityManager.clear();  // ← 釋放 L1 Cache 中已處理的 entities
    }
});

效果:L1 Cache 最多只累積 1,000 個 entity,而不是 50,000 個。


修復 2:降低 fetchSize(可選)

// 目前
@QueryHint(name = "org.hibernate.fetchSize", value = "12000")

// 建議
@QueryHint(name = "org.hibernate.fetchSize", value = "1000")

效果:JDBC Buffer 從 12,000 rows 縮小到 1,000 rows。 代價:網路 round-trips 增加(50K 行需要 50 次,而不是 5 次),但對 streaming backup 影響不大。


修復 3:降低 chunkSize(治本)

// 目前(在 RuleSetting 或 DEFAULT_CHUNK_SIZE)
DEFAULT_CHUNK_SIZE = 50000

// 建議
DEFAULT_CHUNK_SIZE = 10000  // 若每筆 20KB → 10K × 20KB = 200MB,可接受

效果:即使 L1 Cache 不清,單 chunk 的峰值記憶體也從 1GB 降到 200MB。


三種修復的綜合效果

修復方式 實施難度 記憶體改善 性能影響
forEach 中每 1K 清 L1 Cache ⭐ 低 95% ↓ 幾乎無
降低 fetchSize(12000→1000) ⭐ 低 20% ↓ Round-trips 增加(可忽略)
降低 chunkSize(50K→10K) ⭐ 低 80% ↓ 更多 blob 檔案
三者合用 ⭐ 低 97%+ ↓ 幾乎無負面影響

八、最終記憶體預估(修復後)

假設每筆 20KB,採用「每 1K 清 L1 Cache + chunkSize=10K」:

JDBC Buffer(fetchSize=1000):  1,000 × 20KB = 20 MB
Hibernate L1 Cache(清理中):  1,000 × 20KB = 20 MB(峰值)
Pipe Buffer:                               32 MB
BufferedWriter:                             1 MB
GC overhead:                              ~15%

單 Chunk 峰值:          ~85 MB  ← 相比原本 1.2GB,改善 93%

附錄:相關檔案

檔案 關鍵設定
ApiLogOldRepository.java fetchSize、QueryHints
CsvExportChunkProcessor.java PIPE_BUFFER_SIZEentityManager.clear()
ApiLogOldEntity.java requestContextresponseContext 欄位大小