做SEO算抓取预算,发现服务器把没改过的页面对爬虫重发了几百遍

做SEO算抓取预算,发现服务器把没改过的页面对爬虫重发了几百遍
张文保 45 分钟阅读 2,552 阅读
本文目录
  1. 抓取预算漏掉的到底是页面数,还是字节数?
  2. Google对这件事的正式建议只有一句话
  3. 0.017%:这条路今天有多窄
  4. 为什么这类故障在浏览器里永远看不见
  5. “我配了ETag”这句话,有多大概率是假的?
  6. 动态页的默认形态:一个验证器都没有
  7. 比“没配”更值得警惕的一格:配了,但没人判决
  8. 顺手一提:session_start()会把整套指令改写掉
  9. If-Modified-Since为什么经常是白发的?
  10. nginx默认只认“分秒不差”
  11. 手写Last-Modified的那个死循环
  12. Google对日期格式还有一条硬要求
  13. 现网实测:一半的Last-Modified是装饰品
  14. 两个验证器同时发过去,谁说了算?
  15. nginx用的是“两个都得点头”
  16. 现网44.6%的服务器也是这么干的
  17. 为什么开个压缩就把验证器换掉了?
  18. 实验台上的三种下场
  19. 现网抓到了四种形态
  20. 连带效应:压缩之后Range请求也没了
  21. 代理和CDN在中间做掉了什么?
  22. 改一个字,两个验证器一起消失
  23. 请求头在入口就被吞掉
  24. 304自己也会丢东西
  25. 现网到底配成了什么样?
  26. 越是没人管的那类URL,配得越好
  27. 按站点看:四分之一的站完全吃不到
  28. 按服务器软件分组:一个偏科得很明显的分布
  29. 怎么改才真的省下抓取预算?
  30. 第一步:先确认你现在在哪一档
  31. 第二步:按站型对号入座
  32. 第三步:三段可以直接抄的配置
  33. 第四步:想清楚“什么才算变了”
  34. 一个诚实的边界
  35. 常见问题解答
  36. 返回304会不会影响排名或者收录?
  37. 我用了CDN,是不是就不用管这件事了?
  38. ETag和Last-Modified只配一个的话,配哪个?
  39. 开了gzip会不会让条件请求失效?
  40. 为什么我在浏览器开发者工具里看不到这个问题?
  41. 怎么快速判断整站的304覆盖情况?
  42. 把If-Modified-Since改成before模式安全吗?
  43. 权威参考资料

摘要:Google在2024年底自己公布了一个数:十年前有0.026%的抓取请求能走缓存,今天这个数字是0.017%。也就是说爬虫每来一万次,能省下正文的不到两次。保哥在一台真实nginx上把16种服务器配置乘21种条件请求写法跑了560组,又对85个线上站点的218个URL逐个发条件请求,结果是——不是站长不配ETag,是配了以后有七八种方式让它悄悄失效:动态页发了ETag没人判决、手写的Last-Modified和服务器内部判决用的根本不是一个值、开个压缩就把验证器换掉、代理层顺手把请求头吞了。这些故障有一个共同点,浏览器里全都看不出来,因为浏览器压根不会发那个请求。

先看一段日志。

这是一个内容站的访问日志,抽出了同一个URL在30天里被Googlebot命中的记录,只保留了状态码和响应体积两列:

66.249.66.1 - [02/Jun] "GET /guide/shipping-policy.html" 200 184203
66.249.66.1 - [04/Jun] "GET /guide/shipping-policy.html" 200 184203
66.249.66.1 - [07/Jun] "GET /guide/shipping-policy.html" 200 184203
66.249.66.1 - [09/Jun] "GET /guide/shipping-policy.html" 200 184203
...
(30天共47次,全部200,全部184203字节)

这个页面在这30天里一个字也没改过。

47次乘184 KB,8.6 MB。单看一个URL不算什么,但这个站有4万多个页面,绝大多数是同一种形态:内容长期不动,爬虫按自己的节奏反复来。整站算下来,一个月里被重复搬运的字节数是三位数的GB。

这些字节不是白花的——它们是从抓取预算里扣的。

爬虫在你这里花掉的每一秒连接时间、每一个字节的下行带宽,都不会再花在别的URL上。

你那批新上线的商品页迟迟不被收录,很可能不是因为爬虫不想来,而是因为它的额度已经花在把同一份没变过的HTML搬了47遍上。

这件事本来有解,而且是HTTP协议里最老的那批解法之一:条件请求。爬虫把上次拿到的凭证带上,服务器一比对,没变就回一个不带正文的304,两边都省。

问题在于,这条路今天基本是断的。

