拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

【HTTP】从零开始理解浏览器缓存——用本地实验看懂 200、304 和 ETag

【HTTP】从零开始理解浏览器缓存——用本地实验看懂 200、304 和 ETag

【HTTP】从零开始理解浏览器缓存——用本地实验看懂 200、304 和 ETag

改完页面,刷新后还是旧内容;打开开发者工具,却又看到请求返回了 304。有人说这是“没有请求服务器”,有人说把所有响应都改成 no-cache 就能解决。两种说法都容易让排查走偏。

我们从一份十五字节的文本开始:第一次读取拿到正文,第二次带着标记问有没有变化,第三次修改内容再请求。用真实的本机 HTTP 往返观察状态、响应头和正文,把“能不能存”“能不能直接用”“需不需要确认”分开讨论。代码只依赖 Python 标准库,不接外部接口,也不修改浏览器设置。

1. 缓存不只是“有”和“没有”

假设浏览器已经保存了一份响应,还需要判断它是否能用于当前请求。存过一份内容,不代表今后每次都能不加检查地使用;新鲜期结束,也不代表正文立即被物理删除。这是理解 HTTP 缓存的第一处分岔。

如果响应仍然新鲜,并且没有其他指令要求确认,缓存可以直接提供它。如果需要重新验证,客户端可能携带之前的校验标记向服务器询问。服务器确认内容没有改变时返回 304,客户端再使用自己持有的正文。这两条路都可能表现为“页面内容没重新下载”,但网络行为不同。

中文教程常把这两种过程称为强缓存与协商缓存。术语可以帮助记忆,实际诊断仍要落到请求头、响应头和缓存状态上。不要只凭状态码一列判断所有事情,因为开发者工具还会标记内存缓存、磁盘缓存等来源。

这篇文章只讨论 GET 获取一个文本资源。浏览器缓存、CDN、Service Worker 和应用自己存进本地数据库的内容,不是同一套机制。先把范围缩到一个请求,才能避免页面上看到的旧内容被错误归因于某个无关的缓存头。

2. 先把几个响应头摆在一起

我们的服务返回一个带引号的 ETag,以及下面的缓存策略:

Cache-Control: private, max-age=5 ETag: "demo-v1"

这段示意里的 demo-v1 是简化标记,完整代码会使用基于正文计算的十六进制摘要。private 表示共享缓存不得存储此响应,不等于资源已经有访问控制;max-age 给出新鲜生命周期,单位是秒。例子里的五秒只为理解方便,不是生产推荐值。

也不能把五秒简单理解成“从浏览器拿到响应以后固定再算五秒”。缓存判断会涉及响应年龄与传输、存储等因素,Date 和 Age 等信息可能参与其中。本文不实现完整的浏览器年龄计算,避免把一个教学计时器伪装成协议全部规则。

ETag 则负责表示某个选定表示的版本。服务器可以按自身策略产生它,并不规定必须使用某种摘要算法。我们用正文的 SHA-256,是为了让内容变化与标记变化可复算,而不是把摘要计算本身当成缓存的安全认证。

If-None-Match: "demo-v1"

客户端把标记放进条件请求。如果目标表示仍匹配,GET 可以得到 304;不匹配时则通常返回新的 200 与正文。请求头和响应头的方向要分清:ETag 是服务器给的,If-None-Match 是客户端带回去的,不能把名字交换后期待相同行为。

3. 运行一个会自动结束的本地实验

把文末完整代码保存为 cache_lab.py,使用 Python 3.10 或更新版本运行。它在回环地址上选择一个空闲端口,创建服务线程,发送九次实际请求,输出检查结果,然后关闭连接和服务。

python cache_lab.py

服务只提供 /doc 这一条演示路径,正文最初是 hello cache v1 后跟换行。状态在程序内更新,不提供外部修改接口。没有固定占用八千端口,没有开放到局域网,也没有写入浏览器的缓存文件。若本机安全软件禁止监听,先阅读运行错误,不要为了练习关闭系统防护。

验证项实际结果
首次响应200 / 正文15字节
条件匹配304 / 正文0字节
内容更新后200 / 新正文
到达服务端9次请求
实际通过检查17项

本次真实结果是:首次响应 200,正文十五字节;带匹配标记的响应 304,正文零字节;内容更新后继续带旧标记,得到 200 和新正文。十七项检查全部通过,且服务端计数明确记录九次请求到达。

