SEO抓取速率被调低的那几天,服务器日志里一条错误都没有

SEO抓取速率被调低的那几天,服务器日志里一条错误都没有
张文保 更新 41 分钟阅读 2,229 阅读
本文目录
  1. 抓取速率到底按什么计价?
  2. 报错会留痕,变慢不会
  3. 抓取速率降下来容易,升回去要等
  4. 按连接时长算,排队比拒绝贵得多
  5. 限速排队为什么在日志里查不到?
  6. burst是排队,不是拒绝
  7. 这一整段过程,错误日志里是空的
  8. access_log里其实有一个变量能区分,但默认格式没带它
  9. nodelay换掉的不是负载,是失败的形式
  10. 同一个模块的邻居,行为完全相反
  11. PHP-FPM池打满时,状态码为什么全是200?
  12. 爬虫读到的不是成功率,是响应时间
  13. 什么时候才会变成502或504?
  14. 那些根本到不了服务器的失败,去哪儿查?
  15. 连接建立之前的耗时占了一半以上
  16. 顺带一个能省事的观察
  17. 半截页面为什么也返回200?
  18. Google拿到半截会怎么处理?
  19. 上游整个挂了,也可以变成200
  20. 页面被截断,先丢的是哪几样?
  21. 关键标记的位置分布
  22. 如果只收到前X%就断了,还剩什么?
  23. 真正被截断吃掉的,其实是链接
  24. 慢到什么程度算慢?
  25. 这些看不见的事,要怎么才能看见?
  26. 第一步:把access_log的格式改了
  27. 第二步:拿两个字段对一下,把截断抓出来
  28. 第三步:分开看爬虫和真人
  29. 第四步:把症状对到字段上
  30. 第五步:三条能直接用的判据
  31. 常见问题解答
  32. 抓取速率被压低之后,多久能恢复?
  33. 把proxy_read_timeout调大,5xx少了,这算优化吗?
  34. 怎么知道自己的页面有没有被截断过?
  35. limit_req配了burst以后就不会拒绝请求了吗?
  36. PHP-FPM的进程池应该配多大?
  37. 爬虫和真人看到的响应时间会差很多吗?
  38. 只测首页的响应时间够不够?
  39. Search Console里的“平均响应时间”能直接用吗?
  40. 权威参考资料

摘要:Google把抓取容量定义成“你的服务器为它保持连接打开的总时长”,于是变慢和报错在爬虫那边是同一件事。区别在于报错会留痕,变慢不会。保哥在一台真实nginx上把限速、上游超时、进程池、连接层排成配置矩阵跑了一遍:排队让6个请求全部返回200、每个多花987毫秒,而错误日志一行没有;PHP-FPM池打满让8个并发从8.2秒排到19.2秒,状态码依旧全是200;上游被掐断时客户端拿到的是200加半截HTML,响应头里的Content-Length还写着完整长度。再拿83个站的首页量了一遍:HTML字节中位数43万,而</head>的中位位置在10.1%,第90百分位在44.1%——只收到前10%就断的话,只有49%的站保住了完整的head。

先说一个很多人都碰上过、但很难往这个方向想的现象。

Search Console的抓取统计信息里,“平均响应时间”那条曲线在某一周开始往上抬,同期“每天抓取请求总数”往下走。翻服务器监控:CPU没满,内存没满,磁盘IO正常,错误日志一天下来干干净净,5xx计数是0。运维那边看完给你一句“服务器没问题”。

他说的是实话。服务器确实没报错。

问题在于,爬虫那边扣分的依据从来就不只是报错。

抓取速率到底按什么计价?

先把口径对齐,不然后面全是鸡同鸭讲。

Google在大型站点抓取预算管理那份文档里,把抓取预算拆成两块:抓取容量上限(crawl capacity limit)和抓取需求(crawl demand)。前者的定义原文是This limits the total amount of time your server spends holding connections open for Google, factoring in both the number of parallel connections and their duration.

注意这个单位。它限制的是时长,而且明确说了要把并行连接数和每条连接的持续时间一起算进去。不是页数,不是字节数,是“你为我占用了多久”。

这个定义一旦当真,很多事情就变了味道。

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

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

往下的条件是:

If the site slows down (latency increases or response times become longer), or responds with server errors (5xx HTTP status codes) or rate-limiting signals (such as HTTP 429), the limit goes down and Google crawls less.

