Sim 进程内缓存实践:用 lru-cache、fetchMethod 与 coalesceLocally 构建正确的 TTL 缓存
【免费下载链接】simSim is the collaborative workspace to build, deploy, and monitor AI agents and workflows. Used by 100,000+ builders.项目地址: https://gitcode.com/GitHub_Trending/sim16/sim
Sim 的.claude/rules/sim-caching.md是一份面向执行路径(executor、tools、providers)的进程内缓存规范:它规定了何时该用缓存、如何用lru-cache的max/ttl约束内存、如何用fetchMethod或coalesceLocally做异步读穿透,以及凭据与权限闸门的缓存边界。读完本文,你可以掌握在长驻 Node.js 进程中设计 TTL 缓存的完整决策树——从"这到底是不是缓存"到失效策略,并获得 Sim 仓库中每一处规则对应的真实源码佐证。
先判断:这根本就不是缓存
Sim 规范的第一条判断是:代码库里大多数模块级Map不是缓存,强行把它们改造成缓存比放任不管更糟。Sim 将模块级状态分为两类形态:
| 形态 | Key 何时死亡 | 正确工具 |
|---|---|---|
生命周期 Map(如activeStreams、pendingChildRuns、memoryStreams、handlerRegistry) | 被追踪的对象结束,且代码在那里删除它 | 普通Map。无 TTL、无上限 |
| TTL 缓存(以租户 org id / user id / workspace id 为 key 的远程读取) | 时间流逝 | LRUCache |
生命周期 Map 的 key 空间是无界的,这没有问题,因为每个 key 都有明确的死亡时刻。给它加 TTL 会引入一个与生命周期竞态的过期;加上限则会悄悄丢弃存活状态。所以判断标准很简单:key 是否有定义明确的删除点。有,就是生命周期 Map;没有、只能靠时间过期,才是 TTL 缓存。
TTL 缓存:必须同时设置max
lru-cache是apps/sim的直接依赖(apps/sim/package.json 中声明lru-cache: 11.3.6),它同时负责过期、容量上限,以及通过fetchMethod实现的请求合并。用Map加Date.now() - entry.fetchedAt < TTL手写这三件事,只会把三件事都做坏。
规范的典型写法(与 lib/auth/session-policy.ts 中真实的policyCache一致):
const policyCache = new LRUCache<string, ResolvedSessionPolicy>({ max: 20_000, ttl: SESSION_POLICY_CACHE_TTL_MS, })几个关键结论,均有源码佐证:
ttl本身不约束内存。在没有ttlAutopurge的情况下(它本身昂贵——每个 entry 一个定时器),过期条目会一直滞留,直到有调用触碰到它的 key,或被上限逐出。真正给进程封顶的是max。getSessionPolicy的注释里写得很直白:这个缓存"每次会话创建和刷新都要读、按组织为 key,因此一个无界的Map会随进程存活时间一直增长"(apps/sim/lib/auth/session-policy.ts#L24-L37)。- 上限是内存兜底,不是运行阈值。超过它会让 LRU 在 TTL 之内就逐出条目,于是每次未命中都变成一次真实读取——答案永远不会错,只是退化回没有缓存时的行为;但它会让缓存所在路径的命中率出现断崖。条目只有几十字节,所以应把上限设得远高于 TTL 窗口内任何合理的单实例工作集,让它始终保持"兜底"姿态。
- 读取用
!== undefined判断,而不是真值判断。当缓存值可能是false、0或null时,if (cached)会把缓存的false当成未命中,每次调用都重新查询——恰恰是对缓存最想保护的那些租户。lib/api-key/byok-entitlement.ts 的注释专门强调了这一点,其实现就是if (cached !== undefined) return cached(apps/sim/lib/api-key/byok-entitlement.ts#L57-L85)。
异步读穿透:优先fetchMethod,但有一个例外
fetchMethod+cache.fetch(key)在一个原语里提供三件事:TTL、请求合并(并发调用方共享同一个 promise)、以及拒绝时逐出(noDeleteOnFetchRejection默认为false)。规范建议:在任何自己组合读穿透逻辑之前,先考虑这个原语。
唯一需要自己组合的理由:挂死的生产者(hung producer)。fetchMethod没有 settle 截止时间,而 Sim 的应用数据库连接池没有设置statement_timeout——从 packages/db/db.ts 的池配置可以看到只设置了idle_timeout和connect_timeout,这两者都不约束一个已经执行中的查询。在这种环境下,一个卡死的读取会让所有调用方在整个 TTL 期间都被挂住。此时的替代方案是用@/lib/concurrency/singleflight的coalesceLocally包住一个读穿透式LRUCache——它会在自己的截止时间上逐出并拒绝。
coalesceLocally的实现值得细看(apps/sim/lib/concurrency/singleflight.ts):
- 进程内用一张
inflightMap 按 key 去重:第一个调用方执行fn,同 key 的并发调用方共享其 promise; - 默认 settle 截止时间为 30 秒(
DEFAULT_SETTLE_TIMEOUT_MS),超时后条目先被逐出、所有等待者收到CoalesceSettleTimeoutError,下一个调用方会创建新的生产者,而不是加入挂死的那个; - 关键的诚实细节:底层
fn在超时时不会被取消,它继续以分离状态运行,只是没有新的调用方会加入它。这一语义直接影响了上层缓存写入的位置——见下文byok-entitlement.ts的注释:写缓存必须放在调用方拿到值的这一侧,而不是生产者内部,否则一个超时后被遗弃的生产者可能在重试已经缓存了更新答案之后才 resolve,把新答案覆盖掉整整一个 TTL。
规范同时明确警告:不要在lru-cache之上再封一层自研 wrapper。因为调用点的需求差异是一个薄助手无法容纳的——providers/client-cache.ts 是同步记忆化(updateAgeOnGet: true让 TTL 变成基于空闲时间:持续使用的 provider 客户端保持 warm 连接,空闲 key 自然老化,max: 1_000、TTL 30 分钟);lib/auth/security-policy.ts 则按条目设置 TTL(membershipCache.set(userId, value, { ttl: ... }))。一个只覆盖常见情况的 wrapper 只会变成第四种模式。
缓存"闸门",绝不缓存凭据
这是规范中最有业务语义的一条规则:
- 权限、套餐、策略这类"闸门"可以容忍有界的陈旧——而且陈旧方向是安全的:一个已失效的组织在最多一个 TTL 内继续用自己的 provider key,只是多花一点计量费,不会错收任何人的钱。但 key 材料不行:吊销必须立即生效。所以
getBYOKKey每次调用都重新读取 key 行,只在其外围缓存权限。byok-entitlement.ts 顶部的注释原样记录了这一权衡:ORGANIZATION_BYOK_ENTITLEMENT_TTL_MS设为 60 秒,与SESSION_POLICY_CACHE_TTL_MS对齐。 - 故障不能被缓存成否定答案。一个把读取失败映射为
false的解析器,会让"故障"与"真实失效"无法区分。解法是给解析器一个onError: 'throw'选项,只在成功路径写缓存。resolveOrganizationPlan(organizationId, { onError: 'throw' })正是 byok-entitlement.ts 中这么调用的。 - 有人在等待的地方,读新鲜数据。保留两个入口而不是一个带缓存的函数:设置页和管理类用例绝不能告诉一个刚升级的组织它还没有套餐,而下方的执行路径可以从缓存取(
isOrganizationBYOKEntitled与isOrganizationBYOKEntitledCached这对函数就是如此拆分的)。byok-entitlement.ts 的注释解释了这个拆分的动因:getBYOKKey每个 agent block 跑一次、每次可托管工具调用跑一次,工作流循环 N 项就要解析 N 次;新鲜读取在组织继承场景下要付出三个串行计费查询,而缓存版把稳态成本降到零。
Reactcache()在 worker 里什么都不是
cache()是请求作用域的。Sim 的工作流运行在 Trigger.dev worker 里,那里没有 React 请求作用域——所以一个在设置页上看起来"免费"的cache()包裹闸门,在执行路径上其实是未缓存的、逐 block 的。任何从 executor 可达的读取都需要真实缓存(即上文LRUCache路线)。规范将此归入 app/worker 运行时边界的总原则(参见 apps/sim/executor 目录下的执行引擎代码所处的进程模型)。
失效(Invalidation):只在同进程读写时才加
规范的最后一节回答了"什么时候该写invalidate*Cache":
- 只有当修改值的代码与读取它的代码跑在同一个进程时,才加按 key 的失效器。
invalidateSessionPolicyCache(apps/sim/lib/auth/session-policy.ts#L88-L91)成立的原因正是:写策略的路由(apps/sim/app/api/organizations/[id]/session-policy/route.ts)与提供读取的是同一进程。 - 反之,权限变更通过 Stripe webhook 到达,落在某一个进程,而读取方分散在各自 worker 中——在那里加失效器只会暗示一种它根本无法兑现的即时性。TTL 才是真正的机制,而"没有失效器"这一事实应当被代码注释明确说出来。byok-entitlement.ts 的
resetOrganizationBYOKEntitlementCache注释就是这样自证的:它只提供全量清空(且仅作为测试接缝),并逐句解释了为什么刻意不提供按组织的失效器。
小结:一条可复用的缓存决策清单
把 Sim 这条规则文档压缩成决策树,就是:
- key 有明确死亡点 → 普通
Map,不加 TTL 也不加上限; - 否则用
LRUCache,max与ttl同时设置,max是内存兜底而非运行阈值,读侧用!== undefined判断; - 异步读穿透优先
fetchMethod;存在挂死读取风险(且数据库无statement_timeout兜底)时,用coalesceLocally+ 读穿透式LRUCache,且缓存写放在调用方一侧; - 缓存闸门不缓存凭据,故障走
onError: 'throw'不落缓存; - 面向人的路径读新鲜数据,面向执行的走缓存;
- 失效器只在同进程读写时添加,跨进程的即时性由 TTL 承担,并注释说明。
这套模式在 Sim 中不是孤例,而是可交叉验证的一族:providers/client-cache.ts(同步记忆化)、lib/auth/session-policy.ts(TTL + 按 key 失效)、lib/api-key/byok-entitlement.ts(coalesceLocally+ 成功路径写入)、lib/oauth/credential-service.ts(同一coalesceLocally形态)与 lib/auth/security-policy.ts(按条目 TTL),共同构成了这条缓存规则在仓库中的完整证据链。
【免费下载链接】simSim is the collaborative workspace to build, deploy, and monitor AI agents and workflows. Used by 100,000+ builders.项目地址: https://gitcode.com/GitHub_Trending/sim16/sim
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考