# 保哥笔记 — 监控与日志 > 本分片含 5 篇文章,按发布日期倒序。全部分片索引见 https://zhangwenbao.com/llms-full.md **站点**:https://zhangwenbao.com/ **分类**:监控与日志 **生成**:2026-09-12 16:00:11 CST --- ## 那一跳丢包96.7%,终点却一个包都没丢 - URL:https://zhangwenbao.com/traceroute-intermediate-hop-latency-loss-misread.html - 分类:监控与日志 - 发布:2026-07-31 | 更新:2026-07-31 - 摘要:实测三条跨境链路:中间跳丢包96.7%而终点0%,连续8跳无响应服务却只要91.5毫秒。附换协议延迟差29倍的对照、读图五步和一张八行判据表。 - 关键词:服务器运维,跨境网络,网络诊断 > **TLDR**:摘要:连续跑了30个周期的路径统计,其中一条链路上第11跳和第13跳的丢包率都是96.7%,第14跳60%,而第15跳是0.0%,终点只有3.3%。这在物理上讲不通——如果第13跳真的丢掉了96.7% 的包,后面的跳不可能收得到。另一条链路更极端:连续8跳100% 无响应,终点丢包0%、平均91.5毫秒。同一台路由器,只把探针从UDP换成ICMP,延迟从45.1毫秒变成1.5毫秒,差29倍。这些数字全部指向同一个结论:中间跳的延迟和丢包,测的不是数据在转发,是那台路由器愿不愿意抽空回你一条ICMP。 > 摘要:连续跑了30个周期的路径统计,其中一条链路上第11跳和第13跳的丢包率都是96.7%,第14跳60%,而第15跳是0.0%,终点只有3.3%。这在物理上讲不通——如果第13跳真的丢掉了96.7% 的包,后面的跳不可能收得到。另一条链路更极端:连续8跳100% 无响应,终点丢包0%、平均91.5毫秒。同一台路由器,只把探针从UDP换成ICMP,延迟从45.1毫秒变成1.5毫秒,差29倍。这些数字全部指向同一个结论:中间跳的延迟和丢包,测的不是数据在转发,是那台路由器愿不愿意抽空回你一条ICMP。 出海站的运营和运维之间有一段经典对话。运营说欧洲客户反馈打不开,运维跑一条路径追踪,截图发过去,图上第12跳标着红色的180毫秒和40% 丢包。于是双方达成共识:问题出在中间某个运营商身上,找线路商去。 这个结论有相当高的概率是错的,而且错得很有迷惑性——因为那个红色数字确实存在,确实是工具打出来的,只是它测的东西跟大家以为的不是一回事。 下面所有数字来自2026年7月31日同一小时内、从同一台国内服务器打出去的实测,目标是三个在国内访问频次都很高的境外站点。 ## 中间那一跳丢包96.7%,为什么终点一个包都没丢? 先看最刺眼的那一组。用连续统计模式跑30个周期,去往某CDN厂商官网的路径上,逐跳丢包率是这样的: 跳号 | 丢包率 | 平均延迟(毫秒) | 这一跳在哪 | 8 | 0.0% | 2.4 | 机房出口 | 9 | 90.0% | 2.6 | 城域网 | 11 | 96.7% | 2.2 | 省网 | 12 | 73.3% | 5.1 | 省网 | 13 | 96.7% | 5.9 | 骨干网入口 | 14 | 60.0% | 7.2 | 骨干网 | 15 | 0.0% | 165.4 | 出境 | 17 | 3.3% | 163.9 | 对方网络 | 18(终点) | 3.3% | 349.3 | 目标服务器 | ## 这张表在物理上自相矛盾 把第13跳和第15跳放在一起看:第13跳丢了96.7% 的包,也就是30个探针只有1个活着通过;而第15跳丢包0.0%,30个探针一个不少全部返回。 第15跳的包必须先经过第13跳。如果第13跳真的在丢包,第15跳不可能是零丢包。数据不会绕过中间那台设备自己飞过去。 所以这张表上唯一自洽的解释是:第13跳根本没在丢包,它只是没有回复那些专门发给它的探针。它照常转发了所有过路的数据,只是懒得给你回执。 同一份实测里另外两条链路完全是同一个形状。去往某电商SaaS平台的路上,第9跳93.3%、第14跳93.3%、第15跳83.3%、第16跳46.7%,而夹在中间的第17跳是0.0%,终点23.3%。去往某代码托管平台的路上,第11跳96.7%、第13跳93.3%、第14跳73.3%,后面的第17、18跳全是0.0%。 三条完全不同的路径,中间高丢包、后面零丢包这个矛盾在每一条上都成立。这就不是巧合,是机制。 ## 机制写在一份1995年的规范里 路径追踪这类工具的工作方式是:故意把数据包的TTL设成1、2、3……,让它在第N跳上耗尽,逼那台路由器回一条ICMP超时消息,从而暴露自己的地址。整套把戏建立在路由器愿意回这条消息上。 RFC 1812第4.3.2.8节对ICMP速率限制的要求 (https://www.rfc-editor.org/rfc/rfc1812)把这件事说得非常直白,原文是: > A router SHOULD also be able to limit the rate at which it sends other sorts of ICMP error messages (Destination Unreachable, Redirect, Time Exceeded, Parameter Problem). 括号里列的四种消息中,Time Exceeded正是路径追踪赖以工作的那一条。规范要求路由器应当有能力限制它的发送速率,并且紧接着解释了理由: > Two problems for a router sending ICMP error message are: (1) The consumption of bandwidth on the reverse path, and (2) The use of router resources (e.g., memory, CPU time) 两条理由都很硬:回一条ICMP要占用回程路径的带宽,还要消耗路由器的内存和CPU。而路由器的本职工作是转发,转发跑在专门的硬件上,生成ICMP却要惊动主控CPU。在一台每秒转发几千万个包的核心设备上,为了让某个人看清路径而去中断CPU,这笔账怎么算都不划算。 更妙的是规范接下来那句:限制怎么施加,“is left to the implementor's discretion”——留给实现者自行决定。也就是说每个厂商、每台设备、每个网管的限速策略都可能不一样,你测出来的丢包率是这台设备当时的心情,不是一个可比的指标。 ## 连这个回复本身都是可选的 再往前追一层,事情更彻底。RFC 792对Time Exceeded消息的定义 (https://www.rfc-editor.org/rfc/rfc792)写于1981年,关于TTL归零时该做什么,原文是: > If the gateway processing a datagram finds the time to live field is zero it must discard the datagram. The gateway may also notify the source host via the time exceeded message. 丢弃数据包是 must,通知源主机是 may。路径追踪这个工具存在了四十多年,它能看见中间跳这件事,从规范第一天起就是路由器的自愿行为。 知道了这一点,中间跳那些红色数字就该换个读法了:它们描述的是“这台设备对探针的配合程度”,跟“数据能不能过去”是两个正交的维度。 ## 同一台路由器,换个探针协议延迟为什么差29倍? 如果上一节还留有“也许真是网络不好”的余地,这一组对照实验能把它彻底堵死。 ## 同一跳,两个数字 对同一个目标,用三种探针协议各跑一次,间隔不到一分钟。第2跳是同一台设备(机房网关,地址完全相同),三次测到的延迟是: 探针协议 | 第2跳延迟(三个探针,毫秒) | 默认UDP | 45.136 / 45.157 / 45.215 | ICMP(-I) | 1.534 / 1.620 / 1.780 | TCP 443(-T) | 1.462 / 1.527 / 1.652 | 同一台设备、同一分钟、同一条链路,UDP探针报45毫秒,另外两种报1.5毫秒,相差29倍。 这45毫秒不是网络延迟。这台设备对UDP探针的ICMP回复排在了一个更低的优先级队列里,或者干脆走了一段限速逻辑,于是回执晚了43毫秒才出来。数据转发本身没有任何变化——三次测量里,它后面的所有跳都是正常的个位数毫秒。 更能说明问题的是三次测量的内部一致性:UDP那三个探针是45.136、45.157、45.215,彼此只差不到0.1毫秒。如果这是链路拥塞,三个探针不该这么整齐;这么整齐的数字,更像是一个固定的处理延迟,而不是排队等待。 ## 三种协议还会走出三条不同的路 换协议影响的不只是数字,连路径本身都会变。同一个目标,三次测量的结果: 探针协议 | 解析到的目标IP | 可见跳数 | 星号跳的位置 | 默认UDP | 172.64.145.93 | 19 | 第6、9、10跳 | ICMP | 172.64.145.93 | 19 | 第6、9、10、11、14、15、16跳 | TCP 443 | 104.18.42.163 | 19 | 第5、6、7、10、12、14、16跳 | 先看最后一行:TCP那次追的根本不是同一台服务器。目标域名的DNS返回了不同的地址,三次测量里出现了两个不同的目标IP。这类大站前面挂着任播网络,同一个域名在不同时刻解析到不同地址是常态。 再看星号的分布:ICMP那次有7跳无响应,UDP那次只有3跳。同一段物理链路,换个探针协议,“看不见”的路由器数量翻了一倍多。 为什么会这样,traceroute手册页对各种探针方式的说明 (https://man7.org/linux/man-pages/man8/traceroute.8.html)里有答案:默认的UDP方式往一个不太可能被占用的高端口发包,ICMP方式发Echo请求,TCP方式发SYN包。中间路由器和防火墙对这三类流量的策略完全独立,某台设备可能放行TCP 443却丢弃UDP高端口,也可能反过来。 由此得到一条相当实用的判据:如果你的业务跑在HTTPS上,就该用TCP 443的探针来追路径。它走的端口跟真实流量一致,途中的策略也就跟真实流量一致;用默认UDP追出来的那条路,可能根本不是你的用户在走的那条。 ## 为什么第2跳比第3跳还慢? 大部分人心里对路径追踪的输出有一个默认模型:延迟应该逐跳递增,因为距离越来越远。这个模型不成立,而且不成立得相当频繁。 ## 非单调是常态 还是那次UDP测量,前八跳的原始数字: 跳号 | 延迟(毫秒) | 与上一跳相比 | 1 | 2.445 | — | 2 | 45.136 | +42.7 | 3 | 5.123 | −40.0 | 4 | 5.767 | +0.6 | 5 | 8.416 | +2.6 | 7 | 6.425 | −2.0 | 8 | 1.968 | −4.5 | 第3跳比第2跳快40毫秒,第8跳比第7跳快4.5毫秒,第8跳甚至比第1跳还快。数据当然没有倒着流。 原因很直接:每一跳的数字是一次独立的往返测量,它们之间没有任何包含关系。第3跳的5.123毫秒是“从我这里到第3跳再回来”的完整耗时,跟第2跳那次测量是两件独立的事。第2跳那台设备回执慢,只影响第2跳这一个数字,不会传递给后面。 所以正确的读法是:把每一行当成一次独立实验的结果,而不是一条流水线上的累计刻度。一行的数字异常,只说明那一行对应的那台设备回执异常。 ## 倒数第二跳比终点还慢 另一条链路上出现了更反直觉的一组:第17跳的三个探针是286.348、272.686、342.695毫秒,而第18跳(终点)是176.854、160.417、252.983毫秒。倒数第二跳比终点慢了大约1.6倍。 这里除了前面说的独立测量之外,还多了一层原因:ICMP差错消息的回程路径,跟正常数据的回程路径可能完全不同。RFC 1812那段解释里第一条理由就点明了ICMP要消耗“reverse path”的带宽——规范自己承认这条消息走的是回程。 互联网上去程和回程走不同路线是常态,不是异常。这意味着你在图上看到的每一个中间跳延迟里,都混着一条你完全看不见的回程路径。你以为在测一条线,其实每一行都在测两条不同的线的总和,而且每一行的那两条线还各不相同。 ## 连着跑三次,路径为什么每次都不一样? 同一条命令,同一个目标,连着跑三次,中间不停顿。按直觉三次结果应该几乎一致,实际上: | 第1次 | 第2次 | 第3次 | 总跳数 | 19 | 19 | 20 | 目标IP | 172.64.145.93 | 104.18.42.163 | 172.64.145.93 | 第12跳地址 | 119.147.220.141 | 119.147.220.137 | 119.147.222.45 | 第15跳地址 | 202.97.35.102 | 202.97.81.182 | 110.94.123.118 | 能看见的跳数 | 13 | 15 | 16 | 跳数变了,目标变了,中间几乎每一跳的具体地址都变了。三次测量里,只有前八跳(机房内和城域网那段)是稳定的。 ## 一跳根本不是一台设备 还有更直接的证据。某一次测量的第11跳,三个探针返回了三个不同的地址: 11 219.133.32.157 3.060 ms 219.133.32.145 2.249 ms 219.133.32.137 2.744 ms 同一跳、同一次命令、三个探针,撞上了三台不同的路由器。第13、14跳也是同样的形态。 这就是等价多路径在起作用。RFC 2992对等价多路径算法的分析 (https://www.rfc-editor.org/rfc/rfc2992)描述了最常见的一种实现:路由器对能标识一条流的包头字段做哈希(文中举的例子是CRC16),再用哈希结果落进哪个区间来决定走哪条下一跳。 关键在于哪些字段参与哈希。路径追踪的每个探针为了区分回执,会用不同的源端口或序号——而端口正是标识流的字段之一。于是每个探针算出来的哈希值不同,被分配到不同的下一跳,你看到的“这一跳有三个地址”就是这么来的。 由此得到第二条判据:中间跳的具体地址对做业务的人几乎没有诊断价值。它每次都不同,你也没法要求对方按某一条固定路线走。真正稳定、真正可比的只有两样东西——终点的延迟,和相邻跳之间的延迟增量落在哪一段。 ## 多测几次的正确姿势 既然单次不可靠,正确做法是把重复测量交给工具而不是自己反复敲命令。mtr官方项目页 (https://www.bitwizard.nl/mtr/)介绍的正是这个思路:它把路径追踪和持续探测合成一件事,连续发很多轮并给出每一跳的丢包率、最好值、最差值和标准差。 但要记住前面两节的结论:它给出的中间跳统计仍然是“对探针的配合程度”的统计,多测几十轮只会让这个不可靠的数字变得更精确,不会让它变得更有意义。多测的真正价值在终点那一行——终点的丢包率和延迟分布,是唯一直接对应用户体验的数据。 ## 连续8跳全是星号,服务照样88毫秒 这一节只有一组数字,但它可能是整篇里最该记住的一组。 ## 八跳全黑,终点全绿 去往某代码托管平台的连续统计,尾部是这样的: 跳号 | 地址 | 丢包率 | 平均延迟(毫秒) | 20 | 51.10.10.130 | 6.7% | 116.7 | 21 | 无响应 | 100.0% | — | 22 | 无响应 | 100.0% | — | 23 | 无响应 | 100.0% | — | 24 | 无响应 | 100.0% | — | 25 | 无响应 | 100.0% | — | 26 | 无响应 | 100.0% | — | 27 | 无响应 | 100.0% | — | 28 | 无响应 | 100.0% | — | 29(终点) | 20.205.243.166 | 0.0% | 91.5 | 连续8跳彻底无响应,紧接着终点零丢包、平均91.5毫秒、标准差只有3.6毫秒。这条链路的健康程度是全场最好的三条之一。 那8跳是对方数据中心内部的网络。大型云平台的内部设备通常统一关闭ICMP差错消息生成,这是一条常规的安全与性能策略,不是故障。 ## 这是最容易做错的一个判断 如果你用的是普通的路径追踪命令,默认最大跳数是30;而我这次为了控制耗时把上限设成了24。结果是命令跑到第24跳时全是星号就停了,看上去像是“路径在第21跳断了,根本到不了目标”。 同一时刻,用命令行去请求这个站的首页,首字节0.5055秒,一切正常。 所以这条判据要单独立出来:路径追踪追不到终点,跟服务不可达是两件完全无关的事。判断一个站通不通,唯一可靠的办法是直接发一个真实的业务请求过去。用什么工具去发、发出去之后哪些数字才可信,curl -I查了一圈说这站没配缓存头,换成GET之后那五个头全在 (https://zhangwenbao.com/curl-head-get-response-header-diagnosis-seo.html)那篇拆得比较细。 顺带一提,这台服务器上默认根本没装路径追踪工具,只有一个功能受限的替代品,跑这批实测之前得先装。真到了要排障的时候,先确认手上有没有工具,比什么都实际。网卡、端口、路由表这些更基础的东西该怎么看,Linux网络配置和排查怎么做才不瞎猜 (https://zhangwenbao.com/linux-network-configuration-troubleshooting-ip-ss-ping-traceroute-dns.html)那篇把常用命令过了一遍,可以当装机后的第一份清单。 ## 同一个终点,两个工具给出76和246毫秒,信哪个? 上一节说“只看最后一行”,但最后一行本身也有讲究。同一个目标、同一小时内,三种工具给出的数字是这样的: 测量方式 | 终点读数(毫秒) | 路径追踪(UDP探针) | 246.055 | 路径追踪(ICMP探针) | 246.081 | 路径追踪(TCP 443探针) | 242.504 | 连续统计的最好值 | 76.3 | 连续统计的平均值 | 228.0 | 持续探测20次的平均值 | 230.4 | 持续探测20次的最小值 | 66.5 | ## 两个数字都是真的,问的问题不同 246和76之间差了3.2倍,但没有哪个是测错的。 路径追踪那三个246是单次采样,三种协议高度一致,说明这个值很稳定——它稳定地反映了“这一刻这条链路的实际表现”。而76.3是三十次采样里的最好那一次,它回答的是另一个问题:“这条链路在完全没有干扰时能跑多快”。 两个数字合起来才有意义:物理下限76毫秒,实际表现230毫秒,中间那154毫秒是排队、限速和拥塞吃掉的。这个差值才是可优化空间,而单看任何一个数字都看不到它。 对照另外两个目标,这个差值的分布很不一样:某代码托管平台最小88.4、平均92.2,差值只有3.8毫秒;某CDN厂商官网最小164.6、平均359.4,差值194.8毫秒。前者是一条几乎跑满物理极限的干净链路,后者的实际表现只有理论能力的一半不到。 ## 最差值那一列为什么会骗人 连续统计模式会给出最好值、平均值、最差值和标准差四列。最差值那一列几乎没有诊断价值,而且经常主动误导。 实测里最典型的一行是某条路径的第4跳: 统计量 | 数值(毫秒) | 最好值 | 3.1 | 平均值 | 19.9 | 最差值 | 470.8 | 标准差 | 85.3 | 三十次采样里有一次跑出了470.8毫秒,是最好值的152倍。这一次异常把平均值从3点几拉到了19.9,整整抬高了六倍。如果你只看平均值那一列,会得出“这台机房内的设备有问题”的结论;实际上另外二十九次全在3毫秒附近。 另一条路径的第8跳是同一个形状:最好1.9、平均7.7、最差162.8、标准差29.3。同样是一次异常拉高了整列。 所以四列的正确用法是:最好值判断链路能力,标准差判断稳定程度,平均值只在标准差很小的时候才可信,最差值除了提醒你“存在过一次异常”之外没有别的用途——尤其不能用来跟别的链路做比较。 单次测量里同样会撞上这种异常。某次TCP探针测到终点时,三个探针分别是262.434、1445.124、391.731毫秒——中间那个1.45秒比同一行的另外两个高出4到5倍。如果那一行只发了一个探针,而恰好是这一个,你会得到一个完全失真的结论。 这就是为什么每跳默认发三个探针,也是为什么把探针数调成1来提速是个坏主意:省下来的那几秒,代价是把单点异常直接变成了唯一的结论。真要提速,减少最大跳数比减少每跳探针数安全得多。 ## 哪些数字才真的进了用户的等待时间? 前面拆了一堆“不能信”的数字,那么该信什么?答案是终点的往返时间,而且它跟用户体验之间有一个相当稳定的换算关系。 ## 一个三倍多的常数 同一批目标,同一小时内,用两种方式各测一次:一次纯网络层的往返时间,一次完整的HTTPS首字节。把首字节扣掉DNS解析的固定开销之后,两者相除: 目标站 | 往返时间均值(秒) | 首字节扣除DNS后(秒) | 倍数 | 某代码托管平台 | 0.0922 | 0.2988 | 3.24 | 某电商SaaS平台 | 0.2304 | 0.7965 | 3.46 | 某CDN厂商官网 | 0.3594 | 1.2522 | 3.48 | 三个站分别在三个不同的大洲、挂着三家不同的基础设施,倍数全部落在3.24到3.48之间。我第一次算出来的时候复算了两遍。 ## 这个常数是怎么来的 它不是巧合,是握手结构决定的。一次HTTPS请求在第一个字节回来之前,至少要走这么几趟: - TCP三次握手:1个往返。 - TLS握手:RFC 8446定义的TLS 1.3完整握手 (https://www.rfc-editor.org/rfc/rfc8446)把这一步压到了1个往返(老的TLS 1.2需要2个)。 - 发请求、等首字节:1个往返,再加上服务器自己的处理时间。 1加1加1等于3,剩下的零点二几就是服务器处理和各种零碎开销。三个站的实测值全部略高于3,正好对应这个结构。 这条换算关系的实用价值非常直接:你在网络层省下的每1毫秒,用户端会体感到大约3.3毫秒。反过来说,一条比现在快50毫秒的线路,能给首字节带来大约165毫秒的改善。这个杠杆比大多数人估计的要大,也解释了为什么出海站换线路的收益经常超出预期。多层缓存怎么在这个基础上继续往下压,TTFB怎么优化才不白费:多层缓存如何同时左右Core Web Vitals与Google抓取 (https://zhangwenbao.com/ttfb-multi-layer-cache-core-web-vitals-crawl-budget-seo.html)那篇算过完整的账。 ## 三个能进用户体验的数字 把可信的数字收拢成一张短表,其余的都可以先放一边: 数字 | 怎么取 | 说明什么 | 终点往返时间的最小值 | 连测20次以上取min | 这条链路在无干扰时的物理下限,换线路能改善的就是它 | 终点往返时间的标准差 | 同上取mdev | 抖动大小,直接影响用户感受到的稳定程度 | 终点丢包率 | 同上 | 唯一有意义的丢包数字,会触发重传并放大延迟 | 三个目标的实测对照很能说明问题:某代码托管平台最小88.4毫秒、标准差只有3.75毫秒、丢包10%;某CDN厂商官网最小164.6毫秒、标准差72.6毫秒、丢包0%。前者延迟低但会丢包,后者不丢包但抖动是前者的19倍。这两种问题对用户的影响完全不同,得分开治。 ## 出海站真正该盯的是哪一跳? 答案是:不盯任何一跳,盯相邻两跳之间的增量,而且只有一个位置的增量真正重要。 ## 那个158毫秒的台阶 去往某CDN厂商官网的路径上,把每一跳跟上一跳的延迟差列出来: 跳号 | 延迟(毫秒) | 增量 | 11 | 2.414 | — | 12 | 5.086 | +2.7 | 13 | 5.898 | +0.8 | 14 | 7.290 | +1.4 | 15 | 169.376 | +162.1 | 16 | 161.761 | −7.6 | 17 | 286.348 | +124.6 | 18(终点) | 176.854 | −109.5 | 整条路径上只有一个位置发生了量级跳变:第14跳到第15跳,从7.3毫秒跳到169.4毫秒。这一跳就是出境。它之前的所有跳加起来只有7毫秒,之后的所有跳再怎么折腾也是在160到350毫秒之间晃。 换个说法:这条链路上96% 的延迟由一个动作产生,就是跨过国境线。国内那14跳无论怎么优化,最多也就是从7毫秒降到5毫秒,改善不到1.2%。 第17跳那个286毫秒和第18跳回落到176毫秒的组合,前面已经解释过——独立测量加上不同的回程路径,不代表第17跳出了问题。 ## 能改的和不能改的 把这条路径按“你能不能动它”重新切一遍,结论就很清楚了: 路段 | 本例耗时 | 你能做什么 | 机房内 + 城域网(第1到8跳) | 约2.4毫秒 | 几乎无事可做,占比可以忽略 | 省网 + 骨干网(第9到14跳) | 约5毫秒 | 换机房/换运营商,收益个位数毫秒 | 出境这一跳 | 约158毫秒 | 换出口线路、上加速节点、或者把服务放到境外 | 境外段(第15跳之后) | 约10毫秒 | 选离用户更近的节点 | 这张表解释了一个常见的困惑:为什么给国内机房做了一堆优化,海外客户还是觉得慢。因为你优化的那部分总共只占5%。真正的做法只有把服务或缓存节点搬到用户那一侧,这件事的完整决策链条,外贸独立站用国内主机还是国外主机?备案、速度与合规 (https://zhangwenbao.com/china-vs-overseas-hosting-for-cross-border-independent-site.html)那篇按备案、速度、合规三条线拆过。 如果站已经在境外、海外客户仍然说慢,那就得往别的方向查了——DNS解析、边缘节点覆盖、乃至客户端设备本身都可能是原因,海外用户说独立站打不开、加载慢?从DNS、线路到CDN的网络层排障 (https://zhangwenbao.com/overseas-store-unreachable-slow-network-layer-diagnosis-dns-routing-cdn.html)那篇给了一条完整的排查顺序,而弱网和低端机这一侧的影响,海外客户用低端机、弱网打开你的独立站有多卡 (https://zhangwenbao.com/overseas-weak-network-low-end-phone-mobile-performance-optimization.html)那篇量过。 ## 保哥读图的五步与一张判据表 把前面所有结论压成一套可以照着做的流程。这套东西我现在给客户看路径图之前会先自己走一遍。 ## 五步 - 先看最后一行,别看中间。终点的延迟、丢包、抖动是唯一直接对应用户体验的数据。中间行先当作背景噪声。 - 用跟业务一致的探针协议。业务跑HTTPS就用TCP 443探针,别用默认的UDP。协议不一致,路径和策略都可能不一致。 - 连测二三十轮,看分布不看单次。取最小值判断链路能力,取标准差判断稳定性,单次结果只能当参考。 - 找增量最大的那一跳,那才是瓶颈的位置。不看绝对值,看相邻两跳的差。跨境链路上通常只有一个台阶是重要的。 - 用真实业务请求做交叉验证。路径图说不通的地方,发一个真实请求过去,以业务请求的结果为准。 ## 一张判据表 你在图上看到 | 大概率的真相 | 该做什么 | 中间某跳丢包50% 以上,后面的跳正常 | ICMP限速,转发没问题 | 忽略,继续看终点 | 连续多跳全星号,终点正常 | 对方网络关闭了ICMP生成 | 忽略 | 某跳延迟高,后面的跳反而低 | 那台设备回执慢,不是链路慢 | 忽略 | 某跳之后所有跳延迟都抬高一个量级 | 真瓶颈,通常是出境或跨洲 | 这才值得动 | 终点丢包高 | 真丢包,会触发重传 | 换线路或换节点 | 终点延迟稳定但绝对值高 | 物理距离,改不了 | 把服务搬近用户 | 终点标准差大 | 拥塞或多路径抖动 | 换线路,或看是否分时段 | 路径追不到终点,但业务请求正常 | 不是问题 | 忽略 | ## 大包探测:什么时候值得单跑一次 路径追踪命令后面可以跟一个字节数,用来指定探针包的大小。默认是60字节,几乎不可能触发任何与包大小有关的问题。用1400字节再跑一次,是排查一类特定故障的标准动作。 本次实测跑了一组1400字节的探针,结果是整条路径与60字节时形态一致,所有能响应的跳都正常响应,终点也照常到达。结论是这条链路上没有分片相关的问题,可以把这个方向排除掉。 什么时候需要跑这一趟:症状是“小请求正常、大传输卡死”。比如打开首页很快、提交一个带附件的表单就一直转圈;或者接口的短响应正常、返回大JSON就超时。这类症状的典型成因是路径上某处的最大传输单元比两端以为的小,而用来协商这件事的ICMP消息又被中途丢掉了——注意,被丢掉的又是ICMP,跟本文前面讲的是同一批消息、同一批原因。 反过来说,如果你的症状不是“大小相关”,跑大包探测意义不大,60字节的默认值已经够用。这是一个用来证伪特定假设的工具,不是常规体检项。 ## 给运维和运营各一句话 给运维的:把路径图发给客户之前,先自己检查一遍中间跳的丢包和后面跳的丢包是否自相矛盾。矛盾就说明那些红色数字不能作为证据,发出去只会把排查方向带偏,回头还得自己收拾。 给运营的:当技术同事拿着一张标红的路径图说“问题在中间某个运营商”时,问一句“终点丢包多少”。如果终点丢包接近零,那张图就不足以支撑这个结论。至于服务器本身是不是真的扛不住,那是另一条完全不同的排查线,Linux服务器突然变慢、负载飙高怎么排查 (https://zhangwenbao.com/linux-server-performance-troubleshooting-high-load-cpu-memory-disk-io-diagnosis.html)那篇讲的是那一侧。而证书这类会在握手阶段拖慢首字节的东西,证书有效期正在砍向47天,一年一换的续期流程撑不到2027年 (https://zhangwenbao.com/tls-certificate-lifetime-47-days-renewal-automation.html)那篇里有一部分相关内容。 ## 常见问题解答 ## 中间跳丢包到底要不要管?一点都不用管吗? 有一种情况要管:某一跳丢包高,并且它后面的所有跳丢包也都高。这时候丢包是往下传递的,说明是真的在丢。判据就是看传递性——真丢包会一路传到终点,假丢包只在自己那一行出现。本文实测的三条链路全部属于后者,中间高丢包后面立刻回到零。另外如果你就是那台设备的管理者,中间跳的读数对你有意义;对使用者来说没有。 ## 为什么我在Windows上跑出来的结果跟Linux不一样? 因为默认探针协议不同。Windows自带的那个命令默认用ICMP,Linux上的默认用UDP高端口。前面那组29倍的差距就是这么来的——不是两个系统测得不准,是它们在问两个不同的问题。要让两边可比,在Linux上加参数切到ICMP模式即可。更实际的建议是两边都用TCP加业务端口,这样测的才是你真正关心的那条路。 ## 看到路径里出现私有地址(10.x、192.168.x)是不是有问题? 不是。本文实测的路径里第3到第7跳全是私有地址,那是机房和运营商内部的转发设备,用私有地址编址是很常规的做法。这些地址不会出现在公网路由表里,也不影响数据转发。真正需要注意的是另一种情况:路径走到一半又绕回一个你熟悉的国内地址,那可能说明流量被牵引了,值得看一眼。 ## 连续统计模式跑多少轮才够? 看你要回答什么问题。判断链路能力,二三十轮就够了,因为最小值收敛得很快。判断稳定性和丢包率,轮数越多越准,一两百轮更可靠。判断是不是分时段的问题,那就不是靠加轮数解决的,得改成每小时跑一次、连跑一整天。有一点要注意:轮数越多,被对方的防护策略盯上的概率越大,实测里就遇到过连续请求之后目标站开始拒绝响应的情况。 ## 路径追踪能看出对方用的是不是CDN吗? 能看出一些线索但不可靠。比较明显的信号是最后几跳的地址属于知名基础设施厂商的地址段,以及同一个域名多次解析到不同IP。但反过来不成立——看不出这些线索不代表没用CDN。更直接的办法是查这个域名的解析记录指向哪里,以及看响应头里有没有边缘节点留下的标记,那比从路径上猜准确得多。 ## 那个三倍多的换算关系,在什么情况下会失效? 至少三种情况。一是连接复用:如果客户端已经和服务器建立了连接(比如同一个页面的第二个请求),握手那两个往返就省掉了,倍数会掉到接近1。二是服务器处理时间很长:如果后端要跑一个几百毫秒的查询,那部分是加在末尾的常数,不随往返时间缩放。三是TLS 1.2或者会话恢复:前者多一个往返,倍数会升到4以上;后者少一个往返,倍数会掉到2出头。把这三种情况排除掉,剩下的场景里这个换算是相当稳的。 ## 权威参考资料 ## 做SEO量出74%的页面对爬虫和用户不一样,补上一组对照之后只剩2.2% - URL:https://zhangwenbao.com/page-content-jitter-baseline-seo-diff-false-positive.html - 分类:监控与日志 - 发布:2026-07-31 | 更新:2026-07-31 - 摘要:实测230个URL连抓三遍:40.9%三次指纹不全同,56.4%字节数却完全相同;一个CSP nonce让304命中从100/100掉到0/100。附抖动成因归类与五步基线测法。 - 关键词:技术SEO,抓取预算,SEO监控 > **TLDR**:摘要:我原本想量一件很简单的事:带不带Cookie,服务器给的页面会不会不一样。第一轮跑完,74.4%的站两次响应字节对不上,我差点就把这个数字写进结论。补了一组对照——同样的请求头、同样的会话、什么都不改,再发一次——才发现绝大部分差异跟Cookie毫无关系,是页面自己在抖。230个真实URL连抓三遍,40.9%三次指纹不全同;这些抖动里56.4%三次字节数分毫不差,靠Content-Length根本看不出来;剥掉nonce、请求ID、时间戳这类一眼就是噪声的串之后,仍有34.3%在变。而真正由Cookie造成的分岔,在稳定样本上只有2.2%。这篇讲怎么先把噪声量出来,再谈差异。 > 摘要:我原本想量一件很简单的事:带不带Cookie,服务器给的页面会不会不一样。第一轮跑完,74.4%的站两次响应字节对不上,我差点就把这个数字写进结论。补了一组对照——同样的请求头、同样的会话、什么都不改,再发一次——才发现绝大部分差异跟Cookie毫无关系,是页面自己在抖。230个真实URL连抓三遍,40.9%三次指纹不全同;这些抖动里56.4%三次字节数分毫不差,靠Content-Length根本看不出来;剥掉nonce、请求ID、时间戳这类一眼就是噪声的串之后,仍有34.3%在变。而真正由Cookie造成的分岔,在稳定样本上只有2.2%。这篇讲怎么先把噪声量出来,再谈差异。 做技术SEO的人手里都有那么几件对比工具:抓一份Googlebot看到的HTML,再抓一份浏览器看到的,摆在一起看差多少;或者今天存一份快照,明天再存一份,看页面有没有被人偷偷改过。这类工具的输出通常很干脆——两版不一致,扣分,红灯。 问题是,这套逻辑默认了一件从来没人验证过的事:同一个地址,在什么都不变的情况下,会稳定地返回同一份内容。 这篇文章要做的事就一件:把这个默认前提拿去实测。230个真实站点URL,每个连抓三遍,同一套请求头、同一个出口、间隔不到一秒,然后看三份字节对不对得上。 ## 你说的这个页面变了,有多大可能它一直在变? 先交代口径。样本来自124个首页加106个深层页,共230个URL,全部在三次基线请求里都返回200。请求头完全固定:同一个Chrome桌面UA、同一个Accept-Language: en-US,en;q=0.9、同一个出口IP,三次之间不做任何改动。指纹用响应体原始字节的SHA-1,不做任何清洗。 结果是这样的: 样本 | URL数 | 三次指纹不全同 | 占比 | 全部 | 230 | 94 | 40.9% | 首页 | 124 | 55 | 44.4% | 深层页 | 106 | 39 | 36.8% | 换句话说,三次完全一致的URL只有136个,占59.1%。剩下那四成,你在什么都没改的情况下,就已经拿到了两份或三份不同的HTML。 首页比深层页更爱抖,这个方向符合直觉:首页上挂的推荐位、榜单、促销条、埋点脚本最多。但36.8%这个深层页的数字仍然不低——那些通常是文章页和商品页,也就是大家平时最认真做SEO的那一批。 这个40.9%意味着什么?意味着任何一份“两版不一致”的报告,都自带一个四成的假阳性地板。你的对比工具抓两份HTML发现它们不同,第一嫌疑人不是Googlebot被区别对待了,而是这个页面本来就不会给你两份一样的东西。 为什么这么基础的一个前提,很少有人真的去验证?我猜有两个原因。 一个是这件事在十几年前确实成立。那时候一个PHP页面渲染出来就是那一份HTML,同一个模板同一批数据,字节级稳定是常态,抖动才是异常。这套直觉一直沿用到今天,可今天的页面上挂着三四个埋点脚本、两套实验框架、一个推荐引擎,中间还隔着CDN——稳定从常态变成了少数派,直觉却没跟着更新。 另一个原因更朴素:验证这件事需要多发一次请求,而多发的那一次不产出任何新信息。你抓两份就能出一张对比图,抓三份只能得到一句“这个页面本身就在抖”,看起来像白干。做数据的人都懂对照组的价值,可轮到自己写巡检脚本的时候,那一步几乎总是第一个被省掉的。 ## 为什么字节数一样,也不能说明内容一样? 很多自建的巡检脚本图省事,用Content-Length或者curl -w '%{size_download}'来判断“页面有没有变”。这个办法有一个非常大的漏洞。 在那94个抖动的URL里,53个三次的字节数完全相同,占56.4%。它们的SHA-1三次全不一样,字节数却一个数都没差。 为什么会这样?我在实验台上造了一个最小的例子。一台干净的nginx后面挂一个PHP页面,页面里输出六个列表项,唯一的处理是每次响应前把顺序打乱: 指纹 | 字节 | 列表顺序 | 3693324aec | 282 | foxtrot alpha delta bravo echo charlie | aa67125512 | 282 | foxtrot bravo alpha echo delta charlie | 0be277628d | 282 | charlie foxtrot delta bravo alpha echo | 2feee55ee9 | 282 | bravo delta alpha echo charlie foxtrot | e5f9e616c0 | 282 | charlie delta alpha foxtrot bravo echo | 43d62c19f4 | 282 | charlie delta foxtrot bravo alpha echo | bf22c0b7da | 282 | alpha echo charlie delta bravo foxtrot | 077ccbb24c | 282 | echo alpha foxtrot charlie bravo delta | 八次请求,八个不同的指纹,八个一模一样的282。凡是“重排”而不是“增删”的变化,在字节数这个维度上是完全隐形的。 现实里这类变化多得很:商品列表按个性化权重重排、推荐位轮播、相关文章随机取N条、多个CDN域名轮询、A/B分桶决定组件顺序。它们改变的是“哪个在前面”,不改变“一共有多少”。 顺带说个数:这94个抖动URL里,三次字节差的中位数是0,p90是536字节,最大的一个是zapier.com首页的42604字节。中位数为0这件事本身就是结论——抖动的典型形态不是页面重写了一半,而是几个字符悄悄换了个位置。 ## 三种在字节数上完全隐形的变化 把这类变化分个类,排查的时候能快很多。 第一种是重排。上面那个实验就是。列表顺序、推荐位顺序、CDN域名轮询、多个脚本标签的先后,全都属于这一类。它们的共同点是元素集合没变,只是走了个位置。 第二种是等长替换。一个32位的请求ID换成另一个32位的请求ID、一个UUID换成另一个UUID、一个毫秒时间戳换成下一个毫秒时间戳——长度天然固定,内容每次都新。这类是所有抖动里最普遍的。 第三种最阴,是等长的内容替换。比如价格从¥129变成¥139、库存从12件变成13件、评分从4.6变成4.7。这些是真实的内容变化,会影响结构化数据,也会影响搜索结果里展示的富媒体信息,可它们在字节数上一动不动。 第三种正是为什么“只看size就行了”这个想法特别危险:它对噪声不敏感,同时对真正该报警的那类变化也不敏感。一个既报不出假警、也报不出真警的指标,其实就是没有指标。 ## 抖动到底抖在哪一段字节上? 知道“在抖”还不够,得知道“抖在哪”。我把那94个URL全部重抓两份完整HTML,逐行做差异比对,再给每一段差异归类。 第一个意外的发现:重抓时只有86个还在抖,另外8个这次三次一致了。也就是说抖动本身也是间歇的——一个页面今天没抖,不等于它稳定,只等于这次没赶上。 在这86个里: - 变化行数中位数4行,p90是20行,最多98行; - 变化行占全文行数的比例,中位数0.743%,p90是25.2%; - 变化块数中位2块; - 第一处差异落在之前的,占55.8%。 最后那条最要命。超过一半的抖动,第一处就发生在head里——而head正是title、meta description、canonical、hreflang、结构化数据的地盘。你的差异报告有一半概率会把手指头点在SEO最敏感的那一段上。 把差异段落做关键词归类之后,成因分布是这样: 抖动来源 | 命中URL数 | 占比 | 广告与追踪脚本 | 51 | 59.3% | 实验与A/B分桶 | 46 | 53.5% | 推荐与排序 | 43 | 50.0% | 时间戳与日期 | 37 | 43.0% | 库存、价格与计数 | 31 | 36.0% | 构建ID与资源版本号 | 25 | 29.1% | 会话ID | 19 | 22.1% | CSRF令牌 | 19 | 22.1% | 请求ID与链路追踪 | 19 | 22.1% | CSP的nonce | 13 | 15.1% | 一类都没命中 | 13 | 15.1% | 一个URL可以同时命中好几类,所以合计超过100%。这张表里最值得盯的是最后一行:有15.1%的抖动,用这十条规则一条都识别不出来。那些是页面自己业务逻辑里的东西——库存数字、评论条数、“3人正在看”这类实时片段,没有任何通用特征可以把它们摘出去。 几条真实的差异行,能让人对这件事有个具体的感觉: > zfc.push(['initialize', '1785433803088', (new Date).getTime(), ...(zappos.com,两次请求相差不到一秒,这个毫秒级时间戳就换了一个数) > '__TGT_DATA__': { configurable: false, enumerable: true, value: deepFreeze(JSON.parse("{ \"formFactor\": \"desktop\",\"assetPrefix...(target.com,整个页面状态被序列化进一个巨大的JSON,里面任何一个字段变了,这一行就变了) 最干净的几个也值得记一下:salesforce.com和mailchimp.com每次只变2行、占比0.019%,moz.com只变2行且十条规则一条都不命中。它们不是不抖,只是抖得极小——小到任何一个按“相似度百分比”打分的工具都会判它们通过。 ## 两个极端的样本 抖动幅度最大的是zapier.com首页,三次分别是577728、535124、535124字节,头一次比后两次多出42604字节。这个量级不是埋点脚本能解释的,更像是首屏内容整块换了一版——可能是实验分桶,也可能是缓存里恰好躺着一份不同的副本。 另一头是paypal.com的深层页,变化行数92行,占全文的27.9%,同时命中了nonce、CSRF令牌、请求ID、会话ID、时间戳五类。金融类站点在安全上下的功夫,全都以抖动的形式写进了HTML。这没什么可指摘的,只是意味着对这类站做内容对比,噪声地板高得离谱。 把这两个极端和中位数的0字节差放在一起看,能得出一个对排查有用的判断:抖动的分布是长尾的,绝大多数是几行的小变动,极少数是整块内容的替换。所以看“变化行数”这个绝对量,比看“相似度百分比”这个相对量更有信息量——后者在一个几千行的页面上,永远是99.9%。 ## 把一眼就是噪声的串剥掉之后,还剩多少? 到这里很自然会想到一个办法:既然大部分抖动是nonce、请求ID、时间戳这种明显的随机串,那就在算指纹之前先把它们统一替换掉,剩下的差异才算数。 这个思路是对的,我也这么做了。做法是在算第二枚指纹之前,用一组正则把UUID、32到64位的十六进制串、10到13位的纯数字、nonce="..."、各类token赋值、ISO 8601时间串,全部替换成固定的占位符,然后对处理后的文本再算一次SHA-1。于是每个响应有两枚指纹:原始指纹和归一化指纹。 结果比我预期的糟: 口径 | 三次不全同 | 占比 | 原始字节指纹 | 94 | 40.9% | 归一化指纹(剥掉高熵串) | 79 | 34.3% | 也就是说,40.9个百分点的抖动里,只有6.5个百分点是纯token噪声,剩下34.3个百分点在剥掉所有随机串之后依然在变。 这个结果推翻了我动手前的假设。我原本以为抖动主要是安全和链路追踪那套东西造成的,清洗一遍就该干净了。实际上不是——大头是内容本身在变:推荐位换了一批、榜单重排了、库存数字动了、A/B分桶给了另一个变体。这些东西你没法用正则摘掉,因为它们长得就跟正常内容一样。 保哥的看法是,这条其实是好消息里裹着坏消息。好消息是“清洗一下就能对比”这个念头可以早点放弃,省得在正则上耗一周;坏消息是只要页面上有一块内容是活的,这个页面就没有一个稳定的“正确版本”可以拿来当基准。 ## 连抓两次和连抓三次,结论会差多少? 几乎所有对比脚本都是抓两份。我这次特意抓了三份,中间还留了一点间隔,就是想看看第三份能多告诉我什么。 第一个数字:头两次指纹一样、第三次才变的URL有5个,占2.2%。比例不高,但方向很明确——抓两次判“稳定”,会有一小撮漏网。 第二个数字更值得注意,就是上一节说的8.5%重抓时不抖了。合起来看,这两个数字说的是同一件事:“这个页面稳不稳定”本身就是一个概率问题,不是一个是非题。你测一次得到的是一个样本,不是一个结论。 第三个数字来自实验台,是我做这批实验时最喜欢的一个小机关。我造了一个只注入时间戳的页面,然后连着请求: 时机 | 指纹 | 是否与第一次相同 | 第一次 | 6d181df64ebb | — | 紧接着第二次 | 6d181df64ebb | 相同 | 隔2秒第三次 | 75295c2096bc | 不同 | 因为这个页面注入的是秒级时间戳,在同一秒里连发两次,它看起来完美稳定。而现实里43.0%的抖动跟时间戳有关,这意味着你的采样间隔本身就是一个变量:抓得越快,页面看起来越稳。 这大概是整件事里最容易让人栽跟头的地方。为了“控制变量”,大家往往把两次请求发得越紧越好,结果恰恰是把一整类抖动屏蔽掉了。想抓时间戳类抖动,你得故意等一会儿。 ## 一个安全用的随机数,能让抓取预算多花多少? 前面讲的都是“你怎么被误导”,这一节讲抖动实打实的代价,而且这笔代价直接记在SEO账上。 Google的爬虫支持条件请求。它会把上次抓取拿到的ETag放进If-None-Match发过来,服务端如果发现内容没变,回一个304、零字节的响应体就完事了。这套机制能省下的是真金白银的抓取预算,具体机制我在条件请求与304怎么省抓取预算 (https://zhangwenbao.com/conditional-request-304-etag-crawl-budget-seo.html)里拆过一遍。 现在问题来了:如果页面每次响应的字节都不一样,应用层算出来的强ETag也就每次都不一样,条件请求会发生什么? 我在实验台上做了个标准的框架式实现——对最终HTML算一次MD5当ETag,收到If-None-Match就比对,一致返304。然后拿同一个页面的几个变体各跑100次条件请求: 页面里注入的东西 | 100次条件请求的304命中 | 累计回传字节 | 什么都不注入 | 100 / 100 | 0 | 一个CSP用的nonce | 0 / 100 | 26200 | 列表顺序随机 | 0 / 100 | 21200 | 一个12字节的随机数,把条件请求的收益从100%清零到0%。不是打了折,是整个机制彻底失效。 让这件事变得棘手的是:那个nonce并不是谁写错了,它是按规范做对了的结果。MDN在讲CSP的script-src时写得很直白: > "It is important to note, this nonce value needs to be dynamically generated as it has to be unique for each HTTP request" 规范要求每次请求都换一个新的nonce,否则这道防线就没意义了。于是你面对的是一个真实的取舍:要么这个页面的内联脚本有CSP保护,要么这个页面能享受条件请求的抓取预算折扣,两个不能同时要——除非你把ETag的算法改成只对“内容部分”取哈希,而不是对整份HTML。 现网的情况怎么样?在230个URL里: - 带ETag响应头的81个,占35.2%;带Last-Modified的66个,占28.7%; - 带ETag的那批里,弱ETag(W/开头)占46.9%; - 三次拿到的ETag值本身就各不相同的有15个,占带ETag的18.5%——这些页面上的条件请求,必然一次都命中不了。 把这三个数连起来读:三分之一的页面根本没给爬虫任何验证器;给了的里面又有接近五分之一,给的是一个每次都变的值。后者比前者更浪费——不发ETag只是没省下,发一个每次都变的ETag,是让爬虫每次多发一个请求头、还是全量重传。 ## 弱ETag为什么没能救场 看到46.9%是弱ETag的时候,我一度以为这是个好消息。弱验证器(W/前缀)在规范里表达的是“语义等价”而不是“字节完全相同”,理论上它就是为这种场景设计的:埋点时间戳变了,但这个页面对读者来说还是同一个页面,那就该返回同一个弱ETag。 实际上没有。这批带弱ETag的页面里,仍有相当比例三次拿到的值各不相同。原因不难猜——绝大多数框架和中间件的“弱ETag”实现,只是在原来的哈希前面加了两个字符W/,哈希本身照旧对整份响应体算。前缀改了,语义没改。 要真正用好弱验证器,应用得自己回答一个问题:“这次响应和上次比,算不算实质变化?”这个判断没法通用化,只能由业务来做:把内容主体单独取哈希、把埋点和令牌排除在外,或者干脆用内容表里的modified字段生成验证器。后一条路最省事,也顺带解决了更新时间戳该怎么诚实地维护 (https://zhangwenbao.com/modified-timestamp-truthful-update-fake-refresh-signal-mechanism.html)那个老问题。 ## 那份爬虫版和用户版不一样的报告,有多少是这40.9%? 现在回到开头那件事。我一开始想量的是Cookie:站点在首访就下发Cookie,爬虫从不回传,那么“带Cookie”和“不带Cookie”拿到的页面差多少? 第一轮我用最直接的办法测:建一个会话,请求一次,把服务器下发的Cookie收下,隔一秒用同一个会话再请求一次,比对两次的字节。40个站里39个可用,29个两次响应不同,占74.4%。 这个数字很漂亮,也很有故事性——“四分之三的站给爬虫和用户的不是同一个页面”。我当时确实准备把它写进结论。 补对照组花了不到二十分钟:同样的URL,再开两个全新的、不带任何Cookie的会话各请求一次。如果这两次之间也不同,那第一轮那个74.4%就跟Cookie没关系。 对照组的结果是68.2%。 换句话说,我原以为测到的是Cookie效应,实际上测到的绝大部分是页面自己在抖。等到用230个URL重做一遍、并且只在三次基线完全一致的136个稳定URL上计算之后,真实的数字是这样: 改变的东西 | 稳定样本数 | 响应不同 | 占比 | 第二次请求带上服务器下发的Cookie | 136 | 3 | 2.2% | Accept-Language换成zh-CN | 136 | 15 | 11.0% | Accept-Language换成de-DE | 136 | 14 | 10.3% | 完全不发Accept-Language | 136 | 7 | 5.1% | 换成Googlebot的User-Agent | 124 | 14 | 11.3% | 74.4%和2.2%,差了33倍。中间隔的就是一组花二十分钟补上的对照。 ## 这组对照该怎么设计 把这次的教训抽象一下,对照组的设计其实只有一条规则:让对照组和实验组之间,只差你想测的那一个变量。听起来像废话,可具体到HTTP请求上,“只差一个变量”比想象中难做到。 我第一轮的实验组是“同一个会话的第二次请求”,对照组本该是“另一个新会话的第一次请求”。但这两者之间差的不只是Cookie——还差了时间(第二次晚了一秒)、差了连接复用状态、差了服务端可能已经把这个页面预热进缓存。任何一个都足以造成字节差异。 所以正确的做法是把对照组也做成一对:两个全新会话、都不带Cookie、间隔和实验组一样。这一对之间的差异率就是噪声地板,实验组的差异率减去它,才是Cookie的净效应。这跟SEO实验设计里的单因素隔离 (https://zhangwenbao.com/seo-ab-testing-experiment-design-statistical-power-single-factor.html)是同一套东西,只不过大家做A/B测试时都记得,做技术巡检时都忘了。 还有一个更省事的版本,也是我最后采用的:先把所有基线不稳定的URL整个剔除,只在稳定样本上做实验。这样噪声地板直接归零,代价是样本量从230掉到136。样本够的时候这个办法最干净。 这件事对市面上的对比类工具意味着什么,我觉得得说清楚,因为它不是“工具不行”这么简单。渲染对比器这类工具 (https://zhangwenbao.com/render-compare-bot-user-cloaking-detection-guide.html)做的事——抓Googlebot版和浏览器版,逐维度比对,按差异扣分——方法本身没有问题,它查出来的cloaking也确实是真的。问题出在它默认每一维只需要各取一个样本。在一个40.9%的页面会自己跟自己对不上的世界里,单样本对比的结论,误差比信号大。 同样的道理也适用于任何一个“快照告警”系统:SEO监控告警体系 (https://zhangwenbao.com/seo-monitoring-alerting-regression-detection-system.html)里最常见的一条规则就是“页面内容变化超过X%就报警”,而这个X如果是拍脑袋定的,你要么天天收到广告脚本换了个时间戳的告警,要么把阈值调到高得连真事故都漏掉。这跟慢查询日志里783MB只有32条是真问题 (https://zhangwenbao.com/mysql-slow-query-log-signal-noise-ttfb-crawl-budget.html)是同一类毛病:不先量噪声,就定不出阈值。 顺便说一句,这个坑不只在HTTP层。AI可见度排名同一个问题查多次名次都不一样 (https://zhangwenbao.com/ai-visibility-ranking-statistical-noise.html)、关键词排名天天上下跳 (https://zhangwenbao.com/ranking-fluctuation-normal-vs-real-drop.html),讲的都是同一件事的不同层:先确认你要测的量有没有一个稳定的值,再谈它变了多少。 ## 怎么给自己的站量一条基线? 方法不复杂,成本也低。保哥把它压成了五步,跑一次十分钟,结论能用很久。 ## 第一步:选URL,别只测首页 至少三类各取几个:首页、一个典型的列表或分类页、一个典型的详情页(文章或商品)。我这批数据里首页44.4%、深层页36.8%,两者差了将近8个百分点,只测首页会高估自己的问题。 ## 第二步:三次请求,而且要拉开间隔 同一套请求头连发两次,再隔至少3到5秒发第三次。前两次抓的是“每次都变”的那类(nonce、请求ID),第三次抓的是“每秒都变”的那类(时间戳、缓存刷新)。只发两次、且发得很紧,会同时漏掉后一类。 ## 第三步:算两枚指纹 一枚是原始字节的SHA-1,一枚是清洗后的。清洗的正则不用写得多讲究,覆盖这几类就够了:UUID格式、连续32位以上的十六进制、10到13位纯数字、nonce=后面的引号串、token/csrf/requestId这类赋值、ISO 8601时间串。哈希指纹的算法选择 (https://zhangwenbao.com/hash-generator-md5-sha256-etag-sri-fingerprint-guide.html)在这里不重要,SHA-1够用,你只是在比对,不是在防篡改。 ## 第四步:不一致的时候,做行级差异,别看百分比 两枚指纹只告诉你“变没变”,逐行逐词的差异比对 (https://zhangwenbao.com/text-diff-lcs-line-word-comparison-guide.html)才告诉你“变在哪”。重点看第一处差异落在之前还是之后——落在之前,说明抖动碰到了SEO标签所在的区域,值得深挖;落在之后而且只有两三行,大概率是个埋点脚本。 ## 第五步:把这个数字写进你所有对比结论的前面 这一步没有技术含量,但它是全部五步里最有价值的一步。以后任何一份差异报告,抬头先写一句“本站基线抖动率X%,本次差异Y%”。Y要显著大于X,这份报告才配叫报告。 我拿自己的站跑了一遍这套流程,结果是这样: URL | 三次指纹 | 是否一致 | ETag | Set-Cookie | Vary | 首页 | b9711033b7b9 ×3 | 一致 | 无 | 无 | Accept-Encoding | 术语表页 | 71a2f1a07840 ×3 | 一致 | 无 | 无 | Accept-Encoding | 工具站页 | ac92ffddc219 ×3 | 一致 | 无 | 无 | Accept-Encoding | 三个页面各三次全部一致,换Accept-Language为zh-CN、de-DE,以及换成Googlebot的UA,拿到的仍然是同一枚指纹。这不是我优化出来的,是这个站根本没有广告脚本、没有推荐位、没有A/B分桶的自然结果。一个不做个性化的站在这件事上是天然干净的,代价是它也没有那些东西带来的收益——这笔账每个站自己算。 顺带一提,这三个页面都没有ETag。按上一节的账,这是个可以捡的便宜:内容稳定却不发验证器,等于把条件请求这项白送的优惠券扔了。这条已经进了保哥的待办。 ## 哪些抖动该修,哪些不必修 把抖动全部消灭既不可能也不划算。真正要判断的是:这段抖动有没有落在会被搜索引擎当成“内容”的字节上。按这个标准分三档: 档位 | 典型来源 | 处理建议 | 该修 | head里的title、description、canonical、hreflang、结构化数据随请求变化 | 必须查。这类抖动会让规范网址判定和富媒体结果不稳定,属于真事故 | 该修 | 正文主体内容按分桶给出不同版本,且没有任何标注 | 按爬虫与用户看到的页面对比 (https://zhangwenbao.com/render-compare-bot-user-cloaking-detection-guide.html)那套口径自查,接近cloaking的红线 | 值得改 | ETag按整份HTML算,被nonce、请求ID带得每次都变 | 改成只对内容部分取哈希,或者干脆改用Last-Modified,把条件请求的收益捡回来 | 值得改 | 推荐位、相关文章每次刷新都换一批 | 加个短TTL让它在几分钟内稳定,对用户体验没损失,对缓存和验证器 (https://zhangwenbao.com/http-browser-cache-control-etag-expires-cache-headers.html)友好得多 | 不必修 | CSP的nonce | 规范要求它每次都变。别为了指纹稳定去牺牲安全,去改ETag的算法 | 不必修 | 埋点脚本里的毫秒时间戳、请求ID | 它们不进索引,也不影响内容判定,记住它们的存在、在对比时剥掉就行 | 不必修 | 库存数字、评论条数、“N人正在看” | 这就是内容本身在变,是对的。只要别让它出现在title里 | 最后回到这篇文章的起点。做技术SEO这些年,保哥见过太多的排查是从一份“两版不一致”的截图开始的,然后一路查到CDN、查到WAF、查到UA判别,最后发现两版差的是一个埋点脚本里的时间戳。同一个页面有11种URL写法都返回200 (https://zhangwenbao.com/url-path-normalization-case-slash-duplicate-seo.html)那篇里我数过一个页面有几个地址,这篇数的是同一个地址下有几份内容。两个问题的答案都是同一句话:你以为的“一个”,实际上从来都是“一批”。 差别只在于,地址那件事你至少能一条条列出来,内容这件事你连“正确版本”是哪一份都指不出来。所以顺序只能是先量噪声、再谈差异——反过来做,你得到的每一个结论都建立在一次运气上。 ## 常见问题解答 ## 页面抖动会直接影响排名吗? 抖动本身不是排名因素,Google没有任何一条规则是针对“响应字节不稳定”的。它的影响是间接的,走两条路:一是让强ETag每次都变,条件请求全部落空,白白多花抓取预算;二是如果抖动落在head里,title、canonical、结构化数据在不同抓取批次拿到不同的值,规范网址判定和富媒体结果就会不稳定。正文里几个数字或者推荐位换一批,基本没有影响。 ## 40.9%这个数字能不能直接套到我的站上? 不能,这是一个参考量级不是一个基准值。样本是230个电商、SaaS和开发者站的URL,这类站上广告脚本、A/B分桶、个性化推荐特别密集。一个纯内容站的抖动率通常低得多——我自己这个站三类页面各测三次全部一致。这个数字的用法是提醒你自己去测一遍,而不是拿来当自己的数。 ## 用无头浏览器渲染后再比对,是不是就能绕过这个问题? 不能,反而更糟。渲染会把JavaScript执行的结果也算进来,广告位、推荐接口、实验分桶的返回全都进入DOM,抖动幅度只会更大。渲染后对比适合查“内容有没有被JS填进来”,不适合查“两版一不一样”。要查一致性,用原始HTML加两级指纹更可控。 ## 为什么剥掉随机串之后还有34.3%在变? 因为大头不是随机串,是内容本身。推荐位换了一批商品、榜单重新排了序、库存数字动了、A/B实验给了另一个变体,这些东西长得跟正常内容一模一样,任何正则都摘不出来。这也是为什么“清洗一遍再对比”这条路走不通——真正在变的那部分,恰恰是你想拿来对比的那部分。 ## 只发两次请求真的会漏掉很多吗? 漏掉的比例不算大但方向很明确。头两次一样、第三次才不同的占2.2%;另有8.5%的URL重抓时干脆不抖了。更关键的是间隔:秒级时间戳类的抖动在同一秒内连发两次完全看不出来,而这类抖动占了43.0%。所以真正的建议不是“多发一次”,是“第三次要故意等几秒”。 ## 抖动和cloaking是一回事吗? 不是,而且必须分清楚。cloaking是有意针对爬虫身份返回不同内容,是违反Google垃圾内容政策的作弊行为。抖动是页面对所有人都不稳定,跟你是谁没关系。判别方法很简单:先测三次基线,如果同一个身份连测三次就已经不一致,那么“爬虫版和用户版不同”这个观察不构成任何证据;只有在基线稳定的页面上,身份造成的差异才是真的。 ## ETag改成弱验证器能不能解决这个问题? 能解决一部分。弱ETag(W/开头)表达的是“语义等价”而不是“字节相同”,理论上允许你在只有埋点时间戳变了的情况下仍然返回同一个值。但这要求应用自己有能力判断“这次的变化算不算实质变化”,多数框架的实现只是简单加个W/前缀、哈希照旧算全文,那就没有任何区别。实测230个URL里带ETag的有46.9%是弱ETag,但其中仍有相当比例三次值各不相同。 ## 权威参考资料 ## 日志里每5次Googlebot抓取,就有1次IP不属于谷歌 - URL:https://zhangwenbao.com/server-log-collection-structured-searchable.html - 分类:监控与日志 - 发布:2026-07-14 | 更新:2026-07-31 - 摘要:一份跑了43天的真实日志体检:为什么查不动、每处卡壳对应哪个配置决定、六条改法怎么排优先级,以及一次把自己坑了的测量复盘。 - 关键词:Googlebot,服务器运维,日志分析 > **TLDR**:摘要:保哥拿自己那台服务器上跑了43天的日志做了一轮体检,结果比预想的难看:访问日志482MB共208万行,而错误日志2.7GB共185万行——行数少了11%,体积却是前者的5.7倍,因为一行里平均塞了5.94条报错。全量数一遍是1097万条报错消息,其中真正需要动手修的那类只有50行。同一份访问日志,数404的四种写法给出两个不同答案;同一台机器上的四份日志,用了四种互不相同的时间格式,其中一份还是另一个时区。最后一个数字最扎眼:日志里声称自己是搜索引擎爬虫的请求有97308条,拿官方公布的IP段逐条比对之后,21.7%的IP根本不属于它,而这两万多次伪装抓取里有20550次拿到了正常的200响应。本文给出把日志从能存变成能查的完整改法,以及一次把自己坑了的测量事故复盘。 > 摘要:保哥拿自己那台服务器上跑了43天的日志做了一轮体检,结果比预想的难看:访问日志482MB共208万行,而错误日志2.7GB共185万行——行数少了11%,体积却是前者的5.7倍,因为一行里平均塞了5.94条报错。全量数一遍是1097万条报错消息,其中真正需要动手修的那类只有50行。同一份访问日志,数404的四种写法给出两个不同答案;同一台机器上的四份日志,用了四种互不相同的时间格式,其中一份还是另一个时区。最后一个数字最扎眼:日志里声称自己是搜索引擎爬虫的请求有97308条,拿官方公布的IP段逐条比对之后,21.7%的IP根本不属于它,而这两万多次伪装抓取里有20550次拿到了正常的200响应。本文给出把日志从能存变成能查的完整改法,以及一次把自己坑了的测量事故复盘。 先交代背景。这台机器上跑着好几个站,日志按站分文件,从6月19日一路长到7月31日没被切过。保哥原本只想看看某个页面被抓了多少次,结果一头撞进了下面这一串问题。 需要先说明的是,下面这些数字全部来自一台正在对外提供服务的机器,不是实验环境造出来的样本。所以它们不好看——真实环境本来就不好看。如果你手上那台机器从来没被这么翻过,大概率能对号入座其中的大半条。 这篇不讲怎么搭一套日志系统——那类文章网上有的是,装几个组件、贴几段配置。这篇讲的是为什么你现在这份日志查不动,以及每一处查不动分别是哪个决定造成的。 ## 2.7GB的错误日志里,真正的错误有几行? 先看体积。同一个站的两份日志: 文件 | 体积 | 行数 | 平均每行 | 访问日志 | 482812001字节 | 2084907 | 231字节 | 错误日志 | 2756279530字节 | 1849063 | 1490字节 | 错误日志的行数比访问日志少11%,体积却是它的5.7倍。单行平均长度差6.4倍。 为什么一行能有1490字节?抽5万行数了一下每行里出现了几条报错: 一行里的报错条数 | 行数 | 0条 | 114 | 1到5条 | 1123 | 6条 | 48747 | 12条以上 | 16 | 5万行里有48747行整整齐齐都是6条。这是因为处理器把一个请求周期内产生的所有报错攒起来,通过一个通道一次性交给服务器,服务器把它们写成了一行。全量数下来,185万行里装着10976226条报错消息。 然后是信噪比。把报错按模板归类,头部长这样: 报错模板 | 出现次数 | 某字符串函数收到空值(已废弃写法) | 1187496 | 未定义的数组键 | 3712 | 另外两个函数收到空值 | 各1856 | 正则表达式修饰符无法识别 | 119 | 注意最后一行。前面那些是废弃写法的提示,属于代码风格问题,不影响功能;而最后那条是真的会让功能出错——正则没编译成功,那次匹配就是空的。全量扫一遍,含这条报错的行只有50行。 1097万条消息里,50行是需要立刻处理的。比例是二十一万分之一。同一台机器上的数据库慢日志也是这个形态:931MB的文件,里面绝大多数记录的执行时间都在几毫秒级别。两次撞上同一个规律:日志的体积和它的信息量几乎不相关。 顺便说说磁盘。整个日志目录4904153052字节,而根分区已用7260790784字节——已用空间的67.5%是日志。而且检查了一遍系统的日志轮转配置目录,里面根本没有管这些文件的规则,所以它们从6月19日起就一直在长。本站在日志管理那篇 (https://zhangwenbao.com/linux-server-log-management-logrotate-journald-analysis-alerting.html)里写过怎么配轮转,这台机器算是活教材:轮转工具装了,配置文件也在,就是没人给这个目录写一条规则 (https://man7.org/linux/man-pages/man8/logrotate.8.html)。 ## 那1GB躺了40天没人看的文件是什么? 体检还翻出一件更尴尬的事。按体积排,这台机器上最大的三个日志文件是: 文件 | 体积 | 最后写入 | 本站错误日志 | 2756279530字节 | 当天 | 一个通用错误日志 | 1060204220字节 | 40天前 | 本站访问日志 | 482817648字节 | 当天 | 中间那个1GB的文件,最后一次写入是40天前,之后再没动过——某次配置调整把输出改到别处了,而这1GB就此留在原地。它不产生任何价值,只是每天参与一次磁盘占用统计。 这类文件在服务器上比想象中常见。它们的共同特征是:曾经有用、后来配置改了、没人记得删。查磁盘占用的时候看见一个眼熟的名字,第一反应还以为它在正常工作。排查磁盘和负载 (https://zhangwenbao.com/linux-server-performance-troubleshooting-high-load-cpu-memory-disk-io-diagnosis.html)时值得顺手加一步:按最后修改时间排一遍,凡是超过一个月没写入还占着大空间的,先确认它是不是已经被遗弃了。 ## 为什么同一份日志,两种数法答案不一样? 接下来是查询本身。最简单的问题:这份日志里有多少个404? 四种常见写法,跑出来是这样: 写法 | 结果 | 按空格切,取第9段等于404 | 65372 | 直接搜前后带空格的404 | 65384 | 正则锚到请求行之后 | 65372 | 按引号切出第3段再取第1个数 | 65372 | 三种一致,一种多了12。多出来的那12行是什么?捞出来看了眼,答案挺有意思—— > 那是有人在做注入探测,把一段构造好的字符串放进了来源地址里,而那段字符串里恰好含有前后带空格的404。 也就是说,攻击者构造的字符串被当成了状态码统计进去。这个误差只有万分之二,看起来无所谓,但它揭示的问题是结构性的:纯文本搜索没有字段概念,它不知道哪一段是状态码、哪一段是别人塞进来的内容。 更麻烦的是按位置取字段这条路也不牢靠。数了一下每行按空格切出来有多少段: > 取值一共36种,从10段一直到47段都有。 原因是来源地址和客户端标识里可以含空格,含几个由对方决定。默认的组合格式确实用引号把这两个字段括起来了,但按空格切的时候引号不起作用。所以那个第9段有时是状态码,有时是别的东西——只不过在这份日志里,含空格的字段恰好都排在状态码后面,才让答案碰巧对了。换个日志格式,把客户端标识挪到前面,这个写法就会全线崩掉。 结论很直接:纯文本日志里没有字段,只有看起来像字段的位置。所有基于位置的解析都是在赌对方不往里塞空格,而对方恰恰是最有动机塞空格的那个人。 ## 一台机器上的四份日志,为什么时间格式全不同? 这一条是保哥觉得最离谱的。同一台机器、同一套软件栈,四份日志的时间戳: 来源 | 样例 | 排列顺序 | 访问日志 | 31/Jul/2026:19:10:36 +0800 | 日、月、年 | 错误日志 | 2026/07/31 18:58:13 | 年、月、日 | 数据库慢日志 | 2026-06-20T02:33:47.394027Z | 年、月、日,而且是零时区 | 处理器慢日志 | 02-Jul-2026 18:04:14 | 日、月、年,分隔符又不一样 | 四种格式,三种字段顺序,两种时区。想把同一分钟内这四份日志并排看,第一步得写四个解析规则,第二步还得给第三份做八小时的时区换算。 这不是谁偷懒,是每个软件各自沿用了自己的历史习惯。但代价是真实的:关联分析的第一道门槛不是技术,是时间对不齐。 ## 为什么日、月、年的排法会让排序失效? 这里有个后果比看起来严重。关于时间格式,有一份规范专门讨论过排列顺序的价值,原文写的是:"If date and time components are ordered from least precise to most precise, then a useful property is achieved." (https://www.rfc-editor.org/rfc/rfc3339)——如果按从粗到细排列,就能得到一个有用的性质。紧接着那句给出了性质本身:这样的时间串可以直接当字符串排序,结果就是时间顺序。 访问日志那个格式正好是反的,最细的日排在最前,最粗的年排在最后。后果就在这份日志里直接撞上了:这43天跨了6月和7月两个月,把月份字段取出来去重排序,得到的顺序是—— > Jul,然后是Jun。 字典序里字母l排在n前面,所以七月被排到了六月前面。这份日志里恰好只有两个月份,而这两个月份的字典序和时间序正好是反的。要是有人拿排序去做时间范围筛选,第一条就错了。 而同一台机器上的数据库慢日志用的正是那份规范里推荐的格式,年在最前、带零时区标记。同一台机器上,一份日志遵守了这条规范,另一份没有。 ## 取最近十分钟的日志有多难? 顺着上一条往下试。要从访问日志里取最近十分钟的记录,纯文本能做到什么程度? 因为格式是日在前,而且没法当字符串比较大小,实际能用的只有前缀匹配。匹配到小时这一级还行,匹配到分钟就得把这十分钟的每一分钟都列出来当或条件。实测按小时前缀粗筛,拿回来177行,而想要的是其中一个子集——你要十分钟,只能拿到整小时,剩下的靠自己再过滤一遍。 跨小时的时候更难看:19点55分到20点05分这十分钟,前缀分成两段;跨零点跨月跨年各有各的写法。这类活写一次能忍,天天写就该换方案了。 ## 全文搜索到底慢在哪,结构化又快在哪? 把耗时量出来才好谈方案。482MB、208万行的访问日志上,几个典型查询: 查询 | 耗时 | 结果 | 搜一个爬虫标识出现多少行 | 645毫秒 | 97521 | 搜另一个爬虫标识 | 778毫秒 | 22580 | 按字段筛状态码等于404 | 1973毫秒 | 65372 | 求404最多的三个路径 | 2416毫秒 | — | 按状态码分组统计 | 7121毫秒 | 21种取值 | 单纯搜一个词并不慢,六百多毫秒。贵的是分组统计:7121毫秒,因为它要把208万行的第9段全部取出来、排序、再合并计数。而这恰恰是分析日志时最常做的动作。 2.7GB的错误日志上跑一次全文搜索是1933毫秒,数一遍报错总条数7203毫秒。都还能忍,但注意这是单次——每换一个问题就要重新扫一遍全文,成本不会因为你刚才扫过而变便宜。 然后是结构化的对照。把同一份日志按字段解析后导入一个带索引的本地库: 一次性成本 | 耗时 | 按引号切分解析成表格文本 | 26651毫秒 | 导入 | 7428毫秒 | 建两个索引 | 4168毫秒 | 合计 | 38247毫秒 | 接近38秒,比任何一次全文搜索都贵得多。但导完之后: 同样的查询 | 纯文本 | 结构化 | 快多少 | 状态码等于404的行数 | 1973毫秒 | 6毫秒 | 329倍 | 按状态码分组统计 | 7121毫秒 | 124毫秒 | 57倍 | 404最多的三个路径 | 2416毫秒 | 102毫秒 | 24倍 | 某爬虫撞404的次数 | 基本写不出来 | 50毫秒 | — | 体积方面也没有想象中夸张:原始日志482MB,导入后的库431MB,比原文还小(因为丢掉了重复的分隔符和固定文本),加两个索引之后涨到533MB,增幅23.7%。 ## 从哪一次查询开始,导入的成本才回本? 这是个能算出准数的问题。一次性成本38247毫秒,之后每做一次分组统计省下7121减124等于6997毫秒。 > 38247除以6997约等于5.5。也就是说,只要你打算问超过6个问题,导入就是划算的。 而真实排查从来不止6个问题。查一个抓取异常,通常是这样一串:先看总量,再按状态码分组,再挑出404看路径,再看这些路径的来源,再看时间分布,再对比上周同期……六个问题只是起步。 这个算法也给出了反向判据:如果你只想查一件事、查完就走,那就别导。搜一个词六百多毫秒,比等38秒快多了。日常那种"看看某个页面今天被抓了几次"的需求,直接搜就好。 划分很清楚:一次性的问题用搜索,成组的问题用结构化。这条判断不需要任何工具支持,靠脑子里那个5.5就够。 ## 结构化之后能问出什么原来问不出的问题? 速度只是表象,真正的差别是问题的形状。举三个实例,都是这轮实测里真跑出来的。 ## 状态码的全景 状态码 | 次数 | 200 | 1872618 | 301 | 75317 | 404 | 65372 | 403 | 35742 | 444 | 9749 | 302 | 5227 | 124毫秒出来的。其中那个444值得单说,它不是标准状态码,是服务端软件自己定义的一个特殊值,意思是直接关掉连接不给任何响应——通常是被拦截规则命中的请求。9749次意味着拦截规则确实在工作,这个数字只有在按字段分组之后才看得到。 ## 404到底是谁在撞 404最多的三个路径里,头一名是一张图片,206次。接着往下问:谁在请求它? 结果里201次的来源是空的,但有2次带着来源,指向本站的某一篇文章;另有1次来自某图片搜索。这两次带来源的记录直接告诉了保哥答案——那篇文章里引用的图片挂了。 这是纯文本给不了的。搜索能告诉你有206次404,但它不会替你把这206行里那2行带来源的挑出来。而恰恰是这2行有定位价值,剩下204行只是噪声。结构化的价值不在于快,在于让稀有信号浮上来。本站在日志分析工具那篇 (https://zhangwenbao.com/log-analyzer-crawl-budget-googlebot-guide.html)里做的也是同一件事,只是那次用的是现成工具。 ## 爬虫拿到了什么 状态码 | 次数 | 200 | 32286 | 301 | 486 | 404 | 280 | 500 | 24 | 410 | 16 | 403 | 4 | 503 | 2 | 50毫秒。这张表对做搜索的人来说信息密度极高:280次撞404说明站内还有死链在被抓,24次500说明有页面在爬虫访问时报了错,2次503说明有那么两个瞬间服务没响应过来。 而这个问题用纯文本几乎写不出来——它需要同时按两个字段过滤,一个在行尾附近,一个在中间,还都可能因为空格而错位。 顺便对比一下另一个爬虫的量:某外链分析工具的爬虫在同一时段抓了22580次,是搜索引擎爬虫的四分之一左右。这类数字平时没人看,但它回答了一个很实际的问题——你的服务器有多少资源花在了对流量没有贡献的抓取上。要不要限速、限到多少,得先有这个数才谈得下去。 ## 那两万次假冒抓取,是我自己量错的吗? 这一节是本文最值得写的一段,因为保哥先把自己坑了一次。 最初的问法是:日志里搜到爬虫标识的有97521行,但按字段限定之后只有33101行,差了2.9倍。当时的第一反应是发现了一个大问题——两万多行里那个标识出现在别的字段里。 结果是自己造的。解析的时候为了省内存,把客户端标识截到了120字符。而移动版爬虫的标识串很长,那个关键词出现在一百五十多字符的位置,全被截掉了。 去掉截断重新跑,真值是这样的: 统计口径 | 行数 | 整行含该标识 | 97524 | 标识字段含该标识 | 97292 | 来源字段含该标识 | 8 | 请求路径里含该标识 | 244 | 真实差距只有232行,占0.24%,而不是2.9倍。更好玩的是,被截断那版数出来的33101,几乎正好等于桌面版爬虫的真实数量33105——因为桌面版的标识串只有72个字符,关键词在第26位,没被截到。 > 这个教训值一整段:当测量工具和被测对象共用同一个假设时,测出来的偏差会伪装成发现。如果没回头验一遍,这篇文章里就会多出一条错误结论,而且它看起来还挺像回事。 ## 去掉自己制造的噪声之后,真实数字是多少 回到正题。搜索引擎官方明确提示过,客户端标识是可以随便写的,给出的验证办法 (https://developers.google.com/search/docs/crawling-indexing/verifying-googlebot)有两条:一条是反查域名再正查回来对比,另一条是拿官方公布的地址段清单比对。保哥用的是第二条——把官方那两份清单下载下来,一共304个网段,逐条做地址段匹配。 用的清单文件生成时间是2026年7月30日,也就是跑这轮实测的前一天。结果: 口径 | 行数 | 占比 | 标识字段声称是该爬虫 | 97308 | 100% | 地址落在官方段内 | 76225 | 78.3% | 地址不在官方段内 | 21083 | 21.7% | 每五次自称是它的抓取,就有一次不是。 真假两边的分布形态完全不同,这一点比比例本身更有说服力: - 真的那批高度集中:排前三的地址加起来占了98.5%,头一个地址就占52%。 - 假的那批高度分散:一共10464个不同地址,最多的那个也才1411次。 再看假的那批在抓什么,答案就更清楚了:排第一的路径被请求了9846次,那是一篇讲某个内容管理系统注入漏洞修复的文章。披着爬虫的外衣,去找漏洞相关的页面,意图不用猜。而且这些请求里有20550次拿到了正常的200响应——也就是说,本站现有的拦截规则基本没拦住它们。 还有一条自曝:假冒名单里出现了这台服务器自己的地址,25次。多半是某个本机脚本顺手带了这个标识去请求自己的站,属于自己伪装自己,无害但很滑稽。 识别办法本站在拦爬虫怎么不误伤那篇 (https://zhangwenbao.com/nginx-ai-bot-blocking-rate-limit-rdns-misblock-account.html)里写过,这次算是给那套方法补了一组真实数据。各类智能体爬虫的识别 (https://zhangwenbao.com/ai-agent-crawler-log-decoding-8-ua-traffic-attribution.html)本站也有专门一篇。 ## 为什么八成的来源字段是空的? 还有一个数字值得放在这里,因为它决定了你能不能追踪流量来路:208万行里,来源字段为空的有1794441行,占86.1%;客户端标识为空的有12368行。 八成六听起来吓人,但这是正常的。直接输地址访问、从加密页面跳到本站、以及大量爬虫都不带来源。真正的问题不是比例高,是很多人不知道这个比例,然后拿剩下那14%去做归因,还以为覆盖了全站。 这也解释了前面那张404图片的例子为什么难得:206次请求里只有2次带来源,占不到1%。能定位问题的那两行,恰好落在最稀有的那个分组里——用纯文本翻,很可能翻到第五十行就放弃了。 顺带一提,队列那套东西在这里也用得上。如果你的应用要把日志实时送到别处,中间挂一个队列比直接写远端可靠得多——保证消息不丢不重那篇 (https://zhangwenbao.com/message-queue-delivery-guarantee-retry-idempotent.html)讲的取出转存、幂等、死信这三件事,换到日志投递场景是完全一样的。日志量大、允许短暂积压、丢了很难补,正好符合该上队列的判据。 ## 日志该怎么收才不用回头返工? 把上面每一处坑对应到一个改法,就是这张表: 问题 | 改法 | 动哪里 | 字段数不定、按位置取会错 | 直接输出结构化格式 | 日志格式配置 | 时间格式排序失效 | 换成年在最前带时区的格式 | 日志格式配置 | 没有耗时字段,看不出哪页慢 | 加上请求耗时和后端耗时 | 日志格式配置 | 多份日志关联不上 | 加一个请求标识,各处都带 | 服务端与应用 | 一行塞六条报错 | 让应用自己写结构化错误日志 | 应用层 | 体积失控、没有轮转 | 补一条轮转规则 | 系统配置 | 前三条最省力,因为它们只改一处配置。服务端软件自带一个转义参数 (https://nginx.org/en/docs/http/ngx_http_log_module.html),把它打开之后所有不合法字符会被转义,输出的每一行就是一个合法的结构化对象。不用装任何额外组件,改一行配置,字段错位问题当场消失。 加请求标识那条是收益最大的。四份日志时间格式各异这件事,本质上是没有一个共同的连接点;给每个请求生成一个唯一串,服务端日志、应用日志、慢查询记录里都带上它,关联就从对时间戳变成了搜一个串。这是整套改造里最值得先做的一步。 字段该有哪些,不用自己拍。有一份被广泛采用的通用字段规范,它的说法是定义一组公共字段,目的是让来自不同来源的事件数据能够被一起分析和关联 (https://www.elastic.co/docs/reference/ecs)。照着它的命名走,将来换任何一套分析工具都不用重新映射。 ## 要不要上一整套采集分析栈? 这是个很实际的问题。市面上标准答案是采集器加处理器加存储加界面,四个组件起步,中间可能还要加一层缓冲。 保哥的判断是先看你有几台机器。单机的话,把格式改对、加上轮转,需要分析时导进一个本地库——就是本文实测那套,38秒一次,够用得很。多机才真正需要集中收集,因为那时候的痛点变成了同一个请求的痕迹散在几台机器上,人肉登录挨个查是不现实的。 如果决定要上,有个次序问题值得提醒:先把格式改对再上采集,不要反过来。采集组件本身有很强的解析能力,能把非结构化的行拆成字段,于是很多人就懒得改格式了,直接靠采集端的规则去解析。这一步走错,后面每加一台机器、每换一种日志,都要再写一套解析规则,而且规则写错了不会报错——它会安安静静地把字段拆歪,让你在错误的数据上做分析。 把源头改成结构化只要一行配置,采集端拿到的就是现成字段,一条解析规则都不用写。一行配置和一堆规则之间,选前者。 判据可以更简单:当你需要同时打开两台以上机器的日志才能回答一个问题时,就该集中了。在那之前,格式和轮转做对,收益已经拿到八成。而且格式这件事越早做越好——改配置只影响之后的日志,已经写下去的那482MB是没法追溯改格式的。 ## 这些数字对独立站的抓取预算意味着什么? 把视角切回搜索。前面那张爬虫状态码表里的三个数值得单拎出来:280次404、24次500、2次503。 官方文档里对抓取预算的说明提到过,服务端持续返回错误会让抓取速度下降。大站抓取预算管理那份文档 (https://developers.google.com/search/docs/crawling-indexing/large-site-managing-crawl-budget)把这个机制讲得很细,核心是抓取速度会根据站点的响应情况自动调整。所以那24次500不只是24个页面没被抓到,它同时在往下压整个站的抓取速度。 而这三个数在纯文本日志里基本查不出来,因为它需要同时按标识字段和状态码字段过滤。查不出来,就等于不存在——很多站的抓取问题不是没发生,是没人有能力看见。本站关于从日志里挖爬虫行为 (https://zhangwenbao.com/seo-log-file-analysis-guide.html)写过完整的方法,前提都是你的日志得先能按字段查。 反过来也成立:那486次301同样值得看一眼。爬虫每撞一次跳转,就要多发一次请求才能拿到真正的内容,这部分开销直接从预算里扣。数量不大,但如果某天这个数突然涨起来,通常意味着有一批链接指向了旧地址。这个信号只有在按爬虫加状态码两个维度分组之后才浮得出来,而这正是前面那张表花50毫秒就能生成的东西。 还有那两万次伪装抓取。它们对搜索本身不直接产生影响,但会污染你的数据:如果你用日志统计爬虫抓取量,那这两万次会被算进去,让你以为抓取覆盖比实际好得多。21.7%的虚高,足以让一份抓取报告得出相反的结论。 最后一个容易被忽略的点:默认格式里没有耗时字段。这意味着你没法从日志回答哪个页面响应最慢这个问题,而响应速度直接关系到同样时间里能被抓走多少页。日志格式该怎么配 (https://zhangwenbao.com/apache-customlog-logformat-access-log-configuration-remoteip.html)本站写过一篇,那里面列的字段清单可以直接照抄,其中耗时那两个字段是本文最想强调的。加它的成本是改一行配置,不加的成本是这个问题永远查不了。 ## 常见问题解答 问:改成结构化格式,会不会让日志变大很多? 会大一些,因为每行都要重复字段名。按本文这份日志估算,结构化之后单行大概涨三到五成。但这个代价换来的是不用再写解析规则,而且体积问题该由轮转和压缩解决,不该靠牺牲可读性来省。顺带说,服务端软件的日志指令本身就支持带压缩写入,开了之后落盘的是压缩块,可以随时解压查看,体积能压下去一大截。 问:一行塞六条报错,是配置问题还是没救? 那是服务端把应用通过标准错误通道传回来的内容整体记成了一行,属于两个软件之间的接口设计,改不了。真正的解法是让应用自己写日志:应用层有完整的上下文,能给每条报错单独一行、带上请求标识和堆栈。让服务端去转记应用的报错,本来就是权宜之计。 问:只有几百兆日志,有必要折腾吗? 按本文那个回本点算,只要你会问超过6个问题就划算。但更实际的建议是分两步:格式和轮转现在就改,成本是改两行配置;导入分析等到真需要时再说。格式这件事的特殊性在于它不能追溯——今天不改,今天写下去的日志就永远是查不动的那种。 问:判断爬虫真假,用地址段比对还是反查域名? 两个都是官方给的办法。地址段比对适合批量离线分析,一次下载几百个网段就能把整份日志跑完,本文那21.7%就是这么算的;反查域名适合在线实时判断,但每次要走一趟域名解析,量大时开销明显。要注意地址段清单是会更新的——本文用的那份生成于跑数据的前一天,如果拿一份半年前的清单去比,误判率会明显上升。 问:请求标识具体怎么加? 服务端软件通常有内置变量能生成一个唯一串,把它写进日志格式,同时通过请求头传给后端,应用再把它写进自己的日志和错误信息里。这样一个请求在几份日志里就有了同一个锚点。改动量不大,但它是把多份日志真正串起来的唯一办法,比对时间戳可靠得多——尤其是在时间格式还各不相同的情况下。 问:那两万次伪装抓取要不要拦? 先分清目的。如果目的是保护服务器,那按行为拦比按标识拦有效得多,因为标识本来就是随便写的;如果目的是让统计数据干净,那不用拦,在分析时按地址段过滤掉就行。本文实测里那批伪装请求有20550次拿到了正常响应,说明现有规则没起作用,但它们的总量占全部请求不到1%,对服务器压力有限,优先级可以往后排。 问:这套体检多久做一次合适? 按季度就够,因为它查的是结构性问题,不是当天的异常。一次完整体检大概半小时,动作是固定的六步:看各文件体积和最后写入时间、按模板归类报错取头部、抽样数每行的字段数、对照四份日志的时间格式、跑一次爬虫真假比对、最后看爬虫拿到的状态码分布。本文这一轮就是这么跑下来的,六步里有五步都发现了问题——所以第一次做的时候不用担心找不到东西。 问:错误日志里那50行真问题,怎么才能不被淹没? 靠分级和分流,不靠翻。把废弃写法这类提示在应用层就关掉或者单独写一个文件,让错误日志里只留真正的错误;再给关键错误配告警,让它主动找你。本文那50行分布在一个多月里,最密集的一天有30条——如果配了告警,那天就该收到通知,而不是一个月后在做体检时才发现。 ## 权威参考资料 ## SEO抓取速率被调低的那几天,服务器日志里一条错误都没有 - URL:https://zhangwenbao.com/slow-server-truncated-response-crawl-rate-seo.html - 分类:监控与日志 - 发布:2026-05-27 | 更新:2026-07-30 - 摘要:限速排队、进程池打满、上游截断、连接层失败——四类拖慢抓取速率的故障全部返回2xx且不进错误日志。含nginx实验台判决矩阵与83站首页字节位置普查。 - 关键词:技术SEO,抓取预算,服务器运维,日志分析 > **TLDR**:摘要: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。 > 摘要:Google把抓取容量定义成“你的服务器为它保持连接打开的总时长”,于是变慢和报错在爬虫那边是同一件事。区别在于报错会留痕,变慢不会。保哥在一台真实nginx上把限速、上游超时、进程池、连接层排成配置矩阵跑了一遍:排队让6个请求全部返回200、每个多花987毫秒,而错误日志一行没有;PHP-FPM池打满让8个并发从8.2秒排到19.2秒,状态码依旧全是200;上游被掐断时客户端拿到的是200加半截HTML,响应头里的Content-Length还写着完整长度。再拿83个站的首页量了一遍:HTML字节中位数43万,而的中位位置在10.1%,第90百分位在44.1%——只收到前10%就断的话,只有49%的站保住了完整的head。 先说一个很多人都碰上过、但很难往这个方向想的现象。 Search Console的抓取统计信息里,“平均响应时间”那条曲线在某一周开始往上抬,同期“每天抓取请求总数”往下走。翻服务器监控:CPU没满,内存没满,磁盘IO正常,错误日志一天下来干干净净,5xx计数是0。运维那边看完给你一句“服务器没问题”。 他说的是实话。服务器确实没报错。 问题在于,爬虫那边扣分的依据从来就不只是报错。 ## 抓取速率到底按什么计价? 先把口径对齐,不然后面全是鸡同鸭讲。 Google在大型站点抓取预算管理那份文档 (https://developers.google.com/search/docs/crawling-indexing/large-site-managing-crawl-budget)里,把抓取预算拆成两块:抓取容量上限(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在降低抓取速率那份文档 (https://developers.google.com/search/docs/crawling-indexing/reduce-crawl-rate)里写得很坦白:出现错误时抓取速率会自动降下来,等错误变少之后the crawl rate will automatically start increasing again,但同一页最后一段又补了一句You cannot request an increase in crawl rate。 降是自动的、随时的;升也是自动的,但你没有任何手动加速的通道。这两件事凑在一起,意味着一次持续几天的静默降速,代价要在之后好几周里慢慢还。抓取预算优化清单 (https://zhangwenbao.com/google-crawl-frequency-optimization-guide-2026.html)里那些减少无效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模块文档 (https://nginx.org/en/docs/http/ngx_http_limit_req_module.html)里,两句话连起来才看得出问题: - 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不区分你是慢在业务逻辑上还是慢在排队上。 这条路径最阴的地方在于它的触发条件:进程池打满通常发生在上量的那几个小时 (https://zhangwenbao.com/apache-performance-tuning-mpm-event-php-fpm-maxrequestworkers-high-concurrency.html),而爬虫恰恰倾向于在你流量低谷的时段来。真人用户和爬虫在时间上错开,于是“用户反馈慢”和“爬虫遇到慢”是两批不重合的样本,你从客服那边永远收不到这个信号。 ## 什么时候才会变成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毫秒)。 结论不是“深层页一定更慢”,而是两者的差异分布很宽,只测首页得到的结论没法外推。而爬虫的绝大部分请求花在深层页上。这一点和站点架构决定爬虫能走多深 (https://zhangwenbao.com/ecommerce-website-architecture-flat-vs-deep-crawl-depth-seo.html)是同一个问题的两面:架构决定它去不去,响应时间决定它去了以后还剩多少额度。 ## 半截页面为什么也返回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状态码与网络错误那份文档 (https://developers.google.com/search/docs/crawling-indexing/http-network-errors)里,对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之所以是个问题 (https://zhangwenbao.com/google-404-crawl-seo-positive-signal.html)就是这个道理,只不过软404一次伤一个URL,这个写法一次伤全站。 这个配置通常不是运维故意写的,而是从某份“友好错误页”的教程里连着=200一起抄过来的——那份教程多半是给内网管理后台写的,那里确实不在乎搜索引擎。 ## 页面被截断,先丢的是哪几样? 既然截断是按字节顺序发生的,那么“丢了什么”就完全取决于“什么东西排在页面的第几个字节上”。 这件事没人量过,保哥就量了一遍:把那批站里首页返回200的83个抓下来,对每份HTML找出几个关键标记的字节位置,换算成占整页的百分比。 先看底数:这83个首页的HTML中位数是434825字节,第90百分位1292740字节,最大的一个5155213字节。四十多万字节的首页现在是中位数,不是异类。 ## 关键标记的位置分布 标记 | 有它的站 | 第10百分位 | 中位 | 第90百分位 | 最靠后的一个 | | 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% | | 83 | 1.7% | 10.1% | 44.1% | 79.9% |