把这两句话并排放,会发现一件挺反直觉的事:“变慢”和“报错”被写在同一个句子里、用同一个连词连着、导向同一个结果。在这套计价体系里它们是等价的输入,因为占用时长这个量,慢的时候涨,错的时候也涨——错误响应虽然便宜,但它意味着这一次连接白占了。

而在你这一侧,这两件事的观测成本差了几个数量级。

报错会留痕,变慢不会

一个5xx会在access_log里留下一行、在error_log里留下一行、在监控面板上加一个计数、可能还会触发一条告警。链路上任何一个环节都能把它捞出来。

而一次“慢”留下什么?access_log默认的combined格式里根本没有响应时间这一列。你翻遍日志,看到的是一行漂漂亮亮的200,跟正常那几万行长得一模一样。

这就是本文要拆的那件事:服务器状况变差有一整族形态,它们全部以2xx收场,全部不进错误日志,而它们对抓取速率的作用和5xx是一个方向的。

抓取速率降下来容易,升回去要等

还有一个不对称,Google在降低抓取速率那份文档里写得很坦白:出现错误时抓取速率会自动降下来,等错误变少之后the crawl rate will automatically start increasing again,但同一页最后一段又补了一句You cannot request an increase in crawl rate

降是自动的、随时的;升也是自动的,但你没有任何手动加速的通道。这两件事凑在一起,意味着一次持续几天的静默降速,代价要在之后好几周里慢慢还。抓取预算优化清单里那些减少无效URL的动作是在管分子,本文管的是分母。

按连接时长算,排队比拒绝贵得多

既然计价单位是时长,那就把两种应对方式的账摆出来算一次。假设爬虫要抓100个页面,你的服务器正常状态下每个响应0.2秒,现在它到了负载上限,两种处理方式:

处理方式爬虫这一轮的实际收获你付出的连接时长
全部正常处理(假设扛得住)100个页面20秒
排队,每个压到1秒100个页面100秒
放行30个,其余直接拒30个页面约6.5秒

第二行和第三行是同一个负载压力下的两种选择。排队保住了“每个请求都成功”这个数字,代价是把服务器为爬虫忙碌的时长翻了五倍——而这正是抓取容量上限直接盯着的那个量。第三行页面拿得少,但占用时长只有第二行的十五分之一。

这不是在说拒绝一定更好——拒绝有拒绝的代价,5xx持续几天会让URL掉出索引,这是另一篇要展开的事。这里只想立一个观念:在这套计价体系里,“没有报错”不是一个免费的成绩,它是用连接时长买来的。

限速排队为什么在日志里查不到?

先从最容易中招的一个说起:nginx的limit_req

这条指令几乎是所有“防采集”“防CC”教程的第一行代码,抄的时候大多数人只关心速率填多少,而它真正的行为分岔点在burstnodelay这两个参数上。

保哥在服务器上另起了一个nginx实例(不碰生产),把同一个页面挂在不同配置的location下,用curl连打,只看两列:状态码和耗时。

burst是排队,不是拒绝

配置是limit_req zone=sz3 burst=5;,速率1r/s。连打6次,结果如下:

第几次状态码总耗时
12000.000627秒
22000.988719秒
32000.985879秒
42000.986737秒
52000.987635秒
62000.986691秒

六个200,一个错误都没有。从第2次起每个请求被压在0.987秒左右——这个数字不是巧合,速率是每秒1个,排队机制就把后面的请求依次推到下一个时间槽上去。

把它和基线比一下:同一台机器、同一个文件、没有限速时是0.000627秒。0.987秒除以0.000627秒,差1575倍。

nginx官方文档对这个行为的描述是their processing is delayed such that requests are processed at a defined rate——延迟处理,而不是拒绝。从服务器视角看,这叫“平滑了流量”;从爬虫视角看,这叫“这个站的响应时间从0.6毫秒涨到了将近1秒”。

这一整段过程,错误日志里是空的

跑完这6个请求,error_log的行数是0

不是级别调低了看不见,是这个实例的error_log就设成了error级——也就是绝大多数生产环境的默认值。原因写在limit_req模块文档里,两句话连起来才看得出问题:

  • limit_req_log_level的默认值是error
  • 紧接着一句Logging level for delays is one point less than for refusals——延迟的日志级别比拒绝低一档。

拒绝记在error,延迟就记在warn。而你的error_log如果是默认的error级,warn是进不来的。于是“我拒了谁”看得见,“我拖慢了谁”看不见,而且这个可见性差异是配置默认值直接决定的,不需要任何人做错什么。

