☰
HTTP协议核心机制:缓存、跨域、幂等与状态码实战
2026/10/27 13:57:24 网站建设 项目流程

有人跟我说,“HTTP 不就是 GET 和 POST 吗,能有多难”。我当时没反驳,只是让他把浏览器开发者工具打开,对着一个普通的页面刷新一次,然后逐个看过去:二十多个请求,一堆 304,几个 200 但从缓存来的,还有一个 OPTIONS 预检,一条 Cookie 属性带 SameSite 的响应头。他看了两分钟,说“这些我好像都没仔细看过”。

这篇东西算是我自己学 HTTP 的摘要整理,偏向“概念 + 为什么 + 怎么验证”的路子,不是抄一遍 RFC 的目录。适合三类人看:刚接触后端或者前端、被缓存和跨域反复折磨的;写过接口但说不清幂等是什么意思的;以及想把零散知识点串成一条线的。里面涉及的所有验证手段都很轻,一台电脑、一个命令行工具就够,不需要搭什么复杂环境。

1. 先把 HTTP 看成一份“点菜单”

1.1 客户端和服务端之间到底发生了什么

HTTP 的中文名是超文本传输协议,这个翻译其实挺误导人的,容易让人以为它只传文本。实际它传的东西早就超出“超文本”的范围了:图片、视频分片、JSON、二进制文件、WebSocket 握手请求,全走这一套格式。它真正定义的是一次对话的格式和顺序,而不是内容本身。

我习惯用点菜来类比。顾客坐下(建立连接),看着菜单说“我要一份番茄炒蛋,不要葱”(请求:方法、目标、补充说明),后厨做好端上来(响应:状态、说明、菜本身),吃完走人(连接关闭或者留着给下一桌用)。整个过程里,顾客不需要知道后厨有几个灶、谁在炒,后厨也不需要记住这位顾客昨天来过。这种“一问一答、各管各的”的模式,就是 HTTP 最核心的骨架。

这个骨架带来两个必然结果。第一,通信永远是客户端发起、服务端响应,服务端不能主动推一份数据给还没发问的客户端(HTTP/2 的 Server Push 曾经想做这件事,后来被主流实现放弃了,原因后面会说)。第二,每一次请求都是独立的,服务端在协议层面不记得你是谁。第二点衍生出来的一切——Cookie、Session、Token——本质上都是人们为了在这个“不记事”的协议上做“记事”的功能,而打的补丁。

理解了这一层,很多事情就顺了。为什么登录之后要带 Cookie?因为协议不记人。为什么刷新页面有时会弹“确认重新提交表单”?因为上一次的 POST 请求结果没有被记录下来,浏览器不敢替你重放。

1.2 无状态是设计取舍,不是缺陷

新手看到“HTTP 是无状态协议”这句话,第一反应往往是“那岂不是很弱”。其实恰恰相反,无状态是它能撑起今天这个规模的原因之一。

想象一下,如果服务端必须记住每一个来访者的上下文,那么一万个用户就是一万元素的会话表,一个机房不够就得上集群,集群之间还得同步这张表。用户量再涨,同步成本指数级上升。而把状态拿掉之后,任何一个服务节点收到请求,只要请求本身携带了足够的信息,就能独立处理完,节点之间不需要互相对账。横向扩容变成了一件纯粹加机器的事。这就是为什么早期的负载均衡可以做得那么简单粗暴——轮询就行。

代价当然也有。每次请求都得把身份信息重新带上,Cookie 就是干这个的;请求体里可能重复传一些其实没变的数据;服务端想推送一条通知,只能靠轮询或者长连接另开通道。这几件事后来分别演化出了 Cookie、缓存、WebSocket 等一整套补丁体系。

所以我在看任何一个 HTTP 相关的问题时,脑子里会先过一遍:这是在跟“无状态”对抗,还是在利用“无状态”?缓存是利用——正因为每次请求独立,才敢把响应存下来给别人用;限流是在对抗——正因为服务端不记人,才需要额外记录每个来源的请求次数。

1.3 URL 里每一段都有明确分工

一个完整的地址拆开来是这样的:

