跳至主要内容

ADR-006: CacheManager 層記憶體預算管制

ADR 編號: 006 狀態: 已接受 (Accepted) 決策日期: 2026-06-22 決策者: Development Team(框架維護者)


摘要

appfuse-server 的 cache 模組新增 CacheManager 層級的記憶體預算管制:以「per-cache byte 上限(runtime 由 Ehcache 驅逐強制)+ manager 層加總檢查(建立時讀 Ehcache live config 重算,超額即 REJECT)」兩層,封住一個 CacheManager 下所有 cache 的記憶體總量;預設啟用(safe-by-default),預設值由 JVM 上限保守推導。


背景 (Context)

問題陳述

目前 cache 模組(CacheBuilder / DualLayerCache / EhcacheCacheManager)的記憶體額度完全綁在單一 cache 上(各自的 TierConfiguration),CacheManager 不掌握、也不限制其下所有 cache 的記憶體總量。後果:

  1. 一個 CacheManager 開了 N 個 cache,總用量 ≈ 各 cache 配置加總,無上限、無統一回收
  2. 光靠 -Xmx / -XX:MaxDirectMemorySize被動防線——爆了才 OOM,而非主動封頂。
  3. heap tier 目前只支援「筆數」計量(CacheBuilder.heap(long entries)),無法以實際記憶體量(MB)封頂,預算在 heap 這層算不出真實佔用

需求是:讓 CacheManager 盡可能安全地控制包含 heap 在內的記憶體總量,避免快取把行程記憶體撐爆。

限制條件

  • Ehcache 本地快取的 resource pool 不可跨 cache 共享——共享池只存在於 clustered cache(Terracotta server off-heap)。純 in-process 無法做「runtime 動態共享一塊池」。
  • Ehcache 3 多層配置必須含 heap 層;offheap-only / disk-only 只能是單層配置。
  • byte-sized heap 由 Ehcache 的 sizeof 引擎走訪物件圖估算大小(寫入時成本,可調遍歷深度 / 單一物件上限);非逐位元組精確,須留安全邊際。
  • direct memory(offheap)與 heap 各自獨立疊加計入容器/OS 上限;-XX:MaxDirectMemorySize 多數部署未明示。
  • OperatingSystemMXBean.getTotalMemorySize() 自 JDK 14 起 container-aware(有 cgroup 限制時回該限制),但容器未設限制 / 裸機時仍回主機總量。

假設前提

  • 既有呼叫端極少且都在開發早期,可接受破壞性變更、最後統一 migrate(故不為向後相容犧牲安全預設)。
  • 平台目標 JDK 為 25(container-aware 行為成立)。
  • 快取為支援性結構,不應是 heap 的主要消費者;應用工作集 + GC 需要 heap 的多數空間。

考量的方案 (Options Considered)

方案 A: Config-time 預算管制 + per-cache byte 封頂 — 本案

說明: 兩層疊加。①每個 cache 的 heap/offheap 以 MB 計,Ehcache 在 runtime 達上限時主動驅逐 entry(真實強制單一 cache 不超標)。②CacheManager 在 createCache 時,讀 Ehcache live runtime config 重算所有 cache 的 byte 池加總,超過 budget 則依 onExceed 拒絕。預設啟用,預設值由 JVM 上限保守推導。

優點

  • ✅ 純框架層、不需外部基礎設施;對既有 in-process 架構零侵入。
  • ✅ 兩層合起來真正防爆倉:單 cache runtime 自我驅逐 + 加總 ≤ budget ≤ JVM 上限合理比例。
  • ✅ 讀 live config 重算 ⇒ source of truth 是 Ehcache 自身,自動計入外部直接建立的 cache,無累加器漂移。

缺點

  • ❌ manager 層只能是 config-time(建立時擋自家 cache),無法攔阻外部直接在底層 manager 建 cache(observe-not-block)。
  • ❌ byte-sized heap 有 sizeof 寫入開銷;只能加總 byte 池,外部「筆數 heap」量不出。

評分: 5


方案 B: Runtime 真共享池 / 真全域上限

說明: 讓多 cache 共用一塊 runtime 池、由引擎統一管控與驅逐、總量恆定。

優點

  • ✅ 真正的 runtime 動態分配(熱門多吃、冷門少吃)。

