JDBC Buffer、L1 Cache 與 EntityManager 讀取流程分析報告
檔案: ChunkProcessor
日期: 2026-05-26
主題: Hibernate Streaming 架構下三層記憶體的關係與 entityManager.clear() 的安全性
1. 三層記憶體架構總覽
Hibernate 使用 Stream 讀取資料時,資料從 DB 到 Java 物件,會經過三個獨立的記憶體區域:
┌─────────────────────────────────────────────────────────────────┐
│ DB Server │
│ SELECT ... WHERE apiLogId >= 0 AND < 40000 ORDER BY apiLogId │
│ server-side cursor 就位,等待 JDBC 拉取 │
└────────────────────────┬────────────────────────────────────────┘
│ 網路傳輸(每次 fetchSize 筆)
▼
┌─────────────────────────────────────────────────────────────────┐
│ Layer 1:JDBC Driver Buffer │
│ - 儲存:raw bytes(ResultSet rows) │
│ - 管理者:SQL Server JDBC Driver │
│ - 大小:固定 fetchSize 筆(滑動視窗) │
│ - Encoding:UTF-8 raw,無 Java 物件 overhead │
└────────────────────────┬────────────────────────────────────────┘
│ Hibernate 逐行 hydrate(bytes → Java 物件)
▼
┌─────────────────────────────────────────────────────────────────┐
│ Layer 2:Hibernate L1 Cache(Session identity map) │
│ - 儲存:hydrated entity 物件(ApiLogOldEntity) │
│ - 管理者:Hibernate Session / JPA EntityManager │
│ - 大小:隨讀取筆數累積,直到 entityManager.clear() 才釋放 │
│ - Encoding:UTF-16 String + Java 物件 header │
└────────────────────────┬────────────────────────────────────────┘
│ forEach lambda 取用
▼
┌─────────────────────────────────────────────────────────────────┐
│ Layer 3:應用層記憶體 │
│ - StringBuilder(單筆 CSV row,~4 KB) │
│ - BufferedWriter(IO buffer,~1 MB) │
│ - PipedOutputStream(pipe buffer,~128 MB) │
└─────────────────────────────────────────────────────────────────┘
2. 各層記憶體特性比較
| 特性 | JDBC Driver Buffer | Hibernate L1 Cache | 應用層 Buffer |
|---|---|---|---|
| 儲存內容 | raw bytes | Java entity 物件 | CSV 字串 |
| Encoding | UTF-8 | UTF-16 + Object header | UTF-8 |
| 大小控制 | fetchSize 常數 |
entityManager.clear() 時機 |
ROW_FLUSH_INTERVAL |
| 是否累積 | ❌ 滑動視窗 | ✅ 會累積 | ❌ 定期 flush |
entityManager.clear() 影響 |
❌ 完全無感知 | ✅ 清空所有 entity | ❌ 無關 |
| 管理者 | JDBC Driver | Hibernate Session | Java IO |
3. Stream 讀取機制:資料來源是 JDBC Cursor,不是 L1 Cache
這是整個討論最核心的概念:
Stream<ApiLogOldEntity> chunk 的底層結構:
Stream<ApiLogOldEntity>
└─ Hibernate ScrollableCursor iterator
└─ JDBC ResultSet(持有 DB server-side cursor 的引用)
└─ DB Server cursor(記錄當前讀取位置)
L1 cache 是「已讀取 entity 的快取副本」,不是 Stream 的資料來源
Stream 的「讀取位置」由 JDBC ResultSet cursor 決定
結論:entityManager.clear() 清除 L1 cache,不影響 JDBC cursor 位置,Stream 可以繼續讀取。
4. fetchSize 的作用:滑動視窗,非固定佔用
@QueryHint(name = "org.hibernate.fetchSize", value = "5000") 的行為:
DB Server [row 1 ..................................... row 40000]
JDBC 第一次拉取(fetchSize=5000)
JDBC Buffer: [row 1~5000 raw bytes]
└─ Hibernate 逐行消耗完畢後 → 向 DB 請求下一批
JDBC 第二次拉取
JDBC Buffer: [row 5001~10000 raw bytes] ← 前一批可被 GC
└─ 繼續消耗...
JDBC 第三次拉取
JDBC Buffer: [row 10001~15000 raw bytes]
└─ ...
- JDBC Buffer 不會同時持有全部 40,000 rows
- 每次 fetch 後,前一批 bytes 無引用 → 可被 GC 回收
- 記憶體峰值固定在
fetchSize筆 raw bytes
5. 完整執行流程:40,000 rows,fetchSize=5000,ROW_FLUSH_INTERVAL=5000
初始狀態:
DB cursor @ row 1
JDBC Buffer 空
L1 cache 空
════════════════════════ 第一批(row 1~5000)════════════════════════
JDBC 向 DB 拉取 5000 rows → JDBC Buffer
DB cursor 移動至 row 5001
forEach 逐行處理:
row 1 → hydrate → L1[1] → appendCsvRow → writer.append
row 2 → hydrate → L1[2] → appendCsvRow → writer.append
...
row 5000 → hydrate → L1[5000] → appendCsvRow → writer.append
↓ rowCount = 5000,觸發 if(rowCount % 5000 == 0)
writer.flush() ← IO buffer → pipe(CSV 資料往前推)
entityManager.clear() ← L1 cache 清空(entity 1~5000 釋放)
結束狀態:
DB cursor @ row 5001 ← 不受影響
JDBC Buffer 耗盡,向 DB 請求下一批
L1 cache 【空】
════════════════════════ 第二批(row 5001~10000)════════════════════
JDBC 向 DB 拉取 5000 rows → JDBC Buffer
DB cursor 移動至 row 10001
forEach 繼續(同一個 forEach,未中斷):
row 5001 → hydrate → L1[5001] → appendCsvRow → writer.append
...
row 10000 → hydrate → L1[10000] → appendCsvRow → writer.append
↓ rowCount = 10000,再次觸發
writer.flush()
entityManager.clear()
結束狀態:
DB cursor @ row 10001
JDBC Buffer 耗盡
L1 cache 【空】
════════════════════════ 重複 ... 共 8 次 ════════════════════════
════════════════════════ 第八批(row 35001~40000)════════════════════
forEach 結束
writer.flush()
countingOut.close() ← 通知上傳端 EOF
uploadFuture.get() ← 等待上傳完成
finally:
entityManager.clear() ← 清尾段剩餘 entity(safety net)
結束狀態:
DB cursor 已讀完,ResultSet 關閉
JDBC Buffer 空
L1 cache 【空】
6. entityManager.clear() 執行當下的記憶體隔離
當 clear() 在 row 5000 被呼叫時:
entityManager.clear() 的作用範圍:
L1 cache(清空) JDBC Buffer(無感知) DB Server(無感知)
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ entity 1 │ ← 釋放 │ cursor pos=5001 │ │ row 5001 │
│ entity 2 │ ← 釋放 │ (等待下次 fetch)│ │ row 5002 │
│ ... │ ← 釋放 │ │ │ ... │
│ entity 5000 │ ← 釋放 │ │ │ row 40000 │
└──────────────┘ └──────────────────┘ └──────────────────┘
↑ ↑ ↑
clear() 清這裡 JDBC driver 自己管理 安靜等待,毫不知情
三個記憶體區域完全獨立,clear() 不會「跨層」影響 cursor 或 DB。
7. 為何 clear() 後 Stream 還能繼續讀取
常見的誤解:「清掉 L1 cache 後,後面的資料不見了」
誤解的模型(錯誤):
Stream 讀資料路徑:L1 cache → forEach
→ clear() 清 L1 cache → 後面的資料消失 ❌
正確的模型:
Stream 讀資料路徑:DB cursor → JDBC Buffer → hydrate → L1 cache → forEach
→ clear() 只清 L1 cache(已用完的 entity)
→ DB cursor 和 JDBC Buffer 完全不受影響
→ 下一行從 JDBC ResultSet.next() 繼續讀取 ✅
Hibernate Streaming 的 forEach 本質上是:
// Hibernate 內部的等效邏輯(偽碼)
while (resultSet.next()) { // ← 讀 JDBC cursor,與 L1 cache 無關
ApiLogOldEntity entity = hydrate(resultSet.currentRow());
session.identityMap.put(entity.getId(), entity); // ← 放入 L1 cache
consumer.accept(entity); // ← 呼叫 forEach lambda
}
// clear() 只影響 identityMap,不影響 resultSet.next() 的行為
8. fetchSize 與 ROW_FLUSH_INTERVAL 的對齊建議
8.1 不對齊的問題(fetchSize > flush interval)
fetchSize = 12000,ROW_FLUSH_INTERVAL = 5000
第一批 fetch 拉取 12000 rows:
JDBC Buffer: [row 1~12000]
└─ forEach 讀到 row 5000 → clear()
JDBC Buffer: [row 1~12000] ← row 5001~12000 仍在 buffer
└─ forEach 繼續到 row 10000 → clear()
JDBC Buffer: [row 1~12000] ← row 10001~12000 仍在 buffer
└─ forEach 到 row 12000 → buffer 耗盡,fetch 下一批
結果:同一批 fetch 的 7000 rows 在第一次 clear() 後仍佔用 JDBC buffer
(預取但尚未 hydrate 的 idle bytes)
8.2 對齊後的效果(fetchSize = flush interval)
fetchSize = 5000,ROW_FLUSH_INTERVAL = 5000
第一批 fetch 拉取 5000 rows:
JDBC Buffer: [row 1~5000]
→ forEach 讀完 5000 rows
→ writer.flush() + entityManager.clear()
→ JDBC Buffer 耗盡,fetch 下一批(節奏一致)
節奏:
fetch 5000 → consume 5000 → clear → fetch 5000 → consume 5000 → clear ...
8.3 建議設定
// 建議:fetchSize 與 ROW_FLUSH_INTERVAL 對齊
@QueryHint(name = "org.hibernate.fetchSize", value = "5000")
private static final int ROW_FLUSH_INTERVAL = 5000; // ← 與 fetchSize 一致
private static final int ROW_REBUILD_INTERVAL = 10000; // ← 每兩批重建 StringBuilder
9. 記憶體大小估算(以 5000 rows 對齊為基準)
9.1 單筆 ApiLogOldEntity 估算
| 欄位 | 估計大小(L1 cache,UTF-16) | 估計大小(JDBC Buffer,UTF-8) |
|---|---|---|
trackingId (UUID) |
~108 bytes | ~36 bytes |
requestContext (JSON) |
~2,040 bytes | ~1,000 bytes |
responseContext (JSON) |
~2,040 bytes | ~1,000 bytes |
| 其他欄位 | ~300 bytes | ~150 bytes |
| Java 物件 overhead | ~96 bytes | 0 |
| 合計(中等情境) | ~4,584 bytes | ~2,186 bytes |
9.2 各層峰值記憶體(5000 rows,中等情境)
| 記憶體層 | 筆數 | 單筆大小 | 峰值 |
|---|---|---|---|
| JDBC Buffer | 5,000 | ~2.2 KB | ~11 MB |
| Hibernate L1 Cache | 5,000 | ~4.6 KB | ~23 MB |
| BufferedWriter | — | 固定 1 MB | 1 MB |
| PipedOutputStream | — | 0~128 MB | 依上傳速度 |
| 合計(不含 pipe) | ~35 MB |
9.3 修改前後對比(chunk = 50,000 rows)
| 時機 | L1 Cache 峰值(筆數) | L1 Cache 峰值(記憶體) |
|---|---|---|
| 修改前(finally clear) | 50,000 entities | ~230 MB |
| 修改後(每 5000 clear) | 5,000 entities | ~23 MB |
| 降幅 | -90% | -90% |
10. 總結
三層記憶體的職責:
DB Server cursor → 記錄「讀到哪一筆」的位置
JDBC Driver Buffer → 減少網路 round trip(滑動視窗,固定大小)
Hibernate L1 Cache → entity 物件的暫存(可透過 clear() 主動釋放)
entityManager.clear() 的作用:
✅ 清除 L1 cache 中已讀取、已用完的 entity 物件
✅ 讓 GC 可以回收這些物件的記憶體
❌ 不影響 JDBC cursor 位置(Stream 繼續讀取不受影響)
❌ 不影響 JDBC Driver Buffer 的內容
❌ 不影響 DB Server 的 cursor 狀態
最佳實踐:
fetchSize = ROW_FLUSH_INTERVAL = 5000
→ JDBC fetch 節奏與 L1 cache clear 節奏一致
→ 記憶體使用最均衡,無「預取 idle bytes」浪費