https://api.example.com:8443/v2/orders/1024?fields=id,total#section-2 └─┬─┘ └──────┬───────┘└┬┘└────┬─────┘└────┬────┘└──────┬──────┘└───┬───┘ scheme authority port path query fragment
  • scheme:协议类型。http 和 https 差的不只是加密,默认端口、安全上下文、能否用某些浏览器 API,全都不一样。
  • authority:主机名,可能带端口。它决定了这次请求要连到哪台机器,也决定了 DNS 解析的结果。
  • path:资源路径。按 REST 的习惯,这里应该放“名词”,/orders/1024比/getOrderById?id=1024更符合语义,但这不是强制规定。
  • query:查询参数。给谁看、能不能缓存、要不要参与签名,都是坑点,第 5 章会详细说。
  • fragment:#后面的部分。关键点在于,它根本不会发给服务端。浏览器自己拿着用,做锚点跳转。很多人把参数写在 fragment 里然后抱怨后端收不到,原因就在这。

还有一个容易忽略的细节:URL 是有长度限制的,但这个限制不由 HTTP 协议规定,而是各浏览器和服务器各自设的。我见过有人把一整个 JSON 塞进 query 里,本地测试没问题,一上线换了个网关就 414 了。请求体才是放大量数据的正地方。

2. 拆开一条报文:起始行、首部、空行、消息体

2.1 请求行和状态行的格式差在哪

HTTP/1.1 的报文是纯文本的,这一点非常友好,随便一个文本编辑器就能看懂。一条请求报文长这样:

POST /api/orders HTTP/1.1 Host: api.example.com Content-Type: application/json Content-Length: 42 {"sku":"A-1001","count":2}

四段结构:起始行、首部字段、一个空行、消息体。空行是硬性要求,哪怕没有消息体也得留着,它是“头部结束”的分界线。少一个空行,服务端就会一直等下去,直到超时——这是我自己手工拼报文时踩过的第一个坑。

请求行是方法 目标 版本三段,中间用单个空格分隔。响应行则是版本 状态码 原因短语三段,比如HTTP/1.1 200 OK。注意顺序是反的,很多人第一次手写响应的时候会写错。

原因短语(那个 OK)在现代实践里没什么用,浏览器和客户端都不解析它,只认状态码。所以理论上你可以返回HTTP/1.1 200 随便写点啥,照样能跑。但这属于能跑不要学的类型,日志和抓包工具看着会很难受。

2.2 首部字段按“管什么”来分组记,比背清单省力

首部字段有上百个,硬背没意义。我自己的记法是按作用域分四组:

分组管什么典型字段
内容协商我想要什么格式、我给了你什么格式Accept、Accept-Encoding、Content-Type、Content-Encoding、Content-Language
连接与传输这条连接怎么用、这个体有多长Host、Connection、Content-Length、Transfer-Encoding、Keep-Alive
缓存与验证这份东西能不能存、存多久、变没变Cache-Control、ETag、Last-Modified、If-None-Match、Vary、Age
状态与安全我是谁、我从哪来、能不能跨域Cookie、Authorization、Origin、Referer、Set-Cookie、Access-Control-*

分组之后你会发现,排查问题的路径也变得清晰了。跨域报错,去第四组找;拿到了旧数据,去第三组找;请求卡住不返回,去第二组找;后端说收到的是乱码,去第一组找。

Host这个字段单独提一下。HTTP/1.1 强制要求请求必须带 Host,因为一台服务器上可能跑着几十个站点,靠它来区分你到底要访问哪一个。这也解释了为什么用 IP 直接访问某些站点会返回一个默认页——没有正确的 Host,服务端不知道给你哪个站的内容。

2.3 一条真实抓包的逐行读法

拿命令行工具发一个请求,打开详细输出,会看到类似这样的东西(我省略了无关行):

curl -v -H "Accept: application/json" https://api.example.com/v2/orders/1024
> GET /v2/orders/1024 HTTP/1.1 > Host: api.example.com > User-Agent: curl/8.x > Accept: application/json > < HTTP/1.1 200 OK < Date: Tue, 01 Apr 2025 08:12:33 GMT < Content-Type: application/json; charset=utf-8 < Content-Length: 137 < Cache-Control: private, max-age=60 < ETag: "a1b2c3" < {"id":1024,"total":"99.00","status":"paid"}

读的顺序我建议是这样:先看>开头的请求行,确认方法和路径没写错;再看<开头的状态行,200 说明链路通了;然后扫一遍响应头里的Content-Type和Content-Length,确认客户端会怎么解析这段字节;最后看Cache-Control和ETag,判断这份数据会不会被缓存、下次会不会走 304。

