HTTP 的 Vary 和缓存键:CDN 上那些「明明改了却没生效」

234
234
2026-10-24 / 0 评论 / 45 阅读 / 8916 字 / 正在检测是否收录...
这篇是协议与缓存笔记。站内的加密 DNS 服务在dns.23-4.cn。

「我明明改了 CSS,刷新还是旧的。」
「这个页面在 Chrome 上是新的,在手机上还是旧的。」
「CDN 上明明 purge 了,用户说没生效。」

这类问题的共同点:你以为缓存键只包含 URL,但实际上不止。

HTTP 里有一个响应头专门用来声明「这个资源的缓存键还包含哪些请求头」——它就是 Vary。

这篇讲清三件事:Vary 到底怎么工作、写错了会有什么后果、以及怎么用命令验证你的站点有没有写错。下面所有响应头都是我从真实站点上抓的。

一、缓存键是什么

先建立一个基本认知:缓存(浏览器、CDN、反向代理)判断「这份响应能不能复用」,靠的是缓存键。

最朴素的理解是「缓存键 = URL」。但实际的缓存键通常是:

缓存键 = URL + Vary 里声明的那些请求头的值

举个例子。假设响应是:

Vary: Accept-Encoding

那么缓存会为下面两个请求分别存两份:

请求头缓存键
Accept-Encoding: gzipURL + gzip
Accept-Encoding: identityURL + identity

如果响应是 Vary: Accept-Language,那就会按语言分别缓存。

关键:Vary 里没列出来的请求头,不参与缓存键。 所以 Vary 写漏了 = 该分开缓存的没分开,用户拿到别人的版本。

二、抓一个真实站点的响应头

看一个正常配置的站点:

curl -sSI https://www.23-4.cn/ | head -20

实测:

HTTP/2 200
server: nginx
date: Fri, 23 Oct 2026 16:42:02 GMT
content-type: text/html; charset=UTF-8
vary: Accept-Encoding
x-pingback: https://www.23-4.cn/action/xmlrpc
strict-transport-security: max-age=31536000
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin

只有一个 vary: Accept-Encoding。 这是最常见的配置——因为 nginx 的 gzip 模块会自动加上它。

再看静态资源(这里的差别很有意思):

curl -sSI https://www.23-4.cn/brand.css | grep -iE "^(cache-control|expires|etag|last-modified|vary)"

实测:

last-modified: Thu, 22 Oct 2026 06:29:27 GMT
vary: Accept-Encoding
etag: "6ad9ad47-5ebc"
expires: Fri, 30 Oct 2026 16:42:02 GMT
cache-control: max-age=604800
cache-control: public

静态资源有完整的 etag / expires / cache-control: max-age=604800(7 天),HTML 页面一个缓存头都没有。

这是合理的默认:HTML 有用户态内容(登录状态、评论数),不该被中间层长缓存;静态资源内容不变,长缓存没问题。

再确认一下 HTML 确实一个都没有:

for u in https://www.23-4.cn/ https://www.23-4.cn/archives/55/; do
  printf "%-40s " "$u"
  curl -sSI "$u" | grep -icE "^(cache-control|expires|etag|last-modified)"
done

实测:

https://www.23-4.cn/                     0
https://www.23-4.cn/archives/55/         0

都是 0。 注意这是 grep -c 的计数,不是「有没有 vary」——vary 是有的,但没有缓存控制头。

这本身不算 bug,但它意味着缓存器只能靠「启发式规则」(RFC 7234 允许的)来猜缓存时长。对小站来说通常无所谓,但如果你前面挂了 CDN,CDN 会按自己的默认值来——那时候你可能不希望它缓存 HTML。

注意 cache-control 出现了两次(max-age=604800 和 public)。这在 nginx 的 expires + add_header 组合下很常见,不是 bug——HTTP 规范允许同一个头出现多次,等效于合并成一个逗号分隔的列表:

cache-control: max-age=604800, public

三、Vary: * 是灾难

如果你写:

add_header Vary "*";

后果是:这个资源永远无法被缓存。

因为 * 的语义是「缓存键包含所有可能变化的请求头」——那等于每个请求都是独一无二的。缓存命中率归零,所有请求都打到源站。

这是最严重的 Vary 错误,而且不会报错、不会有日志,只是性能悄悄变差。

判断方法:

curl -sSI https://www.example.com/ | grep -i "^vary"

出现 vary: * 就是这个情况。

四、Vary 漏了什么会怎样

场景 1:多语言站点漏了 Accept-Language

# ⛔ 错:返回不同语言,但没声明 Vary
location / {
    if ($http_accept_language ~* "zh") {
        rewrite ^ /zh$uri last;
    }
}

