Python 爬虫 ETag 实战:正确处理 304 与本地缓存,附 5 个可运行验证
2026/9/15 9:00:47 网站建设 项目流程

定时采集同一个接口时,即使内容没有更新,客户端也可能每次下载正文、解析 JSON,再重复写入。服务端支持 ETag 时,可以通过条件请求减少重复传输;但代码如果只保存 ETag,没有保存对应正文,收到 304 后反而无法交付数据。

本文把这个问题缩到一个本地 JSON 接口:首次下载、内容未变、内容变化、缓存丢失,以及新正文损坏。示例仅使用 Python 标准库,2026-09-08 在 Python 3.9.6 下执行通过;服务绑定 127.0.0.1 随机端口,不访问外站,也不需要第三方账号。

1. ETag、If-None-Match 与 304 分别负责什么

ETag 是服务端给当前表示的验证标记。客户端保存它,下一次 GET 时原样放入 If-None-Match;条件匹配时,服务端可以返回 304,客户端复用已有正文。304 本身没有正文,所以不能把它的空响应覆盖进缓存。ETag 也不保证是文件 MD5,不要自行去掉引号或重新计算后替换服务端值。参见 RFC 9110:ETag、If-None-Match 与 304。

这个实验的缓存条目包含两项:服务端返回的 ETag,以及通过业务校验的原始正文。二者必须属于同一次成功响应。只更新标记、不更新正文,会把旧数据伪装成新版本。

2. 先把实验契约写清楚

代码只有一个固定 GET /doc,固定 JSON 表示,无登录态、无重定向、无压缩和语言协商。服务端用正文的 SHA-256 生成强 ETag,客户端只回传这个单一标记;服务端中的字符串比较只覆盖此实验输入。

完整 HTTP 的 If-None-Match 需要弱比较,还支持标记列表和星号。本例没有实现这些分支,也不应把 Handler 拿去当通用条件请求服务。相关语义见 RFC 9110 第 13.1.2 节。

另外,客户端每次都联系本地服务器。它没有实现新鲜度判断、304 响应头合并、Vary、no-store 等缓存规则;通用 HTTP 缓存需要遵循 RFC 9111。这里的重点是应用内一个已知端点的“正文与验证标记一起更新”。

3. 完整本地示例

保存为 etag_demo.py,运行python3 etag_demo.py。代码使用 assert 验证结果,请不要加-O

"""Local ETag experiment, not a general-purpose HTTP cache."""importhashlibimporthttp.clientimportjsonimportthreadingfromdataclassesimportdataclassfromhttp.serverimportBaseHTTPRequestHandler,HTTPServer@dataclass(frozen=True)classEntry:etag:strbody:bytesstate={"body":b'{"version":1}',"force304":False}classHandler(BaseHTTPRequestHandler):deflog_message(self,*args):passdefdo_GET(self):body=state["body"]etag='"'+hashlib.sha256(body).hexdigest()+'"'# This fixture accepts one unchanged strong tag from our own client.unchanged=self.headers.get("If-None-Match")==etag status=304ifunchangedorstate["force304"]else200self.send_response(status)self.send_header("ETag",etag)self.send_header("Cache-Control","no-cache")ifstatus==200:self.send_header("Content-Type","application/json")self.send_header("Content-Length",str(len(body)))self.end_headers()ifstatus==200:self.wfile.write(body)deffetch(port,old=None):headers={"Accept":"application/json"}ifoldisnotNone:headers["If-None-Match"]=old.etag conn=http.client.HTTPConnection("127.0.0.1",port,timeout=3)try:conn.request("GET","/doc",headers=headers)response=conn.getresponse()status,etag=response.status,response.getheader("ETag")body=response.read()finally:conn.close()ifstatus==304:ifoldisNone:raiseRuntimeError("304 without cached body")ifetag!=old.etag:raiseRuntimeError("unexpected ETag in local fixture")returnold,304ifstatus!=200ornotetag:raiseRuntimeError("expected 200 with ETag")# Validate before returning a replacement: failures keep the old entry.value=json.loads(body)iftype(value.get("version"))isnotint:raiseValueError("version must be an integer")returnEntry(etag,body),200defmain():server=HTTPServer(("127.0.0.1",0),Handler)worker=threading.Thread(target=server.serve_forever,daemon=True)worker.start()port=server.server_address[1]cache=Nonetry:cache,status=fetch(port,cache)assertstatus==200andjson.loads(cache.body)["version"]==1print("PASS first: 200, version=1")previous=cache cache,status=fetch(port,cache)assertstatus==304andcacheispreviousprint("PASS unchanged: 304, cached body reused")state["body"]=b'{"version":2}'cache,status=fetch(port,cache)assertstatus==200andcache.etag!=previous.etagassertjson.loads(cache.body)["version"]==2print("PASS changed: 200, version=2")state["force304"]=Truetry:fetch(port,None)exceptRuntimeErroraserror:assertstr(error)=="304 without cached body"else:raiseAssertionError("missing cache was accepted")state["force304"]=Falseprint("PASS missing cache: 304 rejected")previous=cache state["body"]=b'not-json'try:cache,status=fetch(port,cache)exceptjson.JSONDecodeError:passelse:raiseAssertionError("invalid JSON was accepted")assertcacheispreviousprint("PASS invalid 200: old ETag and body preserved")finally:server.shutdown()server.server_close()worker.join()if__name__=="__main__":main()

