事故:别人能看到我的购物车
去年冬天有位读者给我写了长邮件,说他登录自己的账号,刷新了一下首页,头像变成了另一个人的,购物车里还躺着几件他从没买过的商品。他怀疑我卖了数据。我第一反应是不可能——首页 HTML 明明设了不缓存,怎么会出现别人的内容?结果查了两天,问题出在一行从来没写过的配置上:我们没给会随登录状态变化的响应发 Vary: Cookie,而 CDN 又选择了「忽略 Cookie 的缓存策略」,于是第一个访客(刚好是个已登录用户)的整页 HTML 被十几个边缘节点存了下来,后面三天所有访客看到的都是这一份。
这件事之后我把站点的缓存体系整个重画了一遍,也把「缓存共用范围」这件事当成了比调 TTL 更重要的认知。这篇文章就把那次事故的完整结论写下来:哪些东西能进共享缓存、哪些绝对不能,Vary 头为什么既是解药也是毒药,以及一套可以直接抄的 Nginx 片段和排查顺序。
先分清:缓存共用范围不是只有「哪个 URL」
绝大多数人配缓存时只考虑一件事:这个 URL 要不要缓存、缓存多久。但真正决定「谁看到谁的副本」的,是缓存的共用范围,也就是缓存键加上响应头共同定义的等价类。同一个 URL,键不同就是两份副本;键相同但响应头要求按某个维度区分,缓存服务就会拆成多份或者干脆不缓存。
| 共用范围 | 典型实现 | 能缓存什么 | 不能缓存什么 |
|---|---|---|---|
| 全站共享 | CDN 边缘、多用户共用一个缓存节点 | 纯静态资源、匿名可访问的列表页 | 任何包含登录信息、用户名、购物车的页面 |
| 单 IP 共享 | CDN 的「按 IP 分片缓存」 | 与地理位置相关但与他人无关的内容 | 同一出口 IP 下多人共用(公司、学校、运营商 NAT) |
| 单浏览器 | 浏览器私有缓存、private | 个人化页面、用户自己的数据 | 会被其他用户间接使用的中间层 |
| 单会话 | 带 Cookie 的 fastcgi_cache 分键 | 可以按会话准确的中间层 | 流量一大命中率就崩塌,成本不划算 |
判断标准只有一句:只要响应内容会随「当前是谁」变化,就绝对不能进全站共享缓存。注意是「响应内容」,不是「页面看起来」。一个页面板块里嵌了一行「欢迎回来,XXX」,或者角落里有个未读消息数,它就属于因人而异——哪怕正文部分对所有人完全一样。
Vary: Cookie 是解药,也是毒药
第一次修这个事故,我很自然地想到了 Vary: Cookie:既然缓存是根据 Cookie 区分内容,那我告诉缓存「请按 Cookie 拆分」不就行了?配完当天页面就正确了,但第二天 CDN 回源率暴涨到 90% 以上,源站 CPU 直接吃掉一半。
原因是 Vary 的语义:它把请求头的值纳入缓存键。而每个访客的 Cookie 都是不同的——会话 ID、统计脚本埋的 uid、AB 测试分组标记,全都塞在 Cookie 里。结果是每个访客都对应一个新键,缓存命中率归零,缓存层彻底退化成一层转发代理。
# 不推荐:相当于让缓存为每个访客存一份
add_header Vary "Cookie";
# 同理危险的还有:按 User-Agent 拆分(UA 组合几乎无限)
add_header Vary "User-Agent";正确的做法不是让缓存学会区分,而是让内容分级:
- 完全公共的部分(首页列表、文章详情、分类页、纯静态资源):渲染时不依赖任何登录态,HTML 里绝不出现用户名。这类页面可以放心进共享缓存,TTL 给几分钟到几十分钟都行。
- 因人而异的部分(用户中心、购物车、订单、后台):直接
Cache-Control: no-store。不要用private加短 TTL 去「优化」它,因为private只约束共享缓存,浏览器仍然可能存;也不要用no-cache,那是「每次用前必须校验」,不是「不许存」。 - 同一页面里的个人化小块(右上角的登录状态、购物车角标):不要服务端渲染进 HTML,改由前端拿到页面后再单独请求一个
no-store接口填充。这是最省事的工程解法。
也就是说,如果架构做对了,你压根不需要发 Vary: Cookie。真正需要 Vary 的场景只有两种:Vary: Accept-Encoding(区分压缩与未压缩副本),以及极少数按语言区域协商的站点(这类更推荐用不同 URL,而不是靠请求头)。
可以抄的 fastcgi_cache 片段
下面这段是我站点上实际在跑的配置,核心思路是:默认不缓存,只有明确满足「无登录 Cookie、无查询串、方法为 GET」时才允许进缓存。
fastcgi_cache_path /var/cache/nginx/fcgi levels=1:2 keys_zone=PHP:100m
inactive=60m max_size=4g use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
map $http_cookie $skip_cache {
default 0;
"~*PHPSESSID=" 1; # 只要有会话就跳过缓存
"~*remember_me=" 1;
}
map $request_method $skip_cache_method {
default 1;
GET 0;
HEAD 0;
}
map $args $skip_cache_args {
default 1; # 带任何查询串都不缓存
"" 0;
}
server {
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_cache PHP;
fastcgi_cache_valid 200 10m;
fastcgi_cache_valid 404 1m;
fastcgi_cache_bypass $skip_cache$skip_cache_method$skip_cache_args;
fastcgi_no_cache $skip_cache$skip_cache_method$skip_cache_args;
fastcgi_cache_lock on; # 同一键并发只回源一次
fastcgi_cache_use_stale updating error timeout http_500 http_503;
fastcgi_hide_header Set-Cookie; # 否则 Set-Cookie 会抑制缓存
add_header X-Cache-Status $upstream_cache_status always;
}
}
几个容易被忽略的点:
fastcgi_cache_bypass和fastcgi_no_cache必须同时配。只配前者是「不读缓存但会写缓存」,那就把个人化响应写进缓存了——这比不缓存更危险。fastcgi_hide_header Set-Cookie的作用是防止后端随手发一个Set-Cookie导致缓存模块默认不缓存该响应。但如果你的页面确实需要下发 Cookie,那说明它是个人化页面,应该走 bypass 而不是靠隐藏头蒙过去。fastcgi_cache_lock on只对「同一键」生效,能挡掉「缓存过期瞬间一百个请求同时回源」的尖峰,但挡不住缓存被清空后的整体回源。真正的兜底是三件事:预热热 URL、给后端加limit_conn背压、允许stale响应顶一段时间。X-Cache-Status always里的always不能省,否则错误响应(4xx/5xx)不带头,你会以为「没走缓存模块」其实只是头没发。
Vary: Accept-Encoding 与双重压缩的互杀
另一个真实踩过的坑:源站 Nginx 开了 gzip,CDN 也开了压缩,并且 CDN 回源时不带 Accept-Encoding,或者带了但源站判断成「客户端不支持」,于是边缘缓存里存的是未压缩副本,出边缘时再压一次。反过来,如果源站已经压过、边缘又压一次,某些客户端会拿到双层压缩流,表现为「图片和 JS 全部加载失败但首页能看到」。
排查方法很直接:直连源站看响应头,再连 CDN 看响应头,对比 Content-Encoding 与 Vary。
# 直连源站(绕过 CDN),看你真实的源站行为
curl -sI --resolve www.example.com:443:203.0.113.10 \
-H 'Accept-Encoding: gzip, br' https://www.example.com/
# 走 CDN
curl -sI -H 'Accept-Encoding: gzip, br' https://www.example.com/
判据:如果两边都有 Content-Encoding: gzip 且 Content-Length 不同,说明压缩可能被叠加;如果源站响应里没有 Vary: Accept-Encoding,而 CDN 缓存键又不区分编码,那么压缩过的副本会被发给不支持压缩的客户端,浏览器就会「解码失败、白屏」。稳妥配置是:压缩只在边缘做,源站对动态页面关掉 gzip(location /api/ { gzip off; }),静态资源用构建期预压缩的 .gz 文件,靠 gzip_static on 直接吐。
Set-Cookie:最隐蔽的缓存杀手
很多框架会在每个响应上都带一个会话 Cookie,哪怕用户根本未登录——PHP 里最常见的是「session_start() 被无条件调用」。这会造成两种后果:一是缓存模块看到 Set-Cookie 就直接不缓存(默认行为),你以为是 TTL 不生效;二是如果 CDN 不管这个头强行缓存,那缓存里的响应带着别人的会话 ID,等于把会话 ID 泄露给了所有人。
判断自己有没有这个问题,只需要一条命令:
curl -sI https://www.example.com/ | grep -i -E 'set-cookie|cache-control|age|x-cache'
如果首页响应带 Set-Cookie: PHPSESSID=...,那么它一定不该进共享缓存,或者必须先做到「未登录访客不开 session」。后者是真正值得改的地方:把 session_start() 移到确实需要登录态的逻辑分支里,匿名访客不落 Cookie,首页的可缓存性就回来了。
HTML 必须短缓存,资源必须带指纹
这条是缓存架构的地基,写在这里备查:
- HTML 一律短缓存(共享缓存 1~10 分钟)或
no-cache+ ETag 校验,绝不给max-age=86400。因为 HTML 是内容的入口,它一过期,全站发布都要等缓存自然失效。 - CSS/JS/图片/字体一律长缓存(
max-age=31536000, immutable),但前提是文件名里带内容指纹。没有指纹的文件名配上长缓存,就是把用户永久钉在旧版本上。 - 上传目录的图片没有指纹,所以不要给一年,给 7 天并保留
ETag做条件请求,代价可控。
APCu:以为是缓存,其实各管各的
那几天排查里还有一个插曲。我给文章页加了一层 APCu 缓存,键里带了用户 ID,逻辑看着完美。结果 CLI 里的清理脚本执行 apcu_clear_cache() 之后前台毫无变化,我又以为是缓存层没生效。
这里有两个必须记住的事实:APCu 是进程共享内存,CLI 与 FPM 是完全不同的两块内存,命令行里的 apcu_clear_cache() 清的是 CLI 自己的那块,对 FPM 毫无影响;多个 FPM pool(哪怕同一个 master)如果配置了不同的 apc.shm_segments,也不共享。所以要清 FPM 的 APCu,只能通过一个走 FPM 的接口(比如带 token 保护的 /admin/flush-cache)去执行,或者直接 reload FPM。
更重要的设计建议:别用逐条删除来失效缓存,改用命名空间版本号。把键写成 posts:v7:1234,把 v7 存在一个单独的键或者配置文件里,要整体失效时把版本号改成 v8,旧键自然过期。这样只写一次、读两次,而且在多机、多 pool 环境下也不会漏。
串号问题的三层排查顺序
如果已经出现「看到别人的内容」,别急着加 no-store 全站止血,先按下面的顺序定位,否则你会把整站性能一起关掉:
- 看响应头判层:
X-Cache-Status、Age、CF-Cache-Status这类头能告诉你这份副本来自哪一层。Age大于 0 说明来自中间缓存;Age缺失且你有缓存配置,说明规则没让响应进缓存。 - 两个账号对照:用两个浏览器分别登录不同账号,请求同一个 URL,把响应体做 md5 比对。如果 md5 相同而内容本该不同,那就是缓存串号坐实;如果 md5 不同但页面上出现了别人的名字,那是应用层的问题(比如全局变量没重置、查询缓存串了),与 HTTP 缓存无关。
- 旁路验证:临时给该路径加
Cache-Control: no-store,如果问题立刻消失,说明确实是缓存层;如果还在,往应用层查。
# 两账号对照的极简做法
curl -s -b 'PHPSESSID=aaa' https://www.example.com/user | md5sum
curl -s -b 'PHPSESSID=bbb' https://www.example.com/user | md5sum
改完之后我做的回归测试
配置改对不代表以后不会再犯。我在发布流程里加了 40 次循环请求的回归脚本,跑在每次缓存配置变更之后:
for i in $(seq 1 40); do
curl -s -o /dev/null -w '%{http_code} ' https://www.example.com/
done
echo
# 期望:全部 200;如果出现 5xx,说明缓存锁或回源被打穿了
同时用带 Cookie 的请求确认个人化路径不被缓存:
curl -sI -b 'PHPSESSID=test123' https://www.example.com/user \
| grep -i -E 'x-cache-status|cache-control'
# 期望:x-cache-status: BYPASS(或 MISS 且 cache-control 含 no-store)
这两条检查加起来不到十秒,但能挡掉我后来遇到的三次同类事故:一次是忘了把新接口加入 bypass 列表,一次是 CDN 规则被同事改了默认缓存时长,一次是模板更新后首页开始输出用户名。
一句话总结
缓存的所有事故里,只有极小一部分是「TTL 设错了」,绝大多数是共用范围没想清楚。判断方法比配置更重要:问自己「这份响应会不会因为换个访客而不同」,会,就 no-store;不会,才谈 TTL。Vary 不是用来给缓存打补丁的,它是用来告知协商维度的,用错方向只会把命中率打穿。把这些想清楚之后,缓存层才会从「出事的地方」变成「省钱的地方」。