news 2026/9/10 11:51:38

Sim 进程内缓存实践:用 lru-cache、fetchMethod 与 coalesceLocally 构建正确的 TTL 缓存

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Sim 进程内缓存实践:用 lru-cache、fetchMethod 与 coalesceLocally 构建正确的 TTL 缓存

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-cachemax/ttl约束内存、如何用fetchMethodcoalesceLocally做异步读穿透,以及凭据与权限闸门的缓存边界。读完本文,你可以掌握在长驻 Node.js 进程中设计 TTL 缓存的完整决策树——从"这到底是不是缓存"到失效策略,并获得 Sim 仓库中每一处规则对应的真实源码佐证。

先判断:这根本就不是缓存

Sim 规范的第一条判断是:代码库里大多数模块级Map不是缓存,强行把它们改造成缓存比放任不管更糟。Sim 将模块级状态分为两类形态:

形态Key 何时死亡正确工具
生命周期 Map(如activeStreamspendingChildRunsmemoryStreamshandlerRegistry被追踪的对象结束,且代码在那里删除它普通Map。无 TTL、无上限
TTL 缓存(以租户 org id / user id / workspace id 为 key 的远程读取)时间流逝LRUCache

生命周期 Map 的 key 空间是无界的,这没有问题,因为每个 key 都有明确的死亡时刻。给它加 TTL 会引入一个与生命周期竞态的过期;加上限则会悄悄丢弃存活状态。所以判断标准很简单:key 是否有定义明确的删除点。有,就是生命周期 Map;没有、只能靠时间过期,才是 TTL 缓存。

TTL 缓存:必须同时设置max

lru-cacheapps/sim的直接依赖(apps/sim/package.json 中声明lru-cache: 11.3.6),它同时负责过期、容量上限,以及通过fetchMethod实现的请求合并。用MapDate.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,或被上限逐出。真正给进程封顶的是maxgetSessionPolicy的注释里写得很直白:这个缓存"每次会话创建和刷新都要读、按组织为 key,因此一个无界的Map会随进程存活时间一直增长"(apps/sim/lib/auth/session-policy.ts#L24-L37)。
  • 上限是内存兜底,不是运行阈值。超过它会让 LRU 在 TTL 之内就逐出条目,于是每次未命中都变成一次真实读取——答案永远不会错,只是退化回没有缓存时的行为;但它会让缓存所在路径的命中率出现断崖。条目只有几十字节,所以应把上限设得远高于 TTL 窗口内任何合理的单实例工作集,让它始终保持"兜底"姿态。
  • 读取用!== undefined判断,而不是真值判断。当缓存值可能是false0null时,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_timeoutconnect_timeout,这两者都不约束一个已经执行中的查询。在这种环境下,一个卡死的读取会让所有调用方在整个 TTL 期间都被挂住。此时的替代方案是用@/lib/concurrency/singleflightcoalesceLocally包住一个读穿透式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 中这么调用的。
  • 有人在等待的地方,读新鲜数据。保留两个入口而不是一个带缓存的函数:设置页和管理类用例绝不能告诉一个刚升级的组织它还没有套餐,而下方的执行路径可以从缓存取(isOrganizationBYOKEntitledisOrganizationBYOKEntitledCached这对函数就是如此拆分的)。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 这条规则文档压缩成决策树,就是:

  1. key 有明确死亡点 → 普通Map,不加 TTL 也不加上限;
  2. 否则用LRUCachemaxttl同时设置,max是内存兜底而非运行阈值,读侧用!== undefined判断;
  3. 异步读穿透优先fetchMethod;存在挂死读取风险(且数据库无statement_timeout兜底)时,用coalesceLocally+ 读穿透式LRUCache,且缓存写放在调用方一侧;
  4. 缓存闸门不缓存凭据,故障走onError: 'throw'不落缓存;
  5. 面向人的路径读新鲜数据,面向执行的走缓存;
  6. 失效器只在同进程读写时添加,跨进程的即时性由 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 11:51:05

锂电池仿真:单RC等效电路模型与参数辨识实践解析

简介&#xff1a;这是一份基于MATLAB/Simulink环境的锂电池仿真学习资源&#xff0c;面向电池建模、状态估计、参数辨识以及电池管理系统相关方向的工程师和高校学生。压缩包共19个文件&#xff0c;以slx仿真模型、m初始化脚本、mat数据文件和png/jpg结果图片为主&#xff0c;整…

作者头像 李华
网站建设 2026/9/10 11:50:57

GE图引擎CreateBoolTensor API文档

CreateBoolTensor 【免费下载链接】ge GE&#xff08;Graph Engine&#xff09;是面向昇腾的图编译器和执行器&#xff0c;提供了计算图优化、多流并行、内存复用和模型下沉等技术手段&#xff0c;加速模型执行效率&#xff0c;减少模型内存占用。 GE 提供对 PyTorch、TensorFl…

作者头像 李华
网站建设 2026/9/10 11:50:05

SpringBoot马拉松赛事管理系统开发实践

1. 项目概述马拉松赛事管理系统是基于SpringBoot框架开发的赛事管理平台&#xff0c;主要解决赛事报名、成绩统计、选手管理等核心业务场景。这个系统特别适合中小型马拉松赛事组委会使用&#xff0c;能够有效降低人工管理成本&#xff0c;提升赛事运营效率。我在实际开发中发现…

作者头像 李华
网站建设 2026/9/10 11:46:57

MySQL视图:原理、创建与性能优化实战

1. 视图的本质与核心价值MySQL视图本质上是一个虚拟表&#xff0c;其内容由查询定义。与物理表不同&#xff0c;视图不存储实际数据&#xff0c;而是通过保存的SQL查询语句动态生成结果集。这个特性带来了几个独特优势&#xff1a;逻辑抽象层&#xff1a;视图可以隐藏底层表的复…

作者头像 李华
网站建设 2026/9/10 11:46:42

中文票据OCR实战:OpenCV预处理+tesseract字段提取

简介&#xff1a;本资源是一套基于PythonOpenCVtesseract实现的中文扫描票据OCR识别完整项目&#xff0c;面向计算机、软件工程、人工智能等专业的本科生及课程设计/毕业设计实践者&#xff0c;解决纸质票据图像预处理、文字定位与高准确率中文识别等典型CV应用问题。压缩包共1…

作者头像 李华
网站建设 2026/9/10 11:44:45

结构化类型系统深入解析:从TypeScript到Go的判型规则与避坑指南

1. 结构化类型系统到底是什么&#xff0c;为什么它总在角落里搞事情 前几天在群里看到有人问“什么是结构化类型系统”&#xff0c;底下回答五花八门&#xff0c;有说就是鸭子类型的&#xff0c;有说就是 TypeScript 的接口的&#xff0c;还有说 Go 的结构体就是结构化类型系统…

作者头像 李华