25 · NestJs DurableProviders 持久化 Provider:把 ContextId 当缓存键

承接上篇 :上一篇《LazyLoadingModules》讲的是"模块什么时候进图";这篇回到 22 期埋的最后一个尾巴------REQUEST 作用域的性能账 。22 期讲过 REQUEST 沿依赖链向上冒泡,还留了钩子"多租户想复用请求级子树 → durable(25 期)";23 期的 resolve/contextId 也预告过这套 ContextId 体系。 这篇把它展开成全文:默认 REQUEST 为什么"每请求重建"、ContextIdStrategy 怎么把"随机键"归一化成"租户稳定键"、durable 的传播规则------连"串数据事故"都有专门的反例可演。
定位:本篇讲 durable 三件套(flag/strategy/apply)、源码层 ContextId 与 getParent 的机制、durability 冒泡与 REQUEST 冒泡的区别、"可聚合属性 vs 请求独有属性"的判断红线。不讲三种 scope 基础(22 期)、不讲 resolve 手动子树(23 期)。读完你能用"缓存键"的语言解释 durable,并判断"该不该上 durable"。

一、一句话回答

Durable providers = 给"请求级但内容按组复用"的 provider 一个"跨请求缓存的 DI 子树"。 它解决 Scope.REQUEST 的副作用------REQUEST 冒泡导致每个请求都把整棵依赖树 new 一遍;可如果"树的差别"只在少数几个属性(多租户里只有 x-tenant-id 不同),每次重建就是纯浪费。

做法三件套,缺一不可:

  • ① durable: true (配 Scope.REQUEST):告诉框架"这棵子树值得跨请求复用,别随请求销毁";
  • ② ContextIdStrategy:键归一化函数------把"这次请求随机生成的子 contextId"归一化到"按租户稳定的父 id";
  • ③ ContextIdFactory.apply(strategy)(首个请求前):把策略装进框架。

一句话记忆:默认 REQUEST = "缓存键每次请求都是新的随机数" → 永不命中 → 每请求重建;durable + strategy = "把键按租户归一化" → 同租户命中同一棵缓存子树。 你天天在 React/数据请求缓存里做的事,换了个名字。

scss 复制代码
默认 REQUEST(每请求重建)               durable(按租户复用)
─────────────────────────            ─────────────────────────
请求1 ──▶ 子树 A₁                     请求1(x-tenant-id=acme) ──┐
请求2 ──▶ 子树 A₂                                          ├─▶ 子树 acme(首载后缓存)
请求3 ──▶ 子树 A₃                     请求2(x-tenant-id=acme) ──┘
... 3万请求 = 3万棵子树                请求N(x-tenant-id=globex) ──▶ 子树 globex

记忆锚点:22 期说 REQUEST 是"状态按请求隔离"的逃生门;durable 说的是**"状态其实是按组隔离的,你却按请求隔离了 → 亏了"**。前端心智 ≈ useMemo/查询缓存的命中策略:把"每次渲染都变的东西"和"跨渲染稳定的东西"分开,前者不缓存、后者按稳定键复用。

二、痛点:REQUEST 冒泡的账单有多贵

多租户场景:10 个客户共用一个应用,每个客户连自己的数据库。直白写法是请求级数据源 provider(从请求拿 tenantId 返回对应连接)------但 REQUEST 冒泡(22 期规则一)让依赖它的整棵 controller 全变请求级。官方给的数字:3 万并发请求 = 3 万个"昙花一现"的 controller 实例,哪怕其中只有 2 个不同租户。

错在哪?树的形状只由 tenantId 决定,跟"这是第几个请求"毫无关系 ------用"第几次请求"当重建单位,等于用随机数当缓存键。前端直觉:这就像每次渲染都 new 一个巨大的、只为当前用户服务的对象,即使前 1000 次渲染的是同一个用户。"这次是谁"和"第几次"是两个正交维度,你错把"第几次"当成了缓存键。

三、三件套拆解

件 是什么 回答什么问题
① durable: true @Injectable({ scope: Scope.REQUEST, durable: true }) 或长写法平级字段 "这棵子树值得跨请求复用"
② ContextIdStrategy attach(contextId, request) 把随机子 id 归一化到租户稳定父 id "哪些请求共享同一棵子树?"(键归一化)
③ ContextIdFactory.apply() 首个请求前注册 "把策略装进框架"

