Skip to content

深入調查:loadSessionStore() 在 cron 頻繁執行下的 I/O 開銷 #348

@thepagent

Description

@thepagent

背景

OpenClaw gateway 在多處呼叫 loadSessionStore(),每次都需讀取並解析整個 sessions.json
隨著 cron job 累積,sessions.json 持續膨脹(目前已達 628KB、96 個 entries),造成可觀的 I/O 與 CPU 解析開銷。

流程圖

每次收到訊息 / cron 執行時:

┌─────────────────────────────────────────────────────────┐
│  loadSessionStore(storePath)                            │
│                                                         │
│  ┌─────────────────────────────────────────────────┐   │
│  │  cache 是否有效?(TTL = 45 秒)                │   │
│  └──────────────┬──────────────────────────────────┘   │
│                 │                                       │
│        Yes ─────┤──── 直接返回記憶體 cache ✅           │
│                 │                                       │
│        No  ─────▼                                       │
│  ┌─────────────────────────────────────────────────┐   │
│  │  readFileSync(sessions.json)  ← SSD 極快,幾乎可忽略  │   │
│  │  JSON.parse(628KB)            ← ⚠️ CPU bound,同步阻塞 event loop │   │
│  └─────────────────────────────────────────────────┘   │
│                                                         │
│  ⚠️  cron job 呼叫時帶 skipCache: true                  │
│     → 強制繞過 cache,每次都重新讀取                    │
└─────────────────────────────────────────────────────────┘

關鍵程式碼

TTL cache 定義(45 秒)

https://github.com/openclaw/openclaw/blob/61d171ab0b2fe4abc9afe89c518586274b4b76c2/src/config/sessions/store.ts#L52

cache miss 時同步讀取 + 解析

https://github.com/openclaw/openclaw/blob/61d171ab0b2fe4abc9afe89c518586274b4b76c2/src/config/sessions/store.ts#L227-L233

每次收到訊息時強制 skipCache: true

https://github.com/openclaw/openclaw/blob/61d171ab0b2fe4abc9afe89c518586274b4b76c2/src/auto-reply/reply/session.ts#L201-L202

cron session-reaper 強制 skipCache: true

https://github.com/openclaw/openclaw/blob/61d171ab0b2fe4abc9afe89c518586274b4b76c2/src/cron/session-reaper.ts#L113

cron isolated-agent 每次執行都讀取

https://github.com/openclaw/openclaw/blob/61d171ab0b2fe4abc9afe89c518586274b4b76c2/src/cron/isolated-agent/session.ts#L23

skillsSnapshot 型別定義

https://github.com/openclaw/openclaw/blob/61d171ab0b2fe4abc9afe89c518586274b4b76c2/src/config/sessions/types.ts#L171

sessions.json 膨脹原因

每個 cron session entry 都帶有 skillsSnapshot(完整技能 prompt 文字),
導致 sessions.json 體積隨 cron 數量線性成長:

sessions.json
├── agent:main:cron:xxxx  → { skillsSnapshot: { prompt: "...幾千字..." } }
├── agent:main:cron:yyyy  → { skillsSnapshot: { prompt: "...幾千字..." } }
├── agent:main:cron:zzzz  → { skillsSnapshot: { prompt: "...幾千字..." } }
└── ... × 90+ 個 cron entries

注意:skillsSnapshot 不會注入 LLM context,純粹是 sessions.json 的體積負擔。

瓶頸性質:CPU bound,非 I/O bound

現代 SSD 讀取 628KB 約 0.1–0.5ms,I/O 本身幾乎可忽略。
真正的問題是 JSON.parse(628KB)同步操作,會完全佔用 Node.js event loop,
期間所有其他請求(訊息、cron、heartbeat)全部排隊等待。

readFileSync(628KB)   ← SSD 極快,~0.1ms,不是瓶頸

JSON.parse(628KB)     ← 同步 CPU,阻塞 event loop
                         sessions.json 越大越慢
                         cron skipCache: true → 每次都重跑
                         ↑ 這才是真正的累積開銷來源

影響

呼叫場景 cache 行為 實際開銷
一般對話(45秒內) 命中 cache 幾乎無
45秒 cache 過期後 cache miss JSON.parse(628KB),CPU 同步阻塞 event loop
cron job 執行 skipCache: true 每次都重跑 JSON.parse,CPU bound
heartbeat / subagent 視 TTL 而定 不定

自動清除機制(Purge)

每次 saveSessionStore() 時觸發(非 skipMaintenance 模式),有三層清除:

┌─────────────────────────────────────────────────────────┐
│  1. pruneStaleEntries()                                 │
│     └─ 刪除 updatedAt 超過 N 天的 entries              │
│        預設:30 天                                      │
│        可設定:config.maintenance.pruneAfter = "7d"     │
│                                                         │
│  2. capEntryCount()                                     │
│     └─ 超過上限時刪除最舊的 entries                    │
│        預設:500 條                                     │
│                                                         │
│  3. rotateSessionFile()                                 │
│     └─ sessions.json 超過閾值時輪替備份                │
│        預設:10MB                                       │
└─────────────────────────────────────────────────────────┘

關鍵程式碼:預設值

https://github.com/openclaw/openclaw/blob/61d171ab0b2fe4abc9afe89c518586274b4b76c2/src/config/sessions/store-maintenance.ts#L12-L14

