缓存 key 设计错误是 CDN 上最隐蔽的一类事故:页面功能完全正常,只有少数用户会看到别人的数据,而且从日志里几乎看不出来。这篇文章从一次真实的越权现象讲起,把缓存 key 的构成、Vary 与私有页面的关系、以及几种常见的错误配置梳理成一套可执行的检查方法。
一、一次「数据串了」的事故
某站点的用户中心页面打开了 CDN 加速,配置里只按 URL 缓存,缓存时长 10 分钟。上线后收到用户反馈:刷新页面偶尔看到别人的订单号。排查下来原因很直接——页面是 /user/orders,登录用户不同,URL 却完全一样,CDN 只认 URL,于是把第一个请求的响应缓存下来,后面的用户全部命中同一份缓存。
这类问题之所以难被发现,是因为它有几个特征:只在热门路径上出现、只在缓存生命周期内出现、清一次缓存就「好了」,然后过一阵复发。如果你在排查「用户偶发看到别人的数据」,第一件事就是确认这个页面有没有被缓存、缓存 key 里到底带了什么。
二、缓存 key 到底由什么构成
默认情况下,绝大多数 CDN 的缓存 key 只包含「协议 + 主机 + 路径 + 查询串」,也就是完整的 URL。这意味着:
- 不同的 Cookie,同一个 key。
- 不同的
Authorization头,同一个 key。 - 不同的 Accept-Language,同一个 key。
- 不同的设备类型,同一个 key。
而页面内容可能完全依赖这些头。所以判断一个页面能不能缓存,核心问题不是「它变不变」,而是「它的输出取决于哪些输入,这些输入有没有进 key」。
需要特别注意查询串的处理策略。有的 CDN 默认忽略某些参数(例如 utm_* 跟踪参数),这能提高命中率,但如果你把业务参数也放进了这类忽略列表,就会出现「参数不同却命中同一份缓存」的问题。曾经有站点为了提升命中率把所有参数都忽略,结果分页参数失效,用户永远只能看到第一页。
三、私有页面的三条处置路线
对于确实依赖登录态的页面,有三种做法,按优先级排列:
第一条,也是最好的做法:不缓存。在源站对这类路径直接返回 Cache-Control: private, no-store,并在 CDN 上配置「不缓存」规则。这最省心,代价是每次都回源,但对于用户中心这类个人页面,流量本来就不大,回源压力可以忽略。
第二条:缓存 key 带用户标识。把用户 ID 或会话标识加进缓存 key。需要注意,把原始 Cookie 整串加进 key 会导致命中率骤降(因为 Cookie 里有各种会变的值),正确做法是选一个稳定的、能代表用户身份的字段,例如登录后写下的 uid。同时要评估 key 的空间规模:一万个用户就是一万份缓存,缓存空间和回源率都要重新算。
第三条:边缘计算或边缘包含。页面骨架缓存,用户相关的片段由边缘函数回源拉取并拼装。这是性能最好但复杂度最高的方案,只适合大流量站点。
实践中一条硬规则可以避免绝大多数事故:任何返回 Set-Cookie 的响应都不应该被公共缓存。部分 CDN 会自动处理这一点,但不能依赖,源站的响应头才是最可靠的声明。
四、Vary 头:有用但不是万能的
Vary 告诉缓存「这个响应因哪些请求头而不同」。常见写法:
Vary: Accept-Encoding
Vary: Accept-Encoding, Accept-Language要注意两个事实:第一,Vary 支持度在 CDN 上参差不齐,很多厂商只支持有限的几个头,写了 Vary: Cookie 可能被直接忽略;第二,Vary 字段一多,缓存会被切得很碎,命中率暴跌。所以 Vary 更适合处理「可选的语言/编码」这类有限维度,不要指望它解决身份隔离问题。
还有一个容易忽略的点:如果你的站点对图片做了格式协商(根据 Accept 头返回 WebP 或 JPEG),那就必须配置 Vary: Accept,否则会串格式——Chrome 用户拿到的 WebP 被 Safari 用户命中,后者拿到一堆无法显示的乱码文件。
五、命名空间与版本号
缓存的另一个常见事故是「改了配置但用户还是看到旧页面」,根因是缓存 key 里没有版本维度。解决办法是给静态资源路径加上版本前缀或查询参数:
/assets/v20261101/app.css
/assets/app.css?v=20261101查询参数版本在部分 CDN 上被忽略(见上文的参数忽略策略),所以更稳的是把版本放进路径。这也顺带解决了浏览器缓存的问题——路径变了,浏览器必然重新拉取。
如果不想每次手工改版本号,可以在构建脚本里用文件内容哈希做路径的一部分。永远不要用时间戳做版本号,那会导致每次发布都把全站资源重新下载一遍,流量成本不可控。
六、上线前的四项自查
这类配置改动上线前,建议固定跑一遍下面四项:
- 用两个不同的登录态请求同一个页面,对比响应内容是否相同。相同即说明有问题。
- 检查响应头里是否有
Set-Cookie,如果有同时又有公共缓存头,就是矛盾配置。 - 对需要隔离的路径,从 CDN 机房和本地分别请求,看
X-Cache或CF-Cache-Status之类的头,确认是否HIT。 - 清缓存后立刻再请求,确认关键的响应头(
Cache-Control、Vary)与预期一致。
curl -sI "https://www.example.com/user/orders" | grep -iE 'cache-control|vary|age|x-cache'
curl -s -o /dev/null -w "%{http_code} %{time_total}\n" "https://www.example.com/user/orders"第三项尤其重要。很多「偶发」问题的根因就是没人确认过请求到底有没有命中缓存,所有讨论都停留在猜测层面。
七、回源防护与缓存穿透
缓存策略一旦出错,还有一个连带风险:缓存穿透导致源站被打穿。用户中心这类页面如果关闭缓存,访问量一高,源站就要承受全部请求。所以「不缓存」的决定必须配合回源防护:
- 源站对同一用户的高频重复请求做短时合并,避免一个刷新被打成十个查询。
- CDN 侧配置「同一 key 并发回源合并」,让并发的相同请求只回源一次。
- 对未登录用户访问私有路径的请求,直接返回 302 到登录页,不产生任何数据库查询。
最后一条最容易被忽略。很多站点的用户中心在未登录时仍会查一次数据库再跳转,白白消耗资源。先判断登录态,再决定要不要干活,这是最便宜的性能优化。
八、日志里怎么发现缓存串号
这类问题很难靠用户反馈及时暴露,但日志里其实有痕迹。最实用的做法是在响应头里带一个缓存状态标识,并在源站的访问日志里记录它:
log_format cache '$remote_addr - $time_local "$request" $status '
'cache=$upstream_http_x_cache age=$upstream_http_age';
access_log /var/log/nginx/access.log cache;有了这个字段,就可以定期统计「私有路径上的 cache=HIT」出现了多少次。正常情况下应该恒为 0,一旦出现就说明 key 策略出了问题,能在用户察觉之前发现。这比事后逐条翻日志要高效得多。
还可以再加一层:在页面渲染时输出一个极小的、仅开发环境可见的标记(例如注释里的用户 ID 哈希前四位),用两个账号并发请求同一路径,比对两次响应是否携带了同一个标记。这个简易探针在每次改动缓存策略后跑一遍,成本极低,但能直接证明隔离是否生效。
最后提醒一句:清缓存不要当成修复手段。清掉缓存只是让缓存回到空状态,配置里的问题依然存在,下一轮缓存重建后同样的串号会再次发生。定位到根因、改掉 key 策略,才是真正的修复。
九、小结
缓存 key 的问题归根结底是一句话:输出了什么,取决于什么输入,这些输入就必须进入 key。拿这条规则去审一遍缓存配置,绝大多数越权、串数据、串格式的问题都能提前发现。
落地时记住三条:带身份信息的响应不要进公共缓存;Vary 只用来处理有限的维度,不承担安全职责;静态资源的版本走路径而不是时间戳。这三条固化到发布检查单里,比事后排查要轻松得多。