← 中文资源

简体中文版本

代理键(缓存标签)

代理键是附加在响应上的缓存标签,让 CDN 一次清除与某个实体相关的所有对象,而无需枚举成千上万个 URL。

阅读英文原文

给缓存对象打标签

代理键是附加在缓存响应上的元数据,用来命名它代表的逻辑实体。一个 HTML 页面可以带 Surrogate-Key: product-123 category-shoes home-featured。缓存为这些标签建立索引,之后可以删除带某标签的所有对象,与 URL 无关。

工作机制

源站在生成响应时输出标签头,CDN 把标签与对象一起存储。商品变化时,后端用 product-123 调用清除 API,CDN 使所有带该标签的对象失效,把失效问题从 URL 枚举变成图查询。

难点在一致性:每个渲染该实体的模板都必须输出相同标签。漏掉一个标签就会留下清除不到的陈旧对象,因此通常把标签生成集中到一个辅助函数。

何时使用与取舍

当一次内容变更影响分类页、搜索结果、信息流和推荐位等多个 URL 时,标签最有效;也适合清除某租户的全部内容。只有单个 URL 且没有扇出时,按 URL 清除更简单。

标签会给每个响应增加少量头部,并为每个标签增加索引,基数因此很重要。按用户打标签可能膨胀到数百万个标签并拖慢清除。清除是最终一致的,传播需要时间;各家 CDN 的 API 和上限不同,多云部署更复杂。

示例与常见误解

零售商修改价格后清除 product-123,分类页、搜索片段和推荐轮播一次全部刷新。没有标签时,团队要计算并清除数百个 URL,而且仍会漏掉一些。

常见误解是标签属于缓存键。并非如此:请求仍按 URL 和 Vary 查找,标签只描述以后要清除什么。相关概念:内容分发网络、缓存键、缓存失效。

代码示例

import { createHash } from "node:crypto";

function getHeader(headers, name) {
  const key = Object.keys(headers).find(
    (candidate) => candidate.toLowerCase() === name,
  );
  return key === undefined ? undefined : headers[key];
}

function requireNonNegativeInteger(value, label) {
  if (!Number.isInteger(value) || value < 0) {
    throw new RangeError(`${label} must be a non-negative integer`);
  }
}

export function createEtag(body, options = {}) {
  if (typeof body !== "string" && !Buffer.isBuffer(body)) {
    throw new TypeError("body must be a string or Buffer");
  }
  const { weak = false } = options;
  const digest = createHash("sha256")
    .update(body)
    .digest("base64url")
    .slice(0, 27);
  return `${weak ? "W/" : ""}"${digest}"`;
}

export function buildCacheControl(options = {}) {
  const {
    visibility = "public",
    maxAge = 0,
    sharedMaxAge,
    staleWhileRevalidate,
    immutable = false,
  } = options;
  if (visibility !== "public" && visibility !== "private") {
    throw new RangeError("visibility must be public or private");
  }
  requireNonNegativeInteger(maxAge, "maxAge");

  const directives = [visibility, `max-age=${maxAge}`];
  if (sharedMaxAge !== undefined) {
    requireNonNegativeInteger(sharedMaxAge, "sharedMaxAge");
    directives.push(`s-maxage=${sharedMaxAge}`);
  }
  if (staleWhileRevalidate !== undefined) {
    requireNonNegativeInteger(staleWhileRevalidate, "staleWhileRevalidate");
    directives.push(`stale-while-revalidate=${staleWhileRevalidate}`);
  }
  if (immutable) {
    if (maxAge === 0) {
      throw new RangeError("immutable requires maxAge greater than zero");
    }
    directives.push("immutable");
  }
  return directives.join(", ");
}

