一次页面排查中,我们遇到这样的现象:浏览器打开的是旧 HTML,里面引用的带哈希 JavaScript 文件已经不在当前部署中。重启浏览器后,页面恢复正常。
检查响应时,又发现一个容易忽略的差异:静态资源有ETag和Last-Modified,HTML 却没有,连Cache-Control也没有。
为什么同一台 nginx 会返回不同的响应头?线索藏在sub_filter中。但要理解缓存风险,还需要分清两件事:响应有没有验证器,以及缓存能否直接复用它。
sub_filter 做了什么
sub_filter用来替换响应正文中的字符串。它属于ngx_http_sub_module,模块源码文件名则是ngx_http_sub_filter_module.c。它做的是忽略大小写的字符串匹配,不解析 HTML,也不是正则替换。nginx 官方模块文档
例如,同一份前端构建需要服务多个站点,分享卡片的图片地址又需要包含站点域名。构建时可以写入占位符:
<metaproperty="og:image"content="__SITE_ORIGIN__/share.png"><metaname="twitter:image"content="__SITE_ORIGIN__/share.png">nginx 在发送 HTML 前替换它:
location / { sub_filter '__SITE_ORIGIN__' 'https://$site_host'; sub_filter_once off; try_files $uri $uri.html /index.html; }这里的$site_host是示例变量,需要另行定义。用于公开站点时,应通过允许的域名列表选择值,未知域名使用固定回退值。仅检查字符串符合 DNS 格式,不能证明域名可信;直接使用任意请求 Host,可能把攻击者控制的地址写入分享标签。
sub_filter_once默认是on,每条规则只替换一次。占位符出现在两个标签中,因此这里使用off。默认处理类型就是text/html;通常无需重复配置。模块也不是默认编译启用的模块,部署前可用nginx -V检查构建参数。
为什么 HTML 的响应头变了
响应正文可能经过替换,原始静态文件的长度和验证信息便不一定适合最终响应。以 nginx 官方当前源码为依据,对于进入替换流程的主请求,响应头过滤逻辑会清除Content-Length;默认还会清除Last-Modified和ETag。nginx 官方源码
有两个细节需要注意。
首先,清理发生在响应头处理阶段。对于进入该过滤流程的主请求,nginx 不会先等正文扫描完,再判断是否真的命中了占位符。因此,“只有实际替换成功才删除响应头”并不准确。
其次,sub_filter_last_modified on的行为不是简单恢复所有原始信息。官方文档说明它保留原响应的Last-Modified;源码进一步显示,它会把已有的强 ETag 转成弱 ETag,例如从"abc"变成W/"abc",并不会生成新的、基于替换后正文的 ETag。Content-Length仍然会被清除。
弱 ETag 不保证响应逐字节一致。更不能因为它变弱,就认为动态替换结果已经有了正确的版本标识。如果替换值改变而底层文件不变,文件验证器可能无法反映这个变化。
另外,sub_filter不会主动删除Cache-Control。HTML 没有缓存策略,需要继续检查add_header、继承关系及其他配置,不能全部归因于正文替换。
没有 Last-Modified,不等于不会缓存
HTTP 缓存有两个不同的问题。
新鲜度决定缓存能在多长时间内直接复用响应。Cache-Control: max-age=600表示有效期为十分钟。
验证器帮助缓存询问服务器:存下来的版本是否仍然有效?常见验证器是ETag和Last-Modified。验证通过时,服务器可以返回304 Not Modified,省去正文传输。
没有显式新鲜度时,符合条件的响应可以使用启发式缓存。RFC 9111 提到,缓存可以参考Last-Modified;文件距上次修改时间的 10% 是一个典型比例,但标准没有规定所有缓存必须使用这个算法。RFC 9111:HTTP Caching,§4.2.2
所以,一个十天前修改的文件,可能被某种缓存算法视为可以直接复用一天。这只是算法示例,不能当作所有浏览器的固定行为。
同样,移除Last-Modified不能保证浏览器或代理每次都请求服务器。它减少了一个常见的估算依据,却没有建立明确的缓存约束。不同缓存实现仍可能采用不同策略。
把这项副作用当作 HTML 缓存策略,风险在于:以后移除正文替换、修改验证器设置,页面的缓存行为可能随之改变,而配置里没有一句话说明原本的意图。
SPA 为什么尤其容易受影响
单页应用(SPA)的入口 HTML 通常引用带内容哈希的资源:
<!-- 演示文件名,不对应实际项目 --><scriptsrc="/assets/main.oldhash.js"></script>发布新版本后,新 HTML 引用新文件名。如果浏览器仍使用旧 HTML,而服务器已删除旧资源,资源请求就可能失败。
沿下图蓝色入口向下读:问题发生在旧 HTML 与新部署之间。红色终点强调的是旧资源已删除这个条件,而不是“缓存存在就必然白屏”。
缓存策略之外,部署也应保留一段时间的旧哈希资源。已经打开的页面可能稍后才加载旧版本的动态模块,入口 HTML 的no-cache无法解决这一类请求。
把 HTML 的缓存策略写清楚
对于需要及时获取新版本的公共 SPA 入口,可以显式设置:
# 示意配置:将路径替换为实际部署路径。 location / { # 文件需事先存在,并包含本项目的安全响应头配置。 include /etc/nginx/snippets/security-headers.conf; add_header Cache-Control "no-cache" always; sub_filter '__SITE_ORIGIN__' 'https://$site_host'; sub_filter_once off; try_files $uri $uri.html /index.html; }no-cache允许存储响应,但要求缓存复用前成功验证。它与禁止存储的no-store不同。没有可用验证器时,通常需要重新获取完整 HTML;对于较小的入口文件,这往往是可以接受的成本。RFC 9111:no-cache
这里重新引入安全响应头有明确原因:在默认继承规则下,只要当前location定义了自己的add_header,就不再继承上层的add_header。添加缓存头时,可能意外丢失 CSP 等安全响应头。nginx 1.29.3 起支持add_header_inherit merge来调整此行为,但使用前要确认实际版本和现有配置,避免重复添加响应头。nginx 官方响应头文档
带内容哈希的资源可以采用长缓存,前提是同一路径的文件内容保持不变。例如,在独立的资源location中设置public, max-age=31536000, immutable。缺失资源应返回真实的 404,避免回退成 HTML 后被长期缓存;长期策略也应只用于成功的资源响应。
如果页面包含个人信息或依赖登录身份,需要另行设计private或no-store策略,不能直接套用公共入口的设置。
配置后怎么检查
先在实际容器或服务器中检查版本、编译参数与配置:
nginx-vnginx-Vnginx-t再使用 GET 检查最终响应。下面的域名和路由均为示例:
curl-sS-Dheaders.txt-opage.html https://example.com/app确认 HTML 返回Cache-Control: no-cache,安全响应头仍然存在,正文中的占位符已经替换。还要分别检查/index.html、实际 SPA 深层路由和哈希资源,因为它们可能匹配不同的location。
经过 CDN 时,应分别观察源站和公开入口。新加的响应头不会自动召回所有已被视为新鲜的旧缓存,已有缓存需要结合平台能力处理。
替换失败时,也要检查压缩。sub_filter不会自行解压 gzip 正文。代理上游可按需要使用proxy_set_header Accept-Encoding "";请求未压缩内容;启用gzip_static时,应确认 HTML 没有绕过替换流程直接使用预压缩文件。最终应同时检查响应头和实际正文,不能仅凭配置文本判断成功。
如果配置由 shell heredoc 生成,还要区分两层变量:nginx 变量通常需要写成\$site_host,让它保留到运行时;需要 shell 展开的变量则不转义。这取决于 heredoc 是否使用带引号的结束标记,不能机械地给所有$加反斜杠。
这次现象能说明什么
重启浏览器后恢复,是客户端状态可能参与故障的线索。它不能单独证明旧 HTML 来自内存缓存,也不能排除 Service Worker、页面恢复、代理或其他因素。排查仍需要查看故障时的 Network 记录、响应来源和资源请求。
因此,我不会把这次旧页面直接归因于sub_filter。这次排查更明确的收获是:正文替换和缓存策略应分别表达。sub_filter负责修改内容,Cache-Control负责规定响应如何被复用,部署策略负责保证旧页面仍能找到它引用的资源。
参考资料
- nginx:ngx_http_sub_module 指令说明
- nginx:sub_filter 模块官方源码
- nginx:add_header 与响应头继承规则
- RFC 9111:HTTP Caching