Nginx配置每次reload都通过,Googlebot每8次抓取却有1次撞在301上
本文目录
- 一台服务器上十几个站,陌生域名请求过来会拿到什么?
- 这个自相矛盾的页面是谁放的
- 为什么它是200而不是404
- HTTPS这边也没拦住
- Google把这种页面叫什么
- server_name下划线到底是不是通配符?
- 官方文档把话说得很不留情面
- 那它凭什么真的兜住了
- 排第一这件事,是文件名说了算的
- 一个返回404的页面,为什么在页面里声明自己可以被收录?
- 三行标签,一行比一行离谱
- 它是怎么长成这样的
- 顺带称了一下这个404有多重
- 同一台Nginx上的两种301,为什么一种保留参数一种把它丢了?
- 文档里写了什么,以及没写什么
- 丢掉的到底是哪几个参数
- 顺手测出的第二个坑:Location里的域名是谁定的
- 那到底该用哪个
- Googlebot每8次抓取有1次拿到301,这些301在跳什么?
- 89%的301来自一个早就下线的目录
- 301很便宜,但便宜的不是它消耗的那样东西
- 那到底该301还是该410
- 顺便,那77次500是怎么回事
- 配置里明明写着那条响应头,为什么线上收不到?
- add_header不是叠加,是全有或全无
- 为什么这条规则专坑做SEO的
- 怎么查,以及怎么防
- Vary声明的是一种可能性,不是这次响应的事实
- 665KB的RSS,为什么gzip一口没咬
- 这事有多严重,得说实话
- 一个查询参数把TTFB从26毫秒推到623毫秒,这笔账该怎么算?
- 但我原本的论证被数据推翻了
- 那这24倍到底谁在付
- 90760次抓取里只有6次304,剩下的都在重传吗?
- 爬虫想省,得先有东西可依
- 这些问题有没有一套能重复跑的检查顺序?
- 四类边界输入
- 三条能带走的判据
- 什么时候该怀疑接缝
- 常见问题解答
- 我的服务器上只有一个站点,还需要管默认server吗?
- 伪静态用if判断文件存在,和try_files比差在哪?
- 404页面自称可索引,具体怎么改?
- 一批已经跑了上万次的301,改成410会不会丢权重?
- Vary既然经常名不副实,是不是干脆删掉更好?
- 权威参考资料
摘要:Nginx不认识“收录”这个词。它只判断一次请求在HTTP层面成不成立,成立就给一个合法响应,至于这个响应在搜索引擎那边被读成什么意思,它不管。
保哥拿自己这台跑着十几个站的机器做了一轮只读实测,几个问题全部复现:陌生域名指过来拿到HTTP 200,页面上却印着“404 Not Found”;不存在的路径返回404,页面里却写着
index, follow、canonical指向首页;两条并排的301,一条把utm参数带走了,另一条把它扔了;配置里那行响应头一直在,只是从没被发出去过。38天日志里Googlebot来了90760次,其中11639次拿到的是301,九成花在一个早就下线的目录上。这些错误没一个会让nginx -t报错。
这篇不是Nginx配置教程,反向代理那一整套和整页缓存怎么落地站里都写过。这次查的是另一类东西:写法完全合法、语法检查一定通过、浏览器打开也看不出毛病,却已经在悄悄改变搜索引擎眼里这个站长什么样的指令。
实验台是保哥自己那台生产服务器,跑着十几个站点,Nginx 1.28.3,访问日志从6月19日攒到7月27日共196万行。下面每个数字都从这台机器上量出来,全程只读,没改过配置。
一台服务器上十几个站,陌生域名请求过来会拿到什么?
这件事太容易被跳过——所有人都测自己的域名,没人测别人的。做法很土:给本机发请求,Host头填一个这台机器上根本不存在的域名。
$ curl -o /dev/null -w '%{http_code}' -H 'Host: evil.example.test' http://127.0.0.1/
200不是404,不是403,是200。那这个200的响应体是什么?
<html>
<head><title>404 Not Found</title></head>
<body><center><h1>404 Not Found</h1></center>
<hr><center>nginx</center></body>
</html>一个写着“404 Not Found”的页面,用HTTP 200发了出来。
这个自相矛盾的页面是谁放的
响应头里Last-Modified停在2023年10月12日,这个138字节的文件在硬盘上躺了快3年,没人看过一眼。它的root目录里就两个文件:50x.html和index.html,后者是元凶。
面板初始化时往默认站点目录里放了个index.html,内容抄的是Nginx那个经典404模板;而默认站点的配置里写着index index.html;。
为什么它是200而不是404
因为这两句话各自都对,并且各自都在正确执行。
请求打到/,Nginx按index指令找首页文件,找到了,于是当成正常首页返回——文件存在,读取成功,状态码理所当然是200。至于这个文件的内容碰巧是一句“404 Not Found”,Nginx完全不关心,它眼里那就是138个字节,不是一句话。
这是本文所有问题的共同形态,值得提前说破:每一层都按自己的规则正确执行了,事故长在层与层的接缝上。面板放文件没错,配置写index index.html也没错,两件对的事凑一起,产出了一个在搜索引擎那边彻底说不通的东西。
HTTPS这边也没拦住
本来还指望SNI不匹配能挡掉,试了一下,照样200——默认站点配了证书,证书对不对得上是浏览器的事,Nginx这边流程是通的。
往深了探一层倒有个小安慰:陌生Host下/和/index.html是200,/nginx-proxy.html和/any/deep/path.html都是真404,因为那个目录里确实没有对应文件。出问题的只有首页——而首页恰恰是搜索引擎最先抓、也最愿意收的那一个。
Google把这种页面叫什么
叫软404。Google在HTTP状态码文档里的判定很直白:"If the content suggests an error for Google Search, an empty page or an error message, Search Console will show a soft 404 error."内容看着像报错,就按软404处理——页面标题直接写着“404 Not Found”,这判定没什么悬念。
更要命的是抓取预算文档那句:"Eliminate soft 404 errors. soft 404 pages will continue to be crawled, and waste your budget."软404不会因为被识破就不抓了,它会继续被抓。站里那篇讲404抓取信号的文章说过,Google抓到真404其实是好事,那是明确的收尾信号;软404的麻烦正在于它不给任何信号,只让抓取一直转圈。
平时确实没人拿陌生域名请求你的IP,但泛解析把整个*.example.com指过来、换服务器时旧域名忘了摘、别人擅自把域名解析到你的IP,这几种都不算罕见——产出的都是同一个200页面,一批可索引、内容雷同、还全都自称404的URL,说它是索引膨胀的种子并不夸张。
server_name下划线到底是不是通配符?
顺着往下追:这些陌生Host凭什么落进默认站点?配置里那行是server_name _;。绝大多数人——包括保哥自己用了很多年——都默认_是通配符,意思是“匹配剩下的一切”。
它不是。
官方文档把话说得很不留情面
"There is nothing special about this name, it is just one of a myriad of invalid domain names which never intersect with any real name. Other invalid names like
--and!@#may equally be used."
翻成人话:这名字一点也不特殊,它只是众多无效域名里的一个,永远不会跟任何真实域名撞上;你写--或者!@#,效果完全一样。文档还补了一刀——兜底这件事压根不归server_name管,它是listen的属性。
那它凭什么真的兜住了
答案在listen的文档里,就一句:
"If none of the directives have the default_server parameter then the first server with the address:port pair will be the default server for this pair."
没有任何server块标了default_server,那么第一个监听这个地址端口的server块自动当默认。保哥把整份展开后的配置扫了一遍:default_server出现0次。这台机器的默认站点,完全靠“谁排第一”决定。
排第一这件事,是文件名说了算的
主配置加载虚拟主机的那行是include /www/server/panel/vhost/nginx/*.conf;,通配符展开按字母序。那个目录里的文件叫0.default.conf,数字0加个点开头,排在所有域名文件前面。这个前缀不是随手起的,它就是用来抢第一位的。
于是一条完整的因果链浮出来了:文件名的字母序 → include展开顺序 → 谁是默认server → 陌生域名拿到什么响应 → 搜索引擎会不会收一批软404。一个SEO结果,最上游的自变量是一个文件名。
这条依赖脆到什么程度?把0.default.conf改名成zz.default.conf,默认站点当场换人,变成字母序最靠前的那个真实站点——从此陌生域名会拿到那个站的完整首页,一字不差,那才是货真价实的重复内容。面板升级重排文件、新加的站点排到了前面,同样的事都会自己发生,没有任何提示。
正确做法只有一个,而且和server_name无关:在listen上把话说死。
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
return 404; # 明确拒绝,别给200
}加上default_server,兜底就从“碰巧排第一”变成“明确指定”;把index和root换成一句return 404,那个躺了3年的index.html再也没有出场机会。
一个返回404的页面,为什么在页面里声明自己可以被收录?
测完陌生域名,顺手测自己域名下不存在的路径。状态码这关是过的,带扩展名、带尾斜杠、都不带,三种形态全是404。保哥本来准备翻篇,顺手看了眼响应体,然后就没能翻成。
三行标签,一行比一行离谱
<title>保哥笔记</title>
<meta name="robots" content="index, follow">
<link rel="canonical" href="/">标题是站点名,没有一个字提示这个页面不存在;index, follow在明确请求被收录;canonical指向首页,等于说“我的规范版本是首页”。HTTP层说“这里没有东西”,HTML层说“我可以被收录,而且我等同于首页”,两层互相拆台。
这是保哥在这轮实测里发现的自家问题,不是找来的教学案例,写到这段时它还挂在线上。
它是怎么长成这样的
又是三层各自正确。Nginx层的伪静态是if (!-e $request_filename) { rewrite ^(.*)$ /index.php$1 last; },文件不存在就交给index.php;应用层找不到文章返回404;模板层的公共head无条件输出canonical和robots,因为它被写的时候压根没考虑过“这次渲染的是个404”。
没有哪一层能被单独指认为写错了。canonical本来就是提示而不是命令,Google有权不理它,但一个自称可索引、还把自己等同于首页的404,信号足够混乱,赌它每次都判对不是好主意。
顺带称了一下这个404有多重
| 请求的路径 | 状态码 | 不压缩 | Brotli压缩后 |
|---|---|---|---|
/this-page-does-not-exist-xyz.html | 404 | 304607 | 36301 |
/css/output.css | 404 | 304607 | 36301 |
/seo/google-seo/ahrefs/ | 404 | 304607 | 36301 |
三个完全不同的路径,字节数一模一样,它们拿到的是同一个页面。304607字节,用来表达“这个页面不存在”,压缩后还有36KB。这么大是因为应用把404也当成一次正常渲染跑完了:侧栏、导航、推荐位、页脚,一样不少。
日志里Googlebot撞上404共469次,累计传了14.79 MB,平均每次33060字节。倒不是心疼这15兆,而是它全部用来重复表达同一句“没有”。撞的URL挺有代表性:删掉的老分类/seo/google-seo/ahrefs/ 14次,下线的标签/tag/迁移宿醉/ 12次,还有一批是友链页把访客提交的整段留言拼进了路径,连人家的头像地址都编码进去了。
这类账该不该修、按什么顺序修站里单独写过。这里只强调一点:404返回得对,不等于这件事处理完了。状态码是第一关,页面内容说了什么是第二关,而第二关基本没人查。
同一台Nginx上的两种301,为什么一种保留参数一种把它丢了?
这台机器上有一批老路径的301规则,2026年5月基于日志分析加的,用来收掉常年刷404的URL,写法清一色是return 301。看着没毛病,带上查询参数再请求一次,问题就出来了。
$ curl -I 'https://zhangwenbao.com/tags/seo?utm_source=newsletter&gclid=ABC'
location: https://zhangwenbao.com/tag/seo ← 参数没了
$ curl -I 'https://zhangwenbao.com/llms?a=1'
location: https://zhangwenbao.com/llms/?a=1 ← 参数还在同一台服务器,两条301对查询参数的处置完全相反。区别在于第二条不是人写的:/llms是真实目录,Nginx发现它少了尾斜杠就自动补了个301,这类自动生成的重定向会带上原查询串;第一条是配置里那句return 301,它把参数扔了。
文档里写了什么,以及没写什么
rewrite指令的文档明说会追加:
"If a replacement string includes the new request arguments, the previous request arguments are appended after them. If this is undesired, putting a question mark at the end of a replacement string avoids having them appended."
原参数会被追加在后面,不想要就在替换串末尾加个问号——文档甚至贴心地告诉你怎么关掉。
再翻return的文档,关于查询参数:一个字都没有。这不是文档偷懒,是return压根没有“追加参数”这回事,你写什么它发什么。两个指令看着都是“做个跳转”,一个默认帮你带上还给开关,另一个默认什么都不带也没开关,中间没有任何提示告诉你它们在这件事上是相反的。
丢掉的到底是哪几个参数
丢的不是随便什么参数。gclid是Google Ads的点击标识,丢了它这次点击和后续转化的链路当场断开;utm_*整套是GA4分渠道的依据,丢了以后这次访问会掉进直接流量;srsltid是Google给购物链接自动挂的参数,站里写过它的四种处置方式,这个你还没法让它不加。
一次301之后这些全部归零。而报表上你只会看到直接流量莫名其妙涨了一截,不会看到任何报错。
顺手测出的第二个坑:Location里的域名是谁定的
$ curl -I -H 'Host: zhangwenbao.com' .../apple-touch-icon.png
Location: http://zhangwenbao.com/logo-180.png
$ curl -I -H 'Host: www.zhangwenbao.com' .../apple-touch-icon.png
Location: http://www.zhangwenbao.com/logo-180.png配置里只有一行return 301 /logo-180.png;,路径是相对的,域名是Nginx现补的——补出来的是请求里带的那个Host。文档说得明白:这种本地URI的跳转,完整地址由$scheme加上server_name_in_redirect和port_in_redirect两个指令决定,而这台机器两个都没显式设置,走默认值,于是域名跟着请求走。
后果有两层。域名被原样保留,从www进来的请求跳完还在www上,还得再吃一次统一跳主域的301;协议也被原样保留,上面两次实测的Location都是http://,于是链条变成http的301→https的301→200,三次请求换一个图标。Google抓取预算文档对此态度明确:"Avoid long redirect chains, which have a negative effect on crawling."
打开server_name_in_redirect能把域名固定住,但在反向代理后面又会引出端口和协议对不上的新问题。更稳的办法是跳转目标直接写全绝对URL。
那到底该用哪个
| 场景 | 该用 | 为什么 |
|---|---|---|
| 固定路径搬家,该URL不带跟踪参数 | return 301 | 最快,不进正则引擎 |
| 落地页、活动页、任何可能挂广告参数的URL | rewrite ... permanent | 参数自动带过去 |
用return但必须留参数 | return 301 /new$is_args$args; | 手动把参数拼回去 |
| 跨域名跳转 | 写全绝对URL | 不给Host反射留机会 |
$is_args$args值得记:没参数时它是空的,有参数时自带一个问号,两种情况都不出错。
Googlebot每8次抓取有1次拿到301,这些301在跳什么?
前面都是单点实验,这节看真实爬虫38天的完整行为。日志跨度是2026年6月19日到7月27日,196万行,筛出Googlebot共90760次请求。
| 状态码 | 次数 | 占比 |
|---|---|---|
| 200 | 78272 | 86.24% |
| 301 | 11639 | 12.82% |
| 404 | 469 | 0.52% |
| 403 | 229 | 0.25% |
| 500 | 77 | 0.08% |
| 410 | 51 | 0.06% |
| 304 | 6 | 0.007% |
Googlebot每8次抓取,就有1次拿到的不是内容,是一句“东西搬走了,去那边看”。
89%的301来自一个早就下线的目录
把这11639条按路径归类,排在前面的是/amp/、/amp/dbs-bank.html、/amp/chatgpt-location-sharing-local-seo.html……清一色AMP路径。整个命名空间加起来数:
Googlebot 抓 /amp/* :10392 次,状态码全部是 301
全部 UA 抓 /amp/* :20349 次10392除以11639,等于89.3%。Googlebot拿到的301里将近九成花在一个已下线的AMP目录上,占它全部抓取次数的11.4%。
这个站早就不做AMP了,页面全删了,Nginx的伪静态把这类路径交给应用,应用认出前缀后301到正文页。链路是通的,用户点进来也能看到内容。唯一的问题是它已经重复了10392次,而且还会继续。
301很便宜,但便宜的不是它消耗的那样东西
| 响应类型 | 次数 | 累计字节 | 平均每次 |
|---|---|---|---|
| 全部Googlebot请求 | 90760 | 3606.65 MB | 41669 |
| 其中200 | 78272 | 3589.86 MB | 48092 |
| 其中301 | 11639 | 1.78 MB | 160 |
| 其中404 | 469 | 14.79 MB | 33060 |
301平均160字节,11639次加起来才1.78兆。从带宽角度看这笔钱少到不值得提,404反而贵得多——469次就吃掉14.79兆,因为每次都要渲染那个304KB的页面。
别拿“省流量”论证要治理301,那个论据站不住。真正被消耗的是抓取次数。Google讲的“抓取容量上限”是一个关于请求频次的额度,不是关于字节的,这11639次实实在在从额度里扣掉了,换回来只有11639个Location头。这些额度要是花在还没被抓过的长尾页上,收益比重复跳转高得多。
那到底该301还是该410
Google文档里301是"a strong signal that the redirect target should be processed",一个指向新目标的强信号;而4xx这边"the indexing pipeline removes the URL from the index if it was previously indexed",索引管线会把它删掉,410和404待遇一样。
差别在于你想说什么:301说的是“内容搬到那边了”,410说的是“这东西没了,别再来”。AMP这里301在技术上说得通,内容确实还在,但它跑了10392次都没让Googlebot放弃——“搬家”这个信号只会让爬虫记住“这地址会跳转”,而不是“这地址不该再来”。301、404和410怎么选站里有完整决策表,这里补一条实测经验:当一批301跑了上万次还在跑,就该考虑换个信号了。
顺便,那77次500是怎么回事
500只有77次,占比不到千分之一,本来懒得看,看了一眼发现全是一类:/tag/Memorial+Day/ 12次、/tag/开尔文/ 9次……全是标签页,带加号的和带中文的都翻车了,而/tag/seo/这种纯ASCII的正常返回301。本地一测就复现,PHP错误日志里对应的是一片Undefined array key。
标签体系早就下线了,但URL还活着,还在用500回应爬虫。77次不多,可这个数字的性质和301不一样:抓取预算文档把5xx和响应变慢并列,后果都是"the limit goes down and Google crawls less."301只是浪费额度,5xx是在主动缩小额度。下线一套URL体系时,让它404或410都行,唯独不能报错。
配置里明明写着那条响应头,为什么线上收不到?
这节的坑最隐蔽:它不改状态码,也不改页面内容,只是让你写的东西悄悄消失。
站点配置的server级别有这么一行add_header Vary "Accept, Accept-Encoding" always;,写的是两个值。实际收到的是vary: Accept-Encoding,只有一个值,配置里那个Accept没了。
add_header不是叠加,是全有或全无
Nginx的headers模块文档里,有一句话决定了这一切:
"These directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level."
当且仅当当前层级一条add_header都没有时,才继承上一层的。
注意粒度:不是按header名字逐个判断,是整体判断。子层级只要写了任意一条add_header——哪怕加的是个八竿子打不着的头——父层级的所有add_header全部作废。/sitemap.xml落进的那个location里有自己的add_header,于是server级那条Vary被整体屏蔽了。
更黑色幽默的是,这台机器的配置里,那行add_header上方就有一句注释写着“会被任何location级的add_header遮蔽”。注释留了,坑还是踩了——知道规则和记得每个location里有什么,是两件事。
为什么这条规则专坑做SEO的
因为SEO要发的那些头全靠add_header:X-Robots-Tag: noindex是PDF、图片这类插不了meta标签的资源唯一的noindex手段;Link: rel="canonical"是非HTML资源声明规范版本的唯一办法;Cache-Control和Vary决定CDN和爬虫怎么缓存。
典型翻车路径:你在server级写了add_header X-Robots-Tag "noindex" always;想把测试环境挡住,三个月后有人为了修跨域,在location ~* \.(jpg|png)$里加了一行add_header Access-Control-Allow-Origin *;。
从那一刻起,这个站所有图片的noindex全部失效。配置里那行noindex还在,语法检查照样通过,谁也不会想到去看它。响应头这层的SEO机制站里讲过原理,这一条是原理落到配置文件之后最容易崩的地方。
怎么查,以及怎么防
查的办法只有一个,而且必须查响应不能查配置。把所有location类型都覆盖到是关键,只测首页永远发现不了图片那个location的问题:
for u in / /a.html /img/x.png /file.pdf /sitemap.xml; do
echo "== $u"
curl -sI "https://example.com$u" | grep -iE 'x-robots-tag|vary|cache-control|link:'
done防的办法有两条:把要全站生效的头在每个有add_header的location里都重写一遍,笨但可靠;或者改用ngx_headers_more模块的more_set_headers,它没有这个继承规则,代价是要额外编译模块。保哥选的是第一条——多写几行重复配置,换掉一个查起来要命的隐患,这买卖划算。
Vary声明的是一种可能性,不是这次响应的事实
接着上节的Vary往下挖,挖出来的东西比预想的有意思。拿站点的sitemap索引做对照,一次声明支持gzip,一次明确要求不压缩:
$ curl -I -H 'Accept-Encoding: gzip' .../sitemap.xml
content-length: 849
vary: Accept-Encoding
$ curl -I -H 'Accept-Encoding: identity' .../sitemap.xml
content-length: 849
vary: Accept-Encoding两次的Content-Length都是849,一个字节不差——压缩根本没发生过,849字节没到阈值,服务器直接原样发了。但两次都老老实实带着Vary: Accept-Encoding。
这不是bug,是这个头的语义常被误读。Vary说的是“这个URL的响应有可能随这个请求头变化”,它描述的是一种可能性,不是这一次响应的事实。服务器在决定压不压之前就把它挂上去了,因为此刻还不知道结果。
平时无所谓,遇到CDN就不一样。CDN按Vary里列的头给缓存分桶,而Accept-Encoding在真实世界里取值五花八门——gzip, deflate、gzip, deflate, br、identity,还有各种老客户端的怪写法。不做归一化的话,同一个URL会分裂出好几份缓存条目,而这几份里存的是同一份849字节。命中率被摊薄了,字节一个没省。
665KB的RSS,为什么gzip一口没咬
| URL | Content-Type | 不压缩 | gzip | Brotli |
|---|---|---|---|---|
/rss.xml | text/xml | 665979 | 665979 | 233113 |
/sitemap.xml | text/xml | 849 | 849 | 849 |
/llms.txt | text/plain | 25723 | 11593 | 10587 |
/nginx-proxy.html | text/html | 486535 | 77040 | 压了 |
看第一行:一个650KB的RSS,客户端明确说了支持gzip,服务器原样发了665979字节,而同一个文件Brotli能压到233113——本来可以省423KB。text/plain压了,text/html压了,text/xml的两个都没压。原因藏在类型清单里:
gzip_types ... application/xml application/json image/jpeg
... image/svg+xml application/xml+rss text/x-js;
brotli_types ... text/plain text/css text/xml text/javascript
... application/xml application/atom+xml application/rss+xml ...两处差别,各造成一个后果。gzip_types里没有text/xml,brotli_types里有,而这两个文件的Content-Type恰好就是text/xml,这解释了为什么Brotli能压gzip不能。更妙的是gzip_types里写的是application/xml+rss——这个MIME类型根本不存在,正确写法是application/rss+xml,注意brotli_types那行写对了。同一个配置文件里两份清单,一份错一份对。
那个写反的MIME是从面板默认模板带出来的,在各种教程里流传了很多年,因为它不会报错——Nginx对gzip_types的值不做校验,你写banana/split它也照单全收,只是永远不会有响应匹配上。
这事有多严重,得说实话
写到这里保哥本来想下个狠结论,量完数据又收回去了。现代爬虫和浏览器基本都支持Brotli,Googlebot拿到的是233KB那版,没受影响。真正吃亏的是只发gzip的那批客户端——一部分RSS阅读器、老抓取脚本、某些企业代理,还有你自己写的那些忘了加br的监控脚本。
所以准确的结论是:这是一个影响面有限但完全没必要存在的问题,修复成本是往gzip_types里补一个text/xml,顺手改掉那个写反的MIME,两分钟的事。顺带一提,Nginx对text/html永远压缩,不需要写进清单——这也是为什么HTML那行看着一切正常,它压根没走这份清单。
一个查询参数把TTFB从26毫秒推到623毫秒,这笔账该怎么算?
这节原本要论证“爬虫带参数抓取会穿透整页缓存、拖慢抓取”。数据把这个论点否掉了一半,那就照实写。
站点的整页缓存有条绕过规则:if ($query_string != "") { set $skip_cache 1; },只要URL带问号一律穿到PHP。实测四次:
| 请求的URL | 缓存状态 | TTFB |
|---|---|---|
/nginx-proxy.html | HIT | 26 ms |
/nginx-proxy.html?utm_source=newsletter | BYPASS | 623 ms |
/nginx-proxy.html?gclid=TEST123 | BYPASS | 540 ms |
/nginx-proxy.html?srsltid=abc | BYPASS | 648 ms |
同一个页面,同样的内容,多一个参数,TTFB慢24倍。而这些URL根本不会被收录——canonical已经把它们全部收口到干净地址了,实测确认过。付24倍成本,换一个永不进索引的响应。
但我原本的论证被数据推翻了
接下来该论证“爬虫经常带参数来,所以代价被放大了”。数了一下:Googlebot的90759条请求里,带查询串的只有366条,0.40%。千分之四。
这366条里还有307条是同两个东西:/?c=评论分页157次、/?s=站内搜索150次,加起来84%。“缓存穿透正在吃掉抓取预算”这个说法,在这台机器上不成立,原本准备好的一整段推论就此作废。这跟上一篇实测里被推翻的那个结论是同一种情况——想当然的因果链,量一遍就散了。
那这24倍到底谁在付
不是爬虫,是花钱买来的那批用户。想想哪些访问必然带参数落地:广告点击带gclid,邮件推广带utm_*,联盟带自家跟踪码,购物广告带srsltid。这些恰恰是单次成本最高的一批流量,而他们每个人的第一次落地都要等600毫秒才收到第一个字节。自然搜索点进来的反而享受26毫秒,因为那些URL是干净的。这套配置在系统性地惩罚付费流量、优待免费流量,逻辑上有点黑色幽默。
所以结论要改写:这不是抓取预算问题,是付费流量的落地体验问题。受害者认错了,问题本身是真的。
改法是别用“有没有问号”这种粗暴判断。参数分两类:utm_*、gclid、fbclid、srsltid这些改变不了服务器吐出的HTML,完全应该复用缓存;分页、搜索词、排序、筛选才必须绕过。做法是把纯跟踪参数从缓存键里剥掉,让它们命中同一份缓存:
set $ckey $args;
if ($ckey ~ "^(?:utm_[^&]*|gclid=|srsltid=|fbclid=)") { set $ckey ""; }
fastcgi_cache_key "$scheme$request_method$host$uri?$ckey";思路示意,参数多的站得写更细。整页缓存那篇讲了缓存键怎么设计,重点只有一个:缓存键该按“这个参数会不会改变响应”来划,不该按“有没有问号”来划。
90760次抓取里只有6次304,剩下的都在重传吗?
回头看状态码表最下面那行:304,6次。这数字小得不正常——对一个1600多篇文章、绝大多数是常青内容的站,理应有相当比例的抓取可以用“你手上那份还是最新的”打发掉。
爬虫想省,得先有东西可依
条件请求需要服务器先给出版本凭据:Last-Modified或ETag。把几类资源摆一起:
| 资源 | Last-Modified | ETag | 能不能条件请求 |
|---|---|---|---|
/sitemap.xml(静态文件) | 有 | 有 | 可以 |
/robots.txt(静态文件) | 有 | 有 | 可以 |
/nginx-proxy.html(PHP生成) | 没有 | 没有 | 不行 |
答案就在这儿。Nginx的静态文件模块按文件的mtime和大小自动生成这两个头,所以sitemap和robots.txt天生支持条件请求;而文章页是PHP生成的,哪怕命中了整页缓存、根本没执行PHP,Nginx也不会替上游补这两个头,因为它不知道该按什么算,上游自己也没发。于是1600多篇文章的每一次抓取都是全量传输,那6次304大概率全是静态文件贡献的。
跟压缩那节连起来看更清楚:Googlebot的200响应平均48092字节,而文章页不压缩是486535、压缩后77040——压缩这层是正常工作的,48KB这个均值就是证据。真正没做的是另一件事:让没变过的内容不必重传。压缩解决“传得小一点”,条件请求解决“根本不用传”,后者收益上限高得多,而这个站一次都没拿到过。
补救不难:在应用层按文章的modified字段发一个Last-Modified,再拿进来的If-Modified-Since跟它比,相等就直接返回304并结束。十来行代码的事。
但有个前提得先想清楚:页面上有没有“每次刷新都会变”的东西。阅读量计数、随机推荐、相对时间显示,只要有一个,Last-Modified就不能只按修改时间算,否则爬虫会长期拿到陈旧版本。这也是很多站宁可不发这两个头的原因——不发只是浪费带宽,发错了是内容更新推不出去。
这些问题有没有一套能重复跑的检查顺序?
核心就一句:别审配置写得对不对,去看边界输入下的响应长什么样。这轮实测里每个问题光读配置都发现不了,index index.html没错,return 301 /rss.xml没错,add_header Vary也没错。
四类边界输入
第一类,不属于你的Host。只看状态码,200就是有问题,不管页面上印着什么,想要的答案是404或444。
curl -o /dev/null -w '%{http_code}\n' -H 'Host: not-my-domain.test' http://你的IP/第二类,不存在的路径,三种形态各来一次——带扩展名、带尾斜杠、都不带,三个都该是404。然后务必再看一眼响应体,这步最容易省掉,本文最离谱的那个问题就藏在这儿:
curl -s https://站点/definitely-not-here-xyz.html \
| grep -oiE '<title>[^<]*|name="robots"[^>]*|rel="canonical"[^>]*'要求很简单:状态码和页面内容必须说同一件事。404页面出现index, follow或指向别处的canonical,就是坏的。
第三类,同一个资源的各种变形。健康的站这一组里只该出现两种状态码:正主200,其余全是301跳回正主。这台机器上就有不达标的:
| 请求 | 结果 | 说明 |
|---|---|---|
/nginx-proxy.html | 200 | 正主 |
//nginx-proxy.html | 301 → 正主 | 好 |
/Nginx-Proxy.html | 301 → 正主 | 好 |
/NGINX-PROXY.HTML | 404 | 不一致 |
同样是大小写变形,只改slug能被规范化,连扩展名一起改成大写就变404——路由是先按.html后缀切的,后缀变成.HTML就没切上,压根走不到规范化那段逻辑。这个404本身无害,但它暴露的是规范化逻辑有边界,而你不知道边界在哪。
第四类,每种Content-Type都请求一次,专抓add_header被遮蔽,顺带看出哪些类型没被压缩。
三条能带走的判据
一、状态码和页面内容必须说同一件事。两者由不同的层产出——状态码归Web服务器或框架,页面内容归模板——所以完全可能各说各话。必须同时看,只看一个等于没查。
二、配置里写了,不等于线上发了。add_header的继承规则、gzip_types里那个不存在的MIME、写在server级却被location盖掉的头,都属于“配置文件是对的,响应是错的”。唯一可信的证据是curl -I的输出。
三、同一个资源在各种变形下,只该有一种正确状态码。多出来的200是重复内容,多出来的404是死链。这条不依赖任何具体软件,Apache、Caddy、各家CDN上同样适用。
什么时候该怀疑接缝
本文所有问题都长在接缝上,而接缝有个共同特征:两边各自都有一套完整、自洽、并且是对的规则。这台机器上的接缝清单:
- 面板放的
index.html× 配置里的index指令 → 200的404页 - Nginx的伪静态 × 应用的404 × 模板的无条件canonical → 自称可索引的404
- server级的
add_header× location级的add_header→ 消失的响应头 - gzip的类型清单 × Brotli的类型清单 → 只有一半资源被压
- 文件名字母序 × include展开顺序 → 谁来兜底陌生域名
每一条里的两个东西,单独看都挑不出毛病。所以逐条审查配置这类办法,对这类问题天生无效——它们的错误不在任何一条里面。
能抓住它们的只有一种办法:把请求真的发出去,看回来的是什么。Nginx只有HTTP层面的判断力,它不知道什么叫“这个页面该不该被收录”;凡是SEO事故,在它看来都只是配置按你写的执行了而已。这层语义得由人补上,而补的方式不是读配置,是看响应。
四类输入不到二十条curl,扔进定时任务即可。日志侧的分析是另一条腿:日志告诉你爬虫撞上过什么,探测告诉你它接下来会撞上什么。
⚡ 顺手一起用:可索引性一键体检
输入一个网址,把状态码、重定向链、robots判定、meta robots、X-Robots-Tag和canonical一次查完——本文第二类边界输入要手工比对的那几样它全给摆出来。
常见问题解答
我的服务器上只有一个站点,还需要管默认server吗?
需要,风险比多站点还高。单站点服务器上很多人根本不建默认server块,唯一那个站就成了默认——任何解析到这个IP的陌生域名,都会拿到你网站的完整首页内容,比本文那个138字节的假404严重得多。测法一样:拿不存在的Host请求你的IP,看返回的是不是你的首页。是的话,加一个带default_server、内容只有return 404;的兜底块。
伪静态用if判断文件存在,和try_files比差在哪?
try_files更好,if在Nginx里有一堆边界行为,官方也建议少用。但这里想提醒if (!-e)一个不出名的风险:它对目录同样返回真。网站根目录下如果有个目录名恰好和某篇文章的slug一样,那篇文章会永远打不开——请求被判定为“文件存在”,重写规则跳过,Nginx按目录去找首页,找不到就是403。没有报错,没有日志异常,就是打不开。
保哥在这台机器上查了一遍:根目录11个目录,1624篇文章,撞车0个。安全,但这个安全是运气。真正的防线是发新文章时把slug和根目录列表比一遍。
404页面自称可索引,具体怎么改?
在模板里拿到当前HTTP状态码,据此决定输出什么:状态码是404时,robots输出noindex, follow,canonical整个不输出——不是指向首页,是不要这个标签。canonical指向首页等于告诉搜索引擎“这个URL和首页是同一个东西”,比不写更糟。顺手也该给那个304KB的404页面减减肥。
一批已经跑了上万次的301,改成410会不会丢权重?
看这批URL有没有外链和历史排名。/amp/*这类由自己生成、从没有过外链的路径,改410没有任何损失;但如果某个老URL确实有外链指着,301是对的,那是把权重导过去的唯一手段。办法是把这批URL丢进外链工具查引荐域,有外链的保留301,没有的转410,别整批一刀切。
Vary既然经常名不副实,是不是干脆删掉更好?
不能删。删掉Vary,CDN就会把压缩过的响应发给不支持压缩的客户端,那是实打实的乱码事故,比缓存被摊薄严重得多。正确做法是在CDN那侧做Accept-Encoding归一化,把千奇百怪的取值收敛成两三种(支持br、只支持gzip、都不支持)再分桶。主流CDN都有这个能力,只是默认往往不开。
权威参考资料
本文标题:《Nginx配置每次reload都通过,Googlebot每8次抓取却有1次撞在301上》
本文链接:https://zhangwenbao.com/nginx-config-silent-seo-side-effects-audit.html
版权声明:本文原创,转载与引用请注明作者与原文链接。许可协议: CC BY 4.0
← 上一篇
商品页上被划掉的那个原价,只有你自己的价格历史能证明它存在过下一篇 →
没有了