mcpField
H
headless-caching-strategy

headless-caching-strategy

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

关于 headless-caching-strategy

面向优化无头 VTEX 店铺性能的开发者,提供 API 缓存策略指导,涵盖缓存分层、缓存失效和数据分类。区分可缓存的公开数据与不可缓存的交易及个人数据,并介绍 TTL 和 stale-while-revalidate。

主要功能

  1. 01区分可缓存与不可缓存 API
  2. 02规划 CDN 与 BFF 缓存层
  3. 03设置 TTL 和 stale-while-revalidate
  4. 04设计缓存失效机制
  5. 05按请求参数构建缓存键

适用场景

  1. 01为搜索和目录 API 添加缓存
  2. 02避免缓存结账及个人数据
  3. 03为目录数据配置缓存失效
  4. 04检查缓存 TTL 与缓存键

安装

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

$npx skills add https://github.com/vtex/skills --skill headless-caching-strategy

安装后在 Claude Code 中输入/headless-caching-strategy确认已加载。

详细介绍

Headless VTEX 的缓存与性能优化

适用场景

在构建或优化 headless VTEX 店面性能时,使用此技能。合理的缓存是 headless 电商中影响最大的性能优化措施。

  • 为 Intelligent Search 和 Catalog API 配置 CDN 或边缘缓存
  • 为频繁请求的数据添加 BFF 层缓存(内存或 Redis)
  • 判断哪些 VTEX API 响应可以缓存,哪些绝不能缓存
  • 在目录数据变更时实现缓存失效

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

决策规则

  • 实施缓存逻辑前,先将每个 VTEX API 分类为可缓存或不可缓存。
  • 可缓存(公开、只读、非个性化):Intelligent Search、Catalog 公开端点、热门搜索、自动补全。
  • 不可缓存(事务性、个性化、敏感):Checkout、Profile、OMS、Payments、Pricing 私有端点。任何缓存层都绝不能缓存这些内容。
  • 使用 stale-while-revalidate,以获得最佳的新鲜度与性能平衡——立即提供缓存数据,同时在后台刷新。
  • 使用适中的 TTL(2-15 分钟),并结合事件驱动的失效机制。没有失效机制时,绝不要将 TTL 设为数小时或数天。
  • 仅按请求 URL/参数缓存,不按用户身份缓存——同一贸易政策下,所有匿名用户的目录数据都相同。
  • 采用分层缓存:直接前端调用(Search)使用 CDN 边缘缓存;代理的目录数据使用 BFF 缓存(Redis/内存)。

推荐 TTL:

API推荐 TTLSWR
Intelligent Search(product_search)2-5 分钟60s
Catalog(类别树)5-15 分钟5 分钟
Intelligent Search(自动补全)1-2 分钟30s
Intelligent Search(热门搜索)5-10 分钟2 分钟
Catalog(商品详情)5 分钟60s

绝不能缓存的 API:

API原因
Checkout (/api/checkout/)购物车数据因用户而异,且每次操作都会变化
Profile (/api/profile-system/pvt/)个人数据,属于 GDPR/LGPD 敏感信息
OMS (/api/oms/pvt/orders)订单状态会变化,且因用户而异
Payments (/api/payments/)金融交易,必须始终反映实时状态
Pricing 私有端点 (/api/pricing/pvt/)可能存在针对用户的定价规则

硬性约束

约束:必须积极缓存公开 API 数据

搜索结果、目录数据、类别树及其他公开只读数据,必须在适当层级(CDN、BFF 或两者)缓存。如果不缓存,每个用户请求都会直接访问 VTEX API。

重要原因

不使用缓存时,headless 店面每次页面浏览、搜索和类别浏览都会产生 API 请求。这会迅速超过 VTEX API 速率限制(导致 429 错误和服务降级),每个请求还会增加 200-500ms 的延迟,并带来糟糕的购物体验。一个拥有 10,000 名并发用户且搜索请求未缓存的店铺,会压垮任何 API。

检测

如果 headless 店面调用 Intelligent Search 或 Catalog API 时没有任何缓存层(没有 CDN 缓存标头、BFF 缓存或 Cache-Control 标头)→ 立即停止。必须为所有公开只读 API 响应实现缓存。

正确