作为对照,同一台实例上把请求打到超限拒绝的路径上,error_log立刻就有了:

2026/05/27 [error] limiting requests, excess: 2.945 by zone "sz",
  client: 127.0.0.1, server: lab.local,
  request: "GET /robots.txt HTTP/1.1", host: "127.0.0.1:18448"

access_log里其实有一个变量能区分,但默认格式没带它

nginx从1.17.6起提供了$limit_req_status这个变量,取值是PASSEDDELAYEDREJECTED,外加两个dry-run变体。把它加进log_format之后,同一批请求在access_log里长这样:

200 "GET / HTTP/1.1"          lrs=PASSED
200 "GET /deep.html HTTP/1.1" lrs=PASSED
200 "GET /deep.html HTTP/1.1" lrs=PASSED
503 "GET /deep.html HTTP/1.1" lrs=REJECTED
200 "GET /robots.txt HTTP/1.1"  lrs=-
200 "GET /sitemap.xml HTTP/1.1" lrs=-

最后两行的-是“这个请求压根没进限速账本”,跟PASSED(进了账本、放行了)是两回事。这个区别在排查时非常有用,可惜默认的combined格式里一个字都没有。

这里有一条可以直接抄走的判据:如果你的access_log格式是从装机默认那一版继承下来的,你对限速的观测能力就是零——不是弱,是零。

nodelay换掉的不是负载,是失败的形式

很多教程会顺手在burst后面加nodelay,理由是“不要让正常用户等”。加上之后行为完全变了。同样是burst=5、速率1r/s,连打12次:

配置12次连打的状态码序列
burst=5200 ×12(第2次起每次约0.978秒)
burst=5 nodelay200 ×6,然后503 ×6(每次约0.6毫秒)
不带burst200 ×1,然后503 ×11

三行配置,三种完全不同的对外表现,而它们在教程里往往被当成同一件事的三种写法。

值得注意的是最后一列的耗时:拒绝比放行快得多。503用了0.6毫秒,成功的响应也是0.6毫秒级——但排队那一版是987毫秒。所以从“占用连接时长”这个计价口径看,nodelay反而对抓取容量更友好,代价是把静默降速换成了明面上的5xx。哪个更划算不能一概而论,但至少得知道自己选的是哪一个。

同一个模块的邻居,行为完全相反

nginx里还有一条限并发的指令limit_conn,名字和limit_req只差一个词,行为却是另一个极端。

实验台把limit_conn设成“同一个客户端最多1条并发”,然后打到一个耗时5秒的慢接口上,错开0.3秒发3个请求:

第几个状态码耗时
12005.007秒
25030.0006秒
35030.0005秒

limit_conn没有队列,超了就立刻拒。它连burst这个参数都没有。

所以同一个配置文件里挨着写的两条限制,一条把超额请求变成“很慢的成功”,另一条把超额请求变成“很快的失败”。它们在你的监控上长得完全不一样,在爬虫的账本上却都是扣分项。这大概是本文第一个、但绝不是最后一个“两条路通向同一个坑”的例子。

PHP-FPM池打满时,状态码为什么全是200?

限速是你主动配的,至少你知道它存在。下面这个不是。

PHP-FPM的进程池有个上限(pm.max_children),并发请求超过这个数,多出来的不会被拒绝,会在队列里等。等到有进程空出来,再依次处理。

实验台把pm.max_children设成4,写了一个sleep(8)的脚本,同时发8个请求:

并发编号状态码首字节时间总耗时
12008.206秒8.206秒
22008.209秒8.209秒
32008.209秒8.209秒
420011.158秒11.158秒
520016.218秒16.218秒
620016.217秒16.217秒
720016.217秒16.217秒
820019.158秒19.158秒

八个请求,八个200。最快的8.2秒,最慢的19.2秒,同一个脚本、同一台机器、同一秒发出,尾部比头部慢了2.3倍。

没有一条5xx。没有一行错误日志。从任何“错误率”口径看,这一批请求的成功率是百分之百。

爬虫读到的不是成功率,是响应时间

回到那个计价口径:抓取容量算的是服务器为爬虫保持连接打开的总时长。这8个请求合计占用了大约103秒的连接时间,而如果池子够大,它们只需要8秒多一点。

