缓存键配错:Vary头与CDN串号排查

事故:一个没人配的头,让 CDN 把敏感页面存了三天

去年帮朋友看一个二手交易论坛,他遇到一件很怪的事:有用户投诉,A 账号登录之后,打开首页看到的是 B 账号的头像和私信角标。刷新两次又好了。朋友第一反应是「PHP session 串号」,把 session 目录翻了一遍,又重启了 PHP-FPM,问题照旧。第二次投诉是三天后,同一台机器,同一个现象。

真正的原因跟 PHP 一点关系都没有:他的站点前面挂了一个 CDN,首页对未登录用户做了缓存,缓存时间三天。而那块显示「登录状态」的 HTML 片段是服务端直接渲染进页面的(Typecho 和 WordPress 都是这么干的,用户名、未读数全在 HTML 里)。CDN 把第一个访客的整页 HTML 存了下来,之后所有人在缓存未过期前拿到的都是同一份——包括那份带着别人用户名的 HTML。

这件事让朋友很后怕:如果页面上带着的是一次性重置密码的 token,或者一个后台管理地址,那就是数据泄漏。而它的根源其实只有一个字——没有发 Vary: Cookie。

这篇文章不讲「怎么秒开」,讲的是缓存体系里最难被发现、代价也最大的那一层:缓存键。一个响应被谁共用、被什么区分,决定了你的缓存到底是加速器还是泄漏源。我会把这些年在几个站上踩过的坑按「共用范围」重新排一遍,从浏览器一直到数据库,每一层都给出「该怎么配、配错了会怎样」。

先搞清楚:五种缓存各管一段,共用范围完全不同

很多人把「缓存」当成一件事,实际上一个页面从点击到渲染,中间至少经过五个可以存东西的地方,它们的生命周期、失效方式、共用对象全都不一样:

  • 浏览器私有缓存:只服务当前这一个浏览器,别的用户拿不到。所以这里放什么都安全,问题只在于「会不会过期太晚导致用户看到旧东西」。
  • CDN 边缘缓存:服务一整片地区的所有访客。它默认不认识 Cookie,除非你显式告诉它。这是本文事故的主因。
  • 反向代理整页缓存(proxy_cache / fastcgi_cache):服务你后面所有用户,键由你自己定义,灵活性最高,也最容易配错。
  • 进程内存缓存(APCu / OPcache):只在一个 PHP-FPM 进程里有效。它在同一台机器的多个 worker 之间都不共享,经常出现「十个用户九个看到新的、一个有旧数据」。
  • 共享缓存(Redis / Memcached):全站共用,键设计错了会串号;但它是唯一能在多台机器之间保持一致的一层。

写出这张表之后,判断规则就很简单了:凡是响应内容会随「当前是谁」而变化的,就绝不能进入任何共享缓存,除非你的缓存键里带上了「当前是谁」的标识。下面所有细节都是这句话的展开。

Vary 头到底在说什么

Vary 是 HTTP 响应头,含义是「这个响应的内容取决于哪些请求头」。它工作的方式是给缓存的键加维度。

假设你的 HTML 响应带 Vary: Cookie, Accept-Encoding,那么缓存里存一个页面的键就是「URL + Cookie 值 + Accept-Encoding 值」的组合。用户 A 请求时带 Cookie: PHPSESSID=abc123,用户 B 带 Cookie: PHPSESSID=def456,两者键不同,缓存自然分开,不会串号。

反过来,如果你的响应没有任何 Vary,CDN 默认只用 URL 当键(可能加上 Host、Accept-Encoding 之类它自己决定的维度),于是所有人都拿到同一份。这就是事故的完整机制。

但这里立刻出现一个反向的坑:Vary: Cookie 在缓存服务器上的实际效果,往往是「缓存命中率归零」。因为每个访客的 Cookie 都不同(里面哪怕只有统计工具的 ID 也不同),键就永远不重复,所有请求都回源,缓存等于关闭。用 CDN 的同学如果随手加上 Vary: Cookie,会发现回源量暴涨、账单跟着涨,还不知道为什么。

所以真正专业的做法不是「加 Vary」,而是分两步:

第一步,先做「内容分级」:把站点的 URL 分成两类——「不管谁来看都一样」的(文章正文、分类列表、静态资源、公告)和「因人而异」的(首页带登录态、个人中心、购物车、后台、带 token 的链接)。

第二步,因人而异的那些,直接禁止缓存,用 Cache-Control: private, no-cache 或 no-store,同时用 CDN 的规则把这些路径排除在缓存之外。不管谁来看都一样的那些,可以放心长缓存,并且在 Nginx 层把 Cookie 对缓存的影响彻底切断。

「禁止缓存」听起来损失性能,但现实是:这些页面本来就只有登录用户访问,量很小,而且它们最不该被共享。90% 的流量来自匿名访客看文章,那部分缓存住就够了。

Nginx 整页缓存最实用的三行:把 Cookie 从键里摘掉

如果你的站点自己用 Nginx 做整页缓存(fastcgi_cache 或 proxy_cache),最常见的配置错误是让 Cookie 混进了缓存键。Nginx 的 fastcgi_cache_key 默认是 $scheme$request_method$host$request_uri,看起来不含 Cookie,似乎没问题——但真正的麻烦在另一头:Nginx 默认对带 Cookie 的请求也要缓存,除非你说 fastcgi_no_cache 或 fastcgi_cache_bypass。

这意味着一个登录用户访问文章页,他的那份「带完整登录态」的 HTML 被存进了缓存,然后下一个匿名访客拿到的就是这份。回到本文开头的事故——CDN 是外因,源站压根就没把它拦住。

一个可以直接抄的写法是:

set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
if ($cookie_comment_author != "") { set $skip_cache 1; }
if ($cookie_comment_author_email != "") { set $skip_cache 1; }
if ($cookie_wordpress_logged_in != "") { set $skip_cache 1; }
if ($request_uri ~* "/wp-admin/|/wp-login.php|/admin/|/login/") { set $skip_cache 1; }
if ($http_cookie ~* "PHPSESSID=(.+)(;|$)") { set $skip_cache 1; }

fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache_valid 200 301 302 10m;
fastcgi_cache_valid 404 1m;
fastcgi_cache_use_stale error timeout updating http_500 http_503;
fastcgi_cache_lock on;
fastcgi_cache_lock_timeout 5s;
add_header X-Cache-Status $upstream_cache_status always;

几个必须解释的点(这套配置我在三台机器上跑了两年,线上很稳):

只认「登录标识 Cookie」,不认所有 Cookie。 上面用 $http_cookie ~* "PHPSESSID=..." 匹配的是会话 Cookie 名,而不是 $cookie_x 一条条枚举。这是最省心也最不容易漏的写法:PHP 的会话 Cookie 名固定,用户一登录就有,匿名访问也不会有。

带查询串的请求一律不缓存。 这一条很多人觉得浪费,但它是「搜索页被灌爆」最有效的防御。个人站的搜索页、带分页参数的列表页、带 ?from=xxx 的追踪链接,缓存价值都远低于风险。

fastcgi_cache_lock on 是必备的。 没有它,缓存刚过期的那一瞬间,一百个并发请求会一起去打数据库重建,于是「缓存越热,尖峰越像 DDOS」。开启后,Nginx 只放一个请求回源,其余等待。配合 use_stale updating,等待期间还能先返旧内容。

X-Cache-Status 一定要加。 没有这个头,你永远不知道缓存到底命中了没有,排查基本靠猜。它在值班时的价值极高,curl -I 一眼就能看到 HIT、MISS、BYPASS、EXPIRED、STALE 五个状态。

排查时最常见的误判是「配置改了没生效」。要排除这种可能,用 nginx -T 看真正加载的合并后配置,而不是看你改的那个文件——include 顺序、if 的覆盖关系都可能导致你以为的那段根本没生效。这一点在调试缓存规则时踩过不止一次。

Vary: Accept-Encoding 与 gzip 的互相残杀

另一个高频的缓存键问题跟压缩有关。Vary: Accept-Encoding 的意思是「这个响应取决于客户端是否支持 gzip」,本身是正确且必要的。但如果你在 Nginx 里手动写 gzip on 而没有发 Vary,中间那层 CDN 会缓存一份响应(可能压了也可能没压),然后发给所有人。结果是:有人拿到一份乱码,有人拿到一份体积巨大的原文。