// BFF route with in-memory cache for category tree
import { Router, Request, Response } from "express";

const router = Router();

interface CacheEntry<T> {
  data: T;
  expiresAt: number;
  staleAt: number;
}

const cache = new Map<string, CacheEntry<unknown>>();

function getCached<T>(key: string): { data: T; isStale: boolean } | null {
  const entry = cache.get(key) as CacheEntry<T> | undefined;
  if (!entry) return null;

  const now = Date.now();
  if (now > entry.expiresAt) {
    cache.delete(key);
    return null;
  }

  return {
    data: entry.data,
    isStale: now > entry.staleAt,
  };
}

function setCache<T>(key: string, data: T, maxAgeMs: number, swrMs: number): void {
  const now = Date.now();
  cache.set(key, {
    data,
    staleAt: now + maxAgeMs,
    expiresAt: now + maxAgeMs + swrMs,
  });
}

const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
const CATALOG_BASE = `https://${VTEX_ACCOUNT}.vtexcommercestable.com.br/api/catalog_system/pub`;

// Category tree — cache for 15 minutes, SWR for 5 minutes
router.get("/categories", async (_req: Request, res: Response) => {
  const cacheKey = "category-tree";
  const cached = getCached<unknown>(cacheKey);

  if (cached && !cached.isStale) {
    res.set("X-Cache", "HIT");
    return res.json(cached.data);
  }

  // If stale, serve stale data and refresh in background
  if (cached && cached.isStale) {
    res.set("X-Cache", "STALE");
    res.json(cached.data);

    // Background refresh
    fetch(`${CATALOG_BASE}/category/tree/3`)
      .then((r) => r.json())
      .then((data) => setCache(cacheKey, data, 15 * 60 * 1000, 5 * 60 * 1000))
      .catch((err) => console.error("Background cache refresh failed:", err));
    return;
  }

  // Cache miss — fetch and cache
  try {
    const response = await fetch(`${CATALOG_BASE}/category/tree/3`);
    const data = await response.json();
    setCache(cacheKey, data, 15 * 60 * 1000, 5 * 60 * 1000);
    res.set("X-Cache", "MISS");
    res.json(data);
  } catch (error) {
    console.error("Error fetching categories:", error);
    res.status(500).json({ error: "Failed to fetch categories" });
  }
});

错误

// No caching — every request hits VTEX directly
router.get("/categories", async (_req: Request, res: Response) => {
  // This fires on EVERY request — 10,000 users = 10,000 API calls
  const response = await fetch(
    `https://mystore.vtexcommercestable.com.br/api/catalog_system/pub/category/tree/3`
  );
  const data = await response.json();
  res.json(data); // No cache headers, no BFF cache, no CDN cache
});

限制:绝不能缓存交易数据或个人数据

Checkout API、Profile API、OMS API 和 Payments API 的响应绝不能在任何层级缓存——无论是在 CDN、BFF 内存、Redis,还是浏览器缓存中。

重要原因

缓存交易数据可能导致灾难性故障。缓存的 OrderForm 会让购物者看到过期的购物车内容(商品或价格错误)。缓存的个人资料数据可能会将一位用户的个人信息泄露给另一位用户(尤其是在共享缓存后面)。缓存的订单数据会显示过期状态。以上任何情况都可能构成安全漏洞、违反数据隐私法规(GDPR/LGPD)或导致业务逻辑故障。

检测

如果发现对结账、订单、个人资料或支付 API 响应应用了缓存逻辑(Redis set、内存缓存、带有 max-age > 0 的 Cache-Control 标头)→ 立即停止。这些端点必须始终返回最新数据。

正确

// BFF checkout route — explicitly no caching
import { Router, Request, Response } from "express";
import { vtexCheckout } from "../vtex-checkout-client";

export const checkoutRoutes = Router();

// Set no-cache headers for ALL checkout responses
checkoutRoutes.use((_req: Request, res: Response, next) => {
  res.set({
    "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
    Pragma: "no-cache",
    Expires: "0",
    "Surrogate-Control": "no-store",
  });
  next();
});