换句话说,同样的8次抓取,你的服务器“忙”了12倍的时长。而Google那句If the site slows down… the limit goes down不区分你是慢在业务逻辑上还是慢在排队上。

这条路径最阴的地方在于它的触发条件:进程池打满通常发生在上量的那几个小时,而爬虫恰恰倾向于在你流量低谷的时段来。真人用户和爬虫在时间上错开,于是“用户反馈慢”和“爬虫遇到慢”是两批不重合的样本,你从客服那边永远收不到这个信号。

什么时候才会变成502或504?

排队不是无限的。真正把状态码从200变成5xx的,是超时值,而不是负载本身。实验台上把同一个慢上游挂在两个location下,只改一个参数:

配置上游行为状态码耗时响应体
proxy_read_timeout 2s后端sleep 10秒5042.202秒160字节
proxy_read_timeout 30s后端sleep 5秒2005.007秒5677字节
fastcgi_read_timeout 2sPHP sleep 8秒5042.203秒160字节
fastcgi_read_timeout 30sPHP sleep 8秒2008.209秒18字节
后端进程不在连接被拒5020.001秒150字节

把这张表读两遍。第二行和第四行,后端都很慢,返回的是200;第一行和第三行,后端同样慢,返回的是504。决定这次抓取算“成功”还是“服务器错误”的,是你在配置文件里填的那个秒数。

这带出一个挺荒谬的推论:把proxy_read_timeout调大,5xx计数就会下降,监控面板会变绿,而爬虫那边看到的是响应时间变长——按照Google的规则,这两种做法都会压低抓取容量,只是一种会告警、一种不会。调大超时值本质上是把一个会响的报警器换成了一个不会响的。

那些根本到不了服务器的失败,去哪儿查?

再往下一层。前面聊的都是HTTP层面的事,而有一整类故障连HTTP都没走到。

实验台上把三种连接层失败各测了一遍,客户端拿到的东西是这样的:

故障HTTP状态码客户端退出码耗时
端口没人监听(connection refused)00070.0002秒
域名解析不出来00060.258秒
客户端等不及主动断(timeout)000282.001秒

三种情况的HTTP状态码都是000,因为压根没有HTTP响应可言。更关键的是:这三种请求在服务器的access_log里一行都没有。它们没到nginx,nginx当然不知道有人来过。

所以“我翻遍日志没发现异常”这句话,在这一层是恒真的——不管出没出事,日志里都是干净的。

连接建立之前的耗时占了一半以上

为了看清这一层到底有多重,保哥对164个电商与SaaS域名各发了一轮请求,把每次请求拆成五段计时。83个首页返回200的站点,各段净耗时的中位数如下:

阶段净耗时中位数占中位总时长第90百分位
DNS解析0.1178秒18.0%0.1869秒
TCP握手0.0473秒7.2%0.2265秒
TLS握手0.1780秒27.2%0.3479秒
服务器处理0.2539秒38.8%0.7954秒
正文下载0.0574秒8.8%0.3208秒

DNS加TCP加TLS,合计52.4%。也就是说,一次请求里超过一半的时间,花在你的应用代码被调用之前。

而这半边有个共同特点:它不在任何应用层监控里。PHP的执行时间统计不到它,慢查询日志统计不到它,nginx的$request_time从收到请求行才开始计。你的APM显示“平均响应时间120毫秒”,同时爬虫记录的是617毫秒,两个数字都没错,只是量的不是同一段。

需要说清楚的是测量位置:这批数字是从中国大陆的一个网络位置发出去的,目标站大多在北美和欧洲,DNS和TLS的绝对值明显偏高,直接拿去说“这些站很慢”是不成立的。但它恰好演示了本节要说的那件事——同一个站,从不同网络位置量出来的分层耗时结构完全不同,而爬虫也是从某个固定的网络位置来看你。你在自己办公室ping出来的数字,和Googlebot记在账上的数字,没有理由相同。

顺带一个能省事的观察

同一批站里有28个能同时拿到首页和一个深层页的200响应,两边TTFB对比:首页中位0.814秒,深层页中位0.773秒,深层页更慢的占46.4%。但比值的第90百分位是1.31,最极端的一个是2.84倍(GitHub的首页210毫秒、深层页595毫秒)。

结论不是“深层页一定更慢”,而是两者的差异分布很宽,只测首页得到的结论没法外推。而爬虫的绝大部分请求花在深层页上。这一点和站点架构决定爬虫能走多深是同一个问题的两面:架构决定它去不去,响应时间决定它去了以后还剩多少额度。