这个顺序不是随便定的。从下往上看报文容易迷失,因为首部字段太多了;从上往下、按“请求对不对 → 响应成没成 → 内容怎么解析 → 缓存怎么处理”的四步走,基本能在三十秒内定位大部分问题。

还有一个经验:抓包时别只看一条。把同一个操作连发两次,对比两次的请求头差异,很多缓存和条件请求的问题会自己浮出水面。

3. 方法、幂等性与状态码:语义用歪了会出事

3.1 九个方法各自的真实用途

规范里定义了九个方法,日常真正用到的也就五六个。

  • GET:取资源。不该有副作用,参数放 query 或者 path 里。可以被缓存、被收藏、被浏览器预取。
  • HEAD:跟 GET 一样,但服务端不返回消息体。用来探活、探文件大小、探资源是否存在,省流量。
  • POST:提交数据,让服务端处理。语义最宽泛,什么都往里塞也不会报错,正因为宽泛,它也是最容易被滥用的一个。
  • PUT:整体替换一个资源。客户端提供完整的表示,服务端用它覆盖掉原来的。
  • PATCH:局部更新。只提交要改的字段。
  • DELETE:删除资源。
  • OPTIONS:问“这个资源支持哪些方法”。跨域预检请求用的就是它。
  • TRACE:把请求原样回显,用于诊断。出于安全考虑,绝大多数生产环境直接禁掉。
  • CONNECT:让服务器建立一条隧道。日常业务代码基本碰不到。

PUT 和 PATCH 的区别经常被搞混。举个具体例子:一个订单对象有status和remark两个字段。用 PUT 更新,客户端必须把两个字段都发上来,少发一个,服务端就当成“你要把它置空”;用 PATCH 更新,只发{"remark":"加急"},status保持原样不动。用 PUT 只发一个字段,是把自己坑在“莫名丢数据”上的经典姿势。

3.2 幂等和安全的判断,决定了重试能不能乱发

安全(safe)指的是这个方法不改变服务端状态。GET、HEAD、OPTIONS、TRACE 是安全的。

幂等(idempotent)指的是同一个请求执行一次和执行 N 次,对服务端产生的最终效果相同。GET、HEAD、PUT、DELETE、OPTIONS、TRACE 是幂等的。POST 不是。PATCH 不保证是。

这个区分不是学术上的洁癖,它直接对应一条工程规则:网络超时了,能不能自动重试。

假设客户端发了一个 POST 创建订单的请求,服务端处理完了,但响应在回程路上丢了。客户端看到超时,如果自动重试,就会创建出两张订单。而如果换成 PUT/orders/1024(幂等地写入指定 ID 的资源),重试多少次,最终也只有一条记录。这就是为什么很多对接外部系统的接口,宁可要求调用方自己生成唯一 ID 走 PUT,也不愿意提供 POST 创建接口。

现实里更常见的做法是给 POST 加一个幂等键:客户端生成一个 UUID 放在首部或者请求体里,服务端记录这个键处理过没有,重复的直接返回上一次的结果。这套机制在支付、下单这类场景里几乎是标配。

注意:DELETE 是幂等的,但不代表“第一次返回 200、第二次返回 200”。第二次完全可以返回 404,只要服务端的最终状态一样,幂等性就成立。判断幂等看的是状态,不是状态码。

3.3 状态码的坑集中在 3xx 和 4xx

状态码按首位数字分五类,这个大家都知道。真正容易出问题的是具体选择。

状态码含义常见误用
200成功业务错误也返回 200,靠 body 里的 code 区分
201已创建创建成功却不带 Location 头
204成功但无内容返回了 204 却还写了响应体
301永久重定向用在临时场景,结果被浏览器长期缓存
302临时重定向大量客户端会把 POST 变成 GET
304未修改服务端返回了 304 却带着 body
400请求有误拿它当“参数校验失败”的万能筐
401未认证和 403 混用
403已认证但无权限跟 401 反过来用
429请求过多限流了却不给 Retry-After

301 和 302 的选择值得单说。301 是永久重定向,浏览器和中间层会把它缓存下来,下一次直接跳到新地址,连原地址都不问了。这意味着一旦你把一个 301 发出去,用户端可能在很长时间内都改不回来。我见过把测试环境的重定向规则误发到线上、结果一片用户被钉死在错误域名的案例。规则还没最终确定的时候,一律用 302 或者 307。