checkoutRoutes.get("/cart", async (req: Request, res: Response) => {
  const orderFormId = req.session.orderFormId;
  if (!orderFormId) {
    return res.status(400).json({ error: "No active cart" });
  }

  try {
    // Always fetch fresh — never cache
    const result = await vtexCheckout({
      path: `/api/checkout/pub/orderForm/${orderFormId}`,
      cookies: req.session.vtexCookies || {},
      userToken: req.session.vtexAuthToken,
    });

    req.session.vtexCookies = result.cookies;
    res.json(result.data);
  } catch (error) {
    console.error("Error fetching cart:", error);
    res.status(500).json({ error: "Failed to fetch cart" });
  }
});

错误

// CATASTROPHIC: Caching checkout data in Redis
import Redis from "ioredis";
const redis = new Redis();

checkoutRoutes.get("/cart", async (req: Request, res: Response) => {
  const orderFormId = req.session.orderFormId;
  const cacheKey = `cart:${orderFormId}`;

  // WRONG: cached cart could have wrong items, old prices, or stale quantities
  const cached = await redis.get(cacheKey);
  if (cached) {
    return res.json(JSON.parse(cached)); // Serving stale transactional data!
  }

  const result = await vtexCheckout({
    path: `/api/checkout/pub/orderForm/${orderFormId}`,
    cookies: req.session.vtexCookies || {},
  });

  // WRONG: caching cart data that changes with every user action
  await redis.setex(cacheKey, 300, JSON.stringify(result.data));
  res.json(result.data);
});

限制:必须实现缓存失效策略

每种缓存实现都必须有明确的失效策略。缓存数据必须设置适当的 TTL,并且必须有一种机制,能在底层数据发生变化时强制使缓存失效。

重要原因

如果没有失效机制,缓存数据会永久过期。已售罄的商品会继续显示为有货。价格变更要等到任意设定的 TTL 到期后才会反映。新商品则无法显示。这会导致购物体验不佳、订单失败(由于库存信息过期)以及价格错误。

检测

如果缓存实现没有 TTL(max-age、过期时间),或者 TTL 很长(数小时或数天)却没有任何失效机制 → 立即停止。所有缓存都需要有上限的 TTL,最好还要支持事件驱动的失效机制。

正确

// Cache with TTL + manual invalidation endpoint + event-driven invalidation
import { Router, Request, Response } from "express";

const router = Router();

// In-memory cache with TTL tracking
const productCache = new Map<string, { data: unknown; expiresAt: number }>();

function setProductCache(productId: string, data: unknown, ttlMs: number): void {
  productCache.set(productId, {
    data,
    expiresAt: Date.now() + ttlMs,
  });
}

function getProductCache(productId: string): unknown | null {
  const entry = productCache.get(productId);
  if (!entry || Date.now() > entry.expiresAt) {
    productCache.delete(productId);
    return null;
  }
  return entry.data;
}

// Regular product endpoint with cache
router.get("/products/:productId", async (req: Request, res: Response) => {
  const { productId } = req.params;
  const cached = getProductCache(productId);

  if (cached) {
    return res.json(cached);
  }

  const response = await fetch(
    `https://${process.env.VTEX_ACCOUNT}.vtexcommercestable.com.br/api/catalog_system/pub/products/search?fq=productId:${productId}`
  );
  const data = await response.json();

  setProductCache(productId, data, 5 * 60 * 1000); // 5-minute TTL
  res.json(data);
});

// Manual invalidation endpoint (secured with API key)
router.post("/cache/invalidate", (req: Request, res: Response) => {
  const adminKey = req.headers["x-admin-key"];
  if (adminKey !== process.env.ADMIN_API_KEY) {
    return res.status(403).json({ error: "Unauthorized" });
  }

  const { productId, pattern } = req.body as { productId?: string; pattern?: string };

  if (productId) {
    productCache.delete(productId);
    return res.json({ invalidated: [productId] });
  }

  if (pattern === "all") {
    const count = productCache.size;
    productCache.clear();
    return res.json({ invalidated: count });
  }

  res.status(400).json({ error: "Provide productId or pattern" });
});

// Webhook endpoint for VTEX catalog change events
router.post("/webhooks/catalog-change", (req: Request, res: Response) => {
  const { productId } = req.body as { productId?: string };

  if (productId) {
    productCache.delete(productId);
    console.log(`Cache invalidated for product ${productId}`);
  }

  res.status(200).json({ received: true });
});

