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」浪費