curl -I查了一圈说这站没配缓存头,换成GET之后那五个头全在

curl -I查了一圈说这站没配缓存头,换成GET之后那五个头全在
张文保 37 分钟阅读 2,899 阅读
本文目录
  1. 同一个页面,curl -I为什么比GET少给你五个响应头?
  2. 六个站里有两个对不上
  3. 规范早就写好了这条免责声明
  4. 为什么偏偏是这几个字段
  5. 那把 -I换成什么
  6. 裸curl量到的字节数,为什么比浏览器看到的大十倍?
  7. 三态实测:不带、gzip、br
  8. Google那2MB上限,按压缩前算
  9. 三个场景,三个该看的数字
  10. -w打出来的五个时间,哪一段才是服务器的锅?
  11. 六站分解表
  12. 那5.47秒根本不在服务器上
  13. 怎么做减法才有意义
  14. 测一次就下结论,为什么几乎一定是错的?
  15. 同一个URL连测五次
  16. 你随手测的那一次,落在哪儿
  17. 该测几次,看什么统计量
  18. 跳转链断在半路,curl为什么还回你一个301?
  19. 一次26秒的成功
  20. 为什么这个误报特别危险
  21. 三个必须一起看的变量
  22. 怎么把CDN和源站分开量?
  23. --resolve是干这个用的
  24. remote_ip是个被低估的变量
  25. 一行命令能查完robots、sitemap和404吗?
  26. 三个站的九个地址
  27. 本站那个304KB的404页
  28. 状态码之外还该顺手看一眼的东西
  29. 保哥的现场诊断命令清单
  30. 六行,按使用频率排
  31. 三个别踩的坑
  32. 什么时候该收手,换别的工具
  33. 常见问题解答
  34. curl -I是不是就完全不能用了?
  35. 为什么我用curl量到的时间,跟浏览器开发者工具里的对不上?
  36. 自动解压那个开关,和手写编码头该用哪个?
  37. 为什么用Googlebot的User-Agent去测,结果跟默认UA一模一样?
  38. time_starttransfer和大家常说的TTFB是一回事吗?
  39. 这些命令在Windows上能直接用吗?
  40. 权威参考资料
摘要:同一天从同一台机器上对六个站各发一次HEAD和一次GET,两个站给出的响应头数量对不上:某电商平台的HEAD少了5个字段,其中就有 cache-control;另一个站少的是 vary。这不是服务器坏了,RFC 9110明写允许,还提前点了Content-Length和Vary的名。同一轮实测还量到:裸curl收到的字节数是压缩后的6到13倍,而Google说抓取上限那2MB恰恰按未压缩数据算;同一个URL连测5次,最慢是最快的3.66倍;一条跳转链已经断在半路,curl却照样回一个301。

做技术排查的人手上都有一条肌肉记忆:想看某个页面的响应头,敲 curl -I。这条命令快、干净、不下载正文,看着没什么可挑剔的。

直到某天你拿它去查一个电商平台的缓存策略,屏幕上干干净净地没有 Cache-Control,于是你在排查记录里写下一句“对方没配缓存头”,然后就带着这个结论去做后面所有的判断了。

问题是那一行结论是错的。同一个URL,把 -I 换成一次普通的GET,cache-control: max-age=900, stale-while-revalidate=86400 就老老实实躺在那儿。少的还不止这一个。

下面这些数字全部来自2026年7月31日同一台服务器、同一个小时之内跑出来的实测,不是从别处抄来的表格。跑之前我也以为最多只是格式上的小差别。

同一个页面,curl -I为什么比GET少给你五个响应头?

先把实验条件说清楚,免得后面的数字失去意义。六个目标站,每个站各发一次 curl -sI(HEAD)和一次 curl -s -o /dev/null -D -(GET,正文丢弃只留头),把两边返回的头字段名各自转小写、去重、排序,然后做集合差。

六个站里有两个对不上

结果如下,同一分钟内跑完:

目标站HEAD头字段数GET头字段数只有GET才返回的字段
某电商SaaS平台首页1116accept-rangesagecache-controlcontent-lengthlast-modified
某建站平台首页1819vary
某CDN厂商官网2323无差异
某代码托管平台1616无差异
某编程语言官网1515无差异
本站1111无差异

