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 的記憶體總量。後果:
- 一個 CacheManager 開了 N 個 cache,總用量 ≈ 各 cache 配置加總,無上限、無統一回收。
- 光靠
-Xmx/-XX:MaxDirectMemorySize是被動防線——爆了才 OOM,而非主動封頂。 - 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
核心理由:
- 「runtime 跨 cache 共享」在本地 Ehcache 不可行(方案 B);config-time 加總 + per-cache runtime 驅逐是在現有架構下可兌現的最強保護。
- 讀 Ehcache live config 重算讓「計入外部 cache」免費取得,且消除私有累加器與真實狀態的漂移。
- 安全性是 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.max 非 max、非等於主機實體量)才信總量;否則保守 |
| safe-by-default REJECT 在啟動時誤擋合法 cache | 中 | 中 | heap 用 -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)
必須遵守的規則
- 兩層強制:per-cache byte 上限(runtime 驅逐)+ manager 加總檢查(config-time,讀 live config 重算)。
- safe-by-default:governance 預設開;保留
ungoverned()opt-out。 onExceed預設REJECT(拋IllegalStateException,訊息含「已用 / budget / 本次申請」);WARN為 opt-in。- 強制 byte heap:governance 啟用時,凡配置 heap tier 的 managed cache 必須 byte 計;筆數 heap → REJECT 並引導改
heapMemory(...)。offheap-only / disk-only(無 heap tier)不受此限。 - 加總只計 byte 池:外部 / 筆數 heap 池無法換算 MB → 略過並 WARN 列出。
- 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.9;reserve = 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 取容器一半):
| 容器 | Xmx | reserve = max(256, 20%) | offheap = (total−Xmx−reserve)×0.9 | 解讀 |
|---|---|---|---|---|
| 512MB | 256 | max(256, 102) = 256 | (512−256−256)×0.9 = 0 → ≤0 → WARN | 小容器扣掉 heap + 開銷後真的沒空間,正確攔下 |
| 2GB | 1024 | max(256, 410) = 410 | (2048−1024−410)×0.9 ≈ 553MB | 比例 410 勝出 |
| 8GB | 4096 | max(256, 1638) = 1638 | (8192−4096−1638)×0.9 ≈ 2212MB | 大容器開銷按比例放大,地板無作用 |
reserve 為 heuristic(非精算)——真實開銷取決於執行緒數、載入類別、GC 演算法、所用框架,啟動前無從精算;風險不對稱(扣多 → 快取小一點、可恢復;扣少 → OOM、致命),故保守高估,×0.9 再疊一層邊際。預設 256MB / 20% 可隨部署調整。
檢查清單
-
CacheManagerBuilder加governed/ungoverned/heapBudgetMB/offheapBudgetMB/onExceed -
CacheBuilder.heapMemory/DualCacheBuilder.fastHeapMemory;TierConfigurationheapEntries/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-serverio.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 為有意識例外)
外部資源
- Ehcache 3 Tiering — heap/offheap/disk、byte-sized heap、金字塔大小、共享池限制
- Java 17: OpenJDK container awareness — Red Hat
- JDK-8228428: OperatingSystemMXBean should be made container aware
- JDK-8292083: Detected container memory limit may exceed physical machine memory
變更歷史 (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 | 實作落地、狀態 → 已接受:新增 OnExceed、MemoryBudget、MemoryBudgetResolver,CacheManagerBuilder governance(governed/ungoverned/heapBudgetMB/offheapBudgetMB/onExceed)、CacheBuilder.heapBytes / DualCacheBuilder.fastHeapBytes,TierConfiguration.heapSize→heapEntries+heapSizeMB,EhcacheCacheManager 讀 live config 加總強制;CHANGELOG 補 Added + Breaking Changes;框架內部 CachingAddressClient 物化閘 migrate 為 heapBytes(8)。新增 MemoryBudgetResolverTest / CacheMemoryBudgetTest,appfuse-server 全測試 527 通過 | Development Team + AI |
| 2026-06-22 | API 命名修正(未發版,僅 [Unreleased]/SNAPSHOT,零下游成本):CacheBuilder.heapBytes → heapMemory、DualCacheBuilder.fastHeapBytes → fastHeapMemory。理由:原名 heapBytes(long sizeMB) 名稱宣稱 byte、參數卻是 MB,名實不符;heapMemory 命名「按記憶體量計」之模式(對比 heap(entries) 的筆數計),單位交由參數名 sizeMB + Javadoc 承載,與 offheap(mb)/disk(mb) 不在方法名編入單位的慣例一致 | Development Team + AI |
文檔維護者: Development Team + AI Assistant 最後審閱: 2026-06-22