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_SIZE、entityManager.clear() |
| ApiLogOldEntity.java | requestContext、responseContext 欄位大小 |