bigywater.tech
爬虫工程 · 43

增量抓取:ETag、Last-Modified 与 304

重复抓同一批页面时,多数页面其实没变。HTTP 的条件请求(ETag、Last-Modified、304)能让服务器在没变时不重发响应体。这篇在本机服务器上量它能省多少、什么写法有效、什么情况会错过更新,以及和 Cache-Control: max-age 的区别。

19.5 → 2.0 MiB
100 个页面第二遍抓取(10 个更新)的响应体总量,无条件请求 对 带 If-None-Match
304 + 0 字节
带正确的条件头时,200 KB 页面的响应
1 个请求
遵守 max-age=60 时,同一页面访问 100 次服务器收到的请求数(重新验证是 100 个)

条件头怎么写

实测 · 本机执行输出
  第一次 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 和内容。

Last-Modified 的坑

实测 · 本机执行输出
  内容从 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。

max-age 与重新验证

实测 · 本机执行输出
  每次都完整下载(不带条件头):服务器收到 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 个请求)。爬虫的“礼貌”和速度都受益于后者,代价是在新鲜期内看不到更新。

分步演示

条件请求与增量抓取
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。服务器:100 个页面 /p/(各 200 KB),可配置只用 ETag、只用 Last-Modified、两者、都不用;ETag 形如 "p-v";条件请求的匹配逻辑和 304 响应是我按常见语义写的;客户端 requests 2.34.2,自己保存 ETag 与内容;max-age 的新鲜度判断是我写的最小实现;每项各跑一次
    • 没有验证:真实服务器和 CDN 的 ETag 行为(例如多台后端各自生成不同的 ETag、压缩和不压缩的版本 ETag 不同);Vary 头对缓存的影响;requests-cache 等现成库;Cache-Control 的 no-cache、no-store、stale-while-revalidate 等指令;HEAD 请求用于检测更新;sitemap 的 lastmod 与条件请求的配合(下一篇)
    爬虫工程 · 增量抓取:ETag、Last-Modified 与 304 · 实验记录 · 2026-10-12