重复抓同一批页面时,多数页面其实没变。HTTP 的条件请求(ETag、Last-Modified、304)能让服务器在没变时不重发响应体。这篇在本机服务器上量它能省多少、什么写法有效、什么情况会错过更新,以及和 Cache-Control: max-age 的区别。
第一次 GET:200,200 KB,耗时 1.6 ms;ETag="p0-v1",Last-Modified=Mon, 21 Sep 2026 14:13:20 GMT
If-None-Match: ETag → 304,响应体 0 字节,耗时 0.7 ms
If-Modified-Since: Last-Modified → 304,响应体 0 字节,耗时 0.8 ms
两个头一起带 → 304,响应体 0 字节,耗时 0.6 ms
If-None-Match 带了错误的 ETag → 200,响应体 204800 字节,耗时 0.8 ms
If-None-Match 是列表(含正确的一个) → 304,响应体 0 字节,耗时 0.5 ms
If-None-Match: * → 304,响应体 0 字节,耗时 0.6 ms
弱校验 W/ 前缀 → 304,响应体 0 字节,耗时 0.6 ms
ETag 去掉引号 → 200,响应体 204800 字节,耗时 0.7 ms
If-Modified-Since 是更早的时间 → 200,响应体 204800 字节,耗时 0.7 ms
If-Modified-Since 格式乱写 → 200,响应体 204800 字节,耗时 0.7 ms
304 响应里也带 ETag/Last-Modified 头:{'ETag': '"p0-v1"', 'Last-Modified': 'Mon, 21 Sep 2026 14:13:20 GMT'}带上次拿到的 ETag 作为 If-None-Match,或带 Last-Modified 作为 If-Modified-Since,服务器认为没变就回 304 和空的响应体。这里的匹配细节(列表、*、弱校验前缀、去引号的 ETag 不匹配)是我写的服务器的实现,真实服务器可能不同。
第一遍(都是新页面):响应体共 19.5 MiB,0.05 秒;服务器回 200 × 100、304 × 0 第二遍,无条件请求:响应体共 19.5 MiB,0.05 秒;200 × 100、304 × 0 第二遍,带 If-None-Match:响应体共 2.0 MiB,0.03 秒;200 × 10、304 × 90(客户端复用了本地保存的 90 个页面)
100 个页面抓两遍,10 个更新:无条件请求第二遍又下载 19.5 MiB;带 If-None-Match 只下载 2.0 MiB(10 个更新的页面),90 个直接复用本地保存的内容。requests 不会替你缓存,要自己保存 ETag 和内容。
内容从 version 1 变成 version 2,Last-Modified 仍是 Tue, 22 Sep 2026 18:00:00 GMT:带 If-Modified-Since 的请求得到 304(客户端会认为没变,实际已经变了:服务器回 304) 换成只用 ETag:内容再变一次后,带旧 ETag 的请求得到 200(拿到新内容)
HTTP-date 只精确到秒。内容变了而 Last-Modified 没变(同一秒内更新两次),带 If-Modified-Since 的请求会得到 304,错过更新;ETag 在内容变了就拿到新内容。我在本机用手工设定的方式复现了这个场景。
响应头里有 ETag:False,有 Last-Modified:False;带条件头重复请求得到 200,响应体 200 KB——条件头被无视,每次都完整下载
动态页面常常既没有 ETag 也没有 Last-Modified,条件头被无视,每次都是完整的 200。
每次都完整下载(不带条件头):服务器收到 100 个请求(200 × 100、304 × 0),响应体共 19.5 MiB,总耗时 44 ms 每次都带 If-None-Match 重新验证:服务器收到 100 个请求(200 × 1、304 × 99),响应体共 0.2 MiB,总耗时 32 ms 遵守 Cache-Control: max-age=60,新鲜期内不发请求:服务器收到 1 个请求(200 × 1、304 × 0),响应体共 0.2 MiB,总耗时 1 ms
条件请求每次仍要一次往返(服务器收到 100 个请求,但响应体只有 0.2 MiB);Cache-Control: max-age 在新鲜期内连请求都不发(只收到 1 个请求)。爬虫的“礼貌”和速度都受益于后者,代价是在新鲜期内看不到更新。