Skip to content

[Memory leak] SessionAffinityMap 保存全量 instructions 导致 RSS 持续上涨 #577

@zhezzma

Description

@zhezzma

问题描述

服务长时间运行后,Node 进程的 RSS 持续上涨,GC 无法回收,最终触发 OOM 或被容器/守护进程杀掉重启。

定位到主要原因是 src/auth/session-affinity.ts 里的 SessionAffinityMap:每次请求成功完成都会往这个进程级单例 Map 中写入一条记录,并把整段 instructions(也就是 system prompt)原样保存

触发位置

写入侧(每次成功响应都会触发一次):

  • src/routes/shared/streaming-handler.ts:80
  • src/routes/shared/non-streaming-affinity.ts:32
affinityMap.record(
  capturedResponseId,
  capturedEntryId,
  conversationId,
  turnState,
  req.codexRequest.instructions ?? undefined,  // ← 整段 system prompt
  usageInfo?.input_tokens,
  Array.from(metadataCollector.responseFunctionCallIds),
  variantHash,
);

记录在内存里保留 DEFAULT_TTL_MS = 4 * 60 * 60 * 1000(4 小时),10 分钟扫一次过期。

量级估算

Codex 的 system prompt 通常在 10–50 KB 之间(包含工具描述时更大)。以一个比较常见的负载估算:

  • 50 req/min × 60 × 4h = 12,000 条 / 4 小时
  • 单条按 30 KB 估算:≈ 360 MB 常驻内存

数据全在 V8 老生代里,触发 Full GC 也无法回收(仍在 TTL 内),实际表现就是 RSS 单调上涨直到进程被杀。

关键观察

instructions 字段的所有真实使用场景都只是做字符串相等比较:

  • src/routes/shared/proxy-session-context.ts:79
    normalizeInstructions(currentInstructions) === normalizeInstructions(implicitStoredInstructions)
  • lookupInstructions / lookupLatestInstructionsByConversationId 的下游消费者也只用于比较

也就是说,完全没有必要保存原文,保存一个稳定 hash 就足够。

影响

  • 长跑实例必须靠定时重启兜底
  • lookupLatestResponseIdByConversationId 是 O(N) 全表扫描,Map 越大每次代理请求越慢
  • 小内存机器(512 MB / 1 GB 容器)几乎不可用

建议修法

instructions 字段改存 SHA-256 截断后的短 hash(约 22 字节),lookup 端同样做 hash 再比较。语义等价,体积压缩 99% 以上:

import { createHash } from "crypto";

function hashInstructions(s: string | undefined | null): string | undefined {
  if (!s) return undefined;
  return createHash("sha256").update(s).digest("base64url").slice(0, 22);
}

// record() 内:
instructions: hashInstructions(instructions),

// 比较侧(proxy-session-context.ts):
hashInstructions(normalizeInstructions(currentInstructions)) === entry.instructions

可选的额外加固:

  1. DEFAULT_TTL_MS 从 4h 收紧到 1h(上游 prompt cache 实际寿命远短于 4h,超过 1h 的 affinity 命中率极低,留 1h 已经足够)
  2. 给 Map 加 LRU 硬顶(例如 5000 条),用 Map 自带的插入顺序淘汰最早项,防止极端流量下失控
  3. lookupLatestResponseIdByConversationId 建一个 Map<conversationId, Set<responseId>> 二级索引,去掉 O(N) 全表扫

环境

  • 版本:v2.0.75
  • Node:18+
  • 部署:Docker / Linux

如果维护者方便,我可以提 PR。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions