在现代 Web 架构中,HTTP 缓存是提升用户体验和降低服务器负载的关键技术。然而,很多团队在实践中会遇到一个看似简单却影响深远的工程问题:URL 中的查询字符串(如 ?_v=1.2.3 或 ?timestamp=1234567890)究竟应该保留还是移除?这个问题之所以棘手,是因为它直接影响浏览器缓存、CDN 缓存命中率和版本更新的及时性。本文将从工程实践角度,深入探讨如何通过路径版本化和 ETag 协商的协同设计,构建一套不依赖查询字符串、却仍能保证缓存新鲜度的缓存策略。
查询字符串的缓存困境
查询字符串在 Web 开发中由来已久,早期的构建工具为了解决静态资源更新后的缓存问题,习惯在 URL 末尾追加版本号或内容哈希值。例如 main.css?v=20230510 或 app.js?_=abc123,这种方式在表面上解决了「更新后用户仍获取旧资源」的问题,但其副作用同样明显且被广泛低估。
从 CDN 的视角来看,查询字符串是缓存键(Cache Key)的组成部分。不同的 CDN 默认行为差异极大:Cloudflare 默认将查询字符串纳入缓存键,而 Akamai 则默认忽略查询字符串,Fastly 又默认包含查询字符串。这种不一致性直接导致同一套前端代码在不同 CDN 提供商环境下表现截然不同。更糟糕的是,当营销团队在链接中追加 utm_source、utm_medium 等追踪参数时,这些参数会人为地将同一个页面的缓存拆分成数十甚至数百个变体,CDN 缓存命中率急剧下降,原本可复用的边缘节点缓存完全失效。
从 HTTP 语义的角度分析,查询字符串并不属于资源的标识符。根据 RFC 7232 的定义,URL 中的路径部分才是资源的真正标识,而查询字符串理论上应仅用于传递请求参数,不应影响资源的身份认定。因此,将版本信息放在路径而非查询字符串中,本身就是对 HTTP 缓存语义的一种尊重。
路径版本化的工程实现
路径版本化是替代查询字符串版本控制的主流方案,其核心思想是将版本信息嵌入 URL 的路径部分,使得每个版本的资源拥有独立的、稳定的 URL 地址。这种设计的最大优势在于:URL 本身就是缓存键,而缓存键天然由完整路径组成,不存在任何歧义。
以现代前端构建工具为例,Webpack、Vite、Rollup 等工具在生产构建时会自动为输出文件添加内容哈希。例如源码文件 main.css 经过构建后可能输出为 main.a1b2c3d4.min.css,其中的哈希值 a1b2c3d4 由文件内容计算得出,内容一旦变化,哈希必然变化,进而生成全新的文件名。这意味着当设计师更新了 CSS 样式表后,生成的 HTML 中对静态资源的引用会自动指向新文件名,浏览器的 HTTP 缓存会因为 URL 不同而发起新请求,整个过程无需任何人工干预。
这种模式的缓存配置极为直接:对于这类带指纹的静态资源,建议将 Cache-Control 设置为 max-age=31536000, immutable。max-age=31536000 表示允许缓存一年,这是 HTTP 规范允许的最大 TTL 值;immutable 指令则明确告知浏览器,该资源在整个缓存生命周期内内容不会变化,浏览器无需在过期后发起任何重新验证请求。需要注意的是,immutable 指令的浏览器支持度已相当普及,现代 Chrome、Firefox 和 Safari 均已支持。
路径版本化的实现方式不止内容哈希一种,还包括显式版本号路径。例如 /v2/js/app.js 与 /v3/js/app.js 分别代表不同版本的资源。这种方案在 API 版本控制场景中尤为常见,其优势在于版本管理直观,便于运维团队通过切换路径前缀来实现灰度发布或蓝绿部署。但显式版本号需要依赖构建流水线或部署脚本主动更新 HTML 中的引用,自动化程度不如内容哈希方案。
ETag 协商:应对无法版本化的资源
在实际的工程场景中,并非所有资源都能通过路径版本化进行管理。HTML 文档本身就是一个典型例子:用户访问的 URL 通常就是根路径下的 index.html 或类似的固定路径,不可能每次更新都将页面重命名。更常见的情况是,某个第三方提供的无指纹 API 响应、动态生成的报表或用户上传的文件,它们同样无法简单地在路径中嵌入版本标识。
对于这类无法版本化的资源,ETag(Entity Tag)提供了一种优雅的协商机制。ETag 是服务器为资源生成的唯一标识符,通常是基于文件内容或最后修改时间的哈希值。当浏览器缓存的资源过期后,再次发起请求时会携带 If-None-Match 头部,其值为上次响应中收到的 ETag 值。服务器收到请求后,比较请求中的 ETag 与当前资源的 ETag 是否一致:如果一致,说明资源未发生变化,服务器返回 304 Not Modified 响应,浏览器直接使用本地缓存;如果不一致,则返回新的资源内容和新的 ETag。
这种机制的优势在于,即使资源的 URL 保持不变,浏览器仍然能够在资源真正发生变化时及时获取最新版本,而无需每次都下载完整的响应体。304 Not Modified 响应的 body 为空,只有状态行和头部,传输量极小,非常适合频繁访问但内容变化不频繁的资源。
ETag 协商的推荐配置需要将 Cache-Control 与 ETag 配合使用。对于可能发生变化但 URL 不可变的资源,建议设置 Cache-Control: max-age=604800, must-revalidate,并同时提供 ETag 响应头。这里的 max-age=604800 表示允许缓存一周,must-revalidate 则强制要求在缓存过期后必须向源站发起重新验证,不能直接使用过期副本。这一组合确保了缓存的新鲜度,同时在 CDN 层面也能正确实现条件请求。
CDN 缓存键的精细化配置
即便采用了路径版本化和 ETag 协商的方案,CDN 层面的缓存键配置仍然是决定整体缓存效率的关键环节。默认情况下,大多数 CDN 会将完整的 URL(包括路径和查询字符串)作为缓存键。但这个默认行为在很多场景下并不理想。
以静态资源为例,/js/app.abc123.js?v=1 和 /js/app.abc123.js?v=2 指向的是完全相同的资源内容,但按完整 URL 作为缓存键的逻辑会将它们视为不同的资源,造成缓存冗余。正确的做法是让 CDN 在为这类静态资源生成缓存键时忽略查询字符串,或者在源站构建时就避免在静态资源 URL 中使用查询字符串。
对于 HTML 页面,查询字符串的处理则需要更加审慎。如果页面内容不因查询参数而变化(如 utm_source 追踪参数),那么忽略这些参数可以显著提升缓存命中率。但如果页面内容会因查询参数动态变化(如分页参数 page=2、筛选条件等),则必须将相关参数纳入缓存键,否则会导致不同用户看到错误的内容。最佳实践是采用白名单机制:明确列出对缓存有影响的查询参数(如 page、category、sort),仅将这些参数纳入缓存键,而将 utm_* 等追踪参数排除在外。
主流 CDN 对查询字符串的默认处理策略存在显著差异。Cloudflare 默认将查询字符串包含在缓存键中,这意味着带有不同追踪参数的 URL 会各自缓存一份副本;Akamai 默认忽略查询字符串,同一资源的所有变体会共享一份缓存;Fastly 则默认包含查询字符串,但允许通过自定义 VCL 规则灵活调整。在设计缓存策略时,必须充分考虑目标 CDN 的这一行为特征,或者在源站层面统一处理。
Vary 头部的正确使用
在 HTTP 缓存体系中,Vary 响应头是一个容易被误解却极为重要的机制。它用于告知缓存中间节点,响应的内容会因请求头部的不同而不同。例如,当服务器根据 Accept-Language 头部返回不同语言的版本时,必须在响应中包含 Vary: Accept-Language,否则 CDN 可能会将英文版本缓存下来,然后错误地返回给请求法文的用户。
对于大多数现代 Web 应用而言,Vary: Accept-Encoding 是必须设置的响应头,因为它告知缓存层,资源可能存在 gzip 或 Brotli 等压缩方式的差异。此外,如果应用根据 User-Agent 提供不同的内容(如桌面版和移动版页面),则需要设置 Vary: User-Agent。但需要警惕的是,Vary 头部的滥用会直接导致缓存效率的大幅下降:每增加一个 Vary 条件,缓存需要存储的变体数量就会成倍增长。
在路径版本化的场景下,由于版本信息已经完全编码在 URL 路径中,大多数静态资源并不需要设置复杂的 Vary 规则。只有那些真正根据请求头内容产生不同响应的资源,才需要在响应中添加对应的 Vary 头部。这种设计让缓存键保持简洁,同时确保了动态内容的正确分发。
工程落地的监控与验证
任何缓存策略的最终效果,都需要通过可量化的监控指标来验证。以下是工程团队在落地这套缓存方案时应当持续关注的几个核心指标。
缓存命中率是最直接反映缓存策略效果的指标,它需要分别从浏览器层、CDN 层和源站层进行监控。浏览器层的缓存命中率可以通过 Performance.getEntriesByType('resource') API 获取资源加载时的网络类型判断;CDN 层的缓存命中率通常在其管理后台提供直观的图表展示;源站层的 304 Not Modified 响应占比则是 ETag 协商有效性的直接体现。
当缓存命中率异常时,需要排查几个常见的配置问题。首先确认源站是否正确设置了 Cache-Control 响应头,很多默认配置不输出该头部的 Web 服务器(如某些框架的默认配置)会导致缓存行为退化。其次检查 CDN 的缓存键配置,确保静态资源的缓存键不包含无意义的查询字符串。最后验证 ETag 响应的正确性,使用 curl -I 命令检查响应头中是否包含 ETag 字段,以及服务器是否正确响应 304 Not Modified。
另一个重要的验证手段是使用 Redbot 等在线工具检查资源 URL 的缓存配置,它能够自动诊断常见的缓存头配置问题并给出警告。此外,Chrome 和 Firefox 的开发者工具 Network 面板提供了直观的缓存状态展示,每条资源请求的 Size 列会显示其来源(from memory cache、from disk cache 或 from prefetch cache),Timing 列则展示了完整的时间分解,这些信息对于快速定位缓存问题极为有用。
总结:构建以 URL 语义为核心的缓存体系
通过对路径版本化、ETag 协商和 CDN 缓存键配置的协同设计,我们可以构建一套不依赖查询字符串版本控制、却能保证高缓存命中率和内容新鲜度的工程方案。这套方案的核心在于尊重 HTTP 缓存语义:版本信息通过路径体现,内容变化通过 ETag 验证,缓存键通过精细配置确保唯一性和可复用性。
对于静态资源,路径版本化配合一年期的 immutable 缓存是最优解;对于无法版本化的动态资源,ETag 协商配合较短期缓存加重新验证是务实方案;对于 CDN 层,查询字符串的处理需要根据资源的性质和 CDN 的默认行为进行专门配置。通过这套分层策略的实施,团队可以显著提升缓存效率,降低带宽成本,同时确保用户在内容更新后能够及时获取最新版本。
资料来源:本文参考了 Google Web Fundamentals 中关于 HTTP 缓存的指南,以及 Simon Hearne 发布的《Caching Header Best Practices》最佳实践总结。
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。