mcpField
V
vtex-io-application-performance

vtex-io-application-performance

github.com/vtex开发471.2k 次安装2 周前更新
代码仓库

关于 vtex-io-application-performance

这是面向 VTEX IO 后端开发者的性能与韧性优化技能,适用于 Node 和 .NET 服务。它指导缓存、请求去重、并行 I/O、超时和租户隔离等设计。

主要功能

  1. 01设计进程内与共享缓存
  2. 02使用过期数据并后台刷新
  3. 03并行化独立上游调用
  4. 04设置超时并处理服务降级
  5. 05按账户和工作区隔离缓存键
  6. 06优化 VBase 状态与写入

适用场景

  1. 01减少重复的上游请求
  2. 02缩短解析器中的串行调用链
  3. 03缓存低频变化的应用设置
  4. 04避免多租户进程缓存数据串用

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/vtex/skills --skill vtex-io-application-performance

安装后在 Claude Code 中输入/vtex-io-application-performance确认已加载。

详细介绍

VTEX IO 应用性能

何时使用此技能

当你要优化 VTEX IO 后端(通常是使用 @vtex/api / Koa 风格中间件的 Node 服务,或 .NET 服务)的性能和弹性时,请使用此技能:包括缓存、去重、并行 I/O 和高效加载配置,而不只是“添加缓存”。

  • 为热点键添加内存 LRU(每个 pod 独立)
  • 添加 VBase 持久化,以便在 pod 间共享缓存;可选使用 stale-while-revalidate(返回过期数据,并在后台刷新)
  • 在启动时或按 TTL 刷新时只加载一次 AppSettings(或类似配置),而不是每次请求都加载
  • 使用 Promise.all 并行执行彼此独立的客户端调用,而不是串行瀑布式调用
  • 将 ctx.clients(例如 vbase)传入客户端辅助函数或解析器,使缓存易于测试且显式

以下情况请勿使用此技能:

  • 选择边缘的 /_v/private 与公开路径,或设置 Cache-Control → vtex-io-service-paths-and-cdn
  • 仅涉及 GraphQL @cacheControl 字段语义 → vtex-io-graphql-api

决策规则

  • 第 1 层 — LRU(进程内) — 速度最快;冷启动时会丢失,且不会在副本间共享。使用有界大小 + TTL 缓存热门键(组织、成本中心、小型配置片段)。
  • 第 2 层 — VBase — 在 Pod 间共享;平台数据像其他 IO 资源一样按账户 / 工作区进行分区。客户端支持并发安全更新时,搭配 hash 或 trySaveIfhashMatches 使用(参见 客户端)。
  • 过期后后台刷新(Stale-while-revalidate) — VBase 命中但新鲜度已过期时,立即返回过期数据,并在后台重新验证(获取源数据 → 写入 VBase + LRU)。相比每次都阻塞等待源数据,可降低尾延迟。
  • 仅使用 TTL — 更简单:缓存直到 TTL 过期,然后阻塞等待获取数据。当无法接受数据过期或源数据获取成本较低时,优先采用此方案。
  • AppSettings — 如果值适用于整个账户且很少变化,则加载一次(或定期刷新)并保存在模块内存中;如果值依赖于工作区,或必须快速反映管理员的更改,则使用每次请求读取或较短 TTL 缓存。没有防护措施时,绝不要在日志或全局状态中缓存机密信息。
  • 上下文 — 使用 ctx.state 在每次请求内去重(例如,“此请求已加载组织信息”)。仅对不可变或通过 TTL 刷新的应用数据使用全局模块缓存;账户和工作区信息位于 ctx.vtex 中——同一个 Pod 为多个租户提供服务时,务必将它们加入内存缓存键。
  • 并行请求 — 当解析器需要调用彼此独立的上游接口时,应并行执行;只有在输出互相依赖时才合并处理。
  • 每个出站调用都设置超时 — 每个 ctx.clients 调用和外部 HTTP 请求都必须明确设置超时。使用 @vtex/api 客户端选项(timeout、retries、exponentialTimeoutCoefficient)调整各客户端的行为。在分布式系统中,无上限的等待是导致级联故障的首要原因。
  • 优雅降级 — 当上游响应缓慢或不可用时,在业务允许的情况下应快速失败并继续(返回缓存数据 / 默认数据,跳过可选的补充信息),而不是阻塞响应。对于长期故障的依赖项,可考虑使用熔断器模式。
  • 谨慎缓存事务数据 — 支付响应和活跃事务状态绝不能缓存。其他事务数据(订单表单、购物车模拟、会话状态)默认不缓存,但当使用方可以接受短暂的数据过期时,允许使用短时缓存(TTL < 5 min)。缓存前务必明确确认使用场景——事务数据设置较长 TTL 会导致价格过期、库存虚报或状态不一致。
  • 解析器链去重 — 当解析器链多次调用同一个客户端方法(例如,在解析器中调用 getCostCenter,然后又在辅助函数中调用),应进行去重:只调用一次,传递结果,或将结果存入 ctx.state。原本可以用 3 次并行调用 + 1 次顺序调用完成,却串行执行 7 次或更多调用,是性能损耗的首要来源。
  • 分阶段使用 Promise.all — 将彼此独立的调用分组成并行阶段。第 1 阶段:Promise.all([getOrderForm(), getCostCenter(), getSession()])。第 2 阶段(依赖第 1 阶段):getSkuMetadata()。第 3 阶段(依赖第 2 阶段):generatePrice()。如果只有两次调用彼此依赖,就不要连续 await 六次调用。
  • 批量执行变更 — 设置多个值时(例如,为每个购物车商品调用 setManualPrice),使用 Promise.all,不要使用顺序循环。循环中的每次 await 都会增加一次完整的往返开销。