为什么缺一不可:只有 ①,框架知道"可复用"但不知道"和谁复用"(缺分组依据);只有 ②,有分组器但不知道复用什么;没有 ③,策略不被注册等于没写。三件套 = "标记可复用 + 定义分组 + 注册生效"。

官方多租户示例:

ts 复制代码
const tenants = new Map<string, ContextId>();      // ① 租户 → 持久子树 id(缓存表)

export class AggregateByTenantContextIdStrategy implements ContextIdStrategy<Request> {
    attach(contextId: ContextId, request: Request) {
        const tenantId = request.headers["x-tenant-id"];   // ② 读可聚合属性
        let tenantSubTreeId = tenants.get(tenantId);
        if (!tenantSubTreeId) {
            tenantSubTreeId = ContextIdFactory.create();   // ③ 新租户:造"父 id"永久存
            tenants.set(tenantId, tenantSubTreeId);
        }
        // ④ 返回"解析函数":框架实例化每个 host 时问"该用哪棵树"
        return (info: HostComponentInfo) =>
            info.isTreeDurable ? tenantSubTreeId : contextId;   // durable→租户树,其余→本请求树
    }
}

运行效果:不 durable 的请求级 controller 仍每请求 new(它可能依赖请求独有信息);标了 durable 的数据源 provider 挂在租户树上------同租户复用、跨租户隔离。

四、机制层:源码里的 ContextId 与 getParent

@nestjs/core/helpers/context-id-factory.js(11.2.3):

js 复制代码
function createContextId() {
    return { id: Math.random() };   // 默认子 contextId:纯随机,每次请求都不同
}

注释写得很直白:id 不需要唯一,因为 WeakMap 用对象引用当键------真正当键的是对象本身,不是那个随机数。所以"键每次都是新对象 → 永不命中 → 每请求重建"。

注册策略后 getByRequest 的路径:先造随机子 id,然后 const resolver = this.strategy.attach(contextId, request)------返回函数就挂到 contextId.getParent,返回 { resolve, payload } 对象则连 payload 一起挂。于是钉死关键事实:"这次请求用哪棵 DI 子树" = contextId.getParent(info) 的返回值 ,而 getParent 是你 attach 塞进去的函数。框架每次实例化请求级 provider 前带着 { isTreeDurable } 来问(23 期讲 injector.getContextId 时埋的正是这一跳),你的函数回答:durable 用租户树,其余用本请求树。

payload 变体 :默认 durable 子树里 @Inject(REQUEST) 拿到的是空。若子树里的 provider 需要知道自己属于哪个租户,attach 改返回 { resolve, payload: { tenantId } }------durable 树里注入 REQUEST 拿到的是 payload,不是原始请求。payload = 缓存条目上顺手存的附属数据。

五、durable 的传播规则与判断红线

规则一:durable 只对请求级子树有意义 ------单例本来就共享,标 durable 无意义。规则二:durability 沿依赖链向上冒泡 (A 依赖 durable 的 B,A 隐式也 durable,除非显式 durable: false)------注意它和 REQUEST 冒泡方向相同、含义相反 :REQUEST 冒泡是"被迫每请求重造",durability 冒泡是"允许按组复用"(A 能依赖的每请求独有信息已被 durable 子树挡在外面,把 A 也缓存是安全的)。规则三(最锋利):能不能 durable,取决于依赖的是"可聚合属性"还是"请求独有属性" ------tenantId/region/AB 分桶可聚合,能 durable;requestId/token/时间戳请求独有,绝不能 durable,缓存了就是串数据事故:请求 B 读到请求 A 的值。

触发信号(才值得上) 反向警戒(别用)
多租户/多环境隔离,同一进程多个客户 租户很多(每租户一棵常驻子树,内存线性涨,官方明说 not ideal)
靠可聚合属性区分,租户数不大 子树依赖请求独有值(requestId 等) → 串数据
数据源/配置被大半个应用依赖,REQUEST 账单最痛 依赖面窄,REQUEST 冒泡账单不痛 → durable 没收益