302 和 307 的差别在于:302 在历史上被实现成“把 POST 改成 GET 再跳”,307 则明确要求保持原有方法和请求体。如果你要从 A 地址把用户带着表单数据跳到 B 地址,307 才是你想要的那个。

再补一个:401 和 403 的区别。401 是“你没告诉我你是谁”,客户端应该去认证,响应里通常会带WWW-Authenticate;403 是“我知道你是谁,但你不能干这个”。把登录过期返回成 403,前端就没法触发跳转登录页,只能弹个“无权限”让用户一脸茫然。

4. 连接层:长度、分块、长连接

4.1 从一次一连接到 keep-alive 的动机

HTTP/1.0 默认是短连接:一个请求,一次 TCP 握手,拿到响应,关掉。听起来很干净,但代价是每次都要重新握手。三次握手加上(如果是 HTTPS)TLS 握手,一两个来回就过去了,而真正传数据可能只有几百字节。在页面要加载几十个资源的年代,这个开销是灾难性的。

HTTP/1.1 把长连接变成了默认行为。同一条 TCP 连接上可以连续发多个请求,Connection: keep-alive在 1.1 里已经是默认值,不需要显式写(写Connection: close才是要求关闭)。服务端一般会配一个空闲超时,比如 60 秒没新请求就断掉。

这里有个不太直观的后果:连接是有状态的,而协议本身是无状态的。一条复用的连接上,前一个请求如果没被正确读完,后一个请求就可能读到脏数据。所以服务端和客户端必须对“这条消息到哪结束”有完全一致的判断,这就是长度字段的重要性。

4.2 Content-Length 和 Transfer-Encoding 的冲突处理

判断消息体边界,HTTP/1.1 有两条路:

  • Content-Length: 137—— 明确告诉对方,接下来 137 个字节就是全部内容。
  • Transfer-Encoding: chunked—— 不预先知道总长度,改成分块发送,每块前面标长度,最后发一个长度为 0 的块表示结束。

两者不能同时用。规范里写得很清楚:如果同时出现,Transfer-Encoding优先,并且必须忽略Content-Length。这条规则不是学术细节,它是历史上一些请求走私攻击的根源——中间层和源站对同一个请求的长度判断不一致,就会把一部分内容当成下一个请求的开头。

我在实际排查里遇到过两种典型症状:

一种是请求一直挂着不返回。原因通常是声明的Content-Length大于实际发送的字节数,服务端老老实实地等剩下的字节,等到超时。反向的情况是声明得比实际小,多出来的字节被当成了下一个请求,服务端报 400。

另一种是响应下载到一半断掉。如果响应用了 chunked,而中间某一层把它“优化”成了带Content-Length的完整响应,长度算错了,客户端就会提前收工。

提示:排查这类问题,先看响应头里到底给的是Content-Length还是Transfer-Encoding,再看实际收到的字节数。两者对不上,问题就在中间某一层,而不在你的业务代码里。

4.3 chunked 真正适用的场景

什么时候该用分块传输?答案是:服务端开始发送时还不知道总长度。

最典型的是流式生成的内容。比如一个接口要实时把处理进度吐给前端,处理多少算多少,事先根本算不出最终会有多少字节。这时候设Transfer-Encoding: chunked,每算完一段就发一块,前端边收边渲染,体验比憋到最后一次性返回好得多。

另一个场景是压缩。服务端先压缩再发送,压缩后的长度在压缩完成前也未知。这里有个顺序问题经常被搞错:Content-Encoding: gzip和Transfer-Encoding: chunked是两个层次的事,压缩是“内容怎么编码”,分块是“消息怎么切分”。先压缩,再分块,接收端先解分块,再解压缩。顺序反了,拿到的东西就是一坨乱码。

顺便说一句 HTTP/2。它把报文改成了二进制分帧,长度信息放在帧头里,Transfer-Encoding: chunked在 HTTP/2 里是被禁止的。所以如果你的代码里硬编码了这个头,从 1.1 升到 2 的时候会出问题。

5. 缓存判定链路:从强缓存到协商缓存

5.1 强缓存:连请求都不发

缓存是 HTTP 里性价比最高的一个特性,也是最容易出线上事故的地方。它的判定分两层,第一层叫强缓存。