缺點

  • ❌ Ehcache 本地做不到——須改用 clustered cache(Terracotta server,部署模型大改)或換快取引擎並自行實作。
  • ❌ 與「純 in-process 工具庫」定位衝突。

評分: 2


方案 C: 維持現狀,靠 JVM 參數 + 人工紀律

說明: 不動框架,靠 -Xmx / -XX:MaxDirectMemorySize 與人工把各 cache 配置加總控管。

優點

  • ✅ 零改動、零新 API。

缺點

  • ❌ 正是問題陳述的痛點——被動防線,爆了才 OOM;人工加總易漏。
  • ❌ heap 無法以 MB 封頂,預算在 heap 層無意義。

評分: 1


決策 (Decision)

選擇方案: A

核心理由:

  1. 「runtime 跨 cache 共享」在本地 Ehcache 不可行(方案 B);config-time 加總 + per-cache runtime 驅逐是在現有架構下可兌現的最強保護
  2. 讀 Ehcache live config 重算讓「計入外部 cache」免費取得,且消除私有累加器與真實狀態的漂移。
  3. 安全性是 footgun,值得 safe-by-default;既有呼叫端少且早期,破壞性變更可接受。

此決策對 10-java.md「提供工具集而非預設實作」哲學開一個有意識的例外:記憶體安全的預設保護,其價值高於此處的「無預設」彈性;且保留 ungoverned() 出口維持彈性。


權衡分析 (Trade-offs)

我們獲得什麼 (Gains)

  • ✅ CacheManager 下記憶體總量主動封頂(含 heap,以 MB 計)。
  • ✅ 預設即安全;外部 cache 自動計入(byte 池)。
  • ✅ heap 終於可 byte 封頂、與 -Xmx 對齊。

我們放棄什麼 (Losses)

  • ❌ 外部 cache 只能 observe、不能 block;外部筆數 heap 量不出。
  • ❌ byte-sized heap 增加寫入時 sizeof 開銷。
  • ❌ 破壞既有預設行為(governance 預設開、強制 byte heap)。

風險與緩解措施 (Risks & Mitigations)

風險嚴重性機率緩解措施
offheap budget 推導自抓不到的 MaxDirectMemorySize,誤算過大 → OOM四段 fallback(見實作指南);判不出真有限制時走保守 fallback 預算值(單一值,預設 64MB)+ WARN,不拿可能是主機總量的數字算大預算
getTotalMemorySize() 在無 cgroup 限制時回主機總量先確認 cgroup 真有上限(讀 /sys/fs/cgroup/memory.maxmax、非等於主機實體量)才信總量;否則保守
safe-by-default REJECT 在啟動時誤擋合法 cacheheap 用 -Xmx(必讀得到)可 REJECT;offheap 上限不確定時降級 WARN
byte-sized heap sizeof 開銷拖慢寫入提供 sizeof 調參(遍歷深度 / 單一物件上限)合理預設;建議熱寫入大物件用 offheap
外部直接建 cache 繞過管制計入加總(observe);文件明示「最佳實踐:一律經 CacheBuilder

影響 (Consequences)

正面影響

  • ➕ 行程記憶體可由框架層保守封頂,降低 OOM kill 風險。
  • ➕ heap 計量由「筆數 only」擴為「筆數 / MB 二擇一」。

負面影響

  • ➖ 既有用 .heap(entries) 的呼叫點在 governance 下被 REJECT,須 migrate 為 .heapMemory(...)
  • ➖ CHANGELOG 須記 Breaking Changes;版本 bump 偏 MAJOR 性質。

中性影響

  • 🔸 offheap-only / 無 heap tier 的既有 cache 在 governance 下直接通過、不受影響。

實作指南 (Implementation Guidelines)

必須遵守的規則

  1. 兩層強制:per-cache byte 上限(runtime 驅逐)+ manager 加總檢查(config-time,讀 live config 重算)。
  2. safe-by-default:governance 預設開;保留 ungoverned() opt-out。
  3. onExceed 預設 REJECT(拋 IllegalStateException,訊息含「已用 / budget / 本次申請」);WARN 為 opt-in。
  4. 強制 byte heap:governance 啟用時,凡配置 heap tier 的 managed cache 必須 byte 計;筆數 heap → REJECT 並引導改 heapMemory(...)。offheap-only / disk-only(無 heap tier)不受此限。
  5. 加總只計 byte 池:外部 / 筆數 heap 池無法換算 MB → 略過並 WARN 列出。
  6. observe-not-block:外部直接建立的 cache 計入加總、但不攔阻其建立。

