为什么你需要 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 最坑的地方在于:它失败时不报错、不写日志,页面照常返回,你只能靠肉眼去发现。下面四类原因占了九成以上的"配了没用":
- 忘了 sub_filter_once off。 只替换第一处,后面几百处原封不动。这是第一大坑。
- 响应被压缩了。 后端或前置 Nginx 返回 gzip/br,Nginx 匹配的是压缩字节流。解决:代理场景加
proxy_set_header Accept-Encoding "";;自身若开了gzip on,确保 sub_filter 处理的是压缩前的数据(Nginx 内部顺序上 sub_filter 是在 gzip 之前执行的,一般无碍,但代理链上的其他节点压过就需要排查)。 - Content-Type 不匹配。 后端把 HTML 错误地返回成
text/plain或application/json,而sub_filter_types只认text/html,于是跳过。用curl -I看返回头里的 Content-Type 是不是 HTML。 - 查找串里的大小写/空格和实际不一致。 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 失败不报错,那到底该怎么查?按下面四步走,基本能定位到根因:
- 确认模块已加载。
nginx -V 2>&1 | grep -o with-http_sub_module,没有任何输出就说明这个 Nginx 是精简编译版本,需要换官方源或重新编译。 - 确认指令真的生效。
nginx -T | grep -n sub_filter会打印最终合并后的配置,检查你要的 server 块里是不是真有那几行。Nginx 的配置是继承加覆盖的,子 location 里的sub_filter会覆盖父级,导致你以为配了、实际被别的块顶掉。 - 确认响应体是明文。
curl -s -I -H 'Accept-Encoding: identity' https://站点/ | grep -i content-encoding,如果返回gzip或br,说明上游在压缩,这就是替换失效的头号原因。 - 确认查找串字节级一致。 把页面下载到本地:
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 理解成一把"应急螺丝刀"最恰当:它适合在改不动源文件、又要快速全站生效的场合救场——域名迁移、协议升级、临时注入脚本。真正长期干净的方案,仍然是从模板和内容源头上处理。手上有这么一把螺丝刀,至少在半夜发现全站混合内容警告时,你半小时内就能把问题按住。