这里的客户端是 http.client,它不会替我们实现浏览器缓存。因此再次无条件请求仍然得到 200,并不说明 max-age 没生效。服务端发送缓存规则,客户端是否执行保存、年龄判断与复用,是另一层责任。这个边界必须写清,否则实验反而会制造新误解。

我们用这份程序验证的是条件请求和响应语义,不是浏览器的完整缓存算法,也不是吞吐量测试。十五字节到零字节指的是响应正文,不包含状态行、响应头、连接开销或协议封装,不能据此计算整个请求节省了百分之百流量。

4. 304 省掉了正文,没有省掉往返

第一次访问时,客户端没有可复用内容,需要拿到正文和标记。后来请求携带同一个标记,服务端仍然处理了这个请求,只是确认无需重新发送表示内容。客户端必须持有合适的已存正文,才能在验证后完成复用。

所以 304 不应该附带完整资源正文。对于我们的 GET 演示,发送响应头后直接结束;脚本断言实际读到的正文为空。同一个响应里仍保留 ETag 和缓存策略,让接收端获得相应元数据,而不是以为 304 什么信息都不用返回。

这也解释了为什么大量 304 仍可能带来延迟。每次确认都可能需要网络往返、连接处理、路由与校验,不能把它和完全在本地完成的读取等同。页面有很多资源时,即使每个正文都没重传,也可能有许多请求需要等待。

反过来,200 也不是失败信号。首次访问、新版本或没有可用校验标记时,返回正文是正常行为。目标不是把所有请求变成 304,而是在数据新鲜度、正确性和成本之间选择合适策略。状态码要结合访问背景解释,而不是做颜色好看的报表。

本地服务客户端本地服务客户端内容更新,标记变为 BGET 无条件200 正文与 ETag AGET,If-None-Match 为 A304 无正文GET,If-None-Match 仍为 A200 新正文与 ETag B

如果客户端已经丢掉正文,却只保留一个旧标记,收到 304 后并不能凭空恢复内容。实现自定义缓存时要同时管理表示正文与元数据,必要时重新无条件获取。只把 ETag 保存下来,并不构成一个完整可用的缓存。

5. 内容变化以后,标记也要跟着变化

程序把正文从 v1 改成 v2,再使用原来的标记发请求。服务端在同一次读取里取得正文快照,基于该快照计算摘要、设置响应头和发送内容,避免标记描述的是一个版本,发出去的却是另一个版本。

线程锁在这个小例子里用来保护状态快照。真实服务可能从数据库、对象存储或模板渲染得到内容,标记生成与表示选择要保持一致。要是正文更新了,ETag 却一直写死,客户端会被错误告知“没有变化”,这就是持续看到旧内容的一种具体原因。

还要观察标记的计算成本。这个例子先取得完整正文再计算摘要,文本很小,便于解释;如果页面需要昂贵的数据库聚合才能生成,再到最后判断没有变化,虽然省了传输,后端计算可能一点也没省。可考虑使用可信版本信息提前判断,但必须保证它确实涵盖影响表示的全部变化,不能为了省计算返回错误的确认。

对于压缩或其他转换后的表示,不能把标记与字节关系想得过于随意。强标记需要满足更严格的表示一致性要求,弱标记则表达较弱的等价关系。本文不压缩、不进行编码转换,始终对发送的同一份字节生成强标记,因此将复杂性控制在读者能逐步核对的范围里。

不要反过来每次请求都生成随机标记。这样即使正文没变,客户端带回去的值也永远不同,验证无法发挥作用。标记应能表达服务器对版本的判断,而不是仅仅保证字符串长得像唯一标识。

脚本还验证了弱标记、标记列表与星号。对 GET 的 If-None-Match 比较采用弱比较,带 W/ 的同值标记也能匹配;星号针对当前存在的表示。弱标记不是“随便改内容也算相同”,更不应直接拿这套逻辑处理写请求的并发控制。

这里没有实现 Last-Modified、If-Modified-Since、Range、内容协商或认证流程。它们各自还有规则。本文匹配函数支持合法带引号标签中的逗号,不能简单用逗号切开后删引号;但整个服务仍是学习子集,不是完整 HTTP 缓存库。

6. no-cache 与 no-store,名字不能望文生义

no-cache 不是“不允许保存”。它允许存储,但使用已存响应满足后续请求前需要成功验证。no-store 要求缓存不存储这次响应,两者不是强弱程度不同的同一个开关。

也不要把后来加上的 no-store 当成远程删除命令。它不能被依赖来清除所有地方此前存下的副本,更不是消除浏览器历史、截图或恶意客户端记录的保证。真正涉及用户隐私时,需要访问控制、正确的缓存策略和明确的数据处理边界共同作用。