强缓存生效时,浏览器压根不会发请求,直接从本地磁盘或者内存里拿。控制它的主要字段是Cache-Control:

  • max-age=3600:从响应生成那一刻算起,3600 秒内直接用本地的,不问服务端。
  • s-maxage=600:专门给共享缓存(比如 CDN、反向代理)看的,优先级高于 max-age。
  • no-cache:名字很坑,它不是“不缓存”,而是“可以存,但每次用之前必须去服务端验证一下”。等价于强制走协商缓存。
  • no-store:真正的“不许存”,任何环节都不能留副本。
  • private/public:能不能被共享缓存存。带用户隐私的响应必须是 private。
  • must-revalidate:过期之后不许用陈旧副本,必须验证。

还有一个老字段Expires,给的是一个绝对时间。它的问题在于依赖客户端时钟,用户把系统时间改一下,缓存就乱了。现代实践里Cache-Control优先,Expires只作为兜底。

强缓存的时间怎么算?不是从你收到响应开始,而是从响应在源站生成开始。中间如果经过了 CDN,CDN 会用Age头告诉你这份副本已经存了多久。实际的剩余新鲜度 ≈max-age减去Age。这一点在排查“为什么刚发的版本用户还看到旧的”时非常关键——你以为的 3600 秒,可能只剩 600 秒了。

5.2 协商缓存:问一句“变了没”

强缓存过期之后,进入第二层:协商缓存。浏览器带着验证信息去问服务端,服务端回 304(没变,你继续用本地的)或者 200(变了,这是新的)。

验证信息有两组,成对出现:

请求头(浏览器发)响应头(服务端发)依据
If-None-Match: "a1b2c3"ETag: "a1b2c3"内容指纹,精确
If-Modified-Since: <时间>Last-Modified: <时间>最后修改时间,秒级精度

两者同时存在时,ETag 优先。原因是Last-Modified有几个硬伤:精度只到秒,一秒内改两次就判断不出来;文件内容没变但被重新保存,时间变了会误判为已更新;还有分布式环境下多台机器的时间不一致问题。ETag 是内容算出来的指纹,这些问题都没有。

ETag 分强验证和弱验证。"a1b2c3"是强 ETag,字节级一致;W/"a1b2c3"是弱 ETag,语义上等价即可。用弱 ETag 做范围请求(断点续传)是不允许的,因为字节可能真的不一样。

304 响应有个特性必须记住:它没有消息体。服务端返回 304 的时候,如果框架还傻乎乎地把 body 写进去,某些客户端会解析出错。而且要意识到,走 304 虽然省了带宽,但请求还是发出去了,一次往返的延迟跑不掉。所以高频接口该配强缓存就配,别指望协商缓存能省掉网络开销。

5.3 一次完整请求的缓存决策过程

把上面两层串起来,浏览器在发请求之前脑子里过的是这么一串判断(我用文字走一遍,画图容易看晕):

第一步,看有没有本地副本。没有,直接发请求。有,进第二步。

第二步,看这份副本是不是还能用(新鲜)。判断依据是Cache-Control的 max-age 减去已存时间,或者Expires跟当前时间比。还能用,直接用,不发请求,开发者工具里会显示(from disk cache)或者(from memory cache)。

第三步,副本过期了,看有没有 ETag 或者 Last-Modified。有,带上If-None-Match/If-Modified-Since发条件请求。没有,发普通请求,拿完整响应。

第四步,服务端比对。没变,回 304,浏览器用本地副本,同时按响应里的新Cache-Control刷新这份副本的过期时间。变了,回 200 带新内容,整体替换。

第五步,还有一种情况:用户按了强制刷新(一般是 Ctrl+F5 或者 Shift+刷新),浏览器会跳过所有缓存,带Cache-Control: no-cache直接要新的。这个行为不受你服务端配置控制。

这个流程里有一个容易被忽略的分支:Vary头。如果你的接口会根据Accept-Encoding、Accept-Language或者自定义头返回不同内容,必须用Vary把它们列出来,否则缓存会把 A 版本的响应喂给需要 B 版本的请求。我见过一个接口对移动端和桌面端返回不同结构,但没配Vary: User-Agent,结果 CDN 上先缓存了移动端的版本,桌面端用户全拿到了移动端的数据。这种问题在本地永远复现不了。

5.4 缓存踩坑的几种典型形态

静态资源更新不生效。常见做法是给文件名加哈希(app.a1b2c3.js),然后给这类资源配超长的 max-age(一年)。因为文件名变了 URL 就变了,等于天然失效。而 HTML 入口文件必须配no-cache,保证用户每次都能拿到最新的资源引用。这两条配反了,就会出现“用户永远打不开新版本”或者“每次刷新都全量下载”的极端。