正确的组合有三条:

  • Nginx 自动为 gzip 生成 Vary 的前提是它自己处理压缩。现在多数 CDN 会在边缘做压缩,源站的 Vary 可能被覆盖。
  • 如果你在 CDN 面板里开了「Brotli 压缩」,务必确认它支持 Vary 或者干脆在边缘统一压缩、源站关压缩,避免双重处理。
  • 不要两边都开又都不发 Vary,这是「内容乱码 + 体积翻倍」双重症状的来源。

验证方法很简单:curl -sI -H "Accept-Encoding: gzip" URL 和 curl -sI URL 各打一次,对比 Content-Length 和 Vary 是否一致。如果不一致而且没有 Vary,那就是个定时炸弹。

Set-Cookie 会「悄悄杀死」缓存

还有一类问题方向相反:不是缓存泄漏,而是缓存彻底不工作。表现是 CDN 命中率常年 0%,回源量等于全站 PV,但你查配置觉得一切正常。

最常见的原因是源站在响应里发了 Set-Cookie。很多 CDN(Cloudflare 的免费版最典型)的默认行为是「只要响应里有 Set-Cookie 就不缓存」。而 PHP 的 session_start() 在没有会话时会主动发一个 Cookie,加上一些统计脚本、评论系统、防刷 SDK 都喜欢给每个访客种 Cookie,于是「每一页都有 Set-Cookie」,缓存全线失效。

处理办法是把这个头的来源掐掉:

  • 文章页、列表页这类公开页面,不要在里面启动 session。如果框架强制启动,用 session_write_close() 尽早结束,并且不要在里面种业务 Cookie。
  • Nginx 侧可以把上游的 Set-Cookie 摘掉:fastcgi_hide_header Set-Cookie;(仅限那些确实不需要 Cookie 的 location)。这个操作很猛,用之前一定要确认那个路径真的不需要会话。
  • CDN 面板里如果有「忽略 Cookie 缓存」或「缓存一切」的开关,谨慎使用:这等于把上面那起事故主动做出来一遍。

顺带一提,WordPress 站点如果用了页面缓存插件,插件通常会在 HTML 底部注入一行注释来告诉你「这是缓存版本」。当你在浏览器里看不到这行注释、但配置说缓存已开启,基本可以断定是 Cookie 或 CDN 规则把它挡住了。

浏览器私有缓存:安全,但带来「我改了怎么还是旧的」

前端资源(CSS/JS/图片)常见的配置是 Cache-Control: public, max-age=31536000, immutable,意思是「这一年都别来问我」。这么做性能最好,但必须搭配一个前提:文件名里带内容指纹。文件内容一变,文件名就变,URL 变了自然就是新文件。

反过来的失败案例我见过好几次:资源没有指纹,却给了 max-age=86400。结果发版后,一部分用户看到的还是旧 CSS,布局全乱;客服三天后还在收到「页面错位」的投诉。这种情况下临时方案只能是改文件名或者加查询串,而真正的修复是上构建指纹。

HTML 本身则要严格相反:必须是 Cache-Control: private, no-cache(或 no-store)。因为 HTML 才是引用资源的入口,它必须每次都校验一下,才能把带新指纹的资源 URL 交给浏览器。所有「发版后用户看到旧页面」的问题,根源要么是 HTML 被缓存,要么是资源没指纹。没有第三种。

还有一个小坑是 immutable。它告诉浏览器「你不用再发条件请求验证了」,在带指纹的资源上是完美的,但如果用在没指纹的文件上,用户即使强制刷新(大部分浏览器的 Ctrl+F5 对 immutable 仍然生效,但部分移动端浏览器不会)也拿不到新的。

APCu 与多 worker:同一台机器上「一半新一半旧」

进程内存缓存是一类特别容易误导人的缓存。APCu 的数据存在共享内存里,但它的生命周期跟 PHP-FPM 的进程绑定:master 起了 10 个 worker,每个 worker 都能读到同一块共享内存,所以 APCu 在 worker 之间是共享的。