半截页面为什么也返回200?

到这里为止,讨论的都是“慢”。接下来这一族更狠:响应开始了、状态码发出去了,然后正文没发完。

状态行是响应的第一件东西。它发出去的那一刻,服务器还不知道后面会不会出事。等出事的时候,200已经在路上了,改不回来。

实验台造了四种截断,客户端看到的结果如下:

造法状态码响应头里的长度实际收到客户端退出码
上游分块传输传一半掐断200chunked(无长度)2838字节18
上游声明5677字节只发2838200Content-Length: 56772838字节18
PHP脚本中途exit()200chunked8700字节0
PHP致命错误500chunked0字节0

前三行都是200。第二行尤其值得盯一会儿:响应头明明白白写着5677字节,实际到手2838字节,状态码200。一个只看状态码的健康检查会说这个页面完全正常。

第三行是最容易在生产上发生的一种——PHP脚本跑到一半因为内存超限、执行超时或者一句exit就走了,前面已经echo出去的内容照发不误,浏览器会渲染出一个“下半页没了”的页面,而HTTP层没有任何异常。

第四行反倒最干净:真正的致命错误给了500,正文0字节。错得越彻底,越容易被发现。

Google拿到半截会怎么处理?

这是本节的关键。Google在HTTP状态码与网络错误那份文档里,对2xx响应的处理写着一句:

Google waits for the content for a limited time, then passes on whatever it received to the next processing step (which is product specific). The timeout is user agent dependent, for example Googlebot Smartphone may have a different timeout than Googlebot Image.

拆开看有三层意思:等有时限;超时之后把已经收到的那部分往下游传;而这个时限取决于是哪个爬虫,你不知道具体是多少。

“把已经收到的那部分往下游传”这半句,意味着下游拿到的不是一个错误,是一个内容变短了的页面。它会被正常解析、正常抽取、正常参与索引判断。你的页面不会因为传了一半就被跳过,它会因为传了一半而变成另一个页面。

上游整个挂了,也可以变成200

还有一种把故障洗成200的方式,而且是很多人主动配的:error_page

实验台上让上游彻底不在(端口没人监听),nginx这边配两种写法,只差一个等号后面的数字:

配置状态码响应体ETagLast-Modified
proxy_intercept_errors on; error_page 502 /maint.html;502171字节维护页
proxy_intercept_errors on; error_page 502 =200 /maint.html;200171字节维护页

第二行做了三件事:把502改成200,把整站所有URL的内容替换成同一段“维护中”,还顺手给这段内容配上了ETag和Last-Modified——因为它现在是一个真真正正的静态文件响应,nginx按静态文件的规矩把验证器都加上了。

于是爬虫拿到的是一个正常的、可缓存的、带完整验证器的页面,内容是“我们正在维护”。它不会触发任何降速逻辑(因为没有5xx),也不会触发任何告警(因为是200),但你站上每一个被抓到的URL,内容都变成了同一段话。软404之所以是个问题就是这个道理,只不过软404一次伤一个URL,这个写法一次伤全站。

这个配置通常不是运维故意写的,而是从某份“友好错误页”的教程里连着=200一起抄过来的——那份教程多半是给内网管理后台写的,那里确实不在乎搜索引擎。

页面被截断,先丢的是哪几样?

既然截断是按字节顺序发生的,那么“丢了什么”就完全取决于“什么东西排在页面的第几个字节上”。

这件事没人量过,保哥就量了一遍:把那批站里首页返回200的83个抓下来,对每份HTML找出几个关键标记的字节位置,换算成占整页的百分比。

先看底数:这83个首页的HTML中位数是434825字节,第90百分位1292740字节,最大的一个5155213字节。四十多万字节的首页现在是中位数,不是异类。

关键标记的位置分布

标记有它的站第10百分位中位第90百分位最靠后的一个
</title>830.0%0.5%5.3%79.6%
meta description780.1%0.9%12.5%59.1%
canonical690.1%1.0%17.8%79.6%
og:title640.1%1.0%27.1%59.2%
hreflang400.2%1.8%36.4%79.6%
JSON-LD结构化数据610.6%4.6%55.7%97.9%
</head>831.7%10.1%44.1%79.9%
<h1>607.7%33.6%71.7%97.4%