接口数据被 CDN 缓存串号。带用户身份的接口如果没配Cache-Control: private,被 CDN 缓存下来,下一个用户可能拿到别人的数据。这类事故的后果比性能问题严重得多,所以涉及个人数据的响应,我现在的习惯是默认加private, no-store,确认可以缓存了再放开。

改了配置但不生效。中间可能隔了好几层缓存:浏览器、Service Worker、CDN、反向代理、应用内的本地缓存。排查的时候要从最外层往里一层层剥,先确认响应头里到底带了什么,再确认每一层有没有按这个头执行。光看代码是看不出来的。

6. Cookie、跨域与 HTTPS 划出的安全边界

6.1 Cookie 的几个属性必须组合着看

Cookie 是补“无状态”这个洞最原始也最有效的工具。服务端通过Set-Cookie下发,浏览器存起来,之后同域的请求自动带上。

一个完整的设置长这样:

Set-Cookie: sid=abc123; Path=/; Domain=example.com; Max-Age=86400; Secure; HttpOnly; SameSite=Lax

逐个说:

  • Path和Domain决定这个 Cookie 会被带到哪些请求上。Domain 设得太宽(比如设成顶级域),所有子域都能读到,风险很大。
  • Max-Age是存活秒数,Expires是绝对时间。不设的话就是会话 Cookie,关浏览器就没了。
  • Secure表示只在 HTTPS 连接上发送。少了这个,同域下任何一个 http 请求都会把 Cookie 明文带出去。
  • HttpOnly表示 JavaScript 读不到。这是防 XSS 窃取会话的第一道墙,服务端下发的会话 Cookie 基本都该加。
  • SameSite控制跨站请求要不要带这个 Cookie。Strict最严,从别的站跳过来一律不带;Lax是默认值,顶级导航的 GET 请求会带;None表示都带,但必须同时加Secure。

SameSite这个属性我踩过坑。有一个从合作方页面跳回自己站点的场景,用户跳回来之后登录态丢了,查了半天以为是 Token 问题,最后发现是SameSite=Strict把跨站跳转带过来的 Cookie 拦掉了。改成Lax之后正常。这类问题的特点是:本地开发时前后端同源,永远测不出来。

6.2 跨域预检到底在问什么

浏览器的同源策略规定:协议、域名、端口三者完全一致才算同源,否则发起的请求要受限制。注意这是浏览器的限制,不是 HTTP 协议的限制。用命令行工具发同样的请求,一点问题没有。

对于那些“不简单”的跨域请求(方法是 PUT/DELETE/PATCH,或者 Content-Type 是 application/json,或者带了自定义头),浏览器会先发一个OPTIONS预检:

OPTIONS /api/orders HTTP/1.1 Origin: https://web.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type,x-trace-id

它在问三件事:我从这个来源来行不行、我要用 POST 行不行、我要带这几个头行不行。服务端必须逐一回答:

HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://web.example.com Access-Control-Allow-Methods: GET,POST,PUT,DELETE Access-Control-Allow-Headers: content-type,x-trace-id Access-Control-Max-Age: 600

几个高频坑:

Access-Control-Allow-Origin写*的时候,不能同时开Access-Control-Allow-Credentials: true。因为带凭证的请求必须明确知道对方是谁,通配符等于放弃了这层校验。要支持多来源,得在服务端判断Origin的值再动态回填,同时记得加Vary: Origin,否则缓存会把 A 站的响应发给 B 站。

预检结果会被缓存,缓存时长由Access-Control-Max-Age决定。不设的话,很多浏览器每次真实请求前都要多问一次,白白多一个往返。设个 600 秒通常够了。

还有一点:预检失败的时候,浏览器控制台报的错往往很模糊,只说“CORS 错误”,不说具体缺哪个头。这时候要去网络面板里找那条 OPTIONS 请求,看它的响应头到底返了什么。我现在的习惯是先把预检请求单独跑通,再去调真实请求,能省一半时间。

6.3 HTTPS 改的是通道,不是语义

很多人以为上了 HTTPS,报文格式就不一样了。其实完全不是。HTTP 的方法、状态码、首部字段、缓存规则,一个字都没变。变的只是这些明文字节在传输之前被套进了一个加密通道里。

