一次页面排查中,我们遇到这样的现象:浏览器打开的是旧 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 官方模块文档
例如,同一份前端构建需要服务多个站点,分享卡片的图片地址又需要包含站点域名。构建时可以写入占位符:
html
<meta property="og:image" content="__SITE_ORIGIN__/share.png">
<meta name="twitter:image" content="__SITE_ORIGIN__/share.png">
nginx 在发送 HTML 前替换它:
nginx
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 通常引用带内容哈希的资源:
html
<!-- 演示文件名,不对应实际项目 -->
<script src="/assets/main.oldhash.js"></script>
发布新版本后,新 HTML 引用新文件名。如果浏览器仍使用旧 HTML,而服务器已删除旧资源,资源请求就可能失败。
沿下图蓝色入口向下读:问题发生在旧 HTML 与新部署之间。红色终点强调的是旧资源已删除这个条件,而不是"缓存存在就必然白屏"。

缓存策略之外,部署也应保留一段时间的旧哈希资源。已经打开的页面可能稍后才加载旧版本的动态模块,入口 HTML 的 no-cache 无法解决这一类请求。
把 HTML 的缓存策略写清楚
对于需要及时获取新版本的公共 SPA 入口,可以显式设置:
nginx
# 示意配置:将路径替换为实际部署路径。
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 策略,不能直接套用公共入口的设置。
配置后怎么检查
先在实际容器或服务器中检查版本、编译参数与配置:
bash
nginx -v
nginx -V
nginx -t
再使用 GET 检查最终响应。下面的域名和路由均为示例:
bash
curl -sS -D headers.txt -o page.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 负责规定响应如何被复用,部署策略负责保证旧页面仍能找到它引用的资源。