Nginx配置每次reload都通过,Googlebot每8次抓取却有1次撞在301上

Nginx配置每次reload都通过,Googlebot每8次抓取却有1次撞在301上
张文保 45 分钟阅读 1,007 阅读
本文目录
  1. 一台服务器上十几个站,陌生域名请求过来会拿到什么?
  2. 这个自相矛盾的页面是谁放的
  3. 为什么它是200而不是404
  4. HTTPS这边也没拦住
  5. Google把这种页面叫什么
  6. server_name下划线到底是不是通配符?
  7. 官方文档把话说得很不留情面
  8. 那它凭什么真的兜住了
  9. 排第一这件事,是文件名说了算的
  10. 一个返回404的页面,为什么在页面里声明自己可以被收录?
  11. 三行标签,一行比一行离谱
  12. 它是怎么长成这样的
  13. 顺带称了一下这个404有多重
  14. 同一台Nginx上的两种301,为什么一种保留参数一种把它丢了?
  15. 文档里写了什么,以及没写什么
  16. 丢掉的到底是哪几个参数
  17. 顺手测出的第二个坑:Location里的域名是谁定的
  18. 那到底该用哪个
  19. Googlebot每8次抓取有1次拿到301,这些301在跳什么?
  20. 89%的301来自一个早就下线的目录
  21. 301很便宜,但便宜的不是它消耗的那样东西
  22. 那到底该301还是该410
  23. 顺便,那77次500是怎么回事
  24. 配置里明明写着那条响应头,为什么线上收不到?
  25. add_header不是叠加,是全有或全无
  26. 为什么这条规则专坑做SEO的
  27. 怎么查,以及怎么防
  28. Vary声明的是一种可能性,不是这次响应的事实
  29. 665KB的RSS,为什么gzip一口没咬
  30. 这事有多严重,得说实话
  31. 一个查询参数把TTFB从26毫秒推到623毫秒,这笔账该怎么算?
  32. 但我原本的论证被数据推翻了
  33. 那这24倍到底谁在付
  34. 90760次抓取里只有6次304,剩下的都在重传吗?
  35. 爬虫想省,得先有东西可依
  36. 这些问题有没有一套能重复跑的检查顺序?
  37. 四类边界输入
  38. 三条能带走的判据
  39. 什么时候该怀疑接缝
  40. 常见问题解答
  41. 我的服务器上只有一个站点,还需要管默认server吗?
  42. 伪静态用if判断文件存在,和try_files比差在哪?
  43. 404页面自称可索引,具体怎么改?
  44. 一批已经跑了上万次的301,改成410会不会丢权重?
  45. Vary既然经常名不副实,是不是干脆删掉更好?
  46. 权威参考资料

摘要: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.htmlindex.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,兜底就从“碰巧排第一”变成“明确指定”;把indexroot换成一句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.html40430460736301
/css/output.css40430460736301
/seo/google-seo/ahrefs/40430460736301

三个完全不同的路径,字节数一模一样,它们拿到的是同一个页面。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_redirectport_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最快,不进正则引擎
落地页、活动页、任何可能挂广告参数的URLrewrite ... 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次请求。

状态码次数占比
2007827286.24%
3011163912.82%
4044690.52%
4032290.25%
500770.08%
410510.06%
30460.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请求907603606.65 MB41669
其中200782723589.86 MB48092
其中301116391.78 MB160
其中40446914.79 MB33060

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_headerX-Robots-Tag: noindex是PDF、图片这类插不了meta标签的资源唯一的noindex手段;Link: rel="canonical"是非HTML资源声明规范版本的唯一办法;Cache-ControlVary决定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, deflategzip, deflate, bridentity,还有各种老客户端的怪写法。不做归一化的话,同一个URL会分裂出好几份缓存条目,而这几份里存的是同一份849字节。命中率被摊薄了,字节一个没省。

665KB的RSS,为什么gzip一口没咬

URLContent-Type不压缩gzipBrotli
/rss.xmltext/xml665979665979233113
/sitemap.xmltext/xml849849849
/llms.txttext/plain257231159310587
/nginx-proxy.htmltext/html48653577040压了

看第一行:一个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/xmlbrotli_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.htmlHIT26 ms
/nginx-proxy.html?utm_source=newsletterBYPASS623 ms
/nginx-proxy.html?gclid=TEST123BYPASS540 ms
/nginx-proxy.html?srsltid=abcBYPASS648 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_*gclidfbclidsrsltid这些改变不了服务器吐出的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-ModifiedETag。把几类资源摆一起:

资源Last-ModifiedETag能不能条件请求
/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.html200正主
//nginx-proxy.html301 → 正主
/Nginx-Proxy.html301 → 正主
/NGINX-PROXY.HTML404不一致

同样是大小写变形,只改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

继续阅读
发表评论
分享到微信 或在下方手动填写
支持 Ctrl + Enter 提交