实际输出:

PASS first: 200, version=1 PASS unchanged: 304, cached body reused PASS changed: 200, version=2 PASS missing cache: 304 rejected PASS invalid 200: old ETag and body preserved

4. 五个结果各自证明了什么

首次请求没有条件头,得到 200 和版本 1。第二次回传已保存的 ETag,得到 304;测试不只检查状态码,还检查客户端返回的是之前那份缓存条目。

把服务端正文改成版本 2 后,旧 ETag 不再匹配。客户端读取新正文、解析并校验,成功后才把新的 ETag 与正文一起交给调用方。

第四个场景故意让测试服务器在客户端没有缓存时仍返回 304。这是异常注入,不是正常协议示范。客户端明确抛错,不把缺失数据伪装成成功。实际采集任务若发现只剩验证标记、正文已被清理,应先清除这份失配状态,再按有界策略发起不带条件头的获取。

第五个场景返回 200,但正文为无效 JSON。由于cache, status = fetch(...)的右侧先求值,函数抛错时左侧不会被替换,原来的 ETag 和正文仍配对保留。保留旧缓存只是保存恢复材料,本次请求依然失败,不能因此在结果中声称拿到了最新数据。

5. 接进采集任务时,先补这三件事

把状态保存成一个完整条目。本例用不可变 Entry 和单次变量赋值展示失败前后状态。持久化时,可以在同一数据库事务中写入正文、ETag 和校验结果;若正文进对象存储,则需要先写正文,再提交指向该对象的元数据,并处理未引用对象。本文没有测试数据库、跨进程并发或断电恢复,内存赋值不能证明这些性质。

让缓存对应正确的请求上下文。本例只有一个端点,所以没有缓存键映射。真实任务中的 URL、会话身份、语言等若会改变结果,就必须隔离对应条目。不要把一个账户获取的 ETag 搭配另一份正文使用,也不要把缓存清理任务只作用于正文、留下可继续发送的标记。

分别记录网络结果与业务结果。200/304 比例可以帮助理解重复传输;业务层还应记录解析成功、缓存缺失、更新失败和最后成功校验时间。304 只说明此次验证的表示可复用,不能代替采集频率、数据完整性和下游更新的检查。本实验没有跑吞吐量或带宽基准,不能据此给出固定的提速百分比。

使用 AI 协助接入时,可以让它补出状态分支和异常用例;验收仍应落到“新正文不合格时,旧正文与旧 ETag 是否一起保留”这类具体断言。先把一致性验证清楚,再评估是否减少了下载与后续处理成本。

示例客户端 API 说明可查 Python 官方 http.client 文档。本文文字和代码由 AI 辅助生成,文中列出的五个场景已在本地实际运行;未进行外站或生产环境验证。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询