export default router;

错误

// Cache with no TTL and no invalidation — data becomes permanently stale
const cache = new Map<string, unknown>();

router.get("/products/:productId", async (req: Request, res: Response) => {
  const { productId } = req.params;

  // Once cached, this data NEVER expires — price changes, stock updates are invisible
  if (cache.has(productId)) {
    return res.json(cache.get(productId));
  }

  const response = await fetch(`https://mystore.vtexcommercestable.com.br/api/catalog_system/pub/products/search?fq=productId:${productId}`);
  const data = await response.json();
  cache.set(productId, data); // No TTL! No invalidation! Stale forever!
  res.json(data);
});

首选模式

无头 VTEX 店面的缓存层架构:

Frontend (Browser)
    │
    ├── Direct to CDN (Intelligent Search)
    │   └── CDN Edge Cache (TTL: 2-5 min, SWR: 60s)
    │       └── VTEX Intelligent Search API
    │
    └── BFF Endpoints
        │
        ├── Cacheable routes (catalog, category tree)
        │   └── BFF Cache Layer (Redis/in-memory)
        │       └── VTEX Catalog API
        │
        └── Non-cacheable routes (checkout, profile, orders)
            └── Direct proxy to VTEX (NO CACHING)

Intelligent Search 的 CDN 缓存标头(边缘函数示例):

// cloudflare-worker.ts or similar edge function
async function handleSearchRequest(request: Request): Promise<Response> {
  const url = new URL(request.url);

  // Only cache GET requests to Intelligent Search
  if (request.method !== "GET") {
    return fetch(request);
  }

  // Check CDN cache first
  const cacheKey = new Request(url.toString(), request);
  const cachedResponse = await caches.default.match(cacheKey);
  if (cachedResponse) {
    return cachedResponse;
  }

  // Fetch from VTEX
  const response = await fetch(request);
  const responseClone = response.clone();

  // Add cache headers
  const cachedRes = new Response(responseClone.body, responseClone);
  cachedRes.headers.set(
    "Cache-Control",
    "public, max-age=120, stale-while-revalidate=60"
  );

  // Store in CDN cache
  await caches.default.put(cacheKey, cachedRes.clone());

  return cachedRes;
}

基于 Redis 的 BFF 缓存与 stale-while-revalidate:

// server/cache/redis-cache.ts
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

interface CacheOptions {
  ttlSeconds: number;
  swrSeconds?: number;
}

export async function getCachedOrFetch<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: CacheOptions
): Promise<{ data: T; cacheStatus: "HIT" | "STALE" | "MISS" }> {
  const { ttlSeconds, swrSeconds = 0 } = options;

  // Try to get from cache
  const cached = await redis.get(key);
  if (cached) {
    const ttl = await redis.ttl(key);
    const isStale = ttl <= swrSeconds;

    if (isStale && swrSeconds > 0) {
      // Serve stale, refresh in background
      fetcher()
        .then((freshData) =>
          redis.setex(key, ttlSeconds + swrSeconds, JSON.stringify(freshData))
        )
        .catch((err) => console.error(`Background refresh failed for ${key}:`, err));

      return { data: JSON.parse(cached) as T, cacheStatus: "STALE" };
    }

    return { data: JSON.parse(cached) as T, cacheStatus: "HIT" };
  }

  // Cache miss — fetch and store
  const data = await fetcher();
  await redis.setex(key, ttlSeconds + swrSeconds, JSON.stringify(data));

  return { data, cacheStatus: "MISS" };
}

export async function invalidateCache(pattern: string): Promise<number> {
  const keys = await redis.keys(pattern);
  if (keys.length === 0) return 0;
  return redis.del(...keys);
}

按路由组应用缓存策略:

// server/middleware/cache-headers.ts
import { Request, Response, NextFunction } from "express";

export function cacheHeaders(type: "public" | "private" | "no-cache") {
  return (_req: Request, res: Response, next: NextFunction) => {
    switch (type) {
      case "public":
        res.set({
          "Cache-Control": "public, max-age=120, stale-while-revalidate=60",
          Vary: "Accept-Encoding",
        });
        break;
      case "private":
        res.set({
          "Cache-Control": "private, max-age=60",
          Vary: "Accept-Encoding, Cookie",
        });
        break;
      case "no-cache":
        res.set({
          "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
          Pragma: "no-cache",
          Expires: "0",
          "Surrogate-Control": "no-store",
        });
        break;
    }
    next();
  };
}