export function cacheHeaders(options = {}) {
  const {
    body,
    maxAge = 0,
    visibility = "public",
    lastModified,
    weak = false,
    staleWhileRevalidate,
  } = options;
  if (body === undefined) {
    throw new TypeError("body is required");
  }

  const headers = {
    "Cache-Control": buildCacheControl({
      visibility,
      maxAge,
      staleWhileRevalidate,
    }),
    ETag: createEtag(body, { weak }),
  };
  if (lastModified !== undefined) {
    const date = new Date(lastModified);
    if (Number.isNaN(date.getTime())) {
      throw new RangeError("lastModified must be a valid date");
    }
    headers["Last-Modified"] = date.toUTCString();
  }
  return headers;
}

function opaqueTag(tag) {
  return tag.trim().replace(/^W\//i, "");
}

export function evaluateConditionalRequest(
  method,
  requestHeaders = {},
  responseHeaders = {},
) {
  const normalizedMethod =
    typeof method === "string" ? method.toUpperCase() : "";
  if (normalizedMethod !== "GET" && normalizedMethod !== "HEAD") {
    return { status: 200, headers: { ...responseHeaders } };
  }

  const etag = getHeader(responseHeaders, "etag");
  const lastModified = getHeader(responseHeaders, "last-modified");
  const cacheControl = getHeader(responseHeaders, "cache-control");
  const ifNoneMatch = getHeader(requestHeaders, "if-none-match");
  let notModified = false;

  if (ifNoneMatch !== undefined && etag !== undefined) {
    const candidates = ifNoneMatch.split(",").map((tag) => tag.trim());
    notModified =
      candidates.includes("*") ||
      candidates.some((tag) => opaqueTag(tag) === opaqueTag(etag));
  } else {
    const ifModifiedSince = getHeader(requestHeaders, "if-modified-since");
    if (ifModifiedSince !== undefined && lastModified !== undefined) {
      const since = Date.parse(ifModifiedSince);
      const modified = Date.parse(lastModified);
      if (!Number.isNaN(since) && !Number.isNaN(modified)) {
        notModified =
          Math.floor(modified / 1000) <= Math.floor(since / 1000);
      }
    }
  }

  if (!notModified) {
    return { status: 200, headers: { ...responseHeaders } };
  }

  const headers = {};
  if (etag !== undefined) headers.ETag = etag;
  if (cacheControl !== undefined) headers["Cache-Control"] = cacheControl;
  if (lastModified !== undefined) headers["Last-Modified"] = lastModified;
  return { status: 304, headers };
}

const body = JSON.stringify({ items: [1, 2, 3] });
const headers = cacheHeaders({
  body,
  maxAge: 60,
  staleWhileRevalidate: 30,
  lastModified: "2026-10-21T10:00:00Z",
});
console.log(headers);
console.log(evaluateConditionalRequest("GET", {}, headers).status);
console.log(
  evaluateConditionalRequest("GET", { "if-none-match": headers.ETag }, headers)
    .status,
);
console.log(
  evaluateConditionalRequest(
    "GET",
    { "if-modified-since": headers["Last-Modified"] },
    headers,
  ).status,
);
console.log(
  evaluateConditionalRequest("POST", { "if-none-match": headers.ETag }, headers)
    .status,
);
import asyncio

import httpx
from httpx_cache import CacheControlTransport

client = httpx.AsyncClient(
    transport=CacheControlTransport(
        cache=httpx_cache.FileCache(cache_dir='.http_cache'),
        cacheable_methods=('GET',),
        cacheable_status_codes=(200, 301, 404),
    )
)


async def fetch_product(product_id: int) -> dict | None:
    try:
        resp = await client.get(f'https://api.example.com/products/{product_id}')
        resp.raise_for_status()
        return resp.json()
    except httpx.HTTPStatusError:
        return None


async def warm_cache(ids: list[int]) -> int:
    results = await asyncio.gather(
        *(fetch_product(pid) for pid in ids),
        return_exceptions=True,
    )
    return sum(1 for r in results if not isinstance(r, Exception))


async def main() -> None:
    await warm_cache(list(range(10)))

    # 断网场景:文件缓存仍可回放此前成功的响应
    product = await fetch_product(3)
    print('served from cache offline:', product is not None)
    await client.aclose()


asyncio.run(main())