抓取预算漏掉的到底是页面数,还是字节数?

先把口径对齐。很多人讲抓取预算,讲的是“爬虫今天抓了多少个URL”,于是优化动作全落在减少URL上——删死链、收参数页、治分面导航。这些都对,筛选过滤产生的海量URL确实会拖垮抓取预算Google抓取预算优化的那份实操清单里也有大半篇幅在讲怎么少给爬虫喂URL。但它只是分子那一半。

Google在大型站点抓取预算管理那份文档里把分母写得很直白。它把抓取预算拆成两个东西:一个是crawl capacity limit(抓取容量上限,文档里又叫hostload),另一个是crawl demand(抓取需求)。前者的定义是This limits the total amount of time your server spends holding connections open for Google.

注意这句话的单位——是时间,不是页数。它限制的是你的服务器为Google把连接保持打开的总时长。

同一份文档接着说明这个上限怎么浮动。往上走的条件是:

If the site responds consistently and its response times (including latency and Time-to-First Byte) remain stable or improve, the limit goes up.

往下掉的条件是站点变慢,或者返回5xx与429这类信号——the limit goes down and Google crawls less

所以抓取预算真正的计价单位是“服务器为爬虫忙了多久”。一个184 KB的200,和一个175字节的304,在这个账本上完全不是一个量级。

Google对这件事的正式建议只有一句话

还是那份文档,在优化清单里明写着:Support 304 (Not Modified) HTTP status codes. If a page hasn't changed since Google last crawled it, returning a 304 code tells Google to reuse the cached version, saving your server bandwidth and resources.

一句话,没有展开。而实际要把这句话落到位,中间隔着七八个能让它失效的环节——这篇剩下的篇幅基本都在讲那七八个环节。

0.017%:这条路今天有多窄

2024年12月,Google搜索中心发了一篇叫Crawling December: HTTP caching的博文,开头第一段就报了个数:

10 years ago about 0.026% of the total fetches were cacheable, which is already not that impressive; today that number is 0.017%.

十年前万分之二点六,今天万分之一点七。而且原文自己都加了一句“已经很不像样了”。

保哥第一眼看到这个数字,以为是没人在乎条件请求。但但把线上站点实际扫一遍之后,结论不太一样:愿意配的人不少,配对的人不多。两者中间隔着的东西,才是这篇文章要拆的。

为什么这类故障在浏览器里永远看不见

这是整件事最反直觉的地方,也是它长期没人发现的原因。

浏览器拿到一个带Cache-Control: max-age=3600的响应,一小时之内再需要这个资源,它根本不会联网——直接从本地缓存拿,连条件请求都不发。只有在缓存过期之后,它才会带上凭证去问一次。而对一个正常访问的用户来说,绝大多数资源在过期之前就已经用完了。

爬虫不一样。爬虫没有“页面停留”这个概念,它按自己的重访节奏回来,回来时手里那份副本往往早就过了新鲜期。于是条件请求这条路,用户几乎不走,爬虫每次都走。你在开发者工具里把网络面板翻烂也测不出问题,因为你测的那个角色根本不走这条路。

这条经验后面还会反复出现,先记下来:凡是只有爬虫会触发的路径,它的故障率跟你在浏览器里的观感完全无关。

“我配了ETag”这句话,有多大概率是假的?

为了把这句话验到底,本文在一台服务器上起了一个独立端口的nginx实例(1.28.3,不碰生产配置),把16种配置形态各开成一个location,再用21种条件请求写法逐个打过去,一共560组应答。下面的每一个数字都来自那张表。

先看基线。一个128 KB的HTML静态文件,nginx出厂配置:

HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 127997
Last-Modified: Wed, 29 Jul 2026 17:17:57 GMT
ETag: "6a6a35c5-1f3fd"
Accept-Ranges: bytes

两个验证器都有,白给的。nginx的etag指令默认就是on,静态文件的Last-Modified直接取文件修改时间。把这个ETag原样带回去问一次:

HTTP/1.1 304 Not Modified
Date: Wed, 29 Jul 2026 17:37:41 GMT
Last-Modified: Wed, 29 Jul 2026 17:17:57 GMT
ETag: "6a6a35c5-1f3fd"

两次的完整字节账是这样的:

响应响应头响应体合计
200 OK236 B127,997 B128,233 B
304 Not Modified175 B0 B175 B

省了99.86%。这是条件请求生效时的样子,也是本文后面所有对照的基准线。

顺带把这个状态码在SEO语境里的位置摆正一下:304不属于301、302、404和410那组需要你做取舍的状态码——那几个是在回答“这个URL还算不算数”,而304回答的是“这份内容要不要重传”,它不改变任何索引层面的判断,只改变一次抓取的成本。