// server/index.ts — apply cache strategies per route group
import express from "express";
import { cacheHeaders } from "./middleware/cache-headers";
import catalogRoutes from "./routes/catalog";
import { checkoutRoutes } from "./routes/checkout";
import { orderRoutes } from "./routes/orders";
import { profileRoutes } from "./routes/profile";

const app = express();
app.use(express.json());

// Cacheable routes — public catalog data
app.use("/api/bff/catalog", cacheHeaders("public"), catalogRoutes);

// Non-cacheable routes — transactional and personal data
app.use("/api/bff/checkout", cacheHeaders("no-cache"), checkoutRoutes);
app.use("/api/bff/orders", cacheHeaders("no-cache"), orderRoutes);
app.use("/api/bff/profile", cacheHeaders("no-cache"), profileRoutes);

常见故障模式

  • 根据会话或用户身份进行缓存:为目录数据创建按用户划分的缓存(例如,按用户 ID 为商品搜索结果设置缓存键)会使存储量随用户数量增加,并失去缓存的主要优势。根据请求 URL/参数缓存公共 API 响应。对于按贸易政策区分的价格,应将贸易政策(而非用户 ID)纳入缓存键。

    // Cache key based on request parameters only — not user identity
    function buildCacheKey(path: string, params: Record<string, string>): string {
      const sortedParams = Object.entries(params)
        .sort(([a], [b]) => a.localeCompare(b))
        .map(([k, v]) => `${k}=${v}`)
        .join("&");
      return `search:${path}:${sortedParams}`;
    }
    
  • 设置极长的缓存 TTL,却不进行失效处理:数小时或数天的 TTL 会导致购物者无法看到价格变更、库存更新和新商品。应使用适中的 TTL(2-15 分钟),并结合事件驱动的失效机制和 stale-while-revalidate。

    // Moderate TTL with stale-while-revalidate
    const CACHE_CONFIG = {
      search: { ttlSeconds: 120, swrSeconds: 60 },      // 2 min + 1 min SWR
      categories: { ttlSeconds: 900, swrSeconds: 300 },  // 15 min + 5 min SWR
      product: { ttlSeconds: 300, swrSeconds: 60 },       // 5 min + 1 min SWR
      topSearches: { ttlSeconds: 600, swrSeconds: 120 },  // 10 min + 2 min SWR
    } as const;
    
  • 缺少缓存监控或可观测性:如果不衡量命中率、未命中率和过期率,就无法判断缓存是否有效,也无法判断 TTL 是否合适。添加 X-Cache 标头,并在可观测性平台中跟踪指标。

    // Add cache observability to every cached response
    interface CacheMetrics {
      hits: number;
      misses: number;
      stale: number;
    }
    
    const metrics: CacheMetrics = { hits: 0, misses: 0, stale: 0 };
    
    export function getCacheMetrics(): CacheMetrics & { hitRate: string } {
      const total = metrics.hits + metrics.misses + metrics.stale;
      const hitRate = total > 0 ? ((metrics.hits / total) * 100).toFixed(1) + "%" : "N/A";
      return { ...metrics, hitRate };
    }
    

审核清单

  • 是否在 CDN 和/或 BFF 层缓存了所有公共只读 API 响应(Search、Catalog)?
  • 是否明确使用 no-store 标头,确保不缓存交易类/个人数据 API 响应(Checkout、Profile、OMS、Payments)?
  • 所有缓存是否都设置了有上限的 TTL(不是永久或无限期)?
  • 是否有缓存失效机制(TTL + 事件驱动失效或手动清除)?
  • 缓存键是否基于请求参数,而非用户身份?
  • 是否使用了 stale-while-revalidate,以实现最佳的新鲜度/性能平衡?
  • TTL 是否适中(2-15 分钟),而非极长(数小时/数天)?
  • 是否具备缓存可观测性(X-Cache 标头、命中/未命中指标)?

参考资料

评论

支持 Markdown