VBase 深层模式
  • 使用实体级键,而非整块数据键 — 缓存单个实体(例如 sku:{region}:{skuId}),而不是组合数据块(例如 allSkus:{sortedCartSkuIds})。添加或移除商品时,实体级键能大幅提高缓存命中率。
  • 使用最精简的 DTO — 只存储使用方需要的字段(例如 { skuId, mappedId, isSpecialItem },约 50 字节),而不是完整的 API 响应(每个商品约 10-50 KB)。这样可以减少 VBase 存储占用、序列化时间和传输量。
  • 预热同组实体 — 如果搜索 API 返回了一个包含 4 个 SKU 变体的商品,即使只请求了其中 1 个,也要缓存这 4 个 SKU。下次请求同组中的其他 SKU 时,就能命中 VBase,而不必调用 API。
  • 将 vbase 作为参数传递 — 客户端无法直接访问其他客户端。将 ctx.clients.vbase 作为参数传递给需要它的客户端方法或工具。这样代码更易测试,依赖关系也更明确。
  • VBase 状态机 — 对于长时间运行的操作(扫描、导入、批处理),可将 VBase 用作状态存储,使用 current-operation.json(锁定状态 + 进度)、心跳续期、检查点/恢复,以及基于 TTL 的锁过期机制,以防止出现僵尸锁。
service.json 调优
  • timeout — 平台终止请求前等待的最长秒数。应根据预期的最长操作设置;如果解析器会调用响应较慢的上游服务,就不要保留默认值。
  • memory — 每个工作进程可用的 MB 数。如果 LRU 缓存或大型负载导致内存溢出(OOM),可增加此值;但应先监控实际用量,避免过度分配。
  • workers — 每个副本可并发处理的请求数。增加工作进程数可以处理更多并发请求,但所有工作进程共享内存预算和进程内 LRU 缓存。
  • minReplicas / maxReplicas — 控制负载下的水平扩缩容。注意:如果应用在其 ttl 时长内(最长 60 分钟)没有收到请求,所有 Pod 都会缩容至零。minReplicas 只控制有流量时的副本数下限,不会让 Pod 永久保持运行。设计时应考虑冷启动:空闲后收到的第一个请求会启动新的 Pod,因此应尽量降低 LRU 预热成本,并避免依赖空闲期间仍能保留的内存状态。
租户隔离与内存缓存

Pod 可以由多个账户共享,但平台始终会将每个请求限定在特定的 {account, workspace} 范围内——ctx.vtex.account 和 ctx.vtex.workspace 由运行时设置,而非开发者设置。所有平台 API(VBase、应用存储桶、Master Data 等)都会自动按账户/工作区隔离数据,因此其响应在设计上就是租户安全的。唯一的风险来自进程内状态:由你管理的 LRU 缓存、模块级 Map 或全局变量——平台不会对它们进行隔离,因此必须使用 ctx.vtex.account 和 ctx.vtex.workspace(以及实体 ID)显式构建键,确保同一个 Pod 上连续收到来自不同账户的请求时,彼此无法读取对方的缓存条目。

硬性限制