第一个中文用户访问 → 缓存了中文版;接着一个英文用户访问 → 拿到中文版。

正确写法:

add_header Vary "Accept-Language";

场景 2:Vary: Accept-Encoding 漏了

如果你的站点开了 gzip,但没声明 Vary: Accept-Encoding,可能出现:一个支持 gzip 的客户端先访问,缓存了 gzip 版本;接着一个不支持 gzip 的客户端拿到 gzip 响应 → 乱码/解压失败。

nginx 的 gzip 模块会自动加这个头,但如果你用了 gzip_static 或者自己在 CDN 上做压缩,就需要手动确认。

验证缓存是否真的按 Accept-Encoding 分开了:

for ae in gzip identity; do
  printf "AE=%-10s " "$ae"
  curl -sSI -H "Accept-Encoding: $ae" https://www.23-4.cn/ | grep -iE "^(content-encoding|vary)" | tr '\n' ' '
  echo
done

实测:

AE=gzip       vary: Accept-Encoding content-encoding: gzip
AE=identity   vary: Accept-Encoding

关键观察:请求 gzip 时返回了 content-encoding: gzip,请求 identity 时没有这个头。两个版本的响应体不同——所以必须有 Vary: Accept-Encoding 来告诉缓存「这两个不是同一份」。

如果这里没有 vary,一个缓存就可能把 gzip 版本发给不支持 gzip 的客户端。

场景 3:Vary 写多了,缓存命中率暴跌

反过来也有问题。写了 Vary: User-Agent:

Vary: User-Agent

User-Agent 的取值空间是几万到几十万种(每个浏览器版本、每个爬虫、每个 SDK 都不同)。结果就是缓存里每个 URL 有几万个副本,命中率接近 0,还浪费存储。

Vary: User-Agent 几乎总是错的。 要按设备区分,应该在 URL 或 cookie 上做,不是在 Vary 上做。

五、一个更隐蔽的问题:Vary 在 CDN 上会「被吃掉」

很多 CDN 默认不按 Vary 分缓存,或者只支持有限的几个字段(通常只支持 Accept-Encoding)。

这意味着:你源站写了 Vary: Accept-Language,但 CDN 忽略了它,仍然按 URL 缓存。

验证方法:直接对比「直连源站」和「走 CDN」的响应头。

# 直连源站(绕过 DNS)
curl -sSI --resolve www.example.com:443:ORIGIN_IP https://www.example.com/ | grep -i "^vary"

# 走 CDN
curl -sSI https://www.example.com/ | grep -i "^vary"

两个 vary 应该一致。如果不一致(比如源站有 Accept-Language,CDN 只返回 Accept-Encoding),说明中间层改了它。

这类问题只能靠对比发现,因为两边单独看都「正常」。

六、怎么验证缓存行为是对的

验证 1:看响应头齐不齐

curl -sSI https://www.example.com/ | grep -iE "^(cache-control|expires|etag|last-modified|vary|age)"
头作用缺失的后果
vary声明缓存键的额外维度缓存串味(给别人返回错误版本)
cache-control声明缓存时长和范围缓存器用启发式规则猜,可能缓存太久
etag / last-modified支持条件请求(304)每次都要全量传输
age已被缓存多久(CDN 加)无法判断命中情况

验证 2:304 是否正常工作