private 用于限制共享缓存存储,同样不替代身份验证。每个用户的订单页面不能因为文件地址相同就被当成所有人的公共资源。看到“缓存可用”之前,先问清楚这份响应到底属于谁,以及哪些组件可能复用它。

内容随语言、压缩方式等请求信息变化时,还需要考虑表示选择和 Vary。它提醒缓存选取响应时考虑相关请求头,并不负责实施权限,也不会替开发者纠正错误的共享缓存设计。本文文本不做内容协商,故意避开这一层,让实验结果保持单一含义。

配置策略时可以先按资源类别区分。带内容指纹的静态资源适合评估较长新鲜期;需要及时确认变更的入口文档可考虑验证;不适合缓存的敏感响应再评估 no-store。具体时长必须由更新方式、失效成本和业务容忍度决定,不把教程中的五秒当成统一答案。

7. 六个经常把排查带偏的现象

第一,刷新以后没有看到预期缓存行为。普通导航、刷新、强制刷新以及开发者工具的禁用缓存选项可能改变请求。先记录具体操作,并检查请求里的 Cache-Control,而不是只看服务器曾发过什么。

第二,内容没变却一直返回 200。检查请求是否真的带了 If-None-Match,再看标记是否每次随机变化、引号是否丢失,以及服务端是否实现了条件处理。单独返回一个 ETag 响应头,不会自动替应用完成所有验证逻辑。

第三,内容变了却仍然复用旧正文。核对当前响应的新鲜期、客户端操作、服务端标记生成以及中间层。新鲜期内允许直接使用旧版本,本来就可能是策略允许的结果;不一定是浏览器故障。

第四,把 304 正文为空当成接口报错。条件请求的客户端应从合适的已存表示取得正文;直接用一个没有缓存实现的底层 HTTP 客户端看 304,自然读不到原资源。这也是本例特意将两层责任分开的原因。

第五,把缓存命中等同于源站完全不工作。共享缓存可能在边缘验证,也可能回源;客户端看到的响应不一定能说明整条链路发生什么。需要结合请求日志、Age、Via 或具体服务提供的诊断头,但不能假设每个系统都提供同样字段。

第六,看到旧页面就先清所有缓存。Service Worker、前端状态、本地存储、CDN 和源站本身都可能参与。清空后暂时正常,只说明某处状态被改变,未必解释了产生旧内容的原因。先保留证据,再做范围尽量小的操作。

8. 给缓存问题留一条可重复的观察路径

打开浏览器网络面板时,先固定同一个 URL、请求方法和访问动作,记录有没有网络请求、条件请求头、响应状态、ETag 与 Cache-Control。把它与源站请求计数对应起来,才能区分本地直接复用、重新验证和重新传正文。

最好分别记录首次加载、重复导航和刷新三个场景,而不是只截取一次状态码。测试之前确认开发者工具是否勾选禁用缓存,观察时也别同时改服务端头部、文件名和页面逻辑。某些资源来源标记只是工具的呈现方式,最后仍以实际请求与服务端日志相互印证,不将某个浏览器版本的界面文字当成协议标准。

与同事讨论时,可以把证据缩成一张记录:请求地址、请求条件、响应策略、响应标记、正文是否变化。涉及账户信息的请求日志需要先脱敏,不复制令牌和Cookie。缓存调试需要的是足以解释版本关系的字段,不是把所有用户信息收集进截图。这样的记录也更容易比较一次修复前后的差异。

否

是

是

否

是

否

准备发起 GET

存在候选缓存吗

发送普通请求

新鲜且允许直接用吗

使用本地正文

有校验标记吗

发送条件请求

这张流程图是 GET 缓存决策的简化观察路线,不涵盖所有协议例外。正式实现还要处理完整年龄计算、缓存键、错误恢复与各项指令。入门时最重要的是别让“有一份缓存”直接跳过所有判断,尤其是在多用户与多表示环境里。

如果想继续扩展本地实验,可以先记录每次请求的条件头和正文长度,再分别尝试内容不变、内容改变、标记过期和路径不存在。一次只改一个因素,保留其余条件。不要先接入 CDN,再让五六层变化同时发生,最后只能靠反复刷新碰运气。

文末代码会自动关闭服务,默认只监听回环地址,不适合拿到生产环境提供服务。它没有 TLS、认证、完整缓存实现和资源大小控制。学习阶段的目标,是亲眼验证每个请求与响应,而不是用演示服务器替换成熟组件。