约束:不要在模块级缓存中存储缺少租户键的敏感数据或特定租户数据

全局或模块级映射中,不得仅以用户 ID或邮箱为键存储个人身份信息(PII)、令牌或与授权相关的数据块,除非键中包含**account 和 workspace**(以及隔离所需的其他维度)。

重要原因——Pod 是多租户的:同一进程可能依次为多个账户提供服务。VBase 和类似 API 的作用域由当前账户/工作区限定,但内存中的 Map 由你负责。键中缺少**account/workspace,可能导致从热缓存中发生跨租户**读取。

检测方法——模块作用域的 Map 仅以 userId 或 email 为键;或者当值是特定租户数据时,缓存键未包含 ctx.vtex.account / ctx.vtex.workspace。

正确做法——使用**ctx.vtex.account、ctx.vtex.workspace** 和实体 ID 构造键;绝不要将应用令牌作为普通缓存值存储在 VBase/LRU 中;优先使用 ctx.clients 和平台授权。

// Pseudocode: in-memory key must mirror tenant scope (same pod, many accounts)
function cacheKey(ctx: Context, subjectId: string) {
  return `${ctx.vtex.account}:${ctx.vtex.workspace}:${subjectId}`;
}

错误做法——globalUserCache.set(email, profile) 仅以邮箱为键,没有 account/workspace 部分——在共享 Pod 上不安全。即使之后的 VBase 读取有账户作用域,此映射也没有按平台进行分区。

约束:在财务或幂等性关键路径中,不要使用即发即弃的 VBase 写入

当 VBase 用作幂等性存储(例如,支付连接器存储交易状态)或数据完整性存储时,写入必须等待完成。即发即弃的写入可能悄无声息地失败:上游操作(例如扣款)成功,但 VBase 记录丢失,导致下次重试时发生重复操作。

对于支付连接器,首要防护措施是使用收单机构/支付网关的幂等性功能:将 VTEX paymentId 作为幂等键传给外部支付提供商(例如通过 Idempotency-Key HTTP 标头)。这样,即使 VBase 状态丢失,提供商本身也会拒绝重复扣款。VBase 对于本地状态跟踪以及向 VTEX Gateway 重试请求返回正确响应仍然至关重要,但外部幂等键能在提供商层面防止最严重的后果(重复扣款)。

重要原因——VTEX Gateway 会使用相同的 paymentId 重试支付请求。如果授权成功后 VBase 写入悄无声息地失败,且收单机构没有幂等键,连接器就会再发送一笔支付请求,导致重复扣款。如果设置了收单机构幂等键,提供商会拒绝重复请求;如果没有,VBase 就是唯一的防护措施。

检测方法——在支付、结算、退款流程中,或在存储值是防止重复执行的唯一记录的任何流程中,检查是否存在未使用 await 的 VBase saveJSON 或 saveOrUpdate 调用。还要检查外部支付调用是否包含幂等键标头。

正确做法——使用收单机构幂等键,并且等待 VBase 写入完成。

const { paymentId } = authorization

// Pass VTEX paymentId as idempotency key to the acquirer
const response = await ctx.clients.paymentProvider.authorize(paymentData, {
  headers: { 'Idempotency-Key': paymentId },
})

// Await VBase write for local state tracking
await ctx.clients.vbase.saveJSON<Transaction>('transactions', paymentId, transactionData)
return Authorizations.approve(authorization, { ... })

错误做法——未使用收单机构幂等键,且 VBase 写入即发即弃。

const { paymentId } = authorization
// No idempotency key to the acquirer + no await on VBase
const response = await ctx.clients.paymentProvider.authorize(paymentData)
ctx.clients.vbase.saveJSON('transactions', paymentId, transactionData)
return Authorizations.approve(authorization, { ... })
约束:缓存事务性或近实时数据时务必谨慎

支付状态和活动事务状态绝不能缓存——它们必须严格实时。对于其他事务性数据,例如订单表单、购物车模拟数据和会话状态,默认不缓存;不过,如果使用场景能够容忍短暂的数据过时,短期缓存(例如 1–5 分钟)也可以接受。这需要逐个案例评估:缓存前,务必明确确认使用方能够接受所选 TTL 下的数据过时。

为何重要——提供缓存的订单表单可能显示虚构的商品或过时的价格。缓存支付响应可能会返回先前交易的状态。不过,在同一数据会在几秒内被反复请求的读取密集型场景中(例如,商品列表页面的多个组件都获取模拟数据),短期缓存可以显著降低上游 API 的负载,而不会对用户造成明显影响。