# 先拿 ETag
ETAG=$(curl -sSI https://www.23-4.cn/brand.css | grep -i '^etag' | tr -d '\r' | cut -d' ' -f2)
echo "ETag = $ETAG"

# 再用 If-None-Match 请求,应该返回 304
curl -sS -o /dev/null -w "code=%{http_code} size=%{size_download}\n" \
  -H "If-None-Match: $ETAG" https://www.23-4.cn/brand.css

期望 code=304 size=0。 如果返回 200 且 size 不为 0,说明条件请求没生效——每个用户每次都要重新下载。

实测这个站点:

ETag="6ad9ad47-5ebc"
with-INM code=304 size=0
no-INM   code=200 size=24252

条件请求工作正常:带上 If-None-Match 拿到 304 且传输 0 字节,不带则是完整体积。这一条是「静态资源缓存配置正确」最硬的证据。

踩坑点:ETag 的值带引号("6ad9ad47-5ebc")。提取的时候别把引号弄丢,更别自作聪明去掉:

# ✅ 正确:整行取第二个字段,引号原样保留
etag=$(curl -sSI "$URL" | grep -i '^etag' | tr -d '\r' | cut -d' ' -f2)
# → "6ad9ad47-5ebc"

# ⛔ 错误:tr -d '"' 去掉引号后,服务端会认为 ETag 不匹配,返回 200

而且注意响应头行尾有 \r(cat -A 能看到 ^M$)。不 tr -d '\r' 的话,变量里会带一个回车,-H 传过去就是坏值。

验证 3:404 页面有没有被缓存

这是一个经典的坑:某个 URL 曾经 404,被 CDN 缓存了,之后你发布了这个页面,用户还是看到 404。

curl -sSI https://www.example.com/some-page/ | grep -iE "^(HTTP|cache-control|vary)"

实测这个站点的 404:

HTTP/2 404
vary: Accept-Encoding
x-pingback: https://www.23-4.cn/action/xmlrpc
strict-transport-security: max-age=31536000

404 没有 cache-control: no-store。 这意味着 CDN 可能用启发式规则缓存这个 404——比如几百秒。

要避免这个坑,给错误页加上明确的缓存指令:

error_page 404 /404.html;

location = /404.html {
    internal;
    add_header Cache-Control "no-store" always;
}

always 参数很重要——它让这个头对错误响应也生效(默认 add_header 只对 2xx/3xx 生效,这是个经典陷阱)。

顺便说一句:这个站点没有自定义 404 页面(/var/www/typecho/404.html 不存在,nginx 配置里也没有 error_page),404 是 Typecho 自己渲染的。所以上面那段 nginx 配置是「如果要加该怎么加」,不是对本站现状的描述。

踩坑点:add_header 不加 always 时,nginx 只在 200/201/204/206/301/302/303/304/307/308 上加头。404、500 这些全都不加——这正好是缓存错误页最危险的地方。

七、nginx 里的正确写法

server {
    # 默认:所有响应按 Accept-Encoding 区分(gzip 模块通常会自动加,这里是显式兜底)
    add_header Vary "Accept-Encoding" always;

    # HTML 不缓存(有用户态内容)
    location ~ \.php$ {
        add_header Cache-Control "no-cache, must-revalidate" always;
    }

    # 静态资源长缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
        expires 7d;
        add_header Cache-Control "public" always;
    }

    # 错误页绝不缓存
    location = /404.html {
        internal;
        add_header Cache-Control "no-store" always;
    }
}

注意 add_header 的继承规则(另一个经典陷阱):如果某个 location 里写了任何 add_header,父级的 add_header 就全部失效——不是合并,是覆盖。

所以上面的配置里,每个 location 都得自己写一遍 Vary,否则静态资源那一段就没有 Vary 头了。

想避免这个坑,用 map + 变量,或者依赖 nginx 的 gzip 自动加(但那只覆盖 gzip 场景)。

八、诊断清单

URL=https://www.example.com/

echo "=== 1. Vary 是否合理(出现 * 就是灾难)==="
curl -sSI "$URL" | grep -i "^vary"

echo "=== 2. 是否有缓存控制 ==="
curl -sSI "$URL" | grep -iE "^(cache-control|expires)"

echo "=== 3. 压缩版本是否分开 ==="
for ae in gzip identity; do
  printf "  AE=%-10s " "$ae"
  curl -sSI -H "Accept-Encoding: $ae" "$URL" | grep -iE "^(content-encoding|vary)" | tr '\n' ' '
  echo
done

echo "=== 4. 条件请求是否工作 ==="
ETAG=$(curl -sSI "$URL" | grep -i '^etag' | tr -d '\r' | cut -d' ' -f2)
[ -n "$ETAG" ] && curl -sS -o /dev/null -w "  304 测试: code=%{http_code}\n" -H "If-None-Match: $ETAG" "$URL" || echo "  无 ETag"

echo "=== 5. 错误页是否被缓存 ==="
curl -sSI "${URL}definitely-not-exist/" | grep -iE "^(HTTP|cache-control)"

怎么判断结果是对的:

  1. vary 里不能有 *,不该有 User-Agent;
  2. 返回不同内容的分支(gzip/identity、zh/en)必须在 vary 里;
  3. cache-control 要和内容性质匹配(HTML 短、静态资源长、错误页 no-store);
  4. 304 测试要返回 304;
  5. 错误页要能明确看到 no-store,而不是什么都没有。

九、一句话总结

Vary 是「缓存键的额外维度声明」。写漏了会导致用户拿到别人的版本,写多了会导致命中率归零,写 * 会导致完全不缓存。

而它最容易出问题的地方不在语法,在于:

  • CDN 可能忽略它(要对比源站和 CDN 的响应头);
  • add_header 在 location 里会覆盖父级(每个 location 都要重写);
  • add_header 不加 always 对错误页无效(错误页正是最不该被缓存的)。

这三个都不是"配置写错了",而是"配置看起来对但行为不对"——所以只能靠 curl -I 实际抓一遍,不能靠读配置文件判断。

相关阅读

0个赞
取消