Hibernate L1 Cache 暫存分析報告

檔案: Csv
日期: 2026-05-26
問題: entityManager.clear() 置於 finally 區塊,導致整個 chunk 期間 L1 cache 持續累積


1. 問題摘要

Chunk() 使用 Hibernate Streaming + JPA 逐行讀取資料並寫入 CSV,但 entityManager.clear() 放在 finally 區塊,意味著只有當整個 chunk **全部讀完、上傳完成後**才會清除 L1 cache。

在 chunk size = 50,000 rows 的情況下,所有 entity 物件會在整個處理期間(可能數十秒)同時存活於 Heap,造成非預期的記憶體峰值。


2. 程式碼位置

位置 行號 說明
Chunk() L73 主方法,@Transactional(readOnly = true)
chunk.forEach() L113–L150 逐行讀取並寫入 CSV
writer.flush() L124–L127 每 5,000 rows 沖 IO buffer
entityManager.clear() L206 finally 才清 L1 cache

3. 執行時間軸(問題重現)

processAndUploadChunk(offset=0, chunkSize=50000)
│
├─ [背景] uploadFuture 啟動(等待 PipedInputStream 資料)
│
├─ chunk.forEach() 開始
│   ├─ row 1     →  DB 讀取  →  entity 加入 L1 cache  →  寫入 CSV
│   ├─ row 2     →  DB 讀取  →  entity 加入 L1 cache  →  寫入 CSV
│   ├─ ...
│   ├─ row 5000  →  writer.flush()  ← 僅沖 BufferedWriter(IO buffer)
│   │                               ← L1 cache 仍有 5000 entities
│   ├─ row 10000 →  writer.flush()  ← 同上,L1 cache 現有 10000 entities
│   ├─ ...
│   └─ row 50000 →  L1 cache 峰值:50,000 entities ← 問題點
│
├─ writer.flush()       ← 最後沖一次 IO buffer
├─ countingOut.close()  ← 關閉 pipe 寫入端,通知上傳完成
├─ uploadFuture.get()   ← 等待上傳結束
│
└─ finally:
    └─ entityManager.clear()  ← 才在這裡清 L1 cache(已太晚)

4. L1 Cache 的本質

Hibernate Session(JPA EntityManager)維護一個 identity map(等同 L1 cache):

Session identity map(存活於 Heap)
┌──────────────────────────────────────────────────────────┐
│  apiLogId=1      →  ApiLogOldEntity@0x1 { trackingId,   │
│                        requestContext(JSON, ~2KB),        │
│                        responseContext(JSON, ~2KB), ... } │
│  apiLogId=2      →  ApiLogOldEntity@0x2 { ... }          │
│  ...                                                      │
│  apiLogId=50000  →  ApiLogOldEntity@0xC350 { ... }        │
└──────────────────────────────────────────────────────────┘
         ↑ 整個 chunk 期間全部在記憶體中

writer.flush() 做的事:

BufferedWriter (1MB buffer)
  └─ OutputStreamWriter
      └─ CountingOutputStream
          └─ PipedOutputStream (128MB pipe buffer)
              └─ [背景] blob upload 讀取
flush() 只是把 Java IO buffer 的資料推進 pipe,與 Hibernate identity map 完全無關。


5. 記憶體消耗估算

5.1 單筆 Entity 大小

欄位 型別 估計大小
trackingId String 36 bytes (UUID)
trxReferenceNo String ~30 bytes
apiId String ~20 bytes
requestContext String (JSON) 500 ~ 5,000 bytes
responseContext String (JSON) 500 ~ 5,000 bytes
其他欄位 String/Date ~100 bytes
JVM 物件 overhead Object header ~16 bytes
合計(估計) ~1.2 ~ 10 KB

5.2 50,000 rows 的 L1 Cache 峰值

情境 單筆大小 50,000 rows 峰值
樂觀(context 小) 1.2 KB ~60 MB
中等 3 KB ~150 MB
悲觀(context 大) 10 KB ~500 MB

注意: 這 60~500 MB 是額外的、不必要的,因為每行寫完 CSV 後 entity 已無用途,卻仍被 identity map 持有引用,GC 無法回收。


6. 為何 @QueryHint(readOnly = true) 不能解決此問題

Repository 有加:

@QueryHint(name = "org.hibernate.readOnly", value = "true")

此 hint 的效果: - ✅ 跳過 dirty checking(不建立 snapshot copy)→ 每筆省 ~50% 記憶體 - ❌ 不會 自動從 identity map 移除 entity - ❌ 不會 讓 GC 可以回收 entity 物件

entity 物件仍被 Session.identityMap 持有強引用,GC 無法介入。


