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. finally 內 clear() 的問題
} 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 可回收) |