如果你的站是一堆静态HTML,恭喜,你什么都不用做就已经在这条线上了。问题是绝大多数站不是。

动态页的默认形态:一个验证器都没有

把同样这份HTML交给PHP输出,代码就是最朴素的那种:

<?php
$body = file_get_contents('page.html');
header('Content-Type: text/html; charset=utf-8');
echo $body;

响应头变成这样:

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Connection: keep-alive

没有ETag,没有Last-Modified。爬虫下次来,手里没有任何可以拿来提问的凭证,只能重新要一次全文。

这不是哪个CMS写得差,这是PHP、以及绝大多数动态语言运行时的默认行为——内容是现算的,运行时没有立场替你声明“这份东西跟上次一样”。WordPress、Typecho、Magento的商品页,出厂状态基本都在这一格。

比“没配”更值得警惕的一格:配了,但没人判决

很多加速插件会做一件看起来很对的事:把ETag发出去。

<?php
$body = file_get_contents('page.html');
header('Content-Type: text/html; charset=utf-8');
header('ETag: "php-fixed-etag-0001"');
echo $body;

响应头里确实多了一行ETag: "php-fixed-etag-0001"。用任何一个在线检测工具扫,都会给你打勾。

然后把这个ETag带回去问:

$ curl -H 'If-None-Match: "php-fixed-etag-0001"' ...
status=200  下行=127997 字节

200,全文,一个字节没省。

原因分两层。第一层,PHP自己没读$_SERVER['HTTP_IF_NONE_MATCH'],它压根不知道客户端问过;第二层——也是几乎没人知道的那层——nginx这次也没有替它兜底。

nginx有一个专门做这件事的过滤器(not_modified),静态文件的304就是它出的。但对于来自上游的响应,只有当这个响应在nginx看来是可缓存的,这个过滤器才会参与;否则nginx会主动让开,把判决权完全交回给上游。而一个没开fastcgi_cache的PHP响应,默认就是不可缓存的。

于是出现了本批实验里最干净的一组对照:同一个PHP文件,同一行header('ETag: ...'),什么都不改,只在nginx侧多开一层fastcgi_cache——

nginx侧配置响应里有ETag吗带ETag回问的结果下行字节
不开fastcgi_cache200127,997
fastcgi_cache3040

PHP代码一行没动,收益从零变成99.86%。

这条的实用价值在于,它把一个很常见的判断推翻了:在应用层加ETag这件事,单独做完等于没做。它必须和你Web服务器那一层的缓存配置对上,才会开始产生收益,而这两件事在文档里分属完全不同的章节,中间那句“否则判决权不在我这儿”没有人写。

顺手一提:session_start()会把整套指令改写掉

还有一格更隐蔽。只要页面里调了session_start(),PHP会自动往响应头里塞这么一套:

Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache

那个1981年的日期是PHP源码里写死的常量,很多人第一次见都会以为服务器时间坏了。它连带的后果是,这个页面在任何一层缓存里都待不住,验证器自然也一个都没有。这件事对CDN的影响更大,PHP在响应头里替你写的那行1981年日期是另一篇专门讲的题,这里只需要知道:只要页面起了会话,条件请求这条路默认就已经关上了。

If-Modified-Since为什么经常是白发的?

ETag那条路走不通的时候,很多人退而求其次用Last-Modified。这条路更老、更好懂,也更容易配错——因为它有一个默认值,跟绝大多数人的直觉相反。

nginx默认只认“分秒不差”

nginx有个指令叫if_modified_since官方文档里三个取值写得清清楚楚:

取值含义是不是默认
exact请求头里的时间必须与响应的修改时间完全相等
before响应的修改时间早于或等于请求头里的时间,就算没变
off永远当作已修改,即永远200

默认是exact。实验台上把这三种各开一个location,用同一个文件打同一组请求,结果是:

If-Modified-Since的值exact(默认)beforeoff
与Last-Modified逐秒相等304304200
2038年(比内容新得多)200304200
2001年(比内容旧)200200200
不是合法日期200200200

看第二行。客户端说“我手上这份是2038年的”,内容明明是2026年的、明明没有比它新,默认配置下nginx照样回200把全文重发一遍。

这不是bug,exact就是这个语义。但它意味着:只要中间任何一个环节让爬虫手里的时间戳和你当前的Last-Modified差了哪怕一秒,条件请求就作废。而这个“差一秒”太容易发生了——重新部署一次、rsync同步一次、容器重建一次,文件的修改时间就全变了,内容一个字节没动。

