Nginx sub_filter 内容替换实战:全站 http 换 https、注入统计代码与旧域名内链批量改写

为什么你需要 sub_filter:一个站长绕不开的真实场景

事情是这样的:你的网站跑了好几年,早期图省事,页面里到处写死了 http://www.example.com/xxx.jpg 这种绝对地址。后来上了 HTTPS,浏览器就开始报混合内容警告,那个小锁图标变成了灰色带感叹号。你想全站改用相对协议 //www.example.com,但页面是 CMS 生成的,几千篇文章的正文都存在数据库里,手工改根本改不完。

又或者,你刚从一个老域名迁移到新域名,文章正文里还留着几百条指向旧域名的内链,SEO 上这叫"权重漏失",每一跳都在浪费你辛苦攒的排名。再比如,你想给所有页面统一注入一段统计代码、一段备案脚本,或者一个漂浮的客服按钮,但模板文件分散在十几个目录里,改起来容易漏。

这些需求背后是同一个动作:在响应出去之前,把 HTML 正文里的某些字符串批量替换掉。这正是 Nginx 的 ngx_http_sub_module 模块提供的 sub_filter 指令要做的事。它工作在 Nginx 把响应体发给客户端的那一瞬间,对动态页面(FastCGI/PHP)和静态文件都生效,而且只改传输内容,数据库和源文件一个字都不会动——想停就从配置里删掉一行,可逆、零风险。

sub_filter 的三个基本指令

这个模块默认是编译进 Nginx 官方包的,用下面这条命令确认一下:

nginx -V 2>&1 | tr ' ' '\n' | grep sub
# 输出里应该有 --with-http_sub_module

核心就三个指令,全部写在 http、server 或 location 块里皆可:

  • sub_filter:定义一对"查找/替换",格式是 sub_filter 查找串 替换串;。可以写多条,逐条生效。
  • sub_filter_once:默认是 on,意思是每个响应只替换第一次匹配。这是最容易翻车的地方——你必须改成 off,否则页面里只有第一处被替换。
  • sub_filter_types:默认只处理 text/html。如果你还想改 CSS、JS 里的域名,得手动加上 sub_filter_types text/css application/javascript;。

还有一个隐藏角色:sub_filter_last_modified。它的默认值是 off,意思是 Nginx 在替换内容后会保留源站的 Last-Modified 响应头不变。这本身没问题,但如果你同时开了缓存,要心里有数——替换后的内容不影响协商缓存的时间戳,浏览器仍会按源站的时间戳判断是否取新。

最小可用配置:全站 http 改 https

假设你要把响应体里所有的 http://www.example.com 换成 https://www.example.com,正确写法如下:

server {
    listen 443 ssl;
    server_name www.example.com;

    # 关键:关掉"只替换一次"
    sub_filter_once off;
    # 同时处理 HTML 和 CSS/JS
    sub_filter_types text/html text/css application/javascript;

    sub_filter 'http://www.example.com' 'https://www.example.com';
    # 顺手把可能存在的旧域名也换掉
    sub_filter 'http://example.com'     'https://www.example.com';

    location / {
        proxy_pass http://127.0.0.1:8080;
        # 反向代理时,必须告诉后端接受压缩,否则替换的是压缩后的乱码
        proxy_set_header Accept-Encoding "";
    }
}

注意最后那一行 proxy_set_header Accept-Encoding "";。这是 sub_filter 和反向代理配合时最常见的静默失败点:如果后端返回的是 gzip 压缩后的 HTML,Nginx 看到的是二进制数据,字符串匹配自然全部落空,替换一条都不会发生,而且日志里不会报任何错。清空 Accept-Encoding 请求头,就是让后端返回未压缩的明文 HTML,Nginx 替换完再自己压缩发给客户端。

用 sub_filter 注入广告 / 统计代码

第二个高频场景是"在不改模板的前提下,给所有页面尾部插入一段代码"。因为 sub_filter 是纯字符串替换,你可以拿一个必定出现的标记(比如 </body>)当作锚点:

sub_filter_once off;
sub_filter_types text/html;

# 在 </body> 前插入统计代码
sub_filter '</body>' '<script src="https://hm.baidu.com/hm.js?xxxxxxxx"></script></body>';

同理,想给所有 <head> 里加一段 meta,把锚点换成 </head> 即可。这种写法特别适合那种模板混乱、你又不想逐个改主题文件的老站。但有一条纪律必须遵守:锚点字符串必须足够唯一。如果你拿 div 这种到处都有的词当查找串,配合 sub_filter_once off,结果就是全页面被插得稀巴烂。永远用闭合标签这种结构性唯一标记。

为什么替换没生效?四个静默失效点

sub_filter 最坑的地方在于:它失败时不报错、不写日志,页面照常返回,你只能靠肉眼去发现。下面四类原因占了九成以上的"配了没用":

  1. 忘了 sub_filter_once off。 只替换第一处,后面几百处原封不动。这是第一大坑。
  2. 响应被压缩了。 后端或前置 Nginx 返回 gzip/br,Nginx 匹配的是压缩字节流。解决:代理场景加 proxy_set_header Accept-Encoding "";;自身若开了 gzip on,确保 sub_filter 处理的是压缩前的数据(Nginx 内部顺序上 sub_filter 是在 gzip 之前执行的,一般无碍,但代理链上的其他节点压过就需要排查)。
  3. Content-Type 不匹配。 后端把 HTML 错误地返回成 text/plain 或 application/json,而 sub_filter_types 只认 text/html,于是跳过。用 curl -I 看返回头里的 Content-Type 是不是 HTML。
  4. 查找串里的大小写/空格和实际不一致。 HTML 里可能是 http://Example.com 首字母大写,或者中间夹了换行。sub_filter 是严格区分大小写的纯文本匹配,不认正则。拿不准就把页面下载下来 grep 一下真实串。

一次可复现的验证流程

配置改完,nginx -t && nginx -s reload 之后,别急着刷浏览器,用 curl 做确定性验证:

# 1. 确认替换真的发生了(应输出 0 处旧串、若干处新串)
curl -s https://www.example.com/ -H 'Accept-Encoding: identity' \
  | grep -c 'http://www.example.com'

# 2. 确认替换后页面结构正常,<body> 只出现一次
curl -s https://www.example.com/ | grep -o '</body>' | wc -l

# 3. 若走代理,确认后端确实返回了未压缩内容
curl -s -I -H 'Accept-Encoding: identity' \
  http://127.0.0.1:8080/ | grep -i content-encoding

第 1 条返回 0 才说明全站旧链接清干净了。第 2 条如果大于 1,说明你注入脚本时的锚点把页面结构改坏了,要回滚。

性能与缓存:sub_filter 的代价

sub_filter 是"内容过滤器",它意味着 Nginx 必须缓冲整个响应体(默认缓冲到内存或临时文件),拿全了才能做替换,无法像纯流式转发那样边收边发。对大页面或大文件来说,这会增加一点内存占用和首字节延迟。所以有三条实践建议:

  • 只对 text/html 开替换,别图省事把图片、视频也纳入 sub_filter_types,否则大文件会被无谓地缓冲。
  • 如果页面本来有强缓存(CDN、proxy_cache),替换只发生一次,后续直接命中缓存,代价可以忽略。
  • 替换的目标串尽量唯一、精确,别用超短的通用串,减少 CPU 在长文本里反复扫描的开销。

和 rewrite 重定向怎么选:一个容易混淆的判断题

很多站长第一次遇到"旧链接要换新域名",脑子里冒出的第一反应是 rewrite 或 return 301,而不是 sub_filter。这两者解决的是完全不同的问题,选错了效果天差地别,值得说清楚。

return 301 https://new.example.com$request_uri; 做的是整站跳转:用户访问旧域名的任何页面,都会被浏览器重新定向到新域名。页面的 HTML 内容本身没变,只是"地址换了"。它适合你彻底放弃旧域名、要把它整个搬到新域名的情况;代价是每一次访问都多一次重定向往返,SEO 上旧域名的权重传递也有损耗,且用户体验上会看到地址栏跳变。

而 sub_filter 做的是就地改写内容:页面还挂在旧域名的地址上正常打开,只是正文里那些写死的绝对链接被悄悄换成了新域名。它适合"页面地址不变、但正文里的引用需要统一"的场景,比如文章里插入的图片、附件、内链。它不产生任何重定向,所以没有往返损耗。

判断口诀很简单:要换的是"地址栏里的那个地址",用 301;要换的是"页面正文里嵌着的那串文字",用 sub_filter。 真实项目里两者常常一起用——用 301 把旧域名的首页和栏目页导到新域名,用 sub_filter 清掉历史文章正文里残留的旧域名内链。想清楚它们的边界,你就不会在错误的工具上浪费时间。

调试 sub_filter 的四步定位法

前面说了 sub_filter 失败不报错,那到底该怎么查?按下面四步走,基本能定位到根因:

  1. 确认模块已加载。 nginx -V 2>&1 | grep -o with-http_sub_module,没有任何输出就说明这个 Nginx 是精简编译版本,需要换官方源或重新编译。
  2. 确认指令真的生效。 nginx -T | grep -n sub_filter 会打印最终合并后的配置,检查你要的 server 块里是不是真有那几行。Nginx 的配置是继承加覆盖的,子 location 里的 sub_filter 会覆盖父级,导致你以为配了、实际被别的块顶掉。
  3. 确认响应体是明文。 curl -s -I -H 'Accept-Encoding: identity' https://站点/ | grep -i content-encoding,如果返回 gzip 或 br,说明上游在压缩,这就是替换失效的头号原因。
  4. 确认查找串字节级一致。 把页面下载到本地:curl -s https://站点/ > /tmp/p.html,再用 grep -c '你的查找串' /tmp/p.html 数出现次数。如果本地 grep 都匹配不到,说明你写的串和实际内容根本不一样(大小写、空格、URL 编码差异)。

这四步从"模块在不在"一路排查到"字符串对不对",逐层收窄,比盲目改配置高效得多。大多数"sub_filter 不生效"的求助,答案都停在第 2 步或第 3 步。

它不能做什么:划清能力边界

最后要泼盆冷水,避免你把 sub_filter 当万能工具:

  • 不能改数据库。 它只改"服务器发出去的内容",下次数据库里存的还是旧串。所以下次改模板、重新生成页面时,旧串会再次出现——这是设计如此,不是 bug。
  • 不能做条件替换。 它不认识"只替换文章正文里的、不替换导航栏里的"这种需求。要按结构精细处理,还是得从模板或数据库层面动手。
  • 不能在 HTTPS 响应上改二进制。 图片、woff 字体这类内容,字符串替换没有意义甚至会损坏文件,务必通过 sub_filter_types 限制白名单。

把 sub_filter 理解成一把"应急螺丝刀"最恰当:它适合在改不动源文件、又要快速全站生效的场合救场——域名迁移、协议升级、临时注入脚本。真正长期干净的方案,仍然是从模板和内容源头上处理。手上有这么一把螺丝刀,至少在半夜发现全站混合内容警告时,你半小时内就能把问题按住。

Last modification:October 3rd, 2026 at 12:24 pm

Leave a Comment