六个站里两个对不上,比例是三分之一。这个比例已经高到不能当成偶发事件来处理了。

更要命的是缺的那几个字段的成色。cache-control 决定缓存活多久,age 告诉你这份内容在边缘节点上躺了多少秒,last-modified 是条件请求的两块基石之一,content-length 是你判断页面胖瘦的直接依据。这四个恰恰是做技术排查时最想看的那一批。

另一个站缺的 vary 单拎出来更值得警惕。这个字段直接决定了CDN会按什么维度把同一个URL切成几份缓存,一旦漏写,同一条地址下发给不同用户的可能是完全不同的版本——Vary漏写把德语版发给所有人那篇把这条链路整个拆开过。你用 -I 去查,会得到“这站压根没配Vary”的结论,而真相是它配了,只是HEAD不给你看。

规范早就写好了这条免责声明

这不是任何一方的实现缺陷。RFC 9110对HEAD方法的定义把话说得非常明白,原文是:

The server SHOULD send the same header fields in response to a HEAD request as it would have sent if the request method had been GET. However, a server MAY omit header fields for which a value is determined only while generating the content.

翻成人话:服务器应当让HEAD的响应头和GET一样,但可以省略那些“只有在生成正文的过程中才能确定值”的字段。前半句是SHOULD,后半句是MAY——两个词都不是MUST,服务器怎么做都不算违规。

真正让我坐直的是紧接着的那句举例。规范自己点名了:

Such a response to GET might contain Content-Length and Vary fields, for example, that are not generated within a HEAD response. These minor inconsistencies are considered preferable to generating and discarding the content for a HEAD request, since HEAD is usually requested for the sake of efficiency.

规范预告的两个字段是Content-Length和Vary,而我实测撞上的两个站,缺的恰好一个是Content-Length(连带另外四个),一个就是Vary。写规范的人二十多年前就知道会出这事,还把它定性成“minor inconsistencies”,理由是与其为了一个HEAD请求把正文生成出来再扔掉,不如接受这点不一致。

站在服务器的效率角度,这个取舍完全合理。站在拿 -I 当诊断工具的人的角度,这句“minor”就有点扎心了:对HTTP协议来说次要的东西,对你手里那份排查报告可能是全部结论所依赖的前提。

为什么偏偏是这几个字段

把规范那句“值只有在生成正文时才能确定”落到具体场景,缺失名单就不神秘了。

content-length 要等正文渲染完才知道有多少字节,动态页面在开始输出前根本算不出来。last-modified 对动态页而言取决于最终拼出来的那份内容。agecache-control 在很多CDN的实现里是随缓存对象一起写下来的,HEAD请求走的是另一条不落缓存对象的短路径,自然带不出这两个值。

注意那个站的 cf-cache-status 在HEAD和GET里都是 BYPASS,说明这个页面本来就被判定为不缓存。不缓存的路径上,跟缓存有关的头字段就没有生成的时机,于是它们只在真正走完整渲染的GET里才出现。

这也解释了为什么另外四个站完全一致:静态资源、或者已经被完整缓存的页面,字节数和修改时间都是现成的,HEAD直接照抄一份就行,没有省略的动机。

那把 -I换成什么

正确的替代只有一条,就是发真正的GET然后把正文扔掉:

curl -s -o /dev/null -D - https://example.com/

-o /dev/null 把正文丢进黑洞,-D - 把响应头打到标准输出。代价是服务器真的会把整页生成并传给你,流量和耗时都是实打实的;收益是你看到的这份头,跟真实访客和爬虫看到的那份是同一份。

还有一个折中写法是 curl -sI -X GET,用 -X 强行把 -I 隐含的HEAD改成GET。curl手册里 -I的说明写的是它让curl只取头,配合 -X GET 之后请求行会变成GET,但curl仍按只读头的模式处理响应,行为上有点别扭,官方也不推荐这么混用。老老实实用第一种写法更省心。