顺带一提,把它改成before要想清楚:这时你是在说“客户端手上的版本只要不比我旧,就当没变”。对于内容型页面通常是安全的,对于会回滚的页面则未必。

手写Last-Modified的那个死循环

这一格是在实验台上撞出来的,之前没预料到。

有一类很常见的做法:内容其实不常变,但服务器给的Last-Modified是文件的物理修改时间,不稳定,于是有人干脆用add_header写死一个:

location /guide/ {
    add_header Last-Modified "Mon, 01 Jan 2024 00:00:00 GMT" always;
}

响应头里确实是2024年1月1日了。客户端老老实实把这个值原样带回来问:

$ curl -H 'If-Modified-Since: Mon, 01 Jan 2024 00:00:00 GMT' ...
status=200

200。再问一次,还是200。永远200。

原因是add_header只改了写出去的那行头,而nginx内部用来做判决的还是文件真实的修改时间。

你告诉客户端的值,和你用来判断的值,根本不是同一个。客户端拿着你给的答案来对,你却拿另一张卷子在批。

这一格的危害比“完全没配”更大:检测工具会告诉你Last-Modified配好了,而实际收益是负的——爬虫每次都多带一个请求头,然后每次都拿到全文。

Google对日期格式还有一条硬要求

Google那篇缓存博文里对Last-Modified补了一句很具体的话:The date in the Last-Modified header must be formatted according to the HTTP standard.并且给了推荐写法——Weekday, DD Mon YYYY HH:MM:SS Timezone,例子是Fri, 4 Sep 1998 19:15:56 GMT

同一篇里还有一句更值得琢磨的:Google说它更推荐ETag,理由是it's less prone to errors and mistakes (the value is not structured unlike the Last-Modified value)——ETag的值没有结构,你写什么都行,所以不会有格式错;Last-Modified有结构,就有写错的空间。紧接着又补了一句:And, if you have the option, set them both.

“两个都设上”这个建议听起来很稳妥。但两个都设上之后,会引出下一节那个几乎没人测过的问题。

现网实测:一半的Last-Modified是装饰品

对85个能正常抓到首页的线上站点、218个URL(首页、一个深层内容页、sitemap、一个静态资源各一个)逐个做了这组测试。其中107个URL发了Last-Modified。用一个必然“不早于”内容的未来时间去问它们:

测试结果
把响应给的Last-Modified原样带回去问304占89/107=83.2%
用2038年这个未来时间问304只占53/107=49.5%

差出来的这一半,就是配成了exact语义的那些。它们不是坏了,只是脆——凭证必须逐秒对上才生效,而爬虫手里那份凭证撑不过一次部署。

两个验证器同时发过去,谁说了算?

Google建议两个都设。爬虫手上两个都有的时候,也会两个都带上。那么当ETag说“没变”、Last-Modified说“变了”的时候,服务器该听谁的?

这件事RFC 9110写得极其明确,一点解释余地都没有。13.2.2节给了一个六步的判决顺序,其中第3步和第4步是这样的:

3. When If-None-Match is present, evaluate the If-None-Match precondition: … if false for GET/HEAD, respond 304 (Not Modified)

4. When the method is GET or HEAD, If-None-Match is not present, and If-Modified-Since is present, evaluate the If-Modified-Since precondition…

请注意第4步那半句加粗的条件。规范的意思是:只要请求里有If-None-Match,就完全不许再看If-Modified-Since。同一份文档在13.1.2节还解释了为什么——entity tags are presumed to be more accurate than date validators,实体标签被认为比日期验证器更准。

nginx用的是“两个都得点头”

实验台上这一组只有两行,但结论很硬。用真实的ETag(匹配)配上一个2001年的If-Modified-Since(按exact语义算作“变了”):

请求带的两个头RFC要求nginx实测
ETag不匹配 +Last-Modified匹配200200 ✓
ETag匹配+If-Modified-Since说旧了304200

第二行是偏离。nginx把两个条件当成了“与”——两个都得点头才给304,任何一个说“变了”就回全文。规范要求的是“有ETag就只看ETag”。

这个偏离的方向是保守的(宁可多发一次全文,也不会发错版本),所以从正确性上讲它不危险。但从抓取预算上讲,它正好把Google那条“两个都设上”的建议变成了一个陷阱:你按建议把两个都设上了,反而多出一条能让304失效的路径。

现网44.6%的服务器也是这么干的

这条如果只在实验台上成立,那它只是nginx的一个脾气。于是把同一组请求原样打到线上——218个URL里有74个同时给了ETag和Last-Modified,可以做这个测试:

测试规范要求现网合规率
ETag不匹配 +Last-Modified匹配20070/74=94.6%
ETag匹配 +If-Modified-Since说旧了30441/74=55.4%