所以前面讲的缓存、Cookie、CORS,在 HTTPS 下全都照旧生效。区别在于:

  • 默认端口从 80 变成 443。
  • 浏览器会把页面标记为“安全上下文”,某些 API(比如地理定位、剪贴板、Service Worker)只在这种情况下可用。
  • Secure属性的 Cookie 只在 HTTPS 下发送。
  • 中间人无法读取或篡改内容,但它能看到的元信息依然存在,比如你连的是哪个域名。

握手过程本身有个细节值得了解:客户端和服务端在 TLS 握手阶段通过 ALPN 协商用哪个 HTTP 版本。这就是为什么同一个地址,有时候走 HTTP/2 有时候走 1.1——取决于双方支持的版本交集。排查“为什么我配了 HTTP/2 但没生效”的时候,先看 ALPN 协商出来的结果,别急着怀疑配置。

7. 版本演进:1.1 到 2 到 3 到底改了什么

7.1 队头阻塞的两层含义

HTTP/1.1 最大的性能问题叫队头阻塞(head-of-line blocking),但它其实有两个层次,很多人混为一谈。

第一层是应用层的队头阻塞。1.1 虽然在一条连接上可以连续发请求,但响应必须按请求的顺序回来。第一个请求处理得慢,后面九个都得排队等着。浏览器为了绕开这个限制,对同一个域名开 6 条左右的并发连接,但这又带来了新的开销。

第二层是传输层的队头阻塞。TCP 保证数据按序到达,一个包丢了,后面的包即使收到了也得在内核缓冲区里等着,等前面那个包重传成功才能一起交给应用层。这个问题在 1.1 时代不明显(因为应用层的瓶颈更大),到了 HTTP/2 就成了主要矛盾。

7.2 HTTP/2 用二进制分帧解决了第一层

HTTP/2 的核心改动是:把报文拆成二进制帧,给每个帧打上流 ID,同一条连接上的多个流可以交错发送、乱序到达,接收端按流 ID 重组。

这一下就把应用层的队头阻塞解决了。一条连接上可以同时跑几十上百个请求,浏览器的 6 连接限制也没必要了,域名分片那种“把资源拆到多个子域”的老优化手段反而成了负担。

配套的还有两个改动:头部压缩(HPACK)和流优先级。HPACK 用静态表加动态表的方式,把重复出现的首部字段压缩成一个索引值。因为 HTTP 的首部重复率极高(User-Agent、Accept、Cookie 每次都差不多),这个压缩效果非常明显,尤其在移动网络下。

但 HTTP/2 没解决传输层的队头阻塞。所有流都挤在一条 TCP 连接上,一个包丢了,整条连接上所有的流全都卡住。丢包率高的网络环境下,HTTP/2 的实际表现可能还不如 1.1 开六个连接。这个问题只能靠换传输层来解决。

另外提一句 Server Push。它本来是 HTTP/2 的卖点,想让服务端主动把 CSS、JS 推给客户端,省掉一次请求。实践下来效果不好:推送的内容客户端可能已经有了,没法精确取消,缓存处理也复杂。主流浏览器已经移除了这个功能,现在学 HTTP/2 的时候可以跳过它。

7.3 HTTP/3 换的是地基

HTTP/3 最本质的变化,是把传输层从 TCP 换成了基于 UDP 的 QUIC。

QUIC 在用户态实现了可靠性传输,流与流之间的数据独立处理。包丢了只影响那一个流,其他的照常交付。这就把传输层的队头阻塞也解决了。它还顺手把 TLS 1.3 集成进了握手流程,新建连接只需要一个往返,恢复连接甚至可以做到 0-RTT。

代价是 UDP 在有些网络环境里会被限制,所以实际部署时通常需要 TCP 兜底,客户端先探测再选择。头部压缩也从 HPACK 换成了 QPACK,因为流之间不再保证顺序,HPACK 那种依赖顺序的编码方式会出问题。

版本传输层多路复用头部压缩队头阻塞
HTTP/1.1TCP无(靠多连接规避)无应用层和传输层都有
HTTP/2TCP有HPACK传输层仍有
HTTP/3QUIC(UDP)有QPACK基本消除

从学习角度讲,我的建议是:把 1.1 吃透,2 和 3 只需要理解“它们各自在解决 1.1 的哪个问题”。因为语义层面(方法、状态码、首部)三个版本是高度兼容的,你写的业务代码基本不用改。真正需要关心版本差异的,是网关配置、链路追踪和故障排查。