一个能立刻用上的判据:任何时候你要做的判断跟“这个页面的内容”有关——缓存多久、多大、什么时候改的、按什么维度分片——就必须用GET。只有当你要判断的东西跟内容无关,比如这个URL还在不在、跳去哪里、返回什么状态码,-I 才是安全的。

裸curl量到的字节数,为什么比浏览器看到的大十倍?

第二个坑比第一个更隐蔽,因为它不会报错,只会给你一个看起来正常、实际上差了一个数量级的数字。

三态实测:不带、gzip、br

curl默认不发 Accept-Encoding 请求头。不发的意思是它告诉服务器“我不接受任何压缩”,于是服务器老老实实把未压缩的原文全量发过来。同一批站点,三种发法量出来的字节数是这样的:

目标站不带Accept-Encoding声明gzip声明br最大倍数
某CDN厂商官网12991932898479961613.04
某建站平台首页13055751094379590213.61
本站48536661784527279.21
某电商SaaS平台首页42085579586688826.11
某编程语言官网5247452474524741.00

差距最大的那个站,裸curl报1.3MB,声明支持Brotli之后只剩99KB,相差13倍。你要是拿裸curl的数字去跟浏览器网络面板里的数字对账,会得出“这个页面比我看到的胖十三倍”这种荒谬结论,然后开始找根本不存在的问题。

倒数第二行那个编程语言官网是个反例,三种发法量到的都是52474字节,一个字节不差。它对首页HTML完全不做内容协商压缩。这类站不是没有,只是很少有人真的去量一下。压缩这件事在服务器上到底覆盖了哪些类型,nginx出厂只压HTML这一种类型那篇量过默认配置的实际覆盖面,结论比大多数人以为的窄得多。

还有一处细节值得记:gzip和Brotli之间也不是小差距。CDN厂商那个站从289847降到99616,Brotli比gzip又省了2.9倍;而建站平台那个站两者只差12%。同一个开关在不同站点上的收益能差出一个数量级,照抄别人的结论没有意义,只能自己量。

Google那2MB上限,按压缩前算

讲到这儿,前面那个“荒谬的大数字”要翻案了。

Google对Googlebot抓取上限的说明里有一句极容易被划过去的话:Googlebot抓取受支持文件类型的前2MB,PDF是前64MB,而紧跟着的那句是——

The file size limit is applied on the uncompressed data.

大小限制适用于未压缩的数据。这句话把整件事翻了过来:裸curl量到的那个“错得离谱”的大数字,恰恰就是Googlebot用来卡2MB上限的那个数字。

拿实测数据代进去算:CDN厂商那个站未压缩1299193字节,已经吃掉2MB配额的 62%;建站平台那个站1305575字节,同样是62%。而你从浏览器网络面板里看到的是97KB和94KB,只有配额的5% 不到。同一个页面,用两个数字去看,一个显示离红线很远,一个显示已经过半。

这不是说这两个站有问题——2MB是个相当宽松的额度,62% 也还留着余量。真正的价值在于知道该盯哪个数字:做压缩优化时看压缩后的,做抓取上限评估时看压缩前的,两个场景两个口径,混用就会看错。

三个场景,三个该看的数字

把这件事整理成一张对照表,省得每次现场再想:

你要判断什么该用哪个数字怎么量
用户实际下载了多少字节压缩后curl --compressed -o /dev/null -w '%{size_download}'
这个页面离Googlebot的2MB上限还有多远压缩前curl -o /dev/null -w '%{size_download}'
压缩这个开关到底省了多少两个都要两次都跑,做除法
服务器有没有真的开压缩看响应头--compressed 后查 content-encoding

顺带提一句 --compressed 和手写 -H 'Accept-Encoding: br' 的差别。MDN的Accept-Encoding字段说明把这个头的语义讲得很细:它是内容协商的一环,客户端声明自己认得哪些编码,服务器从中挑一个。--compressed 的额外动作是收到之后自动解压,所以你还能顺手检查解压出来的正文对不对;手写那个头curl不会解压,拿到手的是一坨二进制。想量字节用哪个都行,想同时看内容就得用 --compressed

-w打出来的五个时间,哪一段才是服务器的锅?

curl -w 能把一次请求拆成好几个时间点打出来,这是它比浏览器网络面板更好用的地方之一——浏览器给你的是渲染视角,curl给你的是纯网络视角,中间没有JavaScript掺和。

六站分解表

用的写法是这一行:

curl -s -o /dev/null -w '%{time_namelookup} %{time_connect} %{time_appconnect} %{time_starttransfer} %{time_total}\n' https://example.com/

curl官方手册里 --write-out的变量清单把每一个变量的含义都列全了,这里只需要记住一件事:这五个数字全部是从命令开始那一刻算起的累计值,不是各阶段耗时。想要某一段单独花了多久,得自己做减法。同一时刻跑出来的原始数据:

目标站DNS累计TCP建连累计TLS完成累计首字节累计全部收完
本站0.20750.20770.23420.23510.2388
某代码托管平台0.20670.29740.41350.50550.9673
某编程语言官网0.20720.36930.55660.71940.9018
某电商SaaS平台0.20650.44950.72091.24723.0726
某CDN厂商官网0.20640.61411.05071.45866.6869
某建站平台5.46605.62585.81086.35777.1480

那5.47秒根本不在服务器上

最后一行是全场最刺眼的。这个站首字节6.36秒,看着像是服务器慢得要死。做完减法就知道冤枉它了:DNS解析一项就吃掉5.4660秒,占首字节的86%。真正属于建连、握手和服务器处理的部分加起来只有0.89秒,跟前面几个站是一个量级。

这是本地解析器第一次解析这个域名、没有缓存命中的结果。换句话说,如果你在这个状态下跑一次测试就得出“这个站服务器很慢”的结论,你查的其实是自己这台机器的DNS。

另一个方向也成立:前五行的DNS一项全部落在0.2064到0.2075之间,五个不同域名、五个不同权威服务器,解析耗时几乎是同一个常数。这个高度一致的0.2秒不是各站点的真实解析成本,是这台机器到它的递归解析器之间的固定开销。所以在这台机器上,所有DNS数字都要先扣掉0.2秒才有可比性。

把这两点合起来就是一条实用规则:time_namelookup这个数字,永远是在描述你自己的测量环境,不是在描述被测的站。

怎么做减法才有意义

几个减法的实际含义,按用得上的顺序排:

  • time_connect − time_namelookup=TCP三次握手,基本等于一个网络往返,是链路质量最干净的读数。
  • time_appconnect − time_connect=TLS握手,现代配置下大约是一到两个往返。
  • time_starttransfer − time_appconnect请求发出到第一个字节回来,这一段才真正包含服务器的处理时间,也是唯一你能靠优化后端影响的部分。
  • time_total − time_starttransfer=正文传输,跟页面胖瘦和带宽有关,跟后端快慢没关系。

拿本站的数字做示范:0.2077减0.2075等于0.0002秒的建连(因为这次测量就是从这台服务器打自己,几乎没有网络距离),0.2342减0.2077等于0.0265秒的TLS,0.2351减0.2342等于 0.0009秒的服务器处理。这个0.9毫秒是全页缓存命中时的读数——请求根本没进PHP,nginx直接把磁盘上那份现成的HTML甩了出来。

对照那个电商SaaS平台:1.2472减0.7209等于0.5263秒,半秒多花在“请求已经送到、正文还没开始回来”这一段。这半秒是它自己的账,跟网络无关。

测一次就下结论,为什么几乎一定是错的?

前面所有的数字都有一个共同前提:它们都是单次测量。而单次测量在这件事上的可靠程度,比大多数人以为的低得多。

同一个URL连测五次

把两个站各连打五次,中间不做任何停顿,只记首字节时间:

目标站第1次第2次第3次第4次第5次最慢÷最快
本站0.23140.23330.23060.23400.23341.01
某电商SaaS平台1.00303.67283.57713.19542.17893.66

本站五次的极差是0.0034秒,波动1.5%,测一次和测五次给出的结论完全一样。那个电商平台五次跨越1.00秒到3.67秒,最慢的一次是最快的一次的3.66倍