問題:預設閾值過於寬鬆

目前狀態:
  entries:96 條  → 遠低於 500 上限,capEntryCount 不觸發
  大小:628KB     → 遠低於 10MB,rotateSessionFile 不觸發
  最舊 entry:    → 若在 30 天內,pruneStaleEntries 不觸發

→ 短期內三層機制全部不會自動清理
→ sessions.json 持續膨脹,JSON.parse 開銷持續累積

最嚴重的場景:每條訊息都強制 parse

auto-reply/reply/session.ts 在每次收到訊息時強制 skipCache: true
45 秒 TTL cache 對這條路徑完全無效

https://github.com/openclaw/openclaw/blob/61d171ab0b2fe4abc9afe89c518586274b4b76c2/src/auto-reply/reply/session.ts#L201-L202

原因(見 #17971):避免多 gateway process 或 Windows mtime 精度問題導致 sessionId 錯誤。

每條訊息的處理路徑:

  收到訊息
     │
     ▼
  loadSessionStore(skipCache: true)
     │
     ├─ readFileSync(sessions.json)   ← 每次都讀
     └─ JSON.parse(sessions.json)     ← 每次都 parse(CPU bound)
                                         499 entries ≈ 數 MB
                                         event loop 阻塞時間隨檔案大小線性成長

  45 秒 TTL cache → 對收訊息路徑無效
  只對 heartbeat / subagent 等其他呼叫有效

結論:sessions.json 越大,每條訊息的延遲越高,無法靠 cache 緩解。

建議解法

問題核心:每條訊息都 JSON.parse 整個 sessions.json

┌─────────────────────────────────────────────────────────┐
│  方案 A:只讀單一 entry(Partial Load)                 │
│                                                         │
│  不讀整個 sessions.json,改用 sqlite 或                 │
│  每個 session 獨立一個小 JSON 檔                        │
│  → 讀取量從 O(全部) 降為 O(1)                          │
│  → 改動最大,需重構儲存層                               │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│  方案 B:移除 skillsSnapshot(最快見效)                │
│                                                         │
│  cron entry 不需要帶完整技能快照                        │
│  → sessions.json 體積大幅縮小                          │
│  → parse 時間直接下降                                   │
│  → 改動小,風險低                                       │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│  方案 C:收訊息路徑改用 mtime 驗證取代 skipCache        │
│                                                         │
│  skipCache: true 的目的是確保 sessionId 正確            │
│  改為:比對 sessions.json 的 mtime                      │
│  若 mtime 未變 → 用 cache                              │
│  若 mtime 改變 → 重新讀取                               │
│  → 多 process 安全,又避免不必要的 parse               │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│  方案 D:調低 pruneAfter / maxEntries(治標)           │
│                                                         │
│  config.maintenance.pruneAfter = "7d"                   │
│  config.maintenance.maxEntries = 100                    │
│  → 限制 sessions.json 體積上限                         │
│  → 不解決根本問題,但可控制損害範圍                     │
└─────────────────────────────────────────────────────────┘

優先順序建議:

  • 短期 → D(調低閾值)+ B(移除 skillsSnapshot)
  • 長期 → C(mtime 驗證)或 A(sqlite)

待深入調查的方向

  • skipCache: true 在 cron 路徑是否必要?能否改為允許短暫 cache?
  • skillsSnapshot 是否應從 sessions.json 中移除或壓縮?
  • 是否可對 sessions.json 做 lazy partial load(只讀取當前 session entry)?
  • 調低預設閾值:pruneAfter: "7d"maxEntries: 100
  • 是否提供 openclaw sessions cleanup 指令讓使用者手動清理舊 cron entries?

sessions.json vs .jsonl 的關係

sessions.json(索引)
  └─ agent:main:main → sessionId: "abc123"   ← 每個 channel/cron 一條 entry
  └─ agent:main:cron:xxxx → sessionId: "def456"
  └─ ...(共 96 條)

sessions/*.jsonl(對話內容)
  └─ abc123.jsonl   ← agent:main:main 的完整對話歷史
  └─ def456.jsonl   ← cron job 的完整執行歷史
  └─ ...(共 321 個檔案,總計 41MB)

每個 .jsonl 的格式(每行一個事件):
  {"type":"message","message":{"role":"user","content":"..."}}
  {"type":"message","message":{"role":"assistant","content":"..."}}
  {"type":"tool_use","name":"execute_bash","input":{...}}
  {"type":"tool_result","content":"..."}
  ...

/new 指令的行為:

/new 觸發時:

  sessions.json 的 agent:main:main
    → sessionId 更新為新值(舊值覆蓋,sessions.json 不變大)

  磁碟上:
    ├── abc123.jsonl          ← 舊對話,封存保留,不刪除
    ├── abc123.jsonl.reset.*  ← 輪替備份
    └── def456.jsonl          ← 新 session,從空白開始

  舊 .jsonl 靠 pruneStaleEntries 30 天後才清理

載入規則:只讀當前 active session 的 .jsonl,其餘 320 個全部跳過。

環境資訊

  • sessions.json 大小:628KB
  • session entries 數量:96
  • .jsonl 檔案數量:321 個(總計 41MB)
  • DEFAULT_SESSION_STORE_TTL_MS:45,000ms

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions