SEO抓取速率被调低的那几天,服务器日志里一条错误都没有
本文目录
- 抓取速率到底按什么计价?
- 报错会留痕,变慢不会
- 抓取速率降下来容易,升回去要等
- 按连接时长算,排队比拒绝贵得多
- 限速排队为什么在日志里查不到?
- burst是排队,不是拒绝
- 这一整段过程,错误日志里是空的
- access_log里其实有一个变量能区分,但默认格式没带它
- nodelay换掉的不是负载,是失败的形式
- 同一个模块的邻居,行为完全相反
- PHP-FPM池打满时,状态码为什么全是200?
- 爬虫读到的不是成功率,是响应时间
- 什么时候才会变成502或504?
- 那些根本到不了服务器的失败,去哪儿查?
- 连接建立之前的耗时占了一半以上
- 顺带一个能省事的观察
- 半截页面为什么也返回200?
- Google拿到半截会怎么处理?
- 上游整个挂了,也可以变成200
- 页面被截断,先丢的是哪几样?
- 关键标记的位置分布
- 如果只收到前X%就断了,还剩什么?
- 真正被截断吃掉的,其实是链接
- 慢到什么程度算慢?
- 这些看不见的事,要怎么才能看见?
- 第一步:把access_log的格式改了
- 第二步:拿两个字段对一下,把截断抓出来
- 第三步:分开看爬虫和真人
- 第四步:把症状对到字段上
- 第五步:三条能直接用的判据
- 常见问题解答
- 抓取速率被压低之后,多久能恢复?
- 把proxy_read_timeout调大,5xx少了,这算优化吗?
- 怎么知道自己的页面有没有被截断过?
- limit_req配了burst以后就不会拒绝请求了吗?
- PHP-FPM的进程池应该配多大?
- 爬虫和真人看到的响应时间会差很多吗?
- 只测首页的响应时间够不够?
- Search Console里的“平均响应时间”能直接用吗?
- 权威参考资料
摘要: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”教程的第一行代码,抄的时候大多数人只关心速率填多少,而它真正的行为分岔点在burst和nodelay这两个参数上。
保哥在服务器上另起了一个nginx实例(不碰生产),把同一个页面挂在不同配置的location下,用curl连打,只看两列:状态码和耗时。
burst是排队,不是拒绝
配置是limit_req zone=sz3 burst=5;,速率1r/s。连打6次,结果如下:
| 第几次 | 状态码 | 总耗时 |
|---|---|---|
| 1 | 200 | 0.000627秒 |
| 2 | 200 | 0.988719秒 |
| 3 | 200 | 0.985879秒 |
| 4 | 200 | 0.986737秒 |
| 5 | 200 | 0.987635秒 |
| 6 | 200 | 0.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这个变量,取值是PASSED、DELAYED、REJECTED,外加两个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=5 | 200 ×12(第2次起每次约0.978秒) |
burst=5 nodelay | 200 ×6,然后503 ×6(每次约0.6毫秒) |
| 不带burst | 200 ×1,然后503 ×11 |
三行配置,三种完全不同的对外表现,而它们在教程里往往被当成同一件事的三种写法。
值得注意的是最后一列的耗时:拒绝比放行快得多。503用了0.6毫秒,成功的响应也是0.6毫秒级——但排队那一版是987毫秒。所以从“占用连接时长”这个计价口径看,nodelay反而对抓取容量更友好,代价是把静默降速换成了明面上的5xx。哪个更划算不能一概而论,但至少得知道自己选的是哪一个。
同一个模块的邻居,行为完全相反
nginx里还有一条限并发的指令limit_conn,名字和limit_req只差一个词,行为却是另一个极端。
实验台把limit_conn设成“同一个客户端最多1条并发”,然后打到一个耗时5秒的慢接口上,错开0.3秒发3个请求:
| 第几个 | 状态码 | 耗时 |
|---|---|---|
| 1 | 200 | 5.007秒 |
| 2 | 503 | 0.0006秒 |
| 3 | 503 | 0.0005秒 |
limit_conn没有队列,超了就立刻拒。它连burst这个参数都没有。
所以同一个配置文件里挨着写的两条限制,一条把超额请求变成“很慢的成功”,另一条把超额请求变成“很快的失败”。它们在你的监控上长得完全不一样,在爬虫的账本上却都是扣分项。这大概是本文第一个、但绝不是最后一个“两条路通向同一个坑”的例子。
PHP-FPM池打满时,状态码为什么全是200?
限速是你主动配的,至少你知道它存在。下面这个不是。
PHP-FPM的进程池有个上限(pm.max_children),并发请求超过这个数,多出来的不会被拒绝,会在队列里等。等到有进程空出来,再依次处理。
实验台把pm.max_children设成4,写了一个sleep(8)的脚本,同时发8个请求:
| 并发编号 | 状态码 | 首字节时间 | 总耗时 |
|---|---|---|---|
| 1 | 200 | 8.206秒 | 8.206秒 |
| 2 | 200 | 8.209秒 | 8.209秒 |
| 3 | 200 | 8.209秒 | 8.209秒 |
| 4 | 200 | 11.158秒 | 11.158秒 |
| 5 | 200 | 16.218秒 | 16.218秒 |
| 6 | 200 | 16.217秒 | 16.217秒 |
| 7 | 200 | 16.217秒 | 16.217秒 |
| 8 | 200 | 19.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秒 | 504 | 2.202秒 | 160字节 |
proxy_read_timeout 30s | 后端sleep 5秒 | 200 | 5.007秒 | 5677字节 |
fastcgi_read_timeout 2s | PHP sleep 8秒 | 504 | 2.203秒 | 160字节 |
fastcgi_read_timeout 30s | PHP sleep 8秒 | 200 | 8.209秒 | 18字节 |
| 后端进程不在 | 连接被拒 | 502 | 0.001秒 | 150字节 |
把这张表读两遍。第二行和第四行,后端都很慢,返回的是200;第一行和第三行,后端同样慢,返回的是504。决定这次抓取算“成功”还是“服务器错误”的,是你在配置文件里填的那个秒数。
这带出一个挺荒谬的推论:把proxy_read_timeout调大,5xx计数就会下降,监控面板会变绿,而爬虫那边看到的是响应时间变长——按照Google的规则,这两种做法都会压低抓取容量,只是一种会告警、一种不会。调大超时值本质上是把一个会响的报警器换成了一个不会响的。
那些根本到不了服务器的失败,去哪儿查?
再往下一层。前面聊的都是HTTP层面的事,而有一整类故障连HTTP都没走到。
实验台上把三种连接层失败各测了一遍,客户端拿到的东西是这样的:
| 故障 | HTTP状态码 | 客户端退出码 | 耗时 |
|---|---|---|---|
| 端口没人监听(connection refused) | 000 | 7 | 0.0002秒 |
| 域名解析不出来 | 000 | 6 | 0.258秒 |
| 客户端等不及主动断(timeout) | 000 | 28 | 2.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已经在路上了,改不回来。
实验台造了四种截断,客户端看到的结果如下:
| 造法 | 状态码 | 响应头里的长度 | 实际收到 | 客户端退出码 |
|---|---|---|---|---|
| 上游分块传输传一半掐断 | 200 | chunked(无长度) | 2838字节 | 18 |
| 上游声明5677字节只发2838 | 200 | Content-Length: 5677 | 2838字节 | 18 |
PHP脚本中途exit() | 200 | chunked | 8700字节 | 0 |
| PHP致命错误 | 500 | chunked | 0字节 | 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这边配两种写法,只差一个等号后面的数字:
| 配置 | 状态码 | 响应体 | ETag | Last-Modified |
|---|---|---|---|---|
proxy_intercept_errors on; error_page 502 /maint.html; | 502 | 171字节维护页 | 有 | 无 |
proxy_intercept_errors on; error_page 502 =200 /maint.html; | 200 | 171字节维护页 | 有 | 有 |
第二行做了三件事:把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> | 83 | 0.0% | 0.5% | 5.3% | 79.6% |
| meta description | 78 | 0.1% | 0.9% | 12.5% | 59.1% |
| canonical | 69 | 0.1% | 1.0% | 17.8% | 79.6% |
| og:title | 64 | 0.1% | 1.0% | 27.1% | 59.2% |
| hreflang | 40 | 0.2% | 1.8% | 36.4% | 79.6% |
| JSON-LD结构化数据 | 61 | 0.6% | 4.6% | 55.7% | 97.9% |
</head> | 83 | 1.7% | 10.1% | 44.1% | 79.9% |
<h1> | 60 | 7.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%就断了,还剩什么?
把上面那张表换个问法,答案更直观:
| 只收到前 | title | meta description | canonical | JSON-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万字节——一个正常的宽带请求几十毫秒就传完了,会在这里断掉的多半是别的原因(上游掐断、脚本中途退出、超时),而不是网速。
三条能直接用的结论:
- title最抗断。93%的站在前10%就把title发完了。所以“搜索结果里标题还在”完全不能证明这个页面被完整抓到过。
- h1最不抗断。只收到前10%时,仅15%的站保住了h1。而h1是很多人用来判断“页面主题被识别了没有”的信号。
- 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毫秒 | 0 | 0.0% |
| 200到500毫秒 | 32 | 38.6% |
| 500毫秒到1秒 | 30 | 36.1% |
| 1到2秒 | 17 | 20.5% |
| 大于2秒 | 4 | 4.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_status | PASSED / 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_timeout或fastcgi_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、你调用的第三方库存或价格接口,每一层都可能有自己的速率策略,而它们的日志分别躺在三个不同的控制台里。
第五步:三条能直接用的判据
- “错误率为0”不等于“服务器没拖累抓取”。要看的是响应时间的分布,尤其是尾部(第95、第99百分位),而不是均值和错误率。
- 凡是能通过“把超时值调大”消掉的告警,都是把可见的问题换成了不可见的问题。调大之前先问一句:调大之后,这个请求会变成什么?答案通常是“一个很慢的200”。
- 只看状态码的健康检查,对本文讲的这一整族故障全部失效。健康检查至少要断言两件事:响应时间在阈值内,响应体里有某个必然存在的标记(比如
</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