检测方式——用于读取时缓存、但没有明确决定或理由说明 TTL 的 LRU 或 VBase 键,例如 orderForm:{id}、cartSim:{hash}、paymentResponse:{id} 或 session:{token}。事务性数据使用较长 TTL(>5 min)几乎总是错误的。对支付响应或活动事务状态进行任何缓存都是错误的。

正确做法——将参考数据(始终缓存)与事务性数据(默认不缓存,只有明确说明理由时才使用短 TTL)区分开。

// Reference data: cached (changes rarely)
const costCenter = await getCostCenterCached(ctx, costCenterId);
const sellerList = await getSellerListCached(ctx);

// Transactional data: default live, but short cache is acceptable if justified
const orderForm = await ctx.clients.checkout.orderForm();
const simulation = await ctx.clients.checkout.simulation(payload);

// Acceptable: short-lived cache for a read-heavy page (e.g. simulation results
// reused across multiple components within the same page render, TTL < 60s)
// Only after confirming staleness is acceptable for this specific consumer.

错误做法——对事务性数据进行长时间缓存,或对支付状态进行任何缓存。

// Never cache payment responses or transaction status
const paymentCache = lru({ max: 1000, ttl: 300_000 });

// Avoid: long TTL on order form without explicit justification
const orderFormCache = lru({ max: 500, ttl: 600_000 }); // 10 min is too long
约束:不要因缓慢或无界的缓存刷新而阻塞购买流程

如果平台 SLA 要求快速响应,过期后再验证或 origin 调用就不得为结账关键路径中间件增加无界延迟。

为何重要——因可选的信息补充而阻塞结账会损害转化率和可靠性。

检测方式——购物车或支付解析器在返回前 await VBase 刷新或外部 API,且没有超时或回退方案。

正确做法——返回过期数据或默认值;加入队列以刷新;在业务规则允许时失败时放行。

错误做法——在热路径中使用 await fetchHeavyPartner(),且没有超时。

首选模式

  1. 分类数据:参考数据(组织、成本中心、配置、卖家列表 → 可缓存)与事务性数据(订单表单、购物车模拟数据、支付 → 绝不缓存)以及用户私有数据(未经加密和键隔离,绝不放入共享缓存)。
  2. 为读取密集型参考数据选择仅使用 LRU、仅使用 VBase,或使用 LRU → VBase → origin(两层)。
  3. 在请求内去重:如果解析器链可能两次调用同一加载器,则设置 ctx.state 标记。
  4. 将独立的 ctx.clients 调用分成多个阶段,并行放入 Promise.all 组中。
  5. 对高基数数据(SKU、用户、组织记录)使用每实体 VBase 键和最精简的 DTO。
  6. 记录 TTL 和失效机制(由谁写入、何时刷新)。
解析器链优化(前后对比)
// BEFORE: 7 sequential awaits, 2 duplicate calls
const settings = await getAppSettings(ctx); // 1
const session = await getSessions(ctx); // 2
const costCenter = await getCostCenter(ctx, ccId); // 3
const orderForm = await getOrderForm(ctx); // 4
const skus = await getSkuById(ctx, skuIds); // 5
const price = await generatePrice(ctx, costCenter); // 6 (calls getCostCenter AGAIN + getSession AGAIN)
for (const item of items) {
  await setManualPrice(ctx, item); // 7, 8, 9... (sequential loop)
}

// AFTER: 3 phases, no duplicates, parallel mutations
const settings = await getAppSettings(ctx);
const [session, costCenter, orderForm] = await Promise.all([
  getSessions(ctx),
  getCostCenter(ctx, ccId),
  getOrderForm(ctx),
]);
const skus = await getSkuMetadataBatch(ctx, skuIds); // per-entity VBase cache
const price = await generatePrice(ctx, costCenter, session); // reuse, no re-fetch
await Promise.all(items.map((item) => setManualPrice(ctx, item)));
每实体 VBase 缓存
interface SkuMetadata {
  skuId: string;
  mappedSku: string | null;
  isSpecialItem: boolean;
}