真正的问题在于「什么时候被清空」和「谁先看到更新」。假设你把一份「分类导航」存进 APCu,TTL 一小时。你改了分类名,然后执行了 apcu_clear_cache(),从命令行(CLI)执行——CLI 进程和 FPM 是两块完全独立的内存空间,这个调用对正在服务的 FPM 完全没有影响。于是你困惑地发现:命令行清完了,页面还是旧的。

APCu 的正确用法有三条经验:

  • 不要试图精准删除单个键。 用「命名空间版本号」:键里带上一个版本号,改数据的时候只更新版本号那一项,所有旧键自然失效并被 LRU 淘汰。这个模式在缓存重建成本较高时特别有用。
  • CLI 与 FPM 不共享内存,测试时必须在 FPM 里测,或者干脆用一个 HTTP 接口去触发清理。
  • 不要存对象。 APCu 存的是序列化后的值,存一个大对象会占用 apc.shm_size 的整块,而且不同 PHP 版本的反序列化行为不同,升级时可能直接报错。存数组和标量最稳。

判断「APCu 有没有真的生效」,最直接的是在你的页面上临时输出 apcu_cache_info(),看 num_hits 是否在涨。只看「代码里写了 apcu_fetch」是不够的,它可能一直在返回 false。

串号排查的固定动作:从外到内,一层层剥

当真的出现「看到别人的内容」这种严重问题,不要慌,按固定顺序剥,一般三轮就能定位:

第 1 轮:确认是哪一层。 用一个隐私窗口、不登录,请求那个有问题的 URL,看 curl -sI 的响应头:有 cf-cache-status: HIT 或 x-cache: HIT,问题在共享缓存;没有缓存头但仍然串号,问题在会话或应用逻辑。

第 2 轮:确认是「同一份 HTML」还是「不同的 HTML」。 用两个不同账号各取一次页面,把 body 存下来,md5sum 对比。完全一致就是缓存泄漏;不一致但两次都拿到对方的,那是应用的会话问题(很可能是用了固定 session id 或者签名 Cookie 校验缺失)。

第 3 轮:给缓存加「旁路」验证。 在 Nginx 里临时加一条 Cache-Control: no-store 或者把缓存关掉,如果串号立刻消失,那就 100% 确认是缓存层。这一轮是决定性的,比任何推测都可靠。

修完之后一定要做一次「回归验证」:让两个不同账号交替请求同一批 URL 各 20 次,检查每个人的响应 md5 是否稳定且彼此不同。只有这一步过了,才敢说问题解决。

几个反直觉的结论

写到这里,把最容易被忽略、但影响最大的几条经验单独列出来:

  • 缓存命中率不是越高越好。 一个 99% 命中率的站,如果那 1% 是登录用户被串号,损失远大于省下的服务器成本。命中率要在「正确的键」上讨论才有意义。
  • 「先清缓存再看」是最差的排查习惯。 清缓存会让现场消失,你下次还会遇到同样的问题。正确的做法是先在带 X-Cache-Status 的环境里复现,再动手。
  • CDN 的「开发模式」不要长期开着。 它会让所有请求都回源,看起来问题解决了,其实只是把缓存关了。上线前务必关掉并复测。
  • 缓存规则一定要进版本管理。 在 CDN 面板上点出来的规则,下次换人接手时就是一个黑盒。把关键规则(哪些路径不缓存、哪些参数参与键)写进仓库里的一个 README,成本十分钟,省的是半年后的一次事故。

回到开头那个论坛,最后的修复只花了十分钟:把首页的登录模块改成前端异步拉取(不参与整页缓存),并在 CDN 上给 /user/、/login、/admin 三个路径设了「绕过缓存」,再给 HTML 加了 no-cache。做完之后,命中率从 92% 掉到 78%——看起来退步了,但那是把不该缓存的页面剔出去之后更真实的数字,而回源量只涨了不到两成。

缓存这件事的正确心态是:先问「这份内容能不能给别人看」,再问「能存多久」。 顺序反了,性能账迟早要用一次事故来还。

Last modification:October 26th, 2026 at 01:26 pm

Leave a Comment