8. 怎么验证自己真的看懂了

8.1 命令行工具的几种组合拳

光看不练记不住。我常用的几个组合:

# 看完整的请求和响应报文 curl -v https://api.example.com/health # 只看响应头,配合 HEAD 方法最省流量 curl -I https://api.example.com/health # 强制用 HTTP/1.1,对比和 2 的差异 curl -v --http1.1 https://api.example.com/health # 看各阶段耗时,判断瓶颈在 DNS、连接还是传输 curl -o /dev/null -s -w "dns:%{time_namelookup} conn:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n" https://api.example.com/health # 带条件请求头,观察 304 curl -v -H 'If-None-Match: "a1b2c3"' https://api.example.com/v2/orders/1024

最后那条我特别推荐练一下。手动发一次带If-None-Match的请求,看着服务端返回 304 和一堆空的响应头,比看十篇文章都印得深。

-w那个格式化输出也值得多用。time_starttransfer也就是常说的 TTFB,它包含了服务端的处理时间加上一个往返;time_appconnect是 TLS 握手完成的时刻。这两个数字的差值能快速区分“是网络慢还是服务端慢”。

8.2 浏览器面板里该盯哪几列

开开发者工具的网络面板,默认的列不够用,我会把这几列调出来:Protocol(看走的 http/1.1 还是 h2)、Size(区分是真实字节数还是(from cache))、Time、Remote Address。

一个很实用的技巧是勾上“保留日志”,然后在地址栏回车刷新,观察整个加载序列。重点看两件事:哪些请求走了缓存(Size 列显示 from memory/disk cache),哪些请求带了304状态码。前者是强缓存命中,后者是协商缓存生效。如果本该缓存的静态资源每一次都是 200,说明缓存头根本没配对。

再一个是看那条OPTIONS预检请求。跨域接口在首次调用时一定会有它,如果没看到,说明你的请求被判定为“简单请求”,那意味着你用的 Content-Type 可能是text/plain这种——很多时候这反而是一个 bug 的信号。

8.3 自己起一个最小服务,把响应头当变量玩

理论看多了会飘,最扎实的办法是自己写一个只返回几个头的小服务,然后改参数、看现象。用你熟悉的任意语言,几十行就能搞定,重点是把响应头做成可控的:

from http.server import BaseHTTPRequestHandler, HTTPServer class H(BaseHTTPRequestHandler): def do_GET(self): body = b'{"ok":true}' self.send_response(200) self.send_header('Content-Type', 'application/json') # 试着把这些值改掉,观察浏览器行为的变化 self.send_header('Cache-Control', 'max-age=10') self.send_header('ETag', '"v1"') self.send_header('Vary', 'Accept-Encoding') self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body) HTTPServer(('127.0.0.1', 8000), H).serve_forever()

这个服务跑起来之后,可以做的实验很多:把max-age改成 0 看是不是每次都发请求;把 ETag 改成固定值,观察第二次请求是不是 304;在 do_GET 里判断If-None-Match手动返回 304,感受一下服务端视角;把 Content-Length 故意写错,看客户端怎么反应。

我当初就是靠改这几个头,把“no-cache 不是不缓存”这件事彻底搞明白的。文档里那句话读十遍记不住,自己动手改一次参数,看一眼网络面板,就再也忘不掉了。

8.4 几个我反复提醒自己的检查点

最后列几条从踩坑里攒下来的经验,都是文档里不会重点写、但实际会用到的:

  • 看到“缓存不生效”,先确认响应头里到底有没有Cache-Control,再看是不是被中间某一层覆盖了。代码里写的不等于线上发的。
  • 看到“跨域报错”,先找 OPTIONS 那一条,别盯着真实请求看。
  • 看到“请求挂住不返回”,第一反应查长度字段,第二反应查是不是少了那个空行。
  • 看到“偶发丢登录态”,去查SameSite和Domain的组合,别一上来就怀疑 Token 过期。
  • 接口要重试,先想清楚这个方法幂不幂等,不幂等就加幂等键,别指望网络层帮你兜。

HTTP 这套东西有意思的地方在于,它的规范写得极其克制,几十年下来核心结构几乎没动,但每一层都有实践打磨出来的细节。我现在的习惯是,遇到任何一个说不清的现象,先去抓一次包,把请求头和响应头从头到尾读一遍。十次里有八次,答案就写在那几十行文本里。

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

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

立即咨询