async function getSkuMetadataBatch(
  ctx: Context,
  skuIds: string[],
): Promise<Map<string, SkuMetadata>> {
  const { vbase, search } = ctx.clients;
  const results = new Map<string, SkuMetadata>();

  // Phase 1: check VBase for each SKU in parallel
  const lookups = await Promise.allSettled(
    skuIds.map((id) => vbase.getJSON<SkuMetadata>("sku-metadata", `sku:${id}`)),
  );

  const missing: string[] = [];
  lookups.forEach((result, i) => {
    if (result.status === "fulfilled" && result.value) {
      results.set(skuIds[i], result.value);
    } else {
      missing.push(skuIds[i]);
    }
  });

  if (missing.length === 0) return results;

  // Phase 2: fetch only missing SKUs from API
  const products = await search.getSkuById(missing);

  // Phase 3: cache ALL sibling SKUs (prewarm)
  for (const product of products) {
    for (const sku of product.items) {
      const metadata: SkuMetadata = {
        skuId: sku.itemId,
        mappedSku: extractMapping(sku),
        isSpecialItem: checkSpecial(sku),
      };
      results.set(sku.itemId, metadata);
      // Fire-and-forget for prewarming (not idempotency-critical)
      vbase
        .saveJSON("sku-metadata", `sku:${sku.itemId}`, metadata)
        .catch(() => {});
    }
  }

  return results;
}

常见故障模式

  • LRU 无界 — 内存增长,条目数没有 max 限制;Pod 出现 OOM。
  • VBase 未搭配 LRU — 每个请求都会针对 hot 键访问 VBase;延迟和成本上升。
  • 内存缓存的键中未包含租户信息 — 同一 Pod 先为账户 A 服务,再为账户 B 服务;模块缓存返回过期或错误的行。
  • 串行等待 — 三个彼此独立的 Janus 调用依次 await;总延迟等于所有调用延迟之和,而非其中最大值。
  • Resolver 链中存在重复调用 — getCostCenter 在 resolver 中调用后,又在辅助函数中调用;getSession 在同一流程中调用两次。每次重复调用都会增加一次完整的往返。
  • Blob 型 VBase 键 — 以 sortedCartSkuIds 作为 VBase 键,意味着向含有 10 个商品的购物车添加 1 件商品时,必须重新完整获取数据,而不是只查找 1 条数据。
  • 对事务性数据使用长生命周期缓存 — 对订单表单或购物车模拟使用较长 TTL 缓存,却没有明确理由;绝不能缓存支付响应或交易状态。
  • 在关键路径中使用即发即弃写入 — 对幂等存储执行未等待的 VBase 写入;静默失败会导致重试时出现重复记录。
  • 没有显式超时 — 上游调用依赖默认超时或无限超时;某个依赖响应缓慢,就会阻塞整个请求链。
  • 全局可变单例 — 并发请求修改模块级可变对象(例如令牌缓存元数据),会导致竞态条件和错误行为。
  • 将 AppSettings 视为实时数据 — 管理员更改会过期,直到 TTL 到期才生效;没有通知机制。
  • 在热路径中使用 console.log — 使用模板字面量记录完整响应对象会生成 [object Object];请使用 ctx.vtex.logger 和 JSON.stringify,并对敏感数据进行脱敏。

审查清单

  • 当值因租户而异时,内存缓存键是否包含 account + workspace(以及实体 ID)?
  • LRU 是否有界(最大条目数),是否已定义 TTL?
  • 对于 VBase,是否明确规定了 stale-while-revalidate 或仅使用 TTL 的行为?
  • 在安全的情况下,是否通过 Promise.all 阶段将彼此独立的上游调用并行化?
  • 同一 resolver 链中是否没有重复调用同一个客户端方法?
  • AppSettings(或类似内容)是否按合适的频率(每次加载一次或每个请求加载一次)加载?
  • 结账或支付路径是否不受阻塞式可选刷新的影响?
  • 每个出站调用是否都设置了显式超时(通过 @vtex/api 客户端选项或等效配置)?
  • 对于不可靠的上游,是否有降级路径(缓存回退、跳过、断路器)?
  • 在涉及资金或幂等性的关键路径中,VBase 写入是否等待完成?
  • 是否始终实时获取支付/交易状态?对于其他事务性数据(订单表单、购物车模拟),任何缓存是否都具有较短的生命周期且有明确理由?
  • 对于 SKU 等高基数数据,VBase 键是否按实体划分(而非使用 Blob)?
  • service.json 资源限制(timeout、memory、workers、replicas)是否根据工作负载进行了调整?
  • 是否通过 ctx.vtex.logger(而非 console.log)记录日志,并对敏感数据进行脱敏?

相关技能

参考

评论

支持 Markdown