用户登录

技术文章

外部链接将在新标签页打开。

HTTP 缓存与 CDN 加速:从响应头到命中率验证

一句话回答

HTTP 缓存与 CDN 加速的关键在于用 Cache-Control 写明复用规则、用缓存键与 Vary 控制命中粒度、用 ETag 与版本化 URL 管理更新,避免仅添加一个加速域名、开启回源后就结束;并用脚本把这些语义固化为可断言的回归测试。

核心要点 TL;DR
  • 缓存先保证正确性再谈命中率,顺序反了,省下的带宽会变成排障工时
  • 用 Cache-Control 的 max-age、s-maxage、no-cache、SWR 显式表达复用规则
  • 缓存键被时间戳参数、参数顺序和 Vary 拆碎,是命中率低的主要原因
  • ETag 条件请求返回 304,配合版本化 URL 与精确 purge 可控地更新内容
  • 把缓存语义写成脚本断言进 smoke test,再用命中率与回源 QPS 验证

一、缓存要先说清楚,再开起来#

很多团队接 CDN(Content Delivery Network,内容分发网络) 的流程是:加一条加速域名、开个回源、压测一次,看到 TTFB(Time To First Byte,首字节时间) 降了就收工。但缓存真正决定的是三件事:谁可以复用这个响应、能复用多久、过期或回源失败时怎么办。这三件事没有标准答案,HTTP 头就是把你的选择显式写下来。

最近围绕 “Slop-Free AI Programming” 的讨论里有个共识:AI 能很快产出一份看起来合理的 nginx 配置或 CDN(Content Delivery Network,内容分发网络) 规则,但它不会替你验证这条规则在真实流量下产生了什么行为。缓存尤其如此:错的缓存配置不会报错,只会安静地返回过期数据,或者在密钥维度上串了数据。所以本文的路径是:先把语义写对,再用脚本证明它是对的。

缓存的第一原则是正确性,第二原则才是命中率。顺序反了,省下的带宽会以排障工时的形式还回去。

二、用响应头把缓存语义写下来#

先把常用指令的语义对齐,再决定每个接口用哪一组:

指令 / 头含义典型用法
max-age私有缓存(浏览器)的新鲜期静态资源、图片
s-maxage共享缓存(CDN(Content Delivery Network,内容分发网络))的新鲜期,优先于 max-age公共只读接口
no-cache可以存,但每次使用前必须回源验证需要强一致又想省带宽
no-store任何缓存都不得存储密钥、账单、个人信息
stale-while-revalidate过期后先返回旧值,同时后台刷新高 QPS(Queries Per Second,每秒请求数) 的读接口
stale-if-error回源失败时用旧值兜底依赖上游的聚合接口
ETag / Last-Modified验证器,配合条件请求返回 304内容会变但不频繁

先看一眼线上真实响应头,注意用 HEAD 之外的 GET 请求,避免部分服务端对 HEAD 的处理差异:

示例说明:以下代码仅用于逻辑验证,生产环境需补充异常处理、超时与重试、日志与监控告警及降级策略。

curl -sS -D - -o /dev/null \
  "https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8"

重点看三个地方:有没有 Cache-Control;有没有 Set-Cookie 或 private(它们会让公共缓存倾向于跳过这个响应,这既是保护,也可能是命中率低的根因);有没有 Vary。像 IP 查询接口 这类结果依赖查询参数、且对同一 IP 相对稳定的接口,是天然的边缘缓存候选;而带密钥的请求要单独判断,见下一节。

三、缓存键与 Vary:命中率的大头在这里#