更有意思的是顺序:最快的那次是第一次,之后越测越慢,第五次才开始回落。这跟“第一次冷、后面热”的直觉正好相反。合理的解释是连续请求触发了对方的某种速率控制,或者被调度到了不同的边缘节点上。不管是哪种,结论都一样——你测的次数本身正在影响你测出来的结果。

你随手测的那一次,落在哪儿

假设你只测了一次,运气不同,写进报告里的句子会完全不同:

  • 抽中第1次:“首字节1秒,还行。”
  • 抽中第2次:“首字节3.7秒,这站有严重问题。”
  • 抽中第5次:“首字节2.2秒,偏慢。”

三句结论,三种后续动作,而它们来自同一个页面同一分钟内的三次测量。这就是为什么现场诊断最容易出的不是技术错误,而是把一个样本当成一个分布。同一个陷阱在做页面内容比对时也成立——量出74% 的页面对爬虫和用户不一样,补上一组对照之后只剩2.2%那篇讲的就是没有对照组时误报率能高到什么程度,机制跟这里是同一个。

该测几次,看什么统计量

我自己现在的做法很简单,不需要什么统计学包袱:

  1. 至少五次,中间不停顿。五次是能看出有没有趋势的最小样本。
  2. 看最小值,不看平均值。最小值最接近“这条链路在没有干扰时的能力”,平均值会被一两次异常拖走。
  3. 看极差比。最慢除以最快,低于1.5说明稳定,超过3说明这个数字不能单独用来下任何结论。
  4. 如果极差比大,换个时段再来一轮。同一时段的连测只能反映当下,不能代表全天。

本站那组1.01的极差比不是什么值得炫耀的成绩,它只是说明这次测量是从服务器打自己、全页缓存命中,几乎不含网络成分。越干净的测量环境,越不能代表真实用户的体验——这句话在下一节还会以另一种形式出现。

跳转链断在半路,curl为什么还回你一个301?

这是本轮实测里最容易骗到人的一条,因为它给出的错误结论看起来特别像一个正常结论。

一次26秒的成功

-L 跟随跳转,同时把跳数、终点、终态码和总耗时一起打出来,三个目标的结果是:

起始地址跳数终点终态码总耗时(秒)
某电商平台裸域(http)1带www的https地址2005.09
本站裸域(http)1https同域2000.43
某代码托管平台(http)1https同域30126.00

第三行的301和26秒是同一件事的两面。那次请求的完整经过是:curl打http地址,拿到301和一个指向https的 Location,按 -L 的约定跟过去,然后那一跳一直没有回应,直到25秒的超时上限被撞破,总耗时26.00秒就是这么来的。

%{http_code} 打出来的301,是curl最后一次成功拿到的那个响应的状态码。跳过去之后什么都没拿到,这个变量就停在了301上。

为什么这个误报特别危险

因为“301”是一个看起来完全正常的值。你在批量脚本的输出里扫过去,看到一列200、200、301、200,第一反应是“哦这条配了个301,正常”,然后就翻篇了。

真相是这条URL对访客来说是彻底打不开的——浏览器会转圈到超时。而你的诊断脚本给它盖了个“有301,符合预期”的章。

curl官方手册对跟随重定向的说明里写明了默认行为:curl默认跟随重定向,必须显式加 -L;跟随的上限默认是30跳,可以用 --max-redirs 调整。这两条大家都知道。少有人注意的是它没有承诺 %{http_code} 会在跳转失败时给你一个错误值——因为那个变量的语义本来就是“最后一个响应的状态码”,而超时压根不产生响应。

三个必须一起看的变量

所以查跳转链的时候,一个变量绝对不够,至少要这三个:

curl -s -L -o /dev/null \
  -w '跳数=%{num_redirects} 终码=%{http_code} 终点=%{url_effective} 总耗时=%{time_total}\n' \
  http://example.com/

判读规则也很简单:终码是3xx而跳数大于0,就说明链路断在半路了。正常走完的跳转链,终码必然是2xx或者4xx——因为最后一跳一定落在一个不再重定向的资源上。终码停在3xx只有一种可能:最后那一跳没走通。

再配上 time_total 做交叉验证。一次正常的跳转,耗时应该跟直接访问终点相当;如果你看到一个跟超时上限高度接近的数字,基本可以确定有一跳挂了。

跳转链本身还有另一类坑——链上某一跳指向的地址被写成了相对形式,不同客户端解析出来的绝对地址还不一样,SEO爬虫报的死链和Googlebot抓的从来不是同一批URL那篇拆过这条链路。而在服务器配置层面,跳转规则本身能悄悄多出来一跳,nginx配置每次reload都通过,Googlebot每8次抓取却有1次撞在301上那篇量过这种沉默副作用的实际发生率。

怎么把CDN和源站分开量?

做出海站的排查,迟早会撞上这个问题:页面慢,到底慢在边缘节点,还是慢在源站?域名解析出来的是CDN的地址,你手上那条curl打过去,量到的永远是边缘节点的表现。

--resolve是干这个用的

curl提供了一个绕过DNS的开关,可以指定“这个域名的这个端口,直接连这个IP”:

curl -s --resolve example.com:443:203.0.113.10 \
  -o /dev/null -w '码=%{http_code} 首字节=%{time_starttransfer} 远端=%{remote_ip}\n' \
  https://example.com/

关键在于它不改Host头也不改SNI,服务器那边看到的仍然是原始域名,证书校验也照常进行。这跟用 -H "Host: example.com" 直接打IP完全不是一回事——后者会因为SNI对不上而在TLS阶段就出问题,得靠 -k 跳过校验才能跑通,而跳过校验之后你测的东西已经不是生产环境的那条路径了。

本站做了一次对照,因为它没挂CDN,两条路应该指向同一个地址:

方式状态码首字节(秒)实际连到的IP
正常走DNS2000.2340本站源站IP
用 --resolve指定2000.2336同一个IP

两条路的首字节差0.0004秒,落在噪声里。这正是没有CDN时应该看到的样子,也说明这个开关本身不引入额外开销。真正的价值在有CDN的场景:用域名量一次,用源站IP量一次,两个数字之差就是CDN这一层的贡献。

remote_ip是个被低估的变量

%{remote_ip} 打出来的是这次请求实际连上的那个IP。它有两个用处经常被忽略:

第一,确认你到底打到了哪个节点。同一个域名连测多次,如果 remote_ip 一直在变,说明DNS在做轮询,那你前面测出来的时间波动可能压根不是性能问题,只是每次打到了不同的机器上。本轮实测里那个电商平台就出现过这种情况——同一条traceroute命令连跑三次,解析到的目标IP出现了两个不同的值。

第二,验证 --resolve真的生效了。这个开关写错格式(比如端口写错、IP写成域名)时curl不一定报错,它可能默默回退到正常解析。把 remote_ip 打出来跟你指定的那个对一下,是最省事的确认方式。

一行命令能查完robots、sitemap和404吗?

能,而且这是我现在做站点初检时的第一动作。三个地址、三条命令,能筛掉相当一部分低级问题。

三个站的九个地址

用的是这个写法,只打状态码、内容类型和字节数,一行一个:

curl -s -o /dev/null -w '%{url_effective} -> %{http_code} %{content_type} %{size_download}B\n' \
  https://example.com/robots.txt

三个站各查 /robots.txt/sitemap.xml 和一个肯定不存在的路径:

目标站/robots.txt/sitemap.xml不存在的路径
本站200,text/plain,3790B200,text/xml,849B404,text/html,304607B
某电商SaaS平台200,text/plain,3605B200,application/xml,172928B404,text/html,58211B
某建站平台200,无content-type,2257B404,text/html,378562B308,text/plain,15B

第三行三个格子全是问题,而且是三种不同类型的问题。

/robots.txt 返回200但没有Content-Type头。这不会立刻炸,但把解释权交给了客户端去猜。/sitemap.xml 直接 404,而且返回的是一个369KB的HTML页面——这个站的站点地图不在这个默认路径上(大站常见,通常在robots.txt里另行声明),但对任何按惯例去试这个地址的工具来说,它拿到的就是一个巨大的404页。

最值得说的是最后一格:一个不存在的路径返回了 308,正文只有15个字节。308是永久重定向,意思是“这个地址永久搬到别处了”。一个从来没存在过的随机路径,被服务器声明为永久搬迁——这类配置最容易在爬虫那边制造出大量指向同一个地方的重定向链。