六、常见坑

  1. 标了 durable 却没配 strategy → 框架不知道和谁复用,形同虚设;
  2. 依赖请求独有值的子树标 durable → 缓存串数据(请求 B 读到请求 A 的值);
  3. 租户很多时用 durable → 每租户一棵常驻子树撑内存;
  4. 忘了首个请求前 apply → 策略没注册,走默认随机 id 全不生效(放启动期钩子如 onModuleInit 最稳);
  5. 给单例标 durable → 无意义;
  6. 以为 durable 树里 @Inject(REQUEST) 拿到整棵 req → 拿到的是 payload(§四变体);
  7. 把 REQUEST 冒泡和 durability 冒泡搞混 → 前者被迫重造、后者允许复用;
  8. 把 durable 当 REQUEST 的免费性能优化 → 它要明确分组属性 + 属性跨请求稳定,无脑开关只会埋雷。

七、前端心智对照 + 自测

前端概念 对应 本质
每次渲染全新 key → 缓存全 miss 默认 contextId = { id: Math.random() } 键随机 → 永不命中 → 全量重建
queryKey/useMemo 依赖归一化 ContextIdStrategy.attach 易变输入 → 稳定 key
Map.get/set 缓存 tenants: Map<tenantId, ContextId> 命中取、未命中建
标记"重型可复用对象" durable: true(须配 REQUEST) DI 子树层面复用
缓存命中判定 getParent({ isTreeDurable }) 分支 框架问,你答
"依赖每次变的值就别想缓存" "请求独有值不可 durable" 同一句判断换宿主
ALS 每请求 context durable 按租户子树 一个管"每请求",一个管"跨请求按组"

自测 3 题(先自己答,再看答案):

  1. 默认 REQUEST 为什么每请求重建?用缓存键语言一句话解释。 → 每次请求框架生成新 contextId = { id: Math.random() },缓存靠对象引用当键------键每次都是新对象,永不命中,请求级子树每请求 new 一遍。strategy attach 就是把随机键归一化成租户稳定键。
  2. 三件套为什么缺一不可? → 只标 durable:知道可复用但不知道和谁复用;只写 strategy:有分组器但不知道复用什么;不 apply:策略没装进框架。= 标记可复用 + 定义分组 + 注册生效。
  3. "我们应用要用 durable 解决多租户"------什么情况下这话是错的? → 三种错:租户很多(每租户一棵常驻子树,内存线性涨)/子树依赖请求独有值(串数据)/依赖面窄(REQUEST 冒泡账单不痛,没收益)。轻分流形态(单库、租户只换集合/配置)用 ALS + Map<tenantId, conn> 更简单:图不动、只在取值点感知租户;durable 留给"每租户一整棵不同服务图"的复杂产品。

八、本篇收束与下一篇

durable 拆完,Injection Scopes 全家桶到顶:22 期三档 scope 讲"给几份",23 期 resolve 讲"手动子树",本期 durable 讲"按组复用子树"------ContextId 这把"子树钥匙"贯穿三期。22 期那句"默认单例不是偷懒,是最优"到这里补完了最后一块论据:durable 是全家桶里最后才该碰的一档,多数项目永远走不到------先确认"整棵依赖子树都按组隔离、租户少、依赖面广"三个前提,再谈用它。

下一篇(26 期《PlatformAgnosticism 平台无关性》)是全专栏收官:rag-server 的分层纪律------core 为什么不知道 Nest 存在、HTTP 层怎么把 req 挡在业务外面、ALS 那道"隐形通道"为什么恰好是平台无关的------把 19 期"core 零改动红线"、09 期 ALS、14 期"业务别碰 req"三条线索收成一张图。

相关推荐
爱丶不疚1 小时前
Electron: Sentry 都采集了什么?你知道吗
前端·electron
VIP_CQCRE1 小时前
在 Visual Studio 里接入 AI 编程能力:用 Ace Data Cloud + LMLocal 打通 OpenAI 兼容接口
openai·ai编程·visual studio·mcp·ace data cloud
?? Daisy1 小时前
ZCode 添加自定义模型:自定义供应商的字段与易错点(2026-09)
人工智能·ai·ai编程
恋猫de小郭1 小时前
Dart 4.0 要彻底移除 dart:mirrors,Augmentations 应该要来了
android·前端·flutter
tltwuyulw1 小时前
Java的函数式编程(四)
后端
流水白开1 小时前
React的Virtual DOM、Diff算法和Fiber
前端·react.js
乘风gg1 小时前
Spec Kit vs OpenSpec vs Superpowers:我为什么最后自己搭了一套
前端·ai编程·claude