缓存键大致等于:请求方法 + 主机 + 规范化后的路径 + 查询串 + Vary 中列出的请求头。命中率上不去,八成是缓存键被拆碎了。

  • 别带时间戳参数。 给请求追加 &t=1730000000 做“防缓存”,等于把命中率按到 0,还会把边缘存储写满。要绕过缓存,用 Cache-Control: no-cache 的请求头或单独的调试开关,而不是改 URL。
  • 参数顺序要统一。 ?ip=8.8.8.8&apikey=K 与 ?apikey=K&ip=8.8.8.8 在某些 CDN(Content Delivery Network,内容分发网络) 上就是两个缓存对象。要么在 SDK 里固定顺序,要么在 CDN 上开启查询参数规范化。
  • 密钥维度的取舍。 带 apikey 的请求,不同 key 的配额与返回内容可能不同,按 key 隔离缓存是对的;但要确认密钥不会出现在可公开访问的缓存键或日志里。如果无法保证,就给这些响应加 private 或在 CDN(Content Delivery Network,内容分发网络) 上配置按路径绕过缓存。
  • Vary 只放真正影响响应的头。 Vary: Accept-Encoding 基本是必开项(gzip / br 是不同变体);把 User-Agent 放进 Vary 会让几乎每个请求都变成独立对象,属于典型的自伤。

以 随机动漫壁纸接口 为例,https://www.api.plus/api/dongman?type=json 的 type 不同就是不同资源,缓存键天然区分;如果接口行为是“每次返回不同图”,那就应该给很短的 s-maxage 或干脆不缓存,而不是指望 CDN(Content Delivery Network,内容分发网络) 帮你兜住随机性。

四、条件请求与边缘失效:安全地换内容#

内容会变但变化不频繁时,用验证器比用短 TTL 更划算。服务端返回 ETag,客户端带上 If-None-Match,内容没变就回 304,省掉整个响应体:

URL="https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8"
ETAG=$(curl -sS -D - -o /dev/null "$URL" \
  | awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r')
echo "ETag: $ETAG"
curl -sS -o /dev/null -w "%{http_code}\n" \
  -H "If-None-Match: $ETAG" "$URL"

如果第二次打印 304,说明验证链路是通的。ETag 建议基于响应体的摘要或业务版本号生成,不要用进程启动时间这类每次都变的值,那样等于没有验证器。

失效策略按可控性排序:

  1. 版本化 URL:把版本放进查询串或路径(如 ?type=json&v=2),发布即生效,不依赖 purge 的传播速度。
  2. 精确 purge:按缓存键删除,必须保证线上请求的查询串与 purge 时完全一致,否则清了个寂寞。
  3. 短 TTL + SWR:放弃“立刻生效”,换取实现简单和回源平滑。

注意 POST 示例接口 这类写操作默认不进共享缓存,别指望 CDN 缓存它;幂等的查询如果要用 POST,只能在应用层做缓存。

五、SWR 与 stale-if-error:把缓存当可用性预算#

一个实用的读接口头可以长这样:

Cache-Control: public, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400

它的意思是:边缘新鲜 60 秒;60 到 360 秒之间返回旧值并在后台刷新;回源出错时,最多可以拿一天内的旧值兜底。对上游偶发抖动的聚合类接口,这比直接 5xx 体面得多。

这类配置在流量突发时价值更高。Cloudflare 2026 Q3 的 DDoS 报告提到攻击正在变成“更多蜂群、更少巨兽”——单次时间更短、频率更高,源站面对的是密集的小突发而不是一次长打。缓存层能吸收掉一部分读流量,把回源 QPS(Queries Per Second,每秒请求数) 压到源站真正能承受的量级,这本身就是一种降级能力。

六、把缓存约束写成回归测试#

这是 Slop-Free 最直接的落地方式:不要只给 Agent 一份配置文件,给它一份可执行的契约。下面这段脚本对线上真实 URL 断言响应头,放进部署流水线的 smoke test 里:

#!/usr/bin/env bash
set -euo pipefail

check() {
  local url="$1"; shift
  local hdr; hdr=$(mktemp)
  curl -sS -D "$hdr" -o /dev/null "$url"
  for rule in "$@"; do
    if ! grep -qiE "$rule" "$hdr"; then
      echo "FAIL $url -> $rule"; rm -f "$hdr"; return 1
    fi
  done
  echo "PASS $url"; rm -f "$hdr"
}

check "https://www.api.plus/api/dongman?type=json" \
  '^cache-control:.*(s-maxage|max-age)=' \
  '^vary:.*accept-encoding'

check "https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8" \
  '^cache-control:'

上面断言的是“期望契约”,请按你站点当前的真实响应调整具体规则。断言的对象应该是语义(有没有新鲜期、有没有 Vary),而不是某个精确的秒数——精确值会随业务调整,语义不应该随便变。

七、观测:判断缓存是否真的在工作#

配置上线后,至少盯住这几项:

  • 边缘命中率,按路径分组看,整体数字会被少数热点路径带偏。
  • 回源 QPS(Queries Per Second,每秒请求数) 与带宽,这是缓存真正的收益指标。
  • TTFB(Time To First Byte,首字节时间) 的 p50 / p95,并区分命中与未命中,否则两组数据混在一起看不出问题。
  • 304 比例,验证器是否真的被用上。
  • 单缓存键请求量 Top N,用来发现参数碎片化。
  • stale 命中次数与错误率,判断降级是否被频繁触发。

快速自检的办法是对同一 URL 连发三次,看响应里的缓存状态头(不同 CDN 命名不同,常见如 X-Cache、CF-Cache-Status)是否从 MISS 变成 HIT。如果一直是 MISS,先回去查第三节的缓存键。

八、总结#

HTTP 缓存和 CDN 加速不是“开个开关”,而是把三件事写成可验证的契约:用 Cache-Control 表达复用规则,用缓存键和 Vary 控制命中粒度,用 ETag 与版本化 URL 控制内容更替,最后用脚本把这些语义钉在流水线里。这套做法对人和对 AI 生成的配置同样适用——能被断言的行为,才值得被信任。

想直接拿真实接口练手,可以在 API.PLUS 注册获取免费 API Key,用 IP 查询接口 和 随机动漫壁纸接口 跑一遍本文的响应头检查与命中率观测;更多接入细节见 API 文章中心,遇到问题可走 技术支持。

常见问题(FAQ)

CDN 开了加速后命中率一直很低,最可能的原因是什么?
大概率是缓存键被拆碎了:URL 里带时间戳或随机参数、查询参数顺序不统一、Vary 里放了 User-Agent 这类无关请求头,都会让同一个资源变成多个缓存对象。可以先按路径分组查看单个缓存键的请求量 Top N,定位碎片化来源。
带 apikey 的接口能不能放在 CDN 上缓存?
可以按密钥维度隔离缓存,但必须确认密钥不会出现在可公开访问的缓存键或日志里;无法保证时,就给响应加 private,或在 CDN 上按路径绕过缓存。密钥、账单、个人信息类响应应使用 no-store,任何缓存都不得存储。
no-cache 和 no-store 有什么区别,什么时候用哪个?
no-cache 允许存储,但每次使用前必须回源验证,适合需要强一致又想省带宽的场景;no-store 则要求任何缓存都不得存储,用于密钥、账单、个人信息等敏感响应。二者语义不同,不能互换使用。
stale-while-revalidate 和 stale-if-error 有什么实际价值?
s-maxage=60 配合 SWR 300,表示过期后先返回旧值并在后台刷新;stale-if-error 可在回源失败时用旧值兜底,最长可达一天。对上游偶发抖动的聚合类接口,这比直接返回 5xx 更合适,等于把缓存当成可用性预算。
怎么快速验证 CDN 缓存是否真的生效?
对同一 URL 连发三次,观察响应中的缓存状态头(如 X-Cache、CF-Cache-Status)是否从 MISS 变成 HIT;若一直是 MISS,就回去查缓存键。同时观察边缘命中率、回源 QPS、区分命中与未命中的 TTFB p50/p95,以及 304 比例。

作者:沐小悠

本文约 12 分钟读完 · 关键词:HTTP 缓存、CDN 加速、Cache-Control、缓存命中率、Vary

更多文章 GET 示例接口

「」标签下的其他文章