本站那个304KB的404页

说别人之前先说自己。本站的404页返回了 304607字节,比那个电商平台的404页大5倍,甚至比本站首页压缩前的字节数(485366)也就小一半。

原因不复杂:这个主题的404页把侧栏、导航、推荐列表整套都渲染了一遍。功能上没错,用户误入时还能顺着推荐走下去。但作为一个“告诉对方这里没有东西”的响应,30万字节的成本实在有点厚。按前面那个2MB上限的口径算,每一个404都要消耗掉15% 的单页配额,而这类地址在大站上从来不会只有几个。

这条属于自己家的活儿,写在这里是因为它正好说明:这类问题不查就发现不了——404页面从来不会有人主动去看,监控也不会报警,它就那么安静地胖着。至于同一个页面会以多少种URL写法返回200从而让这类成本翻倍,同一个页面11种URL写法都返回200那篇量过一个更极端的数字。

状态码之外还该顺手看一眼的东西

三行命令跑完,再加一条查条件请求的支持情况就基本齐了:

curl -sI https://example.com/ | grep -iE 'etag|last-modified'

本轮实测里三个站的HTML首页全都没有ETag,也没有Last-Modified。而同一个站的静态资源(比如一张封面图)两个都有,还带着 cache-control: public, max-age=31536000, immutable

这个差异是合理的:动态HTML每次生成的字节可能都不一样,发一个ETag出去反而会造成误判。但代价是这些页面无法用条件请求省下重复传输——爬虫每次来都得整页重收一遍。这笔账在抓取量大的站上不小,服务器把没改过的页面对爬虫重发了几百遍那篇按真实日志算过一次。

保哥的现场诊断命令清单

把前面所有的教训压成一组可以直接抄走的命令。这是我自己现在开工时贴在终端里的那一份。

六行,按使用频率排

# 1. 看真实响应头(不要用 -I)
curl -s -o /dev/null -D - https://example.com/

# 2. 时间分解(记得做减法)
curl -s -o /dev/null -w 'DNS=%{time_namelookup} TCP=%{time_connect} TLS=%{time_appconnect} TTFB=%{time_starttransfer} 总=%{time_total}\n' https://example.com/

# 3. 连测五次看波动
for i in 1 2 3 4 5; do curl -s -o /dev/null -w '%{time_starttransfer} ' https://example.com/; done; echo

# 4. 跳转链(三个变量一起看)
curl -s -L -o /dev/null -w '跳数=%{num_redirects} 终码=%{http_code} 终点=%{url_effective} 总=%{time_total}\n' http://example.com/

# 5. 压缩前后各量一次
curl -s -o /dev/null -w '未压缩=%{size_download}\n' https://example.com/
curl -s --compressed -o /dev/null -w '压缩后=%{size_download}\n' https://example.com/

# 6. 绕过 DNS 直接量源站
curl -s --resolve example.com:443:203.0.113.10 -o /dev/null -w '码=%{http_code} 首字节=%{time_starttransfer} 远端=%{remote_ip}\n' https://example.com/

三个别踩的坑

第一,先看看你手上这把工具多老。本轮实测用的那台服务器上,curl --version 报的是7.61.1,发布日期2018年9月5日——将近八年前的版本。这意味着后来加的一堆变量和开关它都没有。诊断工具本身就是变量,开工前花两秒看一眼版本,比事后怀疑人生划算。

第二,别把目标站测到限流。本轮实测跑到后半段时,那个代码托管平台开始对这台服务器返回空响应,curl的状态码变成了 000。这不是对方挂了,是连续几十个请求触发了防护。诊断动作本身改变了被诊断的对象,这在自动化批量检测里尤其常见。加个 sleep,或者把目标打散,比拿一堆000去猜要好。

第三,别用带query的地址做基准。很多站对带参数的地址走的是完全不同的缓存路径,你用 ?utm_source=test 这种地址量出来的数字,跟干净地址的数字可能差一个量级。做基准测量就用最干净的那条URL。

什么时候该收手,换别的工具