下次再看到 304,可以先问两个具体问题:这个请求有没有真正发出去,客户端将复用哪一份正文?而当你修改页面却仍看到旧内容时,最值得记录的是修改前后的标记、响应策略和访问动作。你遇到的更像“新鲜期内继续复用”,还是“内容变化但标记没更新”?

附录:完整可运行代码

保存为 cache_lab.py 后执行前文命令。全部数据和条件请求均由程序构造,结果中的九次请求都是真实回环网络往返,没有模拟浏览器直接命中的次数。

"""Local GET/ETag experiment. Python 3.10+, no third-party packages."""importhashlibimporthttp.clientimportjsonimportreimportthreadingfromhttp.serverimportBaseHTTPRequestHandler,ThreadingHTTPServer TAG=r'(?:W/)?"[\x21\x23-\x7e\x80-\xff]*"'defmatches(header,current):ifheaderisNone:returnFalsevalue=header.strip(' \t')ifvalue=='*':returnTrueifnotre.fullmatch(TAG+r'(?:[ \t]*,[ \t]*'+TAG+r')*',value):returnFalsereturnany(m.group().removeprefix('W/')==currentforminre.finditer(TAG,value))defmain():state={'body':b'hello cache v1\n','requests':0}lock=threading.Lock()classHandler(BaseHTTPRequestHandler):defdo_GET(self):withlock:state['requests']+=1body=state['body']ifself.path!='/doc':self.send_response(404)self.send_header('Cache-Control','no-store')self.send_header('Content-Length','0')self.end_headers()returnetag='"'+hashlib.sha256(body).hexdigest()+'"'not_modified=matches(self.headers.get('If-None-Match'),etag)self.send_response(304ifnot_modifiedelse200)self.send_header('ETag',etag)self.send_header('Cache-Control','private, max-age=5')ifnotnot_modified:self.send_header('Content-Type','text/plain; charset=utf-8')self.send_header('Content-Length',str(len(body)))self.end_headers()ifnotnot_modified:self.wfile.write(body)deflog_message(self,*_):passserver=ThreadingHTTPServer(('127.0.0.1',0),Handler)worker=threading.Thread(target=server.serve_forever,daemon=True)worker.start()defget(tag=None,path='/doc'):conn=http.client.HTTPConnection('127.0.0.1',server.server_port,timeout=3)try:headers={}iftagisNoneelse{'If-None-Match':tag}conn.request('GET',path,headers=headers)response=conn.getresponse()returnresponse.status,dict(response.getheaders()),response.read()finally:conn.close()try:first=get()old=first[1]['ETag']again=get()same=get(old)weak=get('W/'+old)listed=get('"other", '+old)star=get('*')stale=get('"outdated"')withlock:state['body']=b'hello cache v2\n'changed=get(old)missing=get(path='/missing')checks={'first_200':first[0]==200,'first_body':first[2]==b'hello cache v1\n','repeat_without_condition_200':again[0]==200,'same_tag_304':same[0]==304,'304_body_empty':same[2]==b'','304_keeps_tag':same[1]['ETag']==old,'304_cache_policy':same[1]['Cache-Control']=='private, max-age=5','weak_comparison_for_get':weak[0]==304,'tag_list':listed[0]==304,'existing_star':star[0]==304,'different_tag_200':stale[0]==200,'changed_body_200':changed[0]==200andchanged[2]==b'hello cache v2\n','changed_tag':changed[1]['ETag']!=old,'missing_404':missing[0]==404,'all_nine_requests_reached_server':state['requests']==9,'quoted_comma_supported':matches('"a,b", '+old,old),'malformed_not_matched':notmatches('unquoted',old),}report={'checks':checks,'passed':sum(checks.values()),'first_status':first[0],'conditional_status':same[0],'changed_status':changed[0],'body_bytes_200':len(first[2]),'body_bytes_304':len(same[2]),'server_requests':state['requests'],'scope':'Loopback HTTP requests; no browser cache implementation or throughput benchmark'}print(json.dumps(report,ensure_ascii=False,indent=2))ifnotall(checks.values()):raiseSystemExit('A check failed; inspect the report.')finally:server.shutdown()server.server_close()worker.join()if__name__=='__main__':main()

技术资料

  • HTTP缓存规范 RFC 9111
  • HTTP语义 RFC 9110
  • MDN:缓存机制
  • MDN:Cache-Control
  • MDN:ETag
  • MDN:If-None-Match
返回列表