第二行:44.6%的服务器把已经匹配上的ETag判决否掉了,返回全文。这不是nginx一家的脾气,是相当一片实现的共同选择。

落到操作上,这条给出了一个反常识的建议:如果你的ETag是靠得住的(内容哈希),那么把Last-Modified一起发出去,在将近一半的服务器上是在给自己添乱。Google的“两个都设上”是站在客户端立场说的——它希望不管遇到哪种服务器都有得用;而站在你自己的抓取预算立场,先把一个做对,比两个都做半拉子强。

为什么开个压缩就把验证器换掉了?

这一节是本批最出乎意料的部分。

压缩和条件请求看起来是两件事:一个管“传多少”,一个管“传不传”。但它们在同一个响应上碰头,而碰头的地方是ETag。

ETag标识的是“表示”(representation),不是“资源”。同一个URL的gzip版和未压缩版,在HTTP的语义里是同一个资源的两个不同表示——所以严格说,它们的ETag应该不一样。各家实现对这句“应该”的执行力度,差别极大。

实验台上的三种下场

同一个128 KB的文件,三种压缩配置,拿到三个不一样的ETag:

配置不压时的ETag要gzip时的ETag变了吗
不开压缩"6a6a35c5-1f3fd""6a6a35c5-1f3fd"没变
gzip on(运行时压)"6a6a35c5-1f3fd"W/"6a6a35c5-1f3fd"值一样,被标成弱的
gzip_static on(发预压件)"6a6a35c5-1f3fd""6a6a35c5-51a1"换了一个

中间那行是nginx的聪明做法:值保持不变,只在前面加个W/标成弱验证器。RFC 9110规定If-None-Match必须用弱比较——A recipient MUST use the weak comparison function when comparing entity-tags for If-None-Match——弱比较会忽略W/前缀,所以拿未压缩版的ETag在压缩请求下回问,仍然给304。实测确认了这一点。

第三行就不行了。gzip_static发的是磁盘上另一个文件(.gz),ETag是按那个文件的属性算的,尾巴上那个51a1正是预压件的字节数。两个ETag完全无关,弱比较也救不回来:

场景gzip ongzip_static on
拿未压缩版ETag,在gzip请求下回问304200(白下一次全文)
拿gzip版ETag,在未压缩请求下回问304200

现网抓到了四种形态

把这个测试铺到线上。218个URL里有109个能同时拿到未压缩版和压缩版两个ETag,其中59个=54.1%的ETag在压缩前后不一样。而且形态相当有辨识度:

未压缩版压缩版做法
"0db679ca7776c5b9…"W/"0db679ca7776c5b9…"值不变,标成弱(nginx这一路)
"260c5-5d4b907971c29""260c5-5d4b907971c29-gzip"尾巴上贴一个后缀
"14a03e4e…-ssl""14a03e4e…-ssl-df"贴另一种后缀
"5d2873ae3fcdc1:0""80c6b139e3fcdc1:0"整个值换掉,看不出关系

只有第一种能被弱比较救回来,后面三种都救不回来。实测的最终损失率是:拿未压缩版的ETag在压缩请求下回问,35/109=32.1%的URL直接给200。

三分之一。而这三分之一里,没有任何一个站长做错了什么——他们只是开了压缩。

连带效应:压缩之后Range请求也没了

还有一个副作用是顺手测出来的。运行时压缩是流式的,nginx没法在压缩流上定位字节区间,于是Accept-Ranges直接从响应头里消失:

配置+请求编码裸Range请求带If-Range的Range请求
不压缩206206
gzip on+ 要gzip200(全量)200(全量)
brotli on+ 要br200(全量)200(全量)
gzip_static on+ 要gzip206206

顺便说,If-Range这条规范上要求比较,弱ETag根本不能用于断点续传——所以哪怕Range还在,一个被W/标弱了的ETag也拿不到206。对大文件(超大sitemap、PDF目录、媒体文件)来说,这意味着任何一次中断都得从头再来。

代理和CDN在中间做掉了什么?

前面几节的故障都发生在源站。真实站点在源站和爬虫之间还隔着一层甚至几层,那一层也会动手。

改一个字,两个验证器一起消失

最干净的一个例子是sub_filter——在代理层做正文替换,用来注入统计代码、改域名、加个横幅,运维圈里非常常见。实验台上给一个正常带ETag的上游套一层sub_filter

响应头不套sub_filter套上sub_filter
ETag"6a6a35c5-1f3fd"没有
Last-Modified没有
条件请求304永远200

