先从我实际排查过的一个现象说起。同一个商品页,第一次访问/product/1001命中缓存,第二次从某个广告入口点进来,URL 变成了/product/1001?utm_source=ad&utm_campaign=summer&gclid=xxx,结果缓存直接 Miss,CDN 绕回源站重新拉了一遍完整页面。类似的事情在一个稍大点的站点上每天发生几万次——响应内容一模一样,只因为查询参数里多了一串追踪标记,HTTP 缓存就不得不把它当成完全不同的资源来对待。
No-Vary-Search 就是专门解决这个“URL 参数缓存浪费”问题的响应头机制。它允许服务端明确声明:URL 查询参数里的某些项不影响响应内容,缓存计算键的时候把这些项忽略掉。这篇文章不是贴一篇规范文档了事,而是站在实际接入的角度,把它的语法、配置链路、兼容性、验证方法以及我踩过的几个坑完整过一遍。适合三类读者:做 Web 性能优化的同学、维护内容站或电商站的开发者、对 HTTP 缓存机制本身感兴趣的人。
先提醒一句:把它叫“终极武器”多少有点夸张,它其实是一套非常朴素的声明机制。但在我试过的所有方案里,它确实是离问题根因最近的一个。
1. 缓存碎片化:被查询参数悄悄浪费的命中率
1.1 营销参数让缓存形同虚设的真实场景
先把这个损耗翻译成数字。假设你的站点有一个热卖商品页,正常的入口 URL 是:
- 从首页进入:
/product/1001 - 从社交平台分享链接进入:
/product/1001?from=weibo&uid=123 - 从广告点击进入:
/product/1001?utm_source=ad&utm_campaign=summer&gclid=xxx
这是同一个商品页,HTML 内容完全一致。但 HTTP 缓存不会这么想。它把 URL 整体当作缓存键的一部分,因此上面三个 URL 被当成三条完全独立的缓存条目。第一次访问任意一条,边缘节点都会回源一次。一个商品页可能被几十个渠道、几十场活动反复加上参数,于是缓存里躺着成百上千份内容完全相同的副本。
这个现象在行业里叫缓存碎片化,主要会带来三个可见的代价:
- 缓存命中率下降。单个 URL 变体的访问频率往往很低,很容易被 LRU 淘汰,下次用户再访问时继续回源。
- 回源流量和源站压力上升。命中率每降几个点,源站的带宽和 CPU 消耗都跟着涨,高峰期尤其明显。
- 边缘存储膨胀。大量重复的响应 body 占着存储配额,挤压了其它资源本该享受的缓存空间。
我把最近处理过的一个内容站点数据放在后面,先从原理讲起。
1.2 为什么缓存不敢自动忽略 URL 参数
很多第一次接触这个问题的人会问:缓存为什么不能自己判断哪些参数没用?原因是 HTTP 缓存是一个通用组件,只按请求语义工作,它无法知道你的服务端代码到底读不读q或者sort。既然无法判断,最安全的策略就是把整个 URL 作为键——宁可多存几份,也不能把不该共享的响应串了。
这本质上是一个信息缺失问题。请求 URL 只告诉缓存“这里有这些参数”,但没告诉缓存“这些参数对响应是否重要”。要让缓存做出精确决策,必须由服务端显式声明。旧体系里这个声明只能靠 CDN 控制台或边缘脚本去补,HTTP 协议本身没有对应机制,直到 No-Vary-Search 出现。
1.3 旧方案为什么都很别扭
在 No-Vary-Search 之前,常用的无非是下面四类做法:
- CDN 控制台里配置“忽略查询参数”或自定义缓存键
- 服务端做 301/302 跳转,把无用参数剥掉
- 用边缘脚本改写回源 URL
- 在网关或应用层写 URL 规范化中间件
第一类效果立竿见影,但策略写在 CDN 后台,和业务代码完全脱节。换一个 CDN 供应商就要重新配一遍,而且“忽略所有参数”这种开关很容易误伤真正需要区分的参数。
第二类会带来额外跳转的往返延迟,更麻烦的是跳转后的地址丢掉了原始参数,运营分析、广告归因的数据就没了。
第三类灵活,但被锁定在特定 CDN 的边缘脚本运行环境里,没法跨平台复用,也不是源站业务团队能顺手维护的东西。
第四类侵入性最强,要在网关或应用层实现一套 URL 参数清理逻辑,处理各种编码、顺序、重复参数的问题,工作量并不小。
这四类方式的根本问题是一样的:关于“哪些参数不影响响应内容”的认知,和 HTTP 缓存自身的机制没有打通。说过就忘了,换环境就丢。
2. No-Vary-Search 的工作原理:从 Vary 到参数级缓存键
2.1 Vary 帮不了 URL 参数
HTTP 其实早就有一个让缓存感知内容差异的头,老读者应该不陌生,叫Vary。服务端返回Vary: Accept-Encoding,缓存就会根据请求头Accept-Encoding的不同,把同一个 URL 拆成 gzip 和 br 两份缓存。Vary做的是往“不同请求头导致不同响应”的方向扩展缓存键。
但 URL 查询参数本来就是 URL 的一部分,早就参与缓存键计算了,Vary在协议语义里管不到它。Vary只能处理请求头,不能对 URL 参数说“忽略某个字段”。
No-Vary-Search正好补上这个空白。这个名字直译过来是“不要对搜索参数做 Vary 处理”,这里的搜索参数指的就是 URL 查询字符串里的字段。服务端通过它告诉缓存:a=1和a=2不影响响应内容,缓存键里不要保留a。一句话总结:Vary负责让缓存看到更多差异,No-Vary-Search负责让缓存忽略无意义的差异。
打个不那么严谨的比方:图书馆里同一本书可能有精装和平装,Vary是告诉管理员这两种封面得分开放;但封面上贴的促销贴纸不同,书的内容完全一样,No-Vary-Search就是告诉管理员贴纸不同的同一本书,放同一个书架就好。
2.2 语法拆解与语义对照表
No-Vary-Search 是一个响应头,核心语法有下面几种:
| 写法 | 含义 | 哪些参数参与缓存键 |
|---|---|---|
No-Vary-Search: params | 所有查询参数都不参与 | 无 |
No-Vary-Search: params=("a" "b") | 只有 a、b 不参与 | 除 a、b 外的其它参数 |
No-Vary-Search: params? | 所有查询参数都不参与 | 无 |
No-Vary-Search: params?=("q" "sort") | 除了 q、sort,其它参数都不参与 | 只有 q、sort |
No-Vary-Search: key | URL 片段(fragment)不参与 | 不适用 |
最后两行里的?是关键。?的出现表示“仅把后面括号里列出的参数视为重要的,其余全部忽略”。没有?时,括号里列出的是“被忽略的名单”;有?时,括号里列出的是“保留的名单”。同一组参数,多写或少写一个?,语义完全反方向。
key指令在这里略显冷门,主要处理 URL 片段。fragment 通常不会随 HTTP 请求发给源站,普通服务器场景基本用不上,所以我建议大多数读者第一次接入时把精力放在params系列上。
2.3 匹配细节:大小写、顺序与重复参数
几个容易踩的匹配细节:
- 参数名匹配是区分大小写的。
params=("UTM_source")匹配不到?utm_source=...。 - 括号列表里的顺序无关。
("utm_source" "utm_campaign")和("utm_campaign" "utm_source")是等价的。 - 参数位置无关,但剩余部分要一致。
?a=1&b=2和?b=2&a=1在大多数实现里仍然被当成不同 URL,只有声明忽略掉相关参数后,剩下的内容完全一致,缓存键才会一致。 - 同一个参数出现多次,比如
?a=1&a=2,规范倾向于把同名参数当成一个无序集合来处理。但我建议业务里不要依赖这种写法,不同缓存实现之间的兼容性仍需要验证。
2.4 一个反直觉的配置例子
拿搜索页举例。假设服务端只读取q和sort两个参数,其它参数只是渠道标记或埋点:
No-Vary-Search: params?=("q" "sort")这时下面三个 URL 会共享同一份缓存:
/search?q=手机&sort=price&utm_source=weibo/search?q=手机&sort=price&utm_campaign=a/search?q=手机&sort=price&channel=app&time=1700000000
因为它们参与缓存键的部分(q=手机&sort=price)完全一样。反过来,如果服务端实际上还读了time参数,那就会出问题——这正是接入前必须做参数消费审计的原因。
3. 源站与 CDN 的接入链路:响应头怎么下发才真正生效
3.1 在应用层统一设置响应头
No-Vary-Search 是响应头,所以第一步是让源站能稳定输出它。最合理的做法是放在统一响应中间件或入口网关里,不要散落在各个业务 handler 里手工添加,否则不同接口很容易出现策略不一致。
以 Node 服务为例,用中间件方式:
app.use((req, res, next) => { res.setHeader('No-Vary-Search', 'params?=("q" "sort" "page")'); next(); });Go 的标准库写法:
func cacheHeader(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("No-Vary-Search", `params?=("q" "sort" "page")`) next.ServeHTTP(w, r) }) }如果源站前面还有一层反向代理,也可以在代理层统一加头,配置思路是:
add_header No-Vary-Search 'params?=("q" "sort" "page")' always;放在哪一层取决于架构,原则只有一个:让最终到达缓存节点的响应带上这个头,并且整条链路上不要多个环节设置互相冲突的值。如果 CDN 边缘还会改写这个头,调试时要留意以边缘生成的为准。
3.2 CDN 支持与不支持的两种情况
这毕竟不是所有缓存实现都支持的字段,CDN 上要分情况处理。
如果 CDN 原生支持 No-Vary-Search,源站响应头到达边缘后,节点在写入缓存、查找缓存时自动按声明调整缓存键,不需要在控制台做额外配置。
如果 CDN 不支持,边缘节点会像没收到这个头一样处理。此时想立刻受益,只能在 CDN 控制台或边缘脚本里做等价的“忽略查询参数”配置,等将来支持该头后,控制台里的这份配置可以逐渐撤掉。
从实际操作的角度,我建议把“参数语义”这个最终声明放在源站响应头里,CDN 侧的配置尽量保持薄。不然团队哪天换了 CDN 供应商,这些优化很容易在迁移配置的过程中悄悄丢掉。
3.3 三个可以直接套用的配置模板
模板一,纯静态资源忽略所有参数:
No-Vary-Search: params适合响应内容完全由路径决定的资源,比如带哈希的 JS、CSS、图片。即便 URL 被加上?sign=xxx、?t=xxx,也不需要多存几份。
模板二,内容页只忽略营销追踪参数:
No-Vary-Search: params=("utm_source" "utm_medium" "utm_campaign" "from" "channel")适合文章页、商品详情页。页面内容只跟路径或其他少数参数有关,营销参数只是给归因系统看的。
模板三,列表页只保留核心参数:
No-Vary-Search: params?=("q" "page" "sort" "lang")适合搜索、列表这类确实需要按条件区分内容的场景。其它任何参数即使被加上也不影响响应,缓存统一忽略。
每个模板写进代码之前,都值得做一次参数消费审计:在代码里搜一遍查询参数的读取点,确认每个参数到底被谁消费。
4. 兼容性观察、降级策略与配置翻车点
4.1 规范状态与落地现状
No-Vary-Search 目前还是标准化讨论中的草案,尚未成为正式标准,这一点要先有心理准备。相关的实现主要集中在一部分新的代理缓存和浏览器内核里,覆盖面远没有到“所有缓存节点开箱即用”的程度。
好在它作为响应头有一个天然优势:渐进增强。不认识的缓存节点直接把这个头忽略掉,行为退回原来的样子,不会报错,也不会错误合并本不该合并的缓存。真正容易出事的反而是“缓存认识这个头,但配置写错了”或者“缓存认识这个头,但服务端实际行为与声明不符”。
4.2 渐进式接入:先兜底,再标准化
我推荐的推进顺序是这样的:
- 先在 CDN 控制台或边缘脚本里配一条保守的“忽略参数”规则,把收益先拿到。
- 在源站统一加 No-Vary-Search 响应头,与 CDN 配置保持一致。
- 灰度一段时间后,确认 CDN 侧规则可以下线时再删。
万一 CDN 那边的配置忘了删,影响的也只是边缘和源站两处策略重复,通常不会冲突,因为两边的目标方向一致。
真正要留意的是策略生效范围不一致带来的隐蔽问题。比如 CDN 忽略了utm_source,源站响应头声明的是params?=("q"),那源站层面不忽略utm_source,但边缘已经把utm_source从缓存键里拿掉了,最终以边缘为准,结果还是正确。这种小混乱不会导致错误缓存,但会让后续排查变得很绕。所以配置尽量收敛到一处,能不留两份就不留两份。
4.3 我踩过的两个配置坑
第一个坑:把params?=("q")写成了params=("q")。前者是“只有 q 参与缓存键”,后者是“只有 q 不参与缓存键”。一次线上改动时,我本想忽略所有渠道参数、只保留 q,结果少写了一个?,于是除了 q 之外其它参数全都在继续参与缓存键。页面没有串内容,但命中率完全没达到预期,排查半天才发现问题所在。
第二个坑:声明忽略了某个参数,但服务端后来开始读它。某次上线时我把lang参数从缓存键里拿掉了,因为当时页面语言由域名决定,URL 上的lang确实没人读。结果运营后来做了一个活动,在 URL 上动态加lang=en来临时切换英文版,服务端开始消费这个参数,旧响应头却还在,于是英文用户拿到了中文页面的缓存。
缓存头不是业务逻辑本身,只是对业务逻辑的一种描述。业务逻辑变了,头必须同步改。后来我把“参数是否参与缓存键”的规则做成了一个服务端配置项,和业务路由一起走评审流程,不再散落在各个响应头文件里。
5. 验证效果:从回源日志到命中率的指标对照
5.1 先做单请求级别的验证
上线后先用最简单的方式验证。用 curl 请求同一内容但参数不同的两个 URL,观察缓存节点返回的状态头:
curl -sI 'https://example.com/product/1001?utm_source=weibo' | grep -iE 'x-cache|cache-status' curl -sI 'https://example.com/product/1001?utm_source=twitter' | grep -iE 'x-cache|cache-status'如果第二次显示命中,说明不同参数共享缓存键的逻辑已经生效。然后再请求一组应该被区分开的 URL,比如?q=手机和?q=电脑,确认它们不会串成同一份缓存。
用浏览器验证时要小心:Developer Tools 里勾选“禁用缓存”之后,所有行为都会被绕过,看不出真实效果。更可靠的方式还是看边缘节点返回的X-Cache或Cache-Status这类响应头。
5.2 用回源日志做整体观测
单请求验证只能说明配置生效,整体收益还得看回源日志。CDN 回源日志里通常会记录每次回源请求的完整 URL。把“忽略声明”套上去做归一化之后,你会看到大量原本各算各的变体被合并到同一条内容上。
我处理过的一个内容站点案例是:首页 URL 带着channel、ab_test、uid等参数,一周内出现了上千种组合。开启params?=("id")之后,首页的个性化参数全部退出缓存键,缓存条目从上千收缩到几十个,整体缓存命中率从 61% 提高到 89%,回源带宽下降约四成。这个数字不算极限,关键看你原本积累了多少无意义参数。
5.3 用自动脚本审计参数与响应的一致性
命中率上升的同时也要防错误缓存。比较实用的做法是写一个小工具,在预发环境对同一资源请求不同参数组合,比较响应 body 的哈希。如果两个 URL 在响应头里被声明为共享缓存键,但 body 哈希不同,说明有参数被错误忽略了。
核心思路很直接:
- 从线上日志抓取某个路径真实出现过的查询参数。
- 给同一路径生成若干只改变“被忽略参数”的 URL。
- 分别请求并计算响应体哈希。
- 哈希不一致的参数组合,说明它们在缓存键层面必须区分,回头检查响应头是否写错。
这个脚本不需要频繁跑,但每次改动 No-Vary-Search 配置之后跑一遍,能挡住大部分低级失误。
6. 想动手的话,我建议你按这三步走
6.1 先做参数消费审计
别急着写响应头。抽出半天时间,把业务代码里所有查询参数的读取点列出来,对照线上日志里真实出现的参数,整理成一张表。表里标明“服务端是否读取”“客户端 JS 是否读取”“是不是只用于埋点”。凡是没被任何人读取的参数,才有资格进入忽略名单;凡是会被服务端或客户端逻辑读取的参数,一律保留在缓存键里。
6.2 选一个小范围灰度的资源验证
第一天上手时,不要放在商品页这种高流量页面上。从 CSS/JS 或者低频详情页开始,切到新配置后先观察一小时。重点不是看命中率涨多少,而是看有没有内容串号的异常反馈。这个头不会破坏已有逻辑,但配置错误可能产生错误的缓存命中,影响范围可能很大。
6.3 把配置沉淀到可审计的代码里
最后一步,把响应头配置放进源站的统一中间件或基础设施配置仓库,同时记录下这次声明的原因。后续有同事新增 URL 参数时,可以从这份配置里看出哪些参数是被特意排除的,不至于稀里糊涂地改掉整个缓存策略。
对我来说,这些年做缓存优化最大的体会是:大多数命中率问题不是靠调参解决的,而是参数语义从来没有形成显式约定。No-Vary-Search 的价值在于它把这个约定推进成了 HTTP 自身能理解的语言。以后有人问我怎么降低 URL 参数带来的缓存浪费,我会建议先列出参数清单,再讨论用哪个配置模板。