中位数那一列看着挺乐观——title在0.5%,canonical在1.0%,</head>在10.1%。但第90百分位那一列是另一个世界:有10%的站,canonical排在整页17.8%之后;有10%的站,JSON-LD排在55.7%之后;有10%的站,</head>要读到44.1%才闭合。

几个具体的:

  • affirm.com的首页972140字节,canonical在79.56%,</head>在79.9%——爬虫要读完77万字节才拿得到这个页面声明的规范URL;
  • bulletproof.com的首页721138字节,canonical在3.11%(很靠前),但JSON-LD在70.8%;
  • squareup.com的首页1977723字节,</head>在38.1%。

为什么会这样?把这几个站的HTML翻开就明白了:head里塞了大段内联CSS、内联的首屏JS、预加载清单、第三方脚本片段,SEO那几行标签被一路往后推。canonical这类声明放在哪一层生效是个老问题,但“放在第几个字节”这个维度,在head变成几十万字节的今天才开始有分量。

如果只收到前X%就断了,还剩什么?

把上面那张表换个问法,答案更直观:

只收到前titlemeta descriptioncanonicalJSON-LD</head>完整h1
10%93%86%86%67%49%15%
25%94%92%90%82%70%33%
50%98%97%94%89%90%72%
75%99%100%99%93%98%92%

读这张表的时候注意它是“百分比”而不是“字节数”。以中位数43万字节算,前10%就是4.3万字节——一个正常的宽带请求几十毫秒就传完了,会在这里断掉的多半是别的原因(上游掐断、脚本中途退出、超时),而不是网速。

三条能直接用的结论:

  1. title最抗断。93%的站在前10%就把title发完了。所以“搜索结果里标题还在”完全不能证明这个页面被完整抓到过。
  2. h1最不抗断。只收到前10%时,仅15%的站保住了h1。而h1是很多人用来判断“页面主题被识别了没有”的信号。
  3. JSON-LD的方差最大。67%到93%这条曲线爬得很慢,因为它的位置分布本身就宽(中位4.6%,第90百分位55.7%)。结构化数据是电商SEO审计的重点项,而它恰好是最容易被截断吃掉的那一样。

顺带说一句,这也解释了为什么Googlebot那个2MB的抓取上限值得当回事。2MB是硬上限、按未压缩算,而超时截断是软上限、按你服务器当天的状态浮动。两个机制切的是同一份HTML,只是切刀的位置一个固定一个不固定。

真正被截断吃掉的,其实是链接

上面那张表里全是页面自己的描述性标签。还有一样东西没算:<a href>

保哥把同一批首页里链接数在5条以上的78个又量了一遍,统计每个站的链接分布在字节轴上的位置:

只收到前包含的链接占全页的比例(中位)第10百分位第90百分位
10%0.0%0.0%40.5%
25%18.2%0.0%80.7%
50%53.8%0.0%100.0%
75%87.0%35.9%100.0%

第一行的中位数是0.0%:一半以上的站,页面前10%的字节里一条链接都没有。这不奇怪——那10%基本被head和内联样式占满了。整页的链接数中位是141条,第90百分位382条,最多的一个站1830条。

把这张表和上面那张并排读,结论就清楚了:

截断对“这个页面是什么”伤害很小,对“从这个页面能走到哪里”伤害很大。收到一半时,标题保住98%、canonical保住94%,而链接只剩53.8%。

一个被截断到一半的页面,从描述性信号上看几乎完好——标题在、描述在、规范URL在,你在Search Console里检查它也看不出破绽。但它作为抓取图上的一个节点,出度掉了将近一半。那些只从这个页面链出去的URL,这一轮就没被发现。

这也是为什么这类故障的症状总是“新页面收录慢”而不是“老页面掉了”:老页面早就在索引里,被截断只是内容少了点;新页面靠链接被发现,而链接恰好排在最容易被切掉的那一半。

慢到什么程度算慢?

说了半天“变慢会掉抓取速率”,那到底几毫秒算慢?

Google没有公布阈值,而且从文档措辞看,它用的是相对判断而不是绝对阈值:remain stable or improve就涨,become longer就降。也就是说,它比的是你自己的历史,不是别人的成绩。

这其实是个好消息——一个TTFB长期稳定在800毫秒的站,不会因为“慢”被持续压制;但一个从200毫秒涨到400毫秒的站,涨的这一倍是会被记账的。危险的不是慢,是变慢。

那批164个站的TTFB分档可以当个参照:

TTFB区间站数占比
小于200毫秒00.0%
200到500毫秒3238.6%
500毫秒到1秒3036.1%
1到2秒1720.5%
大于2秒44.8%

再强调一次口径:这是跨境测的,含DNS和TLS,绝对值偏高。有意思的是没有一个站落在200毫秒以内——而如果只看服务器处理那一段(中位0.2539秒),分布会好看得多。同一批站,换个测量口径,结论能差出一个档位。TTFB优化到底优化的是哪一段,第一步永远是先确认你量的是哪几段。

这些看不见的事,要怎么才能看见?

前面五节全是坏消息,这节给可落地的。

第一步:把access_log的格式改了

默认的combined格式是1996年的产物,它诚实地记录了那个年代关心的一切。今天需要的是这几个变量:

变量它回答什么问题
$request_time这次请求一共占了服务器多久(爬虫的计价口径)
$upstream_response_time其中上游用了多久
$upstream_connect_time连上游用了多久(连接池打满时这一段会涨)
$upstream_status上游的真实状态码(被error_page洗过时和$status不一样)
$limit_req_statusPASSED / DELAYED / REJECTED / 空
$body_bytes_sent实际发出去多少字节

$request_time$upstream_response_time,差出来的就是排队、限速延迟和网络传输——也就是本文前半篇讲的那一族。这个差值平时应该接近0,一旦稳定地大于几百毫秒,说明有东西在你的应用之外拖时间。

第二步:拿两个字段对一下,把截断抓出来

响应头里的Content-Length和实际发出的$body_bytes_sent,正常情况下应该相等。不等就是截断。这个检查一行awk就能跑,可惜前提是你的日志里得有这两列——又绕回第一步了。

Transfer-Encoding: chunked的响应没有Content-Length,这时候可以退而求其次:把同一个URL在一段时间内的$body_bytes_sent拉个分布,正常页面应该很集中,出现明显小于中位数的那一簇就是嫌疑。

第三步:分开看爬虫和真人

前面提过,进程池打满发生在流量高峰,爬虫来在流量低谷,两批样本不重合。所以整站的平均响应时间对这件事没有诊断力,得按UA分组算。日志分析工具的抓取报表能直接出这个分组,自己用awk也就几行。

要注意的是UA可以伪造,按UA统计前必须先做反向DNS验证,否则算进去的可能是一堆冒充Googlebot的采集器。

第四步:把症状对到字段上

本文出现过的所有故障形态,按“你会先看到什么”排一遍,对应到用哪个字段确认:

你先看到的现象可能是哪一族用什么确认
状态码200,但响应时间比平时高一个量级限速排队 / 进程池排队$request_time$upstream_response_time$limit_req_status是否为DELAYED
状态码200,页面比平时短上游截断 / 脚本中途退出Content-Length$body_bytes_sent是否相等;同URL的$body_bytes_sent分布有没有低值簇
状态码200,内容是维护页error_page带了=200$upstream_status$status是否一致
504集中出现,耗时几乎全等于某个整数秒读超时被触发耗时是否精确等于proxy_read_timeoutfastcgi_read_timeout
502瞬间返回,耗时接近0上游进程不在或拒连$upstream_connect_time极小且$upstream_status为空
Search Console报抓取失败,但日志里查不到这些请求连接层(DNS、拒连、防火墙丢包)日志里必然没有;只能从Search Console的抓取统计信息按响应类型分,或者从外部拨测
整站响应时间正常,爬虫报慢按IP限速的中间件(CDN、WAF、第三方接口)按UA分组统计$request_time;对比爬虫IP段与真人IP的分布

最后一行是所有排查里最容易漏的:限速这件事经常不发生在你的nginx上。CDN那一层、云厂商的WAF、你调用的第三方库存或价格接口,每一层都可能有自己的速率策略,而它们的日志分别躺在三个不同的控制台里。

第五步:三条能直接用的判据

  1. “错误率为0”不等于“服务器没拖累抓取”。要看的是响应时间的分布,尤其是尾部(第95、第99百分位),而不是均值和错误率。
  2. 凡是能通过“把超时值调大”消掉的告警,都是把可见的问题换成了不可见的问题。调大之前先问一句:调大之后,这个请求会变成什么?答案通常是“一个很慢的200”。
  3. 只看状态码的健康检查,对本文讲的这一整族故障全部失效。健康检查至少要断言两件事:响应时间在阈值内,响应体里有某个必然存在的标记(比如</html>或者页脚的某个字符串)。第二条能一次性覆盖所有截断形态。