这个行为本身是对的——正文被改过了,上游那个ETag已经不能代表实际发出去的内容,留着才危险。但代价是这个location下的所有URL永久失去条件请求能力,而配置里没有任何一处提到这件事。

请求头在入口就被吞掉

另一种更难查:响应里ETag好好的,条件请求却永远不命中。原因是中间层把If-None-Match请求头去掉了——WAF、某些反代默认配置、部分“防缓存穿透”策略都会这么做。

实验台上模拟了这个形态:响应头里ETag: "6a6a35c5-1f3fd"照发,客户端原样带回来问,结果是200。你在响应侧怎么检查都是对的,问题在请求侧,而请求侧没有任何回显。

自查方法很简单:在源站开一条只记$http_if_none_match的日志,看爬虫来的时候这个字段是不是空的。这跟Nginx配置里那些reload能过、却在抓取上出事的静默副作用是同一类问题——配置校验永远不会告诉你这件事。

304自己也会丢东西

RFC 9110的15.4.5节对304有一条硬要求:

The server generating a 304 response MUST generate any of the following header fields that would have been sent in a 200 (OK) response to the same request: Content-Location, Date, ETag, and Vary; Cache-Control and Expires.

意思是200里有的这几个头,304里必须也有。实验台上开了gzip_vary on之后:

200 响应: Vary: Accept-Encoding   ✓
304 响应: (没有 Vary)          ✗

nginx的304把Vary丢了。这条的后果落在共享缓存上:缓存收到304之后要用其中的头去更新已存条目,Vary一丢,它就可能把某个编码版本当成通用版本复用,最后给不支持该编码的客户端发了压缩内容。

线上的93个304样本里,这个问题的规模是可测的:

304里带了这个头占比
Date100.0%
ETag94.6%
Cache-Control76.3%
Vary53.8%
Last-Modified36.6%
Set-Cookie26.9%

其中“200里有Vary、304里没有”的确认样本是8个。最后一行是个意外收获:四分之一的304响应里带着Set-Cookie。规范说得很直接——A 304 response is terminated by the end of the header section; it cannot contain content or trailers,同时建议不要发多余的表示元数据。往一个“什么都没变”的响应里塞一个新Cookie,对共享缓存是纯粹的麻烦。

好消息是,93个样本里带响应体的304是0个。这条底线大家还是守住了。

现网到底配成了什么样?

实验台能穷举,但穷举出来的分布不代表真实世界。所以保哥又扫了一遍线上:164个域名里有85个能正常抓到首页,对每个站取四类URL——首页、一个深层内容页、robots.txt里声明的sitemap、页面上第一个静态资源,一共218个返回200的测量点。

越是没人管的那类URL,配得越好

URL类型样本有ETag有Last-Modified两个都没有
首页8535.3%29.4%48.2%
深层内容页2955.2%37.9%34.5%
sitemap6656.1%59.1%27.3%
静态资源(CSS/JS)3873.7%84.2%13.2%
合计21850.9%49.1%33.9%

这张表的形状很说明问题:覆盖率最高的是静态资源,最低的是首页。

而这个顺序恰好跟“有没有人为它做过决策”成反比。静态资源的验证器是Web服务器自动给的,没人碰过,所以86.8%都有;首页是被改得最多的那个页面——挂了个人化模块、加了A/B测试、起了会话、套了三层中间件——每一次改动都可能把验证器碰掉,最后48.2%什么都不剩。

顺带一提,111个带ETag的URL里有33.3%是弱ETag。弱ETag对条件请求完全够用,但正如上一节说的,它不能用于Range。

按站点看:四分之一的站完全吃不到

把测量点按站归总,每个站落进四档之一:

站点状态站数占比
测到的URL全都能拿到3042731.8%
一部分能,一部分不能3642.4%
一个验证器都没有1416.5%
给了验证器,却一个304都拿不到89.4%

最后那一档是本文真正的主角:这8个站是配过的——响应头里有ETag或Last-Modified,检测工具会给它们打勾——但拿这些值回去问,没有一个能换来304。前面几节讲的每一种失效方式,都会把一个站送进这一档。

加上完全没配的14个,25.9%的站点在条件请求这件事上收益为零。另外42.4%是半拉子。

按服务器软件分组:一个偏科得很明显的分布

把每个站按响应头里的Server归类,同时看两件事——能不能拿到304,以及给不给压缩:

Server测量点有验证器能拿到304给压缩
cloudflare7263.9%50.0%100.0%
nginx3256.2%56.2%90.6%
netlify1275.0%66.7%83.3%
vercel1172.7%54.5%81.8%
AmazonS38100.0%100.0%100.0%
Microsoft-IIS4100.0%100.0%100.0%

看第一行和最后两行的对比。