API 草案

// CacheManagerBuilder
.governed() // 預設即此;明確表態用
.ungoverned() // opt-out(向後相容/測試/小工具)
.heapBudgetMB(long) // 明示 heap 預算(省略 → 25% of -Xmx)
.offheapBudgetMB(long) // 明示 offheap 預算(省略 → 四段 fallback)
.onExceed(REJECT | WARN) // 預設 REJECT

// CacheBuilder / DualCacheBuilder
.heapMemory(long mb) // heap 以 MB 計(governance 下 heap tier 唯一合法寫法)
.fastHeapMemory(long mb) // DualCacheBuilder 快速層對應

// TierConfiguration:heapEntries 與 heapSizeMB 二擇一(互斥)
// EhcacheConfigMapper:heapSizeMB → heap(mb, MemoryUnit.MB);heapEntries → heap(entries)
// EhcacheCacheManager.createCache:讀 getRuntimeConfiguration().getCacheConfigurations()
// 逐 cache getResourcePools().getPoolForResource(HEAP/OFFHEAP) 之 SizedResourcePool
// getSize()/getUnit() 換算 MB 加總,超 budget → onExceed

預設值推導策略

heap budget 預設 = 25% of Runtime.maxMemory()-Xmx

  • 保守:heap 與應用工作集 + GC 共享,快取應為少數;風險不對稱(太高 → OOM 致命;太低 → 多驅逐、可恢復)。
  • -Xmx 必讀得到 → heap 管制可正常 REJECT

offheap budget 預設 = 四段 fallback(由穩到險,逐段降級)

第1段 明示 offheapBudgetMB ──────────────► 用它
第2段 -XX:MaxDirectMemorySize 明示 ───────► × 75%
第3段 確認 cgroup 真有上限 ───────────────► (total − Xmx − reserve) × 0.9 ← reserve 在此
第4段 完全判不出 ────────────────────────► 固定 fallback 預算值 + WARN ← 環境未知的安全網

四段互斥:前一段成立即採用、不再下降;只有不成立才掉到下一段。

優先序條件動作
1呼叫端明示 offheapBudgetMB直接用(唯一全環境可靠)
2-XX:MaxDirectMemorySize 明示(讀 RuntimeMXBean args)× 75%
3確認 cgroup 真有上限(讀 /sys/fs/cgroup/memory.max,非 max、非等於主機實體量)才信 getTotalMemorySize()總量反推:(total − Xmx − reserve) × 0.9reserve = max(256MB, 20% × total)
4判不出真有限制(unconstrained / 裸機 / 讀不到)固定 fallback 預算值(單一值,預設 64MB,可由 offheapBudgetMB 覆寫) + 大聲 WARN,提示明示 offheapBudgetMB 或設 -XX:MaxDirectMemorySize

定調:最安全的控制是封住「行程總量」而非各抓 -Xmx 比例;不確定性一律轉成保守行為,不拿可能是主機總量的數字算大預算。

第 4 段「固定 fallback 預算值」(預設 64MB)

環境完全未知時不敢推導、只給一個寫死的小額度 + 大聲 WARN——單一數字、非範圍、非「最小值」,功能上是個保守上限。取 64MB 的理由:每個 cache 預設 offheap 20MB(TierConfiguration.defaultConfig()),64MB ≈ 容得下約 3 個,基本可跑;且此段一定伴隨 WARN、預期使用者會去設明確值,故定位為安全網、寧小勿大