保哥给一个做户外装备的客户排查过一次类似的事。现象是新上的一批产品页迟迟不进索引,而站内所有监控都正常。最后是在access_log里加了$request_time之后才看出来:同一批URL,真人访问的中位数是0.3秒,Googlebot命中的中位数是4秒以上。原因是那批页面的库存查询走了一个第三方接口,接口对来源IP做了限速,真人流量分散在很多IP上没触发,爬虫集中在几个IP段上天天触发。这个故障不产生任何错误码,只产生等待。

说白了,服务器和爬虫之间的这条反馈回路里,服务器只有一个输出端口(响应),而爬虫只有一个输入口(这次响应花了多久、是什么)。你在中间加的所有中间件——限速、缓存、WAF、代理——都会改写这个输出,而它们各自的日志分散在各自的地盘上。把这些拼回一条时间线,是本文全部内容的落点。

常见问题解答

抓取速率被压低之后,多久能恢复?

Google的说法是错误减少之后the crawl rate will automatically start increasing again,但没有给时间表,同时明确写了You cannot request an increase in crawl rate——没有手动加速的通道。实际观察下来,恢复比下降慢得多,因为下降只需要一批坏样本,上升需要一段持续稳定的好样本。所以对待这件事的正确姿势是防止发生,而不是发生后补救。

把proxy_read_timeout调大,5xx少了,这算优化吗?

不算。它把504换成了很慢的200。在Google的规则里,5xx会降抓取容量,响应时间变长同样会降抓取容量,两条路都通向同一个结果,区别只是前者会在你的监控上亮红灯。真正的优化是让那个请求变快,或者让它明确地快速失败,而不是让它慢慢地成功。

怎么知道自己的页面有没有被截断过?

三个入口,从便宜到贵:一是比日志里的Content-Length$body_bytes_sent;二是给健康检查加一条“响应体必须包含某个页尾标记”的断言,能一次覆盖所有截断形态;三是用Search Console的网址检查工具看已抓取的HTML,直接确认Google手上那份是不是完整的。第三条最准,但只能一个URL一个URL地看。

limit_req配了burst以后就不会拒绝请求了吗?

会。burst只是给了一个缓冲队列,超出队列长度还是会拒。区别在于不带nodelay时先排队后拒绝,带nodelay时立刻拒绝。实测同样的burst=5、速率1r/s,连打12次:不带nodelay是12个200(每个约0.98秒),带nodelay是6个200加6个503。两种都要在access_log里加$limit_req_status才看得出来发生过什么。

PHP-FPM的进程池应该配多大?

这个问题没有通用答案,但有个能自查的信号:把$request_time减去$upstream_response_time,如果这个差值在某些时段稳定地涨上去,而$upstream_response_time本身没变,那就是在排队。真正要盯的不是pm.max_children填几,而是单个请求的执行时长——同样的内存预算下,请求跑得越快,池子的等效容量越大。

爬虫和真人看到的响应时间会差很多吗?

会,而且差异有系统性来源,不是噪声。三条常见原因:爬虫多在流量低谷来,命中的缓存状态和真人不同;爬虫抓的多是深层页,而深层页的缓存命中率通常低于首页;爬虫来自集中的几个IP段,容易触发按IP计的限速与风控,真人流量分散在成千上万个IP上则不会。这三条都指向同一个操作建议:响应时间要按UA分组统计,整站均值对这个问题没有诊断力。

只测首页的响应时间够不够?

不够。实测28个能同时拿到首页和深层页的站,深层页比首页慢的占46.4%,比值的第90百分位是1.31,最极端的一个是2.84倍。两边差异的分布很宽,说明首页的数字没法外推到深层页。而爬虫的绝大部分请求恰恰花在深层页上。

Search Console里的“平均响应时间”能直接用吗?

能用来看趋势,不适合用来定位。它是整站所有抓取请求的汇总,把不同类型的资源、不同深度的页面、命中缓存和没命中缓存的全混在一起。它的价值在于“这条线是不是在往上抬”,一旦确认在抬,接下来的定位工作还是得回到自己的日志里,按URL类型和UA分组去拆。

权威参考资料

分享到
标签
版权声明

本文标题:《SEO抓取速率被调低的那几天,服务器日志里一条错误都没有》

本文链接:https://zhangwenbao.com/slow-server-truncated-response-crawl-rate-seo.html

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

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