← 中文资源

简体中文版本

缓存键规范化

规范化缓存键把语义相同但顺序、大小写或无关参数不同的请求合并为一条记录,提升命中率而不误合并变体。

阅读英文原文

概念概述

缓存键规范化(Cache Key Normalisation)指的是:规范化缓存键把语义相同但顺序、大小写或无关参数不同的请求合并为一条记录,提升命中率而不误合并变体。

从更严格的定义看,Cache key normalisation is the process of canonicalising a request’s identity, such as sorting query parameters and trimming insignificant values, so equivalent requests share one entry. 该概念在工程实践中的价值,在于它能帮助团队用统一的语言描述一个具体的质量属性或设计约束,从而在评审与协作中减少歧义。

为什么需要它

It matters for API caches and CDNs where clients construct the same logical request in many textual forms. 它与 Read-Through Caching、Negative Versus Positive Caching、Cache Preloading and Warming 等概念经常一起出现,用于解决相近的工程问题。理解它的适用场景,有助于在多种备选方案之间做出有依据的取舍,而不是凭直觉选择技术方案。

常见误解

  • the raw URL is always a correct cache key
  • normalising keys cannot cause incorrect responses

需要避免的反模式

  • including volatile parameters such as a request identifier in the key
  • normalising away a parameter that actually changes the response

实践要点

  1. Two URLs that differ only in parameter order or default values denote the same resource, so treating them as distinct splits the cache and lowers the hit rate.
  2. Over-normalisation is the opposite risk: dropping or reordering a parameter that does change the response causes one variant to be served for another request.
  3. The key should include exactly the inputs that affect the response, which means auditing vary behaviour and the parameter set whenever the endpoint changes.

参考来源

https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#cache_keys

代码示例

// 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,
);