这篇是协议与缓存笔记。站内的加密 DNS 服务在dns.23-4.cn。
「我明明改了 CSS,刷新还是旧的。」
「这个页面在 Chrome 上是新的,在手机上还是旧的。」
「CDN 上明明 purge 了,用户说没生效。」
这类问题的共同点:你以为缓存键只包含 URL,但实际上不止。
HTTP 里有一个响应头专门用来声明「这个资源的缓存键还包含哪些请求头」——它就是 Vary。
这篇讲清三件事:Vary 到底怎么工作、写错了会有什么后果、以及怎么用命令验证你的站点有没有写错。下面所有响应头都是我从真实站点上抓的。
一、缓存键是什么
先建立一个基本认知:缓存(浏览器、CDN、反向代理)判断「这份响应能不能复用」,靠的是缓存键。
最朴素的理解是「缓存键 = URL」。但实际的缓存键通常是:
缓存键 = URL + Vary 里声明的那些请求头的值举个例子。假设响应是:
Vary: Accept-Encoding那么缓存会为下面两个请求分别存两份:
| 请求头 | 缓存键 |
|---|---|
Accept-Encoding: gzip | URL + gzip |
Accept-Encoding: identity | URL + 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-AgentUser-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=31536000404 没有 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)"怎么判断结果是对的:
vary里不能有*,不该有User-Agent;- 返回不同内容的分支(gzip/identity、zh/en)必须在
vary里; cache-control要和内容性质匹配(HTML 短、静态资源长、错误页 no-store);304测试要返回 304;- 错误页要能明确看到
no-store,而不是什么都没有。
九、一句话总结
Vary 是「缓存键的额外维度声明」。写漏了会导致用户拿到别人的版本,写多了会导致命中率归零,写 * 会导致完全不缓存。
而它最容易出问题的地方不在语法,在于:
- CDN 可能忽略它(要对比源站和 CDN 的响应头);
add_header在location里会覆盖父级(每个 location 都要重写);add_header不加always对错误页无效(错误页正是最不该被缓存的)。
这三个都不是"配置写错了",而是"配置看起来对但行为不对"——所以只能靠 curl -I 实际抓一遍,不能靠读配置文件判断。