纯对象存储和传统Web服务器直出的场景,条件请求是100%——因为它们本来就只是把磁盘上的文件按规矩发出去,没有任何一层来得及把验证器弄丢。而经过了一层现代边缘网络之后,同一件事掉到了50%。

更值得琢磨的是同一行里那两个数的落差:压缩100%,条件请求50%。

这两件事都是省字节,为什么一个做满了一个只做了一半?因为压缩是边缘节点可以单方面完成的——内容在我手上,压不压我说了算,不需要问任何人。而条件请求要求边缘节点知道“源站那份东西到底变没变”,这是一次跨层的确认。同样是省字节,那件在自己这一层就能做完的,做到了100%;那件需要跟上一层对齐的,做到了一半。

怎么改才真的省下抓取预算?

把前面所有实测收敛成可执行的动作。顺序是按“投入产出比”排的,不是按重要性。

第一步:先确认你现在在哪一档

不用装任何工具,三条命令:

# 1) 看有没有验证器
curl -sI -H 'Accept-Encoding: identity' https://你的域名/某个页面 | grep -i 'etag\|last-modified'

# 2) 把拿到的 ETag 原样带回去问
curl -s -o /dev/null -w '%{http_code}\n' \
     -H 'If-None-Match: "刚才那个值"' https://你的域名/某个页面

# 3) 换成浏览器/爬虫的真实编码偏好再问一次
curl -s -o /dev/null -w '%{http_code}\n' \
     -H 'Accept-Encoding: gzip, deflate, br' \
     -H 'If-None-Match: "刚才那个值"' https://你的域名/某个页面

第2条给304、第3条给200,就是本文那个32.1%的压缩换ETag问题。两条都给200,往下看第二步。

测的时候记得把首页、一个商品或文章页、sitemap各测一遍——前面那张表已经说明,同一个站的这三类URL可能落在完全不同的档上。

第二步:按站型对号入座

你的形态症状动作
静态站/对象存储/纯CDN托管通常已经是304只需确认压缩没把ETag换掉
动态CMS,响应头里什么都没有永远200上页面级缓存(fastcgi_cacheproxy_cache/整页缓存插件),验证器会跟着一起来
动态CMS,有ETag但回问给200验证器没人判决要么在应用里自己判决并exit,要么在Web服务器侧开缓存让它接管
有Last-Modified但回问给200多半是exact语义或手写值去掉手写的add_header;确实需要就改if_modified_since before
套了sub_filter/正文改写验证器整体消失把改写移到构建期或应用层,别放在代理层
ETag在压缩前后不一样三分之一的回问白费优先用运行时压缩而不是gzip_static;用CDN的话确认它的缓存键与ETag策略

第三步:三段可以直接抄的配置

nginx静态资源,把默认行为坐实:

location /static/ {
    etag on;                      # 默认就是 on,写出来是为了防止被上层关掉
    if_modified_since before;     # 默认是 exact,改成 before 更宽容
    add_header Cache-Control "public, max-age=604800";
}

动态页交给nginx接管判决(PHP侧一行都不用改):

fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=SITE:64m inactive=60m;

location ~ \.php$ {
    fastcgi_pass unix:/run/php-fpm.sock;
    include fastcgi_params;
    fastcgi_cache SITE;
    fastcgi_cache_key "$request_method$host$request_uri";
    fastcgi_cache_valid 200 10m;
    fastcgi_cache_bypass $cookie_session_id;   # 登录态绕开
    add_header X-Cache $upstream_cache_status;
}

或者在应用层自己做完整判决——注意要用弱比较(忽略W/前缀),并且命中之后必须exit,一个字节正文都不能发:

<?php
$etag = '"' . md5($body) . '"';
header('ETag: ' . $etag);

$inm = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
foreach (array_map('trim', explode(',', $inm)) as $t) {
    if ($t === '*' || ltrim($t, 'W/') === ltrim($etag, 'W/')) {
        http_response_code(304);
        exit;                     // 关键:不能有任何正文
    }
}
echo $body;

第四步:想清楚“什么才算变了”

这一步没有配置可抄,但它决定前面三步的实际收益。

Google那篇博文里有一句被很多人跳过的话:

Our recommendation is that you require a cache refresh on significant changes to your content; if you only updated the copyright date at the bottom of your page, that's probably not significant.

只改了页脚的版权年份,不算实质变化,不该让缓存失效。

但绝大多数实现的ETag是整页响应体的哈希。页脚年份改一个字符,哈希就变,全站每一个URL的ETag同时失效——爬虫下一轮回来,一次304也拿不到。同理还有:随机排序的“相关推荐”、带时间戳的调试注释、每次渲染都变的CSRF令牌、轮播的广告位。

