← 中文资源

简体中文版本

缓存键

缓存键是缓存条目的派生身份,由方法、URL 和 Vary 选定的请求头组成,决定两个请求能否共享同一份缓存。

阅读英文原文

缓存键包含什么

缓存键至少包含请求方法和目标 URI。HTTP 缓存还会加入响应 Vary 中列出的请求头,以及缓存保存多个变体时的状态码。没有进入键的维度会被视为无关,这正是缺陷的来源。

工作机制

CDN 收到 GET /products?page=1&sort=asc 与 GET /products?sort=asc&page=1 时,如果不规范化参数顺序,会当成两个键。响应带 Vary: Accept-Encoding 时,gzip 与 Brotli 变体分开保存;Vary: Accept-Language 则按语言分开。

应用层缓存通常自定义键,如 entity:tenant:version。最安全的做法是只包含真正改变正文的维度:租户、语言、表示形式和授权范围。

何时使用与取舍

在设计 CDN 边缘、昂贵片段和多租户共享缓存时显式设计键。用户私有数据要么绕过共享缓存,要么把用户身份纳入键并标记为 private。参数组合过多时,应限制参与键的参数。

键越细,正确性越好,但命中率下降、存储上升;键越粗,命中率越高,却可能把一个租户的数据返回给另一个租户,这是泄露而不是性能问题。规范化能提高命中率,但不能合并语义不同的请求。

示例与常见误解

某商店加入 Vary: Accept-Encoding, Accept-Language 并规范化参数顺序,等价 URL 共享条目,命中率上升。后来新增 currency Cookie 却没有加入键,价格便跨地区串了。

常见误解是 URL 唯一标识响应。请求头、Cookie 和认证上下文都会改变正文,必须纳入键或明确排除在缓存之外。相关概念:HTTP 缓存、内容分发网络、缓存失效。

代码示例

// Cache-busting URL builder for static assets.
export function withCacheBusting(url, version, param = "v") {
  if (typeof url !== "string" || url.length === 0) {
    throw new TypeError("url must be a non-empty string");
  }
  if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(param)) {
    throw new TypeError("param must be a valid query parameter name");
  }
  const base = "https://cache-busting.invalid";
  const parsed = new URL(url, base);
  if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
    throw new TypeError("only http(s) URLs are supported");
  }
  parsed.searchParams.set(param, String(version));
  if (parsed.origin === base) {
    return parsed.pathname + parsed.search + parsed.hash;
  }
  return parsed.toString();
}

export function hashAssetContent(content) {
  let hash = 0x811c9dc5;
  const text = String(content);
  for (let index = 0; index < text.length; index += 1) {
    hash ^= text.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193);
  }
  return (hash >>> 0).toString(36);
}
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,
);