7. finallyclear() 的問題

} finally {
    // ⚠️ CRITICAL: Clear Hibernate Session L1 cache after each chunk
    // Chunk may have loaded 50K+ entities into persistence context
    try {
        entityManager.clear();  // ← 此行的時機錯了

雖然 finally 保證**一定**會執行,確保 chunk 結束後 cache 被清除,但:

  • clear() 發生時,所有工作已完成(DB 讀完、CSV 寫完、上傳完)
  • entity 物件在整個 chunk 生命週期內持續佔用記憶體
  • 多個 chunk 若有任何並發(或下個 chunk 被呼叫前 GC 未觸發),記憶體壓力疊加

8. 修正方案

方案 A:每行 detach(最精細,記憶體最省)

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

        entityManager.detach(model);  // ← entity 用完立即釋放

        if (rowCount[0] % ROW_FLUSH_INTERVAL == 0) {
            writer.flush();
        }
        // ...
    }
});

優點: entity 離開 L1 cache 的時間最早,GC 壓力最小
缺點: 每行一次 detach() 方法呼叫(雖然是 O(1) HashMap.remove,但高頻次仍有微小 overhead)


方案 B:與 flush 同步 clear(平衡方案,推薦)

if (rowCount[0] % ROW_FLUSH_INTERVAL == 0) {
    long flushStartMs = System.currentTimeMillis();
    writer.flush();
    entityManager.clear();  // ← 與 IO flush 同步清 L1 cache
    long flushTimeMs = System.currentTimeMillis() - flushStartMs;

    if (flushTimeMs > 1000) {
        log.warn("{} ⚠️ BACKPRESSURE: flush took {}ms at row {} ...", ...);
    }
}

優點: 每 5,000 rows 清一次,overhead 極低,記憶體峰值從 50K 降至 5K entities
缺點: 相比方案 A 仍有最多 5,000 entities 短暫存在(可接受)


方案比較

方案 L1 Cache 峰值 呼叫次數 (50K rows) 複雜度
現狀(finally clear) 50,000 entities 1 次
方案 A(每行 detach) 1 entity 50,000 次
方案 B(每 5K clear) 5,000 entities 10 次

9. Streaming Cursor 安全性確認

@Transactional(readOnly = true) 內呼叫 entityManager.clear() 是否安全?

JDBC cursor (DB 端)
  └─ Hibernate ScrollableResults / Stream
      └─ 已讀取的 row → entity(L1 cache)
      └─ 未讀取的 row → 仍在 DB cursor 等待
  • entityManager.clear() 只清除 已載入到 Session 的 entity
  • 不影響 JDBC cursor 的位置(cursor 在 DB connection 層維持)
  • 不影響 後續 chunk.forEach() 繼續讀取下一批 rows
  • @Transactional 保持同一個 DB connection,cursor 持續有效

✅ 在 forEach 內部呼叫 entityManager.clear() 是安全的。


10. 建議修改的完整 forEach 區塊

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

        writer.append(rowBuilder[0]);
        rowCount[0]++;

        if (rowCount[0] % ROW_FLUSH_INTERVAL == 0) {
            long flushStartMs = System.currentTimeMillis();
            writer.flush();
            entityManager.clear();  // 與 IO flush 同步清 L1 cache
            long flushTimeMs = System.currentTimeMillis() - flushStartMs;

            if (flushTimeMs > 1000) {
                log.warn("{} ⚠️ BACKPRESSURE: flush took {}ms at row {} (Reader may be slow, pipe filling)",
                    LOG_PREFIX, flushTimeMs, rowCount[0]);
            } else if (debugEnabled) {
                log.debug("{} Flushed {} rows at offset {}", LOG_PREFIX, rowCount[0], offset);
            }
        }

        if (rowCount[0] % ROW_REBUILD_INTERVAL == 0) {
            rowBuilder[0] = new StringBuilder(INITIAL_STRING_BUILDER_CAPACITY);
        }

    } catch (IOException e) {
        throw new CsvExportException(
            "Failed writing CSV row at offset " + offset + ", row " + rowCount[0], e);
    }
});

finally 內的 entityManager.clear() 可保留作為 safety net(處理最後不足 5,000 rows 的尾段),不需移除。


11. 修改前後記憶體行為對比

修改前(finally clear):
Heap
 │
 │     ████████████████████████████████████  ← 50K entities 常駐
 │     ████████████████████████████████████
 │     ████████████████████████████████████
 │                                         ↓ clear()
 └─────────────────────────────────────────────── time
       chunk 開始                      chunk 結束

修改後(每 5K clear):
Heap
 │
 │     █████                               ← 最多 5K entities
 │          ↓clear  █████                  ← 下一批 5K
 │                       ↓clear  █████
 │                                    ↓clear
 └─────────────────────────────────────────────── time
       chunk 開始                      chunk 結束

12. 結論

項目 現狀 修改後
L1 cache 峰值 chunk 全量(最多 50K entities) 5K entities(方案 B)
記憶體峰值(中等情境) ~150 MB ~15 MB
entityManager.clear() 呼叫時機 chunk 結束後 每 5,000 rows
Streaming cursor 安全性
程式碼複雜度增加 極低(一行)
GC 壓力 高(大量長命物件) 低(短命物件,Minor GC 可回收)