curl的能力边界很清楚:它只看网络层和HTTP层,不执行JavaScript,不渲染。所以下面这些问题它给不了答案,硬用只会误导:

  • 页面内容是不是靠JS拼出来的——curl拿到的是原始HTML,看不到渲染后的结果。
  • 用户实际感受到的加载速度——那是渲染指标,跟首字节是两回事。
  • 某个元素在移动端有没有被挡住——这需要真实浏览器。

反过来,需要快速把一批URL的状态码、响应头和跳转关系过一遍时,命令行仍然是最快的。不想敲命令的场合,API测试工具怎么用?快速看清一个URL的状态码与响应头那篇里那个网页版工具能干同一件事,输出格式友好一些,代价是没法批量。

常见问题解答

curl -I是不是就完全不能用了?

不是,它在自己的适用范围内仍然是最快的选择。判据前面给过:你要判断的东西跟“内容”有关就必须换GET,跟内容无关就可以继续用 -I。查一个URL还在不在、跳去哪里、是不是404,用 -I 完全没问题,还省流量。要查缓存策略、页面体积、修改时间、Vary分片这些,就必须发真正的GET。真正危险的不是这条命令,是不知道它什么时候会少给你东西。

为什么我用curl量到的时间,跟浏览器开发者工具里的对不上?

因为两者测的根本不是同一件事。curl的 time_total 是“HTML文档本身收完”,浏览器面板里那个总时间通常包含了CSS、JS、图片、字体等所有子资源,还包含解析和渲染。一个页面的HTML可能0.2秒就收完了,整页加载完要4秒,这两个数字都对,只是回答的是不同的问题。做后端和网络排查看curl的数字,做用户体验优化看浏览器的数字,别混着用。

自动解压那个开关,和手写编码头该用哪个?

想模拟真实客户端、并且要看正文内容,用 --compressed——它会声明支持的编码并自动解压。想精确控制声明哪一种编码、只关心字节数,用手写的 -H——但要注意curl不会替你解压,正文拿到手是二进制。还有一个容易忘的点:手写 -H 'Accept-Encoding: br' 时,如果服务器不支持Brotli,它会返回未压缩的内容而不是报错,你看到的字节数就跟裸curl一样了,这时候要看 content-encoding 响应头才能确认到底压没压。

为什么用Googlebot的User-Agent去测,结果跟默认UA一模一样?

本轮实测里六个站都是这个结果,字节数和状态码完全一致(只有一个站差了1个字节,那是页面里的动态内容造成的)。原因是正规站点极少针对UA做内容差异化——那属于伪装,风险很高。UA伪装真正能测出来的是另一类东西:有没有针对爬虫的封禁规则、有没有针对爬虫的限速、有没有为爬虫准备的简化版页面。测不出差异是正常的、也是好事;测出差异才要紧张。顺带一提,光换UA骗不过谷歌,反向DNS验证那条路是绕不开的。

time_starttransfer和大家常说的TTFB是一回事吗?

基本是,但要注意口径。time_starttransfer 是从curl启动算起到收到第一个字节的累计时间,包含了DNS、TCP、TLS全部前置开销。而很多性能工具里说的TTFB只从请求发出那一刻算起。两者能差出好几百毫秒,尤其在DNS没命中缓存的时候。跨工具比对之前一定要先对齐口径,否则会得出“我们的TTFB突然涨了5秒”这种由DNS缓存失效造成的假警报。

这些命令在Windows上能直接用吗?

Windows 10以后自带了curl,但有两个坑。一是PowerShell里 curl 默认是 Invoke-WebRequest 的别名,参数完全不同,要用真正的curl得写 curl.exe。二是单引号在PowerShell里不做变量展开但在传参时的行为跟bash不一样,-w 那种带格式串的参数最容易出问题,建议用双引号并注意转义。最省事的办法是在WSL或者Git Bash里跑,行为跟Linux一致,前面那些命令可以原样复制。

权威参考资料

分享到
标签
版权声明

本文标题:《curl -I查了一圈说这站没配缓存头,换成GET之后那五个头全在》

本文链接:https://zhangwenbao.com/curl-head-get-response-header-diagnosis-seo.html

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

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