ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/10/4 20:18:13 拓冰建站 浏览量
【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-age5 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 AGETIf-None-Match 为 A304 无正文GETIf-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 TAGr(?:W/)?[\x21\x23-\x7e\x80-\xff]*defmatches(header,current):ifheaderisNone:returnFalsevalueheader.strip( \t)ifvalue*:returnTrueifnotre.fullmatch(TAGr(?:[ \t]*,[ \t]*TAGr)*,value):returnFalsereturnany(m.group().removeprefix(W/)currentforminre.finditer(TAG,value))defmain():state{body:bhello cache v1\n,requests:0}lockthreading.Lock()classHandler(BaseHTTPRequestHandler):defdo_GET(self):withlock:state[requests]1bodystate[body]ifself.path!/doc:self.send_response(404)self.send_header(Cache-Control,no-store)self.send_header(Content-Length,0)self.end_headers()returnetaghashlib.sha256(body).hexdigest()not_modifiedmatches(self.headers.get(If-None-Match),etag)self.send_response(304ifnot_modifiedelse200)self.send_header(ETag,etag)self.send_header(Cache-Control,private, max-age5)ifnotnot_modified:self.send_header(Content-Type,text/plain; charsetutf-8)self.send_header(Content-Length,str(len(body)))self.end_headers()ifnotnot_modified:self.wfile.write(body)deflog_message(self,*_):passserverThreadingHTTPServer((127.0.0.1,0),Handler)workerthreading.Thread(targetserver.serve_forever,daemonTrue)worker.start()defget(tagNone,path/doc):connhttp.client.HTTPConnection(127.0.0.1,server.server_port,timeout3)try:headers{}iftagisNoneelse{If-None-Match:tag}conn.request(GET,path,headersheaders)responseconn.getresponse()returnresponse.status,dict(response.getheaders()),response.read()finally:conn.close()try:firstget()oldfirst[1][ETag]againget()sameget(old)weakget(W/old)listedget(other, old)starget(*)staleget(outdated)withlock:state[body]bhello cache v2\nchangedget(old)missingget(path/missing)checks{first_200:first[0]200,first_body:first[2]bhello 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-age5,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]bhello 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_asciiFalse,indent2))ifnotall(checks.values()):raiseSystemExit(A check failed; inspect the report.)finally:server.shutdown()server.server_close()worker.join()if__name____main__:main()技术资料HTTP缓存规范 RFC 9111HTTP语义 RFC 9110MDN缓存机制MDNCache-ControlMDNETagMDNIf-None-Match