给博客做压力测试时,ETag 的计算出现在了响应处理路径里。一个直接的优化是关掉它,少遍历一次 HTML。但我对这个方案有疑问:没有 ETag,浏览器下次访问时不就得重新下载全文了吗?
当时线上首页返回这些响应头:
Cache-Control: s-maxage=31536000
Content-Encoding: gzip
ETag: "msuj79ykge2msw"
Via: 1.1 Caddy
X-Nextjs-Cache: HIT这里同时出现了共享缓存时间、压缩、条件请求标记和 Next.js 的缓存命中。它们看起来都与“更快”有关,却不能互相替代。把它们分别对照实际请求,才知道哪些设置值得改。
先看 ETag 省掉了什么
那轮线上验证,对同一个首页做了普通请求和条件请求,响应体大小分别是:
未压缩 HTML:200,130,382 字节
gzip 响应: 200, 19,820 字节
匹配 ETag: 304, 0 字节响应体这些是当时页面的测量值,后续内容变化后大小也会变。304 的“0 字节”只指响应体,请求与响应头仍然需要传输,服务器也仍然要处理请求。
ETag 是服务器给响应表示的标记。客户端保存了正文和标记,后续就可以带着 If-None-Match 询问是否仍然有效。相同时返回 304,客户端继续使用已有正文;不同时返回新内容。Next.js 的 generateEtags 文档说明框架默认为页面生成 ETag。
所以那次关闭 generateEtags 的改动撤回了。压测关注的是服务器在大量请求下的处理能力,读者访问还涉及传输成本。省掉一次哈希的同时,如果让原本可以得到 304 的请求重新下载近 20 KB gzip 正文,就需要有额外证据说明值得。
Caddy 的压缩功能也不能接替这件事。压缩处理的是要发送的正文;ETag 用来判断是否需要发送正文。普通反向代理不会因为配置了 encode,就自动替上游 HTML 生成内容哈希。
s-maxage 的一年写给谁
31536000 是一年的秒数,但 s-maxage 控制的是共享缓存,例如 CDN 和具备响应缓存能力的代理。浏览器私有缓存会忽略这个指令。Cache-Control 定义对此有明确区分。
因此只看到这一行,不能得出“浏览器会保存首页一年”,也不能反过来保证“浏览器每次一定向源站验证”。这里没有明确给浏览器设置 max-age 或 no-cache,还要结合其他响应头和客户端行为判断。
当时站点是 Docker 中的 Next.js,前面用 Caddy 反向代理和压缩,没有配置共享 HTML 缓存。Via: 1.1 Caddy 表示响应经过 Caddy;X-Nextjs-Cache: HIT 表示 Next.js 页面缓存命中。这两个头都不能证明 CDN 或 Caddy 缓存了 HTML。
Next.js 给静态页面生成较长的共享缓存时间有其部署前提。CDN 缓存文档说明了静态页面和 ISR 的响应头,也提醒外部 CDN 的失效需要协调。应用重新部署了,不代表任意 CDN 中的旧页面都会自动删除。
我的首页展示最新文章。当前没有共享缓存,并不意味着以后加 CDN 时仍然可以忽略这条一年缓存规则。我希望把首页的行为写明确。
首页允许保存,使用前验证
首页最后采用:
Cache-Control: private, no-cache, max-age=0, must-revalidateprivate 禁止共享缓存保存这个响应;no-cache 允许浏览器保存,但要求复用前验证。max-age=0 和 must-revalidate 进一步明确立即过期、过期后需要验证的意图。其中有语义重叠,并不是少一个指令就会失效的固定配方。
MDN 对 no-cache 的说明特别区分了“保存”和“直接复用”。我需要的是打开首页时确认有没有新文章,不必禁止浏览器保存已有 HTML。保留正文和 ETag,内容没变时仍可返回 304。
文章列表、分页、分类和标签页面也采用同样的策略。这些页面会随新文章发布和分类调整发生变化。配置时要列出实际路由,避免用 /posts/:path* 把文章详情页一起覆盖。
这里的“每次验证”指 HTTP 缓存复用规则。Next.js 客户端导航还可能复用 Router Cache,浏览器后退也可能恢复页面快照;一个响应头不能承诺所有回到首页的动作都会重新发请求。验证配置时,要分开观察完整加载、刷新和站内导航。
文章为什么没有再加五分钟
讨论中一度提出给文章设置五分钟浏览器缓存。我没有采用。
对这个博客,我更关心读者从搜索结果进来后能正常阅读。同一个客户端在几分钟内反复打开同一篇文章,暂时没有足够的访问数据表明这是值得专门优化的主要场景。为了减少这部分请求,再增加一种 HTML 过期时间,目前收益不明确。
文章详情保留 Next.js 默认策略。这个选择只针对当前部署:如果以后增加共享缓存,文章同样需要检查部署失效和旧资源保留,不能因为内容是文章就默认缓存一年也安全。
带内容哈希的 JS、CSS 和字体则不同。文件内容变化会生成新的 URL,可以使用长期缓存。HTML 地址通常保持不变,不能照搬这套策略。
压缩交给 Caddy,配置后再看最终响应
生产站点已经有 Caddy 处理压缩,所以 Next.js 设置:
const nextConfig = {
output: 'standalone',
compress: false,
// 保留默认 ETag,供页面条件请求使用。
};Next.js 的 compress 文档支持在外部代理承担压缩时关闭框架内置压缩。Caddy 侧的相关配置可以是:
encode zstd gzipCaddy 的 encode 文档说明,它会结合客户端的 Accept-Encoding 选择编码。这要求代理上确实启用了压缩;只关闭 Next.js 的 compress,并不会自动让响应变小。
验证时,我会对完整的公网链路发请求,检查 Content-Encoding,再取同一响应的 ETag 发条件请求。下面的命令在 PowerShell 中可直接使用,先替换地址:
$targetUrl = 'https://example.com/'
$first = Invoke-WebRequest -Uri $targetUrl -Method Get
$etag = [string]($first.Headers.ETag | Select-Object -First 1)
if (-not $etag) { throw '响应没有 ETag' }
curl.exe -sS -D - -o NUL -H "If-None-Match: $etag" $targetUrl页面未变化时预期是 304。测试期间如果刚好部署了新版本,得到新的 200 和 ETag 也可能是正常的;要比较同一 URL、相同表示和最终响应头。
manifest 有自己的更新频率
这轮还给博客加了安装用的 manifest,没有加入 Service Worker 或离线文章缓存。开发日志里每次完整加载都会出现 /manifest.webmanifest,所以又检查了一次生产产物。
当时 standalone 返回:
Cache-Control: public, max-age=0, must-revalidate该响应没有 ETag。验证中,完整页面加载会重新传输 manifest 的 JSON。文件虽然很小,但站名、图标和启动地址不会频繁变化,于是给它单独设置了一天缓存:
Cache-Control: public, max-age=86400随后请求 standalone,确认最终响应头生效。一天缓存也意味着清单修改后,客户端可能需要等缓存过期才能拿到新内容;这对当前博客可以接受。
页面缓存、manifest 缓存和字体缓存最终用了不同设置。后续判断是否要改,可以直接看各自的更新频率和请求数据。仅仅因为配置里出现了几个不同的时间,没有必要把它们统一。