文章指出,网站因协商缓存失效导致爬虫频繁抓取旧文章,消耗抓取配额,影响新内容收录。主要问题包括Nginx gzip抹除ETag与Last-Modified、格式不合规和时区错误等。文章提供了排查和解决方法,如设置弱ETag、正确格式化时间戳等。
前阵子翻看博客服务器的 Nginx 访问日志,发现百度爬虫和 Googlebot 天天爬得挺欢,每天都有大几千次请求。但奇怪的是,我刚发了三四天的新文章,在搜索引擎后台死活搜不到收录记录,甚至在抓取日志里连一次爬虫光顾的痕迹都没有。
把日志单独过滤出来仔细一算,直接看傻了:每天近八成的爬虫请求,都在反反复复抓取我两年前写的那几十篇旧文章。而且每一次抓取,服务器都老老实实返回了 HTTP 200 OK,顺带着把三四十 KB 的 HTML 正文完整吐回去一遍。
搜索引擎给每个中小型站点的日抓取配额(Crawl Budget)是有上限的。爬虫一天分配给站点的抓取额度就那么多,如果全耗费在旧内容的全量重抓上,新发布的页面自然只能在等待队列里无限期延后。
按理说,只要内容没有变动,客户端发起条件请求时,服务端应该直接返回 304 Not Modified。这样爬虫只下载两百多字节的响应头,几乎不消耗带宽和渲染时间,也能立刻知道内容未改,转头去爬那些新生成的 URL。
但在实际部署中,304 往往因为各种隐蔽的配置原因彻底失效,变成满屏的 200。下面把我排查这套协商缓存链路时踩过的几个典型坑逐一复盘。
坑一:Nginx 开启动态 gzip 后,强 ETag 被悄悄抹除
这是绝大多数用 Nginx 做反向代理或 Web 服务器最容易撞上的暗坑。
很多人为了让协商缓存生效,在 nginx.conf 里加上了 etag on;,以为这样就能高枕无忧。结果自己用 curl 模拟带上 If-None-Match 请求时,服务端死活还是返回 200。
问题出在 Nginx 的 ngx_http_gzip_filter_module 上。
当 Nginx 对后端返回的内容做动态 gzip 压缩时,它认为压缩后的字节流与原始文件的字节流已经不一致了。强 ETag 的规范要求字节级别严格相同,因此 Nginx 会默认把强 ETag 抹除,或者将其转换成弱 ETag(形如 W/"xxxxxx")。
更要命的是,在一些旧版本的 Nginx 或反向代理链路中,如果上游返回的是强 ETag,一旦经过 gzip 处理,ETag 响应头会直接丢失。当爬虫下一次发起请求时,根本拿不到可以对比的 ETag 凭证;即便客户端带上了原本记录的强 ETag,经过网关层比对不上,服务器就只能回退到全量下载的 200。
排查与对策:
如果后端应用自己生成了 ETag,在配置 Nginx 时要留意这一点。对于支持弱验证的场景,可以让应用直接输出弱 ETag:
# 确保开启了 etag 支持(默认即为 on)
etag on;
# 如果是静态资源,可以考虑预先 gzip 生成 .gz 文件(gzip_static)
# 这样 Nginx 可以直接基于静态文件提供强 ETag,不用动态压缩时抹除
gzip_static on;
测试时用 curl 观察返回头,看 ETag 是否带有 W/ 前缀:
curl -I -H "Accept-Encoding: gzip" https://www.llbbs.cn/post-100.html
如果看到类似 ETag: W/"66f12345-abc",说明弱 ETag 生效了。此时客户端后续发送 If-None-Match: W/"66f12345-abc",Nginx 才能正确命中 304。
坑二:Last-Modified 格式不合规与时区换算乌龙
除了 ETag,另一个核心维度是基于修改时间的 Last-Modified 与 If-Modified-Since。
很多自己手写脚本或轻量博客系统的朋友,喜欢在后端代码里直接把数据库的 update_time 字段掏出来当缓存标记。结果写出这种响应头:
Last-Modified: 2026-05-18 14:30:00
或者直接输出了本地时间戳。这在 HTTP 规范里是完全非法的。
HTTP 规范(RFC 7231)严格规定,Last-Modified 的时间必须是标准的 RFC 1123 格式(即 GMT 时间),比如:
Last-Modified: Mon, 18 May 2026 06:30:00 GMT
如果格式不对,或者时区没转成 GMT,就会引发连锁反应:
- 搜索引擎爬虫(如 Googlebot)无法解析这个时间字符串,下一次请求时干脆不带
If-Modified-Since头。 - 即使爬虫原样把字符串送回来了,后端在做字符串比对或时间解析时直接报错,默认判定为“内容已更新”,再次送出 200。
- 如果把北京时间直接拼上
GMT字样,会导致实际时间比真实时间平白无故快了 8 小时,造成时间对比逻辑彻底错乱。
在后端输出时,必须正确转换成 GMT 字符串。以 Python 后端为例:
import datetime
# 假设文章更新时间是带时区或 UTC 时间
last_updated = datetime.datetime.now(datetime.timezone.utc)
# 格式化为标准的 RFC 1123 格式
last_modified_header = last_updated.strftime("%a, %d %b %Y %H:%M:%S GMT")
如果是 PHP 系统:
$last_modified = gmdate("D, d M Y H:i:s", $article['update_time']) . " GMT";
header("Last-Modified: " . $last_modified);
格式差一个字符,爬虫就没办法做时间比对。
坑三:动态程序压根不读客户端发来的条件请求头
很多基于 PHP、Node.js 或 Python 开发的博客程序,所有页面渲染全部交给入口文件处理。
程序内部写得非常直接:每次收到请求,连连数据库,查分类、查标签、查最新评论,拼好 HTML 输出。
也就是说,客户端在请求头里带了:
If-Modified-Since: Mon, 18 May 2026 06:30:00 GMT
If-None-Match: "3a5b-610a"
但很多后端代码根本没有写读取和校验这些头部的逻辑。
既然代码里压根没去判断这些变量,那么不管爬虫带了什么历史凭证,程序每次都会把整套数据库查询和模板渲染走一遍,最后把完整的 HTML 和 200 状态码吐回给爬虫。服务器负载居高不下不说,爬虫配额全砸在这些无意义的渲染上。
解决办法:在业务入口做前置条件检查
在真正执行繁重的数据库查询和模板渲染之前,先根据文章 ID 查一下它的更新时间戳。
如果发现客户端上报的 If-Modified-Since 大于等于文章最后更新时间,且没有新评论需要展示,直接中断执行,返回 304:
$post_id = intval($_GET['id']);
$update_time = get_post_update_time($post_id); // 仅查询单个时间字段
// 1. 生成标准的 Last-Modified
$gmt_time = gmdate('D, d M Y H:i:s', $update_time) . ' GMT';
// 2. 检查客户端是否发送了 If-Modified-Since
if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
$client_time = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']);
if ($client_time && $client_time >= $update_time) {
// 判定未改动,直接返回 304,立即终止脚本
header('HTTP/1.1 304 Not Modified');
header('Last-Modified: ' . $gmt_time);
exit;
}
}
// 只有真正更新了,才继续往下走完整渲染流程
header('Last-Modified: ' . $gmt_time);
header('Cache-Control: public, max-age=3600');
加入这一层轻量拦截后,爬虫来检查旧文章时,数据库甚至不需要执行任何联表查询,几十毫秒内就完成了 304 握手。
坑四:CDN 边缘节点擅自改写头或吞掉 304 响应
很多站点前面套了 Cloudflare、腾讯云 EdgeOne 或者阿里云全站加速。CDN 在这里经常会搞出两类幺蛾子:
1. 边缘节点对动态请求直接穿透,但在回源时去掉了条件头
部分 CDN 的默认缓存规则将 .html 或带参数的 URL 标记为“不缓存 / 纯动态”。在转发给源站时,为了保证获取最新内容,CDN 的某些默认策略会把客户端的 If-Modified-Since 和 If-None-Match 过滤掉。
源站收不到条件请求头,自然只能老老实实回传 200 给 CDN,CDN 再回传 200 给爬虫。
2. 缓存了 200 响应后,给客户端返回带有正文的 304
这一类更危险。某些 CDN 节点缓存了源站的 200 HTML,当爬虫带着 If-None-Match 请求过来时,CDN 识别出命中缓存,本该构造一个不带正文的 304 响应,却因为插件规则或压缩配置错误,把状态码改成了 304,同时却把正文流也塞了进去。
根据 RFC 7230 规范,304 响应禁止包含任何消息体。如果爬虫收到了带着几十 KB 内容的 304,部分搜索引擎蜘蛛会判定为协议异常直接丢弃,严重的甚至会报错记录为抓取故障。
排查建议:
绕过 CDN 直接对源站 IP 发请求,对比通过 CDN 和直连源站的返回差异:
# 直连源站测试 304
curl -I -H "If-None-Match: W/\"xxx\"" http://源站IP/post-100.html -H "Host: www.llbbs.cn"
# 经由 CDN 测试 304
curl -I -H "If-None-Match: W/\"xxx\"" https://www.llbbs.cn/post-100.html
如果直连源站返回 304,但通过 CDN 返回 200,需要在 CDN 控制台检查“缓存配置”,确保开启了“允许条件请求透传(Conditional Requests Pass-through)”,并且关掉对 HTML 页面的强行覆盖 Cache-Control 规则。
坑五:304 响应头里塞入 Content-Length 导致客户端挂起
在手动实现 304 返回逻辑时,另一个常见细节是 Content-Length。
当服务器返回 200 时,Content-Length 表示接下来传输的正文长度,比如 Content-Length: 45210。
但在返回 304 时,响应体必须为空。如果你的应用框架或中间件在输出 304 头时,没有清空缓冲区的长度信息,依然附带了原始页面的长度:
HTTP/1.1 304 Not Modified
Content-Length: 45210
此时很多遵守 HTTP 规范的爬虫或客户端会停在原地,等待那 45210 个字节的数据到来,直到请求超时。
对于 Nginx 反向代理环境,通常 Nginx 会自动剔除 304 上的非法 Content-Length。但如果你直接用 Node.js 或 Go 等自己搭建的轻量 HTTP 服务,务必记得在设置 304 状态码时清空正文与长度头:
// Go 语言示例
w.WriteHeader(http.StatusNotModified)
// 切勿再调用 w.Write([]byte(...))
终验:两条命令验证协商缓存是否真正闭环
搞定上面的配置之后,不要只看页面能不能打开,必须用命令行完整跑一次闭环验证。
步骤一:先发一次正常请求,拿回服务端的凭证
curl -i -s https://www.llbbs.cn/post-100.html | head -n 20
观察输出中是否包含合规的凭证字段:
HTTP/2 200
date: Mon, 18 May 2026 08:00:00 GMT
content-type: text/html; charset=UTF-8
last-modified: Mon, 18 May 2026 06:30:00 GMT
etag: W/"66f12345-abc"
cache-control: public, max-age=3600
步骤二:带上拿到的凭证,模拟爬虫第二次发起请求
curl -i -s \
-H 'If-None-Match: W/"66f12345-abc"' \
-H 'If-Modified-Since: Mon, 18 May 2026 06:30:00 GMT' \
https://www.llbbs.cn/post-100.html | head -n 15
此时预期的正确响应必须是:
HTTP/2 304
date: Mon, 18 May 2026 08:05:00 GMT
last-modified: Mon, 18 May 2026 06:30:00 GMT
etag: W/"66f12345-abc"
并且整个响应后面没有任何多余的正文字符。
每天也可以用一行 awk 快速统计一下 Nginx 日志里各大搜索引擎爬虫的 304 占比:
grep -E "Baiduspider|Googlebot" /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c
改完这套配置一周后,我这边爬虫日志里的 304 比例从原先的接近 0% 升到了 70% 以上。旧页面不再反反复复占着抓取额度,每天新发的内容基本上当天或者隔天就能在爬虫访问日志里看到抓取记录,收录时间明显缩短了不少。
文章标题:网站旧文章天天重抓吃光爬虫配额?排查 HTTP 304 协商缓存失效、Nginx gzip 抹除 ETag 与 Last-Modified 陷阱我踩过的几个坑
文章链接:https://llbbs.cn/jishujaocheng/236.html
本站文章均为原创,未经授权请勿用于任何商业用途
评论一下?