所以真正把收益做满的做法是:别拿整页哈希当ETag,拿内容版本当ETag。

文章的更新时间、商品的版本号、模板的构建号,三者拼一起做哈希,页脚年份改了它不动,正文改了它才动。

这跟改修改日期能不能骗过Google那件事是一体两面:那边讲的是别把没变的说成变了,这边讲的是别让没变的看起来变了。

另外,Google在同一篇里还建议顺手把Cache-Controlmax-age配上,用来帮爬虫判断多久该回来问一次——Set the value of the max-age field to the expected number of seconds the content will be unchanged.这一项跟浏览器侧那套缓存头怎么配用的是同一组指令,只是收益方向不同:那边省的是回头客的加载时间,这边省的是爬虫的抓取额度。

一个诚实的边界

最后保哥想把这件事的天花板说清楚,免得预期跑偏。

Google那篇缓存博文通篇没有提排名,也没有提抓取预算,它的落脚点是perhaps also want to potentially save a few bucks on your hosting bill——省点服务器钱。真正把304和抓取预算挂上钩的是另一份文档(大型站点抓取预算管理),而那份文档开头就限定了适用范围:百万级URL、或者一万级URL但内容变化极快的站。

如果你的站只有几百个页面,把304配对了也不会让你多收录一个URL——爬虫本来就抓得完。

这件事的收益是随站点规模非线性放大的:URL越多、内容越静、爬虫回访越频繁,它越值得做。

反过来,一个小站花两天折腾这个,不如去把孤岛页面的内链补上

常见问题解答

返回304会不会影响排名或者收录?

304本身不是排名信号,Google的缓存文档里对排名一个字没提。它的作用是让同样的抓取额度覆盖更多URL。所以它影响的不是“这个页面排第几”,而是“你有多少页面来得及被抓到、被更新”。对几百个页面的小站,这个差别接近于零;对几十万URL的电商站,它决定新品多久进索引。

我用了CDN,是不是就不用管这件事了?

不是。本次普查里经过主流边缘网络的72个测量点,压缩覆盖率100%,条件请求只有50%。CDN能替你把“压多少”这件事做满,因为那是它自己一层就能决定的;但“变没变”这件事它必须跟源站对齐,只要源站不给验证器,边缘也编不出来。

ETag和Last-Modified只配一个的话,配哪个?

配ETag。Google的原话是We strongly recommend using ETag,理由是它的值没有格式约束、不会写错。而且本文实测了另一个理由:现网44.6%的服务器在两个都收到时会把它们当成“与”关系,一个说变了就全文重发——单配一个可靠的ETag,反而比两个都配半拉子稳。

开了gzip会不会让条件请求失效?

取决于用哪种压缩方式。运行时压缩(gzip on)只是把ETag标成弱验证器,值不变,弱比较照样命中;预压缩(gzip_static on)发的是磁盘上另一个文件,ETag完全不同,回问必然200。现网实测54.1%的URL在压缩前后ETag不一样,其中32.1%会因此白下载一次全文。

为什么我在浏览器开发者工具里看不到这个问题?

因为浏览器在缓存有效期内根本不发条件请求,直接用本地副本;过期了才会问一次。而爬虫按自己的重访节奏回来,回来时手上那份几乎总是过期的。这条路用户基本不走,爬虫每次都走,所以在浏览器里怎么测都是正常的。要看真相,得去服务器日志里筛爬虫UA的状态码分布。

怎么快速判断整站的304覆盖情况?

在访问日志里按UA筛出Googlebot,统计状态码分布。健康的内容站应该能看到相当比例的304;如果连续30天全是200,而这些页面并没有天天更新,那这部分抓取额度就是白花的。这件事跟用日志分析工具读Googlebot的抓取与预算浪费是同一套方法,只是这次要看的那一列是状态码而不是URL。

把If-Modified-Since改成before模式安全吗?

对内容型页面通常安全,它的语义是“你手上那份只要不比我旧,就当没变”。需要留意的是会回滚的场景:如果你把内容回退到了旧版本,文件修改时间反而变早了,客户端手上那份“更新”的副本会被判定为有效,从而拿不到新内容。有回滚流程的站,改这个之前先确认部署方式会不会把mtime往回调。

权威参考资料

分享到
标签
版权声明

本文标题:《做SEO算抓取预算,发现服务器把没改过的页面对爬虫重发了几百遍》

本文链接:https://zhangwenbao.com/conditional-request-304-etag-crawl-budget-seo.html

版权声明:本文原创,转载与引用请注明作者与原文链接。许可协议: CC BY 4.0

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