第 3 段 reserve 的語意(為何 max(256MB, 20%)

reserve = 從總量扣掉 heap 後,**再扣給「heap 與 offheap 快取以外的原生記憶體」**的安全空間:metaspace、執行緒堆疊、code cache、JIT、GC 結構、框架 direct buffer(如 Netty)。不扣 → 這些被餓死 → OOM kill。

max(256MB, 20% × total) 是兩個保護同時生效:256MB 絕對地板護住小容器(JVM 基本開銷不縮到零);20% 比例護住大容器(開銷隨規模放大)。worked example(Xmx 取容器一半):

容器Xmxreserve = max(256, 20%)offheap = (total−Xmx−reserve)×0.9解讀
512MB256max(256, 102) = 256(512−256−256)×0.9 = 0 → ≤0 → WARN小容器扣掉 heap + 開銷後真的沒空間,正確攔下
2GB1024max(256, 410) = 410(2048−1024−410)×0.9 ≈ 553MB比例 410 勝出
8GB4096max(256, 1638) = 1638(8192−4096−1638)×0.9 ≈ 2212MB大容器開銷按比例放大,地板無作用

reserveheuristic(非精算)——真實開銷取決於執行緒數、載入類別、GC 演算法、所用框架,啟動前無從精算;風險不對稱(扣多 → 快取小一點、可恢復;扣少 → OOM、致命),故保守高估,×0.9 再疊一層邊際。預設 256MB / 20% 可隨部署調整。

檢查清單

  • CacheManagerBuildergoverned/ungoverned/heapBudgetMB/offheapBudgetMB/onExceed
  • CacheBuilder.heapMemory / DualCacheBuilder.fastHeapMemoryTierConfiguration heapEntries/heapSizeMB 互斥
  • EhcacheConfigMapper 依 heap 計量選 overload
  • EhcacheCacheManager.createCache 讀 live config 重算 + onExceed
  • 預設值推導(heap 25% / offheap 四段)+ cgroup 真有上限的判斷
  • 既有 .heap(entries) 呼叫點 migrate 為 .heapMemory(...)
  • 測試:per-cache byte 驅逐封頂、budget REJECT、外部 cache 計入、強制 byte heap、offheap 上限不確定時降級 WARN
  • CHANGELOG.md [Unreleased] 補 Added + Breaking Changes(依 m-changelog-format.md

相關文檔 (References)

內部文檔

  • cache 模組原始碼:appfuse-server io.leandev.appfuse.cache.{api,core,config,builder,adapter}
  • HTTP/Cache/CSV/Content 工具指南:../guides/core/cache.md

相關規範

  • 03-versioning.md(框架層 SemVer)、m-changelog-format.md(CHANGELOG 與 Breaking Changes 格式)、30-public-api.md(公開 API 範圍觸發 CHANGELOG)
  • 10-java.md(「工具集而非預設」哲學——本案 safe-by-default 為有意識例外)

外部資源


變更歷史 (Change Log)

日期變更內容變更者
2026-06-22初版草稿(提議中):兩層記憶體預算管制、safe-by-default、heap 25% / offheap 四段 fallback、強制 byte heap、onExceed REJECT、計入外部 cache(observe-not-block)。向後相容已豁免(既有呼叫端少、早期,最後統一 migrate)Development Team + AI
2026-06-22釐清 offheap 預設推導:補四段 fallback 定位流程圖;第 4 段「保守絕對下限」正名為「固定 fallback 預算值」(單一值、預設 64MB、非範圍非最小值);補第 3 段 reserve 語意與 max(256MB, 20%) 的 worked example(小容器靠地板、大容器靠比例)Development Team + AI
2026-06-22實作落地、狀態 → 已接受:新增 OnExceedMemoryBudgetMemoryBudgetResolverCacheManagerBuilder governance(governed/ungoverned/heapBudgetMB/offheapBudgetMB/onExceed)、CacheBuilder.heapBytes / DualCacheBuilder.fastHeapBytesTierConfiguration.heapSizeheapEntries+heapSizeMBEhcacheCacheManager 讀 live config 加總強制;CHANGELOG 補 Added + Breaking Changes;框架內部 CachingAddressClient 物化閘 migrate 為 heapBytes(8)。新增 MemoryBudgetResolverTest / CacheMemoryBudgetTest,appfuse-server 全測試 527 通過Development Team + AI
2026-06-22API 命名修正(未發版,僅 [Unreleased]/SNAPSHOT,零下游成本):CacheBuilder.heapBytesheapMemoryDualCacheBuilder.fastHeapBytesfastHeapMemory。理由:原名 heapBytes(long sizeMB) 名稱宣稱 byte、參數卻是 MB,名實不符;heapMemory 命名「按記憶體量計」之模式(對比 heap(entries) 的筆數計),單位交由參數名 sizeMB + Javadoc 承載,與 offheap(mb)disk(mb) 不在方法名編入單位的慣例一致Development Team + AI

文檔維護者: Development Team + AI Assistant 最後審閱: 2026-06-22