HTTP / HTTPS
HTTP 是你每天用得最多的协议——只要打开网页、刷 App、看公众号,背后都是 HTTP 在跑。它定义了"浏览器和服务器怎么对话":你问什么、它答什么、答错了怎么告诉你。
HTTP 就像你跟服务员的对话:
你说:"我要一份牛肉面,加香菜不加葱。"(请求)
服务员记下来,送到后厨。(处理)
后厨做好,服务员端给你:"您的牛肉面。"(响应)
如果没牛肉了,服务员说:"不好意思,卖完了。"(错误响应)
整个过程有固定格式——你不能上来就喊"面!",得说"我要一份……"——这就是 HTTP 的"协议格式"。
先把这一篇的术语翻译成人话
这一篇会冒出十几个英文缩写,看着吓人。但只要翻译成人话,你会发现它们全是你生活里早就用惯的东西,只不过被套了个洋名字。下面这张表先过一遍,后面再遇到就不慌了。
| 术语 | 换成大白话 | 生活里对应的东西 |
|---|---|---|
| 协议(Protocol,通信双方事先约定好的说话格式) | 说白了就是"咱俩先约好怎么开口" | 在餐厅点菜要说"我要一份牛肉面",光喊一声"面!"服务员没法记单 |
| 请求(Request,客户端主动发出的一次询问) | 说白了就是你张嘴问的那句话 | 在银行柜台说"我要取五百块" |
| 响应(Response,服务器针对请求回的那一份内容) | 说白了就是对方回你的那句话加东西 | 柜员把五百块和一张凭条推给你 |
| 报文(Message,一次请求或响应的完整文字内容) | 说白了就是一整张写满字的单子 | 快递面单:上面有寄件人、收件人、物品、重量,缺一栏就没法发 |
| 头部(Header,报文正文之前那些说明性的字段) | 说白了就是箱子外面贴的那张标签 | 快递纸箱外面的面单——不用拆箱就知道里面大概是什么、寄给谁 |
| 请求体 / 响应体(Body,报文里真正的内容部分) | 说白了就是箱子里装的东西本身 | 纸箱里那双鞋 |
| 状态码(Status Code,服务器用三位数字表示这次办得怎么样) | 说白了就是办事窗口给你的一个结果编号 | 医院挂号机吐出的小票:叫到号是成功,"该科室今日已满"是失败 |
| 方法(Method,这次请求想干什么动作) | 说白了就是你到窗口是要"取"、要"存"还是要"销户" | 去邮局可以寄件、可以取件、可以退件——动作不同,单子也不同 |
| 无状态(Stateless,服务器默认不记得你上次来过) | 说白了就是每次去人家都当你是生面孔 | 一家超市的收银员每天见几千人,绝不可能记住你昨天买了什么 |
| 缓存(Cache,把拿过的东西先存在近处,下次直接用) | 说白了就是把常用的东西先搁手边,别每次都跑一趟 | 厨房灶台边那一小罐盐——不用每次炒菜都去储物间取整袋 |
看完这张表你大概已经感觉到了:HTTP 这套东西,本质上就是把"人到窗口办事"这件事,写成了机器能严格执行的格式。后面所有细节,都可以往这个画面上套。
HTTP 请求长什么样
你在浏览器输 baidu.com,浏览器实际发了这么一段文字给服务器:
GET / HTTP/1.1
Host: baidu.com
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)
Cookie: BAIDUID=1234567890ABCDEF
Accept: text/html,application/xhtml+xml
- GET方法——我要"获取"东西(还有 POST 提交、PUT 更新、DELETE 删除)
- Host我要访问哪个网站
- User-Agent我是谁——浏览器型号、操作系统
- Cookie我上次来过的"凭证"——这是重点,后面详讲
- Accept我能接受什么格式的内容
把这段报文的结构正规化,你会发现 HTTP 请求严格由四部分组成,顺序一个都不能错(RFC 9112 规定):
①请求行 方法 空格 请求目标 空格 协议版本 CRLF
②请求头部 字段名: 值 CRLF (可以有很多行)
③空行 CRLF (唯一的分界标志,必须有)
④请求体 可选,GET 通常没有
完整示例(<CR><LF> 表示回车换行,实际是 0x0D 0x0A):
POST /api/login HTTP/1.1<CR><LF>
Host: example.com<CR><LF>
Content-Type: application/json<CR><LF>
Content-Length: 41<CR><LF>
Accept: application/json<CR><LF>
Accept-Encoding: gzip, br<CR><LF>
Connection: keep-alive<CR><LF>
<CR><LF> ← 空行,头部到此结束
{"user":"alice","password":"s3cret!"} ← 正好 41 字节
这里有三个容易被忽略但极其重要的细节。第一,换行必须是 CRLF(\r\n)而不是单独的 \n——虽然多数服务器出于宽容会同时接受,但规范写的是 CRLF,跨平台手写协议时踩过坑的人都记得这一点。第二,那个空行是唯一的头部终止标志,服务器就是靠连续两个 CRLF(\r\n\r\n)判断"该开始读正文了"。第三,Content-Length 必须准确:写大了服务器会一直等到超时,写小了多出来的字节会被当成下一个请求的开头,整条连接彻底错乱——这类错配正是"HTTP 请求走私"(Request Smuggling)攻击的原理。
再说"请求目标"这个字段,它有四种形式,平时只见到第一种,但另外三种在特定场景下会出现:
打个比方,你要寄一箱苹果,得先在快递网点填一张面单。这张面单的结构,和 HTTP 请求是一模一样的四段:
第一行写"寄什么件"——是普通件、生鲜件还是退货件(这就是请求行里的"方法");
接下来一串栏目写收件人、地址、电话、重量、是否易碎(这就是一行行的请求头部,每行一个"字段名: 值");
栏目填完要留一道横线,横线以下才是"内件清单"(这就是那个必须存在的空行——快递员靠横线知道"表格填完了,下面是货物描述");
横线以下写具体装了什么(这就是请求体)。
那个 Content-Length: 41(内容长度,正文一共多少字节)干的活儿相当于面单上的"重量:3.2 公斤"。写错重量会出什么事?写大了,快递员在传送带前一直等剩下那部分货,等到超时;写小了,多出来的货被当成下一位客人的东西,整条传送带上的包裹从此全部错位。HTTP 上这个错位有个专门的名字,叫"请求走私"——一个填错重量的箱子,能把后面所有人的箱子都送错地方。
| 形式 | 写法 | 什么时候用 |
|---|---|---|
| origin-form | GET /index.html?a=1 HTTP/1.1 | 最常见,直连服务器时用 |
| absolute-form | GET http://example.com/ HTTP/1.1 | 发给正向代理时,代理需要完整 URL |
| authority-form | CONNECT example.com:443 HTTP/1.1 | 只用于 CONNECT 建隧道(HTTPS 走代理) |
| asterisk-form | OPTIONS * HTTP/1.1 | 询问服务器整体支持的能力,不针对具体资源 |
HTTP 的三十年演进史
在深入细节之前,先看清 HTTP 是怎么一步步长成今天这样的——因为它今天的每一个古怪之处,几乎都能在历史里找到原因。
这段演进史换成大白话就一句:一家小饭馆,从"只卖一种面、老板一个人跑堂",慢慢开成了"几百桌同时上菜、后厨流水线作业"的大酒楼。每一代 HTTP 改的都是"上菜的组织方式",不是"菜品本身"。往下看表格时,你可以一边想这家饭馆:0.9 是只有一张纸条的路边摊,1.0 学会了开发票,1.1 学会了"一个服务员招呼你整桌所有菜",2 学会了"多个服务员端着托盘交错穿行",3 干脆把整条走廊重修了一遍。
| 版本 | 年份 / RFC | 带来了什么 | 解决的痛点 |
|---|---|---|---|
| HTTP/0.9 | 1991(无 RFC) | 只有 GET /path 一行,返回纯 HTML,没有头部、没有状态码 | 能传超文本就够了 |
| HTTP/1.0 | 1996 / RFC 1945 | 头部、状态码、Content-Type、版本号 | 能传图片和非 HTML 内容 |
| HTTP/1.1 | 1997 / RFC 2068 → 2616 → 9110~9112 | Host 头(必填)、keep-alive 长连接、分块传输、缓存机制、Range 断点续传 | 一个 IP 托管多站;不用每个资源重开 TCP |
| HTTP/2 | 2015 / RFC 7540 → 9113 | 二进制分帧、多路复用、HPACK 头部压缩、流优先级 | HTTP 层的队头阻塞 |
| HTTP/3 | 2022 / RFC 9114 | 换底座到 QUIC(跑在 UDP 上),QPACK 压缩,连接迁移 | TCP 层的队头阻塞、移动切网断连 |
注意 HTTP/1.1 那一行的 RFC 编号变迁:RFC 2616(1999)曾经是最著名的一份网络规范,长达 176 页,但因为把太多东西糅在一起导致歧义丛生。2014 年 IETF 把它拆成了 RFC 7230~7235 六份文档,2022 年又重整为 RFC 9110(语义)、9111(缓存)、9112(HTTP/1.1 语法)、9113(HTTP/2)、9114(HTTP/3)。这次重构最重要的成果是把"HTTP 的语义"和"HTTP 的传输语法"彻底分开了——方法、状态码、头部的含义(9110)对 1.1/2/3 通用,各版本只是用不同的方式在线路上表达它们。
还有一个有意思的历史细节:HTTP/1.1 强制要求 Host 头,是整个 Web 商业化的技术前提。HTTP/1.0 时代,服务器只知道你请求了 /index.html,不知道你想访问哪个域名——因为域名在建立 TCP 连接时就已经被解析成 IP 了,报文里没有它。这意味着一个 IP 只能放一个网站。加了 Host 头之后,虚拟主机成为可能,一台机器托管几千个小网站的廉价虚拟主机行业才得以诞生。
Host 头这件事好比一栋写字楼的门牌号。HTTP/1.0 那会儿,快递员只知道"送到人民路 88 号",进了大门就傻眼了——这栋楼里有三百家公司,到底给谁?所以早期只能一栋楼放一家公司,太浪费。Host 头(Host Header,请求里那一行"我要找哪个域名")干的活儿相当于在面单上补一句"12 层 1203 室"。有了这一行,一栋楼才能塞进三百家公司——这就是"一台服务器托管几千个网站"的全部秘密。
HTTP 响应长什么样
服务器收到后,回一段文字 + 网页内容:
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Set-Cookie: BAIDUID=1234567890ABCDEF; Path=/; Max-Age=31536000
<!DOCTYPE html><html>...网页内容...</html>
- 200 OK状态码——成功(404 没找到、500 服务器挂了、302 跳转)
- Content-Type我给你的是什么格式——HTML、JSON、图片、视频
- Set-Cookie我给你发个"凭证",下次来带着
响应报文的结构和请求几乎对称,只是第一行不同——它叫状态行,格式是"协议版本 空格 状态码 空格 原因短语"。那个"原因短语"(Reason Phrase,比如 OK、Not Found)纯粹是给人看的,程序绝对不该依赖它:它可以被服务器随意改写,HTTP/2 和 HTTP/3 里干脆完全取消了这个字段。
响应体的长度有三种表达方式,理解它们能解释很多"下载卡在 99%"的怪事:
# 方式一:Content-Length —— 提前知道总长度
HTTP/1.1 200 OK
Content-Length: 3241
(读满 3241 字节就结束,浏览器能显示准确进度条)
# 方式二:Transfer-Encoding: chunked —— 边生成边发,不知道总长
HTTP/1.1 200 OK
Transfer-Encoding: chunked
7\r\n ← 这一块 7 字节(十六进制)
Mozilla\r\n
9\r\n
Developer\r\n
0\r\n ← 长度 0 表示传完了
\r\n
(动态生成的页面、大文件流式输出、SSE 都用这个;
代价是浏览器不知道总大小,进度条只能转圈)
# 方式三:连接关闭即结束(HTTP/1.0 的老做法,已不推荐)
HTTP/1.0 200 OK
Connection: close
(服务器发完就断开 TCP,接收方靠 FIN 判断结束
致命缺陷:无法区分"传完了"和"传到一半网断了")
方式三的缺陷值得特意记住:如果只靠连接关闭来判断结束,接收方永远无法确定自己拿到的是完整数据还是被截断的残片。这就是为什么 HTTP/1.1 把 Content-Length 或 chunked 变成了实质上的必须项。
这三种方式换成大白话,就是餐厅上菜的三种告知方式。方式一(Content-Length)好比服务员上菜前先说"您这桌一共八道菜"——你数到第八道就知道齐了,心里有底,还能看着进度条估时间。方式二(分块传输 Transfer-Encoding: chunked,一边做好一道就先端一道,不预告总数)好比自助烤肉店:厨房烤好一串就端一串,最后端上来一个空盘子表示"没了"。好处是不用等全部做完才开始吃,坏处是你永远不知道还剩几串,进度条只能一直转圈。网页上那些"边加载边显示"的长列表、直播弹幕、AI 一个字一个字往外吐的回答,走的全是这一种。方式三(发完就断开连接)好比服务员端完最后一道菜就默默下班了,连"上齐了"都不说一声——你只能靠"半天没人来"猜菜齐了。可万一是厨房失火了他跑了呢?你根本分不清"上齐了"和"出事了",这就是它被淘汰的原因。
九种方法:安全性与幂等性
HTTP 定义了九个标准方法(RFC 9110 第 9 章)。但真正需要理解的不是它们"叫什么",而是两个属性:安全(Safe)和幂等(Idempotent)。这两个概念决定了浏览器、代理、CDN 敢对你的请求做什么。
这两个词听着玄,其实就是银行柜台上两件最朴素的事。安全(Safe,只查不改)说白了就是"我只是来查余额的"——查一百遍,你账上的钱一分不变,所以随便查、让谁帮你查都无所谓。幂等(Idempotent,做一次和做十次的最终结果一样)说白了就是"我来把地址改成人民路 88 号"——你改一次是这个地址,连着改十次还是这个地址,重复提交不会出岔子。而"存进五百块"既不安全也不幂等——它改了余额,而且做十次你账上会多出五千。这就是为什么柜员会盯着确认"您只提交了一次吧?"
- 安全 · Safe这个请求只读,不会改变服务器状态。安全的方法可以被浏览器随意预取、被爬虫随意访问、被代理随意缓存。把"删除"功能做成 GET 是经典事故——某公司做过一个 GET 形式的删除接口,结果 Google 爬虫把全站数据删了个精光。
- 幂等 · Idempotent执行一次和执行 N 次的最终结果相同。注意"结果相同"指的是服务器状态,不是返回值。
DELETE /post/1第一次返回 204、第二次返回 404,但服务器状态都是"这篇文章不存在",所以它是幂等的。幂等的意义在于:超时后客户端可以放心重试。
| 方法 | 安全 | 幂等 | 可缓存 | 有请求体 | 用途 |
|---|---|---|---|---|---|
| GET | 是 | 是 | 是 | 不应有 | 获取资源,占 Web 流量绝大部分 |
| HEAD | 是 | 是 | 是 | 不应有 | 只要头部不要正文,用于探测存在性和大小 |
| POST | 否 | 否 | 极少 | 有 | 提交数据、创建资源、触发操作 |
| PUT | 否 | 是 | 否 | 有 | 整体替换资源(发全量) |
| PATCH | 否 | 否(除非设计成幂等) | 否 | 有 | 局部修改(RFC 5789 单独定义) |
| DELETE | 否 | 是 | 否 | 可有 | 删除资源 |
| OPTIONS | 是 | 是 | 否 | 可有 | 询问支持的方法,CORS 预检用它 |
| TRACE | 是 | 是 | 否 | 无 | 回显请求用于诊断,因安全风险普遍被禁用 |
| CONNECT | 否 | 否 | 否 | 无 | 建立隧道,HTTPS 走代理时用 |
POST 不幂等是所有支付类事故的根源。想象这个场景:你点"确认支付",请求发出去了,服务器扣款成功但响应在回来的路上丢了。客户端超时后重试——如果服务端没有防护,你就被扣了两次款。解决办法叫幂等键(Idempotency Key):客户端为每次业务操作生成一个唯一 ID 放在头部,服务端记住已处理过的 ID,重复请求直接返回第一次的结果。
# 支付类接口的标准做法(Stripe、支付宝等都是这个模式)
POST /v1/charges HTTP/1.1
Host: api.example.com
Idempotency-Key: a7f3e91c-4b2d-4c8e-9f1a-2b3c4d5e6f70
Content-Type: application/json
{"amount": 9900, "currency": "cny", "order_id": "ORD-20260801-001"}
# 服务端逻辑:
# 1. 拿 Idempotency-Key 查 Redis
# 2. 命中 → 直接返回上次存的响应,不重复扣款
# 3. 未命中 → 执行扣款,把结果连同 key 存进 Redis(存 24h)
# 这就是"在不幂等的方法上人为造出幂等性"
PUT 和 POST 的区别也常被问到,一句话说清:PUT 是"把这个 URL 的内容替换成我给的东西",所以客户端要自己决定 URL;POST 是"把这个东西交给这个端点去处理",URL 由服务端决定。所以 PUT /users/123 是合理的(我知道要改 123),POST /users 是合理的(服务端分配新 ID);而 POST /users/123 通常意味着"对 123 做某个动作"而不是"替换 123"。
幂等键(Idempotency Key,客户端给每笔业务生成的一个唯一编号)这套做法,好比去医院挂号。你在自助机上点了"挂号",机器却卡住了没吐小票——你不知道到底挂上没有,于是又点了一次。要是医院没有防重机制,你就被扣了两次挂号费、占了两个号。医院的解法很朴素:把你的身份证号 + 今天的日期 + 科室,合起来当作一个唯一标识。系统一看"这个人今天这个科室已经有号了",就直接把第一次那张号推给你,绝不重复收钱。幂等键干的活儿相当于这个"身份证号 + 日期 + 科室"的组合——支付宝、Stripe 的所有支付接口,靠的都是这一招。
常见状态码速查
| 状态码 | 含义 | 你什么时候见到 |
|---|---|---|
| 200 | 成功 | 正常打开网页 |
| 301 / 302 | 永久/临时跳转 | 旧网址换新网址 |
| 304 | 没变化,用缓存 | 刷新网页,内容没变 |
| 400 | 请求格式错了 | 网址输错参数 |
| 401 | 没登录,没权限 | 访问需要登录的页面 |
| 403 | 禁止访问 | IP 被封、地区限制 |
| 404 | 没找到 | 网址不存在、页面被删 |
| 500 | 服务器内部错误 | 网站崩了 |
| 502 / 503 | 网关错误/服务不可用 | 服务器过载、维护中 |
状态码的五大类:第一位数字定性质
状态码是三位数,第一位就决定了这个响应的"性质"。哪怕你遇到一个没见过的状态码(比如 451、418、511),只看第一位也能立刻判断该怎么处理:
想象一下你在医院大厅里排队挂号,窗口里的护士只会说五种话。1 开头——"材料收到了,您先坐着等"(还没办完,别急)。2 开头——"办好了,这是您的号"(成事了)。3 开头——"这个不在我这办,您去走廊尽头那个窗口"(换个地方)。4 开头——"您这表填错了 / 忘带身份证了"(是你自己的问题,你不改,再排一百次队还是同样的答复)。5 开头——"我们系统崩了,您稍后再来"(是他们的问题,等会儿再来说不定就好了)。说白了就是:4 开头别重试,5 开头可以重试。这一条判断,比背下几十个数字有用得多。
| 类别 | 含义 | 客户端该怎么做 | 典型成员 |
|---|---|---|---|
| 1xx 信息 | 已收到,处理中,还有后续 | 继续等 | 100 Continue、101 Switching Protocols、103 Early Hints |
| 2xx 成功 | 请求被成功处理 | 正常用结果 | 200 OK、201 Created、204 No Content、206 Partial Content |
| 3xx 重定向 | 要的东西在别处,或没变化 | 去新地址,或用缓存 | 301、302、304、307、308 |
| 4xx 客户端错 | 你的请求有问题 | 改请求再来,重试没用 | 400、401、403、404、405、409、413、415、422、429 |
| 5xx 服务端错 | 服务器自己出问题了 | 可以稍后重试(配合退避) | 500、502、503、504 |
4xx 和 5xx 的分界线极其重要,因为它决定了要不要重试。收到 4xx 时重试是毫无意义的——你的请求本身就有毛病,重发一百次还是一样的错。收到 5xx 时重试是合理的,但必须配合指数退避(Exponential Backoff):第一次等 1 秒、第二次 2 秒、第四次 4 秒,并加一点随机抖动,否则所有客户端会在同一时刻涌回来把刚喘上气的服务器再次打垮(这叫"惊群")。
指数退避(Exponential Backoff,每次重试都把等待时间翻倍)这个词听着玄,其实就是你去银行发现今天系统故障后的正常反应:第一次隔一会儿再问,第二次隔久一点,第三次干脆下午再来——总不能站在窗口前每秒问一遍。那个"随机抖动"又是干什么的?因为如果全城两千个客户都被告知"一小时后再来",那一小时后这两千人会同时挤在门口,把刚修好的系统再次压垮。所以正确做法是让每个人的等待时间上再随机加减几分钟,把人流摊开——这在技术上叫防"惊群",在银行里叫错峰。
下面这些状态码是日常最容易搞混的几组,值得逐一辨析:
| 易混淆组 | 区别 |
|---|---|
| 301 vs 308 | 都是永久重定向。301 允许把 POST 改成 GET 再重定向(历史上浏览器都这么干),308 严格要求保持原方法和请求体。API 重定向应该用 308。 |
| 302 vs 307 | 都是临时重定向。同上,307 保证方法不变,302 可能被改成 GET。 |
| 401 vs 403 | 401 = 你没证明自己是谁(没登录/token 过期),带上凭证再来可能就行;403 = 我知道你是谁,但你没这个权限,再试也没用。401 响应必须带 WWW-Authenticate 头。 |
| 404 vs 410 | 404 = 找不到(可能以后会有);410 Gone = 确定已被永久删除,搜索引擎看到 410 会更快地从索引里剔除。 |
| 400 vs 422 | 400 = 报文语法就不对(JSON 解析失败等);422 Unprocessable Content = 语法没问题但业务校验不通过(比如邮箱格式对但已被注册)。 |
| 502 vs 503 vs 504 | 502 Bad Gateway = 反向代理连上后端了但后端返回了垃圾或直接断开;503 Service Unavailable = 服务主动说"我现在不可用"(维护中、过载熔断);504 Gateway Timeout = 反代等后端等超时了。三者定位方向完全不同。 |
| 429 Too Many Requests | 限流专用(RFC 6585)。规范做法是同时返回 Retry-After: 30 告诉客户端隔多久再来。 |
还有几个"冷门但真实存在"的状态码,知道它们能显出功底:100 Continue(客户端要传大文件前先问"你收不收",避免白传几百兆才被拒)、206 Partial Content(断点续传和视频拖进度条的基础,配合 Range 请求头)、418 I'm a teapot(RFC 2324 愚人节文档,但真的被实现了,常被用作反爬标记)、451 Unavailable For Legal Reasons(RFC 7725,因法律原因不可用,编号致敬《华氏 451 度》)。
Cookie · HTTP 的"记忆机制"
HTTP 协议本身是无状态的——服务器不记得你上次来过。每次请求都是"陌生人"。但网站需要"记住"你——登录状态、购物车、浏览记录。Cookie 就是干这个的。
无状态(Stateless,服务器默认不记得你上一次来干了什么)这件事好比一家生意极好的超市:收银员一天扫两千个人的码,你昨天买了什么、上周有没有来过,他一概不记得,也不可能记得。那超市怎么做会员权益?给你一张会员卡,你每次结账主动掏出来刷一下。Cookie(浏览器帮你保存、每次访问自动带上的一小段凭证)干的活儿相当于这张会员卡——不是超市记住了你,而是你自己随身带着证明。这就是"无状态协议上人为补出状态"的全部含义。
你去迪士尼,第一次入园时给你一张"快速通行证"(Cookie)。
之后每次玩项目,你出示这张证——工作人员一看就知道"你是 VIP,不用排队"。
没有这张证,你每次都要重新排队、重新验证身份。
Cookie 就是浏览器里的"快速通行证"——网站给你发一张,你每次访问都带着,网站就知道"你是张三,已登录"。
Cookie 的工作流程
1. 你第一次访问网站
服务器响应里带 Set-Cookie: sessionid=abc123——浏览器自动存下来。
2. 你之后每次访问
浏览器自动在请求头里加 Cookie: sessionid=abc123——服务器一看就知道"你是刚才那个人"。
3. 你登录
服务器验证账号密码后,发一个"登录态" Cookie(比如 token=xyz789)——之后你访问任何页面都带着它,不用反复登录。
4. Cookie 过期
服务器可以设 Max-Age(有效期)——过期后浏览器自动删除,你得重新登录。
Cookie 能看到什么
| Cookie 里存什么 | 例子 |
|---|---|
| 会话 ID | sessionid=abc123——标识"这次访问" |
| 登录态 | token=xyz789——标识"你已登录" |
| 用户偏好 | theme=dark——记住你喜欢深色模式 |
| 购物车 | cart=item1,item2——记住你加了什么 |
| 追踪 ID | uid=456——广告商用来追踪你看了什么 |
请求头与响应头速查:最该记住的那二十个
HTTP 头部有上百个,IANA 的注册表里登记了两百多项。但真正天天打交道的就那么二十来个。按用途分组记,比按字母顺序背有效得多。
| 请求头 | 作用 | 典型值 / 备注 |
|---|---|---|
Host | 要访问哪个域名 | HTTP/1.1 唯一强制必填的头 |
User-Agent | 客户端自我介绍 | 为兼容历史包袱,Chrome 的 UA 里同时写着 Mozilla、AppleWebKit、Safari |
Accept | 我能接受什么 MIME 类型 | text/html,image/webp;q=0.8——q 是权重 |
Accept-Encoding | 我支持哪些压缩 | gzip, br, zstd——br 是 Brotli,压缩率比 gzip 高约 15% |
Accept-Language | 我想要哪种语言 | zh-CN,zh;q=0.9,en;q=0.8 |
Referer | 我是从哪个页面点过来的 | 拼写就是错的(应为 Referrer),1996 年的笔误延续至今 |
Authorization | 身份凭证 | Bearer eyJhbGci...(JWT)或 Basic base64(user:pass) |
Cookie | 把之前存的凭证带回来 | 浏览器自动附加,无需代码干预 |
Range | 只要资源的某一段 | bytes=1024-2047——断点续传和视频拖动的基础 |
If-None-Match | 我手上的版本是这个,变了吗 | 配合 ETag 做协商缓存 |
If-Modified-Since | 我上次拿的时间是这个 | 配合 Last-Modified 做协商缓存 |
Origin | 我这个请求来自哪个源 | CORS 的核心字段,浏览器自动加,不可伪造 |
Connection | 连接要不要留着 | keep-alive(1.1 默认)或 close;HTTP/2 起被禁用 |
| 响应头 | 作用 | 典型值 / 备注 |
|---|---|---|
Content-Type | 我给你的是什么格式 | text/html; charset=utf-8——charset 漏了就会中文乱码 |
Content-Length | 正文有多少字节 | 必须精确,否则连接错乱 |
Content-Encoding | 正文用什么压缩了 | gzip / br——注意它描述"正文本身",与 Transfer-Encoding 不同 |
Cache-Control | 你可以怎么缓存这份内容 | max-age=31536000, immutable 或 no-store |
ETag | 这份内容的版本指纹 | "33a64df5" 或弱标记 W/"33a64df5" |
Last-Modified | 上次修改时间 | 精度只到秒,所以不如 ETag 可靠 |
Set-Cookie | 请存下这个凭证 | 可以出现多次,每个 Cookie 一行 |
Location | 去这个新地址 | 配合 3xx 使用;201 Created 也用它指向新资源 |
Access-Control-Allow-Origin | 允许哪些源跨域读我 | CORS 核心;* 表示所有人但不能带凭证 |
Strict-Transport-Security | 以后只准用 HTTPS 访问我 | max-age=63072000; includeSubDomains,简称 HSTS |
Content-Security-Policy | 这个页面只准加载哪些来源的脚本 | 防 XSS 最有效的手段 |
Vary | 缓存时还要看哪些请求头 | Vary: Accept-Encoding, User-Agent——漏了会导致缓存串味 |
Server | 我用的什么软件 | 安全上建议隐藏,暴露版本等于给攻击者报菜名 |
Vary 这个头值得多说一句,因为它引起的 bug 极其隐蔽。假设你的服务器会根据 Accept-Encoding 决定返回 gzip 还是明文,但忘了加 Vary: Accept-Encoding——那么 CDN 缓存了一份 gzip 版本后,会把它发给一个不支持 gzip 的老客户端,对方看到一堆二进制乱码。凡是"响应内容会随某个请求头变化",就必须在 Vary 里声明那个头,否则缓存一定出错。
Content-Type 的常见取值也值得记牢,因为它决定了服务端怎么解析请求体:
application/json 最常见的 API 格式
application/x-www-form-urlencoded HTML 表单默认:a=1&b=2
multipart/form-data 上传文件必用,用 boundary 分隔各字段
text/html; charset=utf-8 网页
text/event-stream SSE 服务器推送
application/octet-stream 未知的二进制流,浏览器会触发下载
image/webp、image/avif 现代图片格式,比 JPEG 小 25%~50%
# multipart/form-data 的真实样子(上传一张图 + 一个文本字段)
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAbc
------WebKitFormBoundaryAbc
Content-Disposition: form-data; name="title"
我的头像
------WebKitFormBoundaryAbc
Content-Disposition: form-data; name="file"; filename="a.png"
Content-Type: image/png
(这里是 PNG 的二进制字节)
------WebKitFormBoundaryAbc-- ← 结尾多两个横线
Cookie 的属性:每一个都关系到安全
一个 Set-Cookie 头除了名值对,还能带一串属性。这些属性不是装饰——每一个都是被某次真实攻击逼出来的。完整形态长这样:
Set-Cookie: sessionid=a8f3e91c4b2d;
Domain=example.com; ← 哪些域名能带上它
Path=/app; ← 哪些路径下才带
Expires=Wed, 01 Aug 2026 12:00:00 GMT; ← 绝对过期时间
Max-Age=604800; ← 相对秒数,优先级高于 Expires
Secure; ← 只在 HTTPS 下发送
HttpOnly; ← JavaScript 读不到(防 XSS 窃取)
SameSite=Lax; ← 跨站请求时是否携带(防 CSRF)
Partitioned ← 新标准 CHIPS,按顶级站点隔离
| 属性 | 不写会怎样 | 建议值 |
|---|---|---|
Secure | HTTP 明文请求也会带上,中间人可直接抓到会话 | 登录态 Cookie 必须加 |
HttpOnly | 页面上任何一段 XSS 脚本都能用 document.cookie 读走 | 会话 ID 必须加 |
SameSite | 别的网站发起的请求也会自动带上你的凭证,即 CSRF | Lax(默认)或 Strict |
Domain | 默认只发给设置它的那个精确主机名 | 需要跨子域共享时才写,写了会扩大暴露面 |
Max-Age | 不写就是"会话 Cookie",关掉浏览器就没了 | 按业务定,登录态常见 7~30 天 |
SameSite 的三个取值差异需要看例子才清楚。假设你在 evil.com 上,页面里有一个指向 bank.com/transfer 的表单:
- SameSite=None任何跨站请求都带 Cookie——这就是 CSRF 攻击成立的条件。现在浏览器要求用 None 时必须同时加 Secure。
- SameSite=Lax现代浏览器的默认值。只有"顶层导航的 GET 请求"才带(比如你从别的站点点链接跳过来,登录态还在);POST、iframe、img、fetch 全都不带。这个折中既防住了大部分 CSRF,又不破坏"从搜索引擎点进来还是登录态"的体验。
- SameSite=Strict任何跨站场景都不带。最安全,但体验割裂——从邮件里点链接进来会显示未登录。常见做法是两个 Cookie:一个 Strict 用于敏感操作,一个 Lax 用于识别身份。
还有一个正在发生的大变化值得知道:第三方 Cookie 正在被浏览器全面淘汰。Safari 从 2020 年起默认拦截,Firefox 有全面 Cookie 保护,Chrome 也在推进限制。这直接摧毁了传统的跨站广告追踪模式,替代方案包括 Partitioned 属性(CHIPS,让第三方 Cookie 按顶级站点分区隔离)、Privacy Sandbox 的 Topics API 等。Cookie 从一个纯技术机制,变成了隐私政治的战场。
Cookie vs Session vs Token:三种存"记忆"的方式
Cookie 只是"运输工具",真正的问题是会话状态存在哪。这里有三种主流方案,是后端架构面试的必考题:
这三种方案换成大白话,就是图书馆管借书证的三种办法。方案一(Session,状态存在服务器内存里)好比:图书馆给你一张只印了编号的塑料卡,你借了哪些书全记在管理员桌上那个本子里。缺点很致命——这个本子只在这一个窗口有,你换到分馆去就查不到;管理员下班把本子锁进抽屉(服务器重启),所有人的借阅记录当场清零。方案二(JWT Token,信息直接写在凭证上并盖章防伪)好比:图书馆发给你一张写满字的纸——"张三,可借 10 本,有效期到年底",还盖了个防伪钢印。任何一个分馆看一眼钢印就能验真,根本不用查本子。但它有个绕不过的毛病:你的卡丢了、或者你被取消了借书资格,图书馆也没法把已经发出去的那张纸收回来——只能等它自己到期。方案三(Session + Redis,把本子挪到一个所有分馆共用的中央档案室)好比:还是发塑料卡,但那个本子改放在总馆,所有分馆联网查同一本。这样既能随时销卡,又不怕某个窗口下班。代价是必须多养一个总馆档案室。
| Session(服务端存) | JWT Token(客户端存) | Session + Redis | |
|---|---|---|---|
| Cookie 里放什么 | 一个无意义的 session ID | 整个签名后的 token(含用户信息) | 一个 session ID |
| 状态存在哪 | 某台服务器的内存 | 客户端自己,服务端不存 | 集中式缓存 |
| 能水平扩容吗 | 难,需要会话粘滞 | 能,任意服务器都能验签 | 能 |
| 服务器重启 | 全员掉线 | 无影响 | 无影响 |
| 能立刻踢人下线吗 | 能(删掉即可) | 不能,签名有效期内一直有效 | 能 |
| 报文大小 | 小(几十字节) | 大(几百字节到 1 KB,每次请求都带) | 小 |
| 额外依赖 | 无 | 无 | Redis 集群 |
JWT"不能立刻踢人"这个缺陷经常被低估。用户改了密码、管理员封了号、token 被盗——在 token 自然过期前你都拿它没办法。业界的标准妥协是"短期 access token + 长期 refresh token":access token 只活 5~15 分钟(被盗的窗口很短),refresh token 存在服务端可撤销的白名单里。这等于承认了"纯无状态"在安全上走不通,最终还是要在服务端留一点状态。
缓存机制:强缓存与协商缓存
HTTP 缓存是整个 Web 性能优化里投入产出比最高的一件事——命中缓存的请求根本不产生网络流量,比任何压缩和加速都快。它分两层,理解顺序很重要:先判断强缓存,不命中才走协商缓存。
打个比方,你在厨房做菜要用盐。强缓存(Strong Cache,本地副本没过期就直接用,一次都不问服务器)说白了就是:灶台边那个小盐罐里还有盐,你抓一把就撒——压根没往储物间跑过一趟,零延迟。协商缓存(Conditional Cache,先问一句"我手上这份还能用吗",能用就不重新拿)说白了就是:盐罐见底了,你走到储物间门口问一嘴"上次那袋盐还是那个牌子吗?"——对方说"没换",你就把手上的空罐拎回来接着用(这就是 304,只回一句话,不搬东西);对方说"换了新的",才递给你一整袋新盐(这就是 200 + 完整内容)。两层的区别,就是"一趟没跑"和"跑了一趟但没搬东西"。一个网页有几十个静态资源,这个差距在信号差的地方极其明显。
浏览器要一个资源时的完整决策流程:
① 有本地副本吗? ── 没有 ──→ 直接发请求
↓ 有
② 强缓存还没过期吗?(看 Cache-Control: max-age / Expires)
↓ 没过期 ↓ 已过期
直接用本地副本 走协商缓存
【不发任何请求!】 ↓
Chrome 显示 200 (from ③ 带上 If-None-Match: "etag值"
memory/disk cache) 和 If-Modified-Since: 时间
发一个请求去问服务器
↓
④ 服务器比对:内容变了吗?
↓ 没变 ↓ 变了
304 Not Modified 200 + 新内容
(不带正文, (完整传输)
只有几百字节头)
两层的差别是"零个请求"和"一个小请求"。强缓存命中时浏览器完全不联网,延迟是 0;协商缓存命中时仍然要跑一个完整的 RTT,只是省了正文流量。对于一个有几十个静态资源的页面,两者的体验差距在弱网下极其明显。
| Cache-Control 指令 | 含义 | 用在哪 |
|---|---|---|
max-age=31536000 | 一年内直接用本地副本,别问我 | 带哈希文件名的 JS/CSS(app.a8f3e9.js) |
immutable | 保证永不改变,连用户按刷新也别来问 | 配合上一条一起用,效果最强 |
no-cache | 可以存,但每次用之前必须来问一次 | HTML 入口文件——名字最容易被误解的指令 |
no-store | 完全不许存,硬盘内存都不许留 | 银行页面、含敏感信息的响应 |
private | 只有浏览器能存,CDN 和代理不许存 | 含用户个人信息的页面 |
public | 谁都能存,包括 CDN | 公共静态资源 |
s-maxage=600 | 专门给 CDN 的过期时间,覆盖 max-age | 让 CDN 存 10 分钟但浏览器只存 1 分钟 |
stale-while-revalidate=60 | 过期后先用旧的顶着,后台悄悄去更新 | 体验优先的场景,避免用户等待 |
no-cache 和 no-store 是最常被搞混的一对。记住这句:no-cache 是"存下来,但每次都要验证"(还能享受 304 省流量),no-store 才是真正的"一点都别存"。想禁止缓存敏感数据,必须用 no-store。
ETag 和 Last-Modified 的取舍也值得说清。Last-Modified 的精度只到秒,所以一秒内改了两次的文件它分辨不出来;而且分布式部署时不同机器上的文件修改时间可能不一致。ETag 是内容的指纹(通常是哈希或版本号),精确得多,但计算它有成本。规范规定两者同时存在时 ETag 优先。ETag 还有强弱之分:"abc" 是强验证器(字节级完全相同),W/"abc" 是弱验证器(语义相同就行,比如只改了注释的 JS)。
这两者的差别翻译成人话:Last-Modified(最后修改时间)好比看洗衣房门口那张"上次换水时间:14:30"的牌子——你只能判断"是不是我上次来之后换过",可要是同一分钟内换了两次水,牌子上根本看不出来。ETag(Entity Tag,内容指纹,内容变一个字它就变)好比直接看水样的颜色和气味:不看时间,只看东西本身到底一样不一样——所以它比时间牌子可靠得多,代价是每次都得取一次样。规范说两者同时有就听 ETag 的,道理就在这。
实践中最常用的组合策略叫"HTML 不缓存 + 资源永久缓存 + 文件名带哈希",这是所有现代前端构建工具的默认方案:
# 入口 HTML:绝不缓存,每次都要拿最新的
Cache-Control: no-cache
(内容里引用 <script src="/js/app.a8f3e91c.js">)
# 带内容哈希的静态资源:缓存一年,永不回源
Cache-Control: public, max-age=31536000, immutable
# 原理:内容一改,构建工具就生成新的哈希文件名,
# HTML 里的引用也跟着改 → 天然的缓存失效机制
# 完全不需要"清缓存"这个操作
# 这个方案能让重复访问的资源请求数降到接近零
顺便提一个真实事故类型:把 HTML 也设成长期强缓存。发布新版本后,老用户的浏览器直接用本地那份旧 HTML,里面引用的还是旧文件名——于是他会永远停留在旧版本,直到缓存自然过期或手动清缓存。这类事故在没有 CDN 刷新权限时会非常难收拾,所以 HTML 入口文件的缓存策略必须谨慎。
Cookie 的安全问题
- XSS 偷 Cookie黑客在网页里插一段恶意 JS,读取你的 Cookie 发给黑客服务器——你的登录态就被盗了。防御:HttpOnly 标记(JS 读不到)。
- CSRF 伪造请求你登录了银行网站,黑客给你发个链接,你一点——浏览器自动带着银行 Cookie 去转账。防御:SameSite 标记(只允许同站请求带 Cookie)。
- 明文泄露HTTP 是明文传输,Cookie 能被中间人截获。防御:HTTPS 加密。
HTTPS · HTTP 的加密版
HTTPS = HTTP + TLS(传输层加密)。它在 HTTP 外面包了一层"加密隧道"——即使数据被截获,黑客也看不懂。
| HTTP | HTTPS | |
|---|---|---|
| 传输内容 | 明文——谁都能看 | 加密——只有双方能解密 |
| 默认端口 | 80 | 443 |
| 证书 | 不需要 | 需要 CA 颁发的证书 |
| 速度 | 快一点 | 慢一点(加密解密要算力) |
| 安全性 | 低——能被监听、篡改 | 高——防监听、防篡改、防冒充 |
HTTP 像明信片——邮递员、分拣员、任何人都能看你写了什么。
HTTPS 像密封信——信封上只有地址,内容只有收信人能拆。
2020 年后,所有正规网站都用 HTTPS——浏览器地址栏有个小锁🔒,就是 HTTPS 的标志。
TLS 握手:那把小锁是怎么挂上的
"HTTPS = HTTP + TLS"这句话太简略了。真正值得理解的是那几个来回里到底交换了什么,因为它同时解决了三个看似矛盾的难题:怎么在公开信道上商定一个只有双方知道的密钥、怎么确认对面不是冒充的、怎么保证内容没被改过。
这三件事换成大白话,就是你去银行办事时下意识会做的三件确认。第一,机密性——你和柜员说话得压低声音,别让后面排队的人听见(这就是加密)。第二,身份认证——你怎么知道对面这人真是银行职员,不是穿了件白衬衫的骗子?你看的是他胸前那张工牌,而工牌之所以可信,是因为它由这家银行统一发放,银行又受金融监管部门认证——这一层层往上追的信任链,就是 HTTPS 证书的全部原理。第三,完整性——柜员递给你的凭条上有一串防伪校验码,你能确认这张纸中途没被人改过数字。三件事凑齐,那把小锁才算真的挂上了。
顺便把那些握手回合的耗时翻译成人话。北京到美国西海岸,一个来回大约 180 毫秒——差不多是你眨一次眼的时间。老版本 TLS 1.2 要来回三趟,加起来 540 毫秒,相当于你在窗口前站着,一句正事没说,先寒暄了半秒钟。TLS 1.3 把它压到两趟(360 毫秒),QUIC 压到一趟(180 毫秒),而 QUIC 的 0-RTT 模式好比你是这家网点的熟客——推门进去连招呼都不用打,第一句话直接就是"取五百"。一个网页上有几十个资源要拿,这半秒钟乘上几十倍,就是"网站快"和"网站卡"的分界线。
【TLS 1.2 握手:2 个 RTT】
客户端 服务器
│──── ClientHello ─────────────────────────────→│
│ 我支持 TLS 1.2,密码套件列表,随机数 A │
│ SNI: example.com(明文,中间人可见) │
│←─── ServerHello + Certificate + Done ─────────│
│ 选定套件,随机数 B,我的证书链 │
│ 【客户端此时验证证书:签名链、域名、有效期】 │
│──── ClientKeyExchange + Finished ────────────→│
│ 用服务器公钥加密的预主密钥 │
│←─── Finished ─────────────────────────────────│
│ 【双方各自用 A + B + 预主密钥算出会话密钥】 │
═════ 之后所有数据用对称加密(AES-GCM)传输 ═════
【TLS 1.3 握手:1 个 RTT】(RFC 8446,2018 年)
客户端 服务器
│──── ClientHello + 密钥份额 ──────────────────→│
│ 猜一个对方大概会选的曲线,直接把公钥带上 │
│←─── ServerHello + 密钥份额 + 证书 + Finished ─│
│ 【一个来回就搞定,省掉了整整一个 RTT】 │
│──── Finished ────────────────────────────────→│
═════ 加密传输 ═════
差别有多大?北京到美西单程约 90 ms,一个 RTT 是 180 ms:
TCP 握手 1 RTT + TLS 1.2 握手 2 RTT = 3 RTT = 540 ms
TCP 握手 1 RTT + TLS 1.3 握手 1 RTT = 2 RTT = 360 ms
QUIC(握手合并) = 1 RTT = 180 ms
QUIC 复用上次密钥(0-RTT) = 0 RTT,首个包就带数据
TLS 1.3 除了快,还做了一次大扫除:删掉了 RSA 密钥传输(不支持前向保密)、删掉了 RC4 和 3DES、删掉了 MD5 和 SHA-1、删掉了压缩(防 CRIME 攻击)、删掉了重协商。它的密码套件从 TLS 1.2 的三百多种砍到只剩五种——这是对"可配置项越多、配错的可能越大"这个教训的直接回应。
关于那把小锁,有两个必须澄清的误解。第一,小锁不代表网站可信。任何人都能用 Let's Encrypt 在几分钟内给钓鱼站申请一张免费的合法证书,浏览器照样显示锁。小锁只保证"传输没被偷看、你连的确实是地址栏那个域名",它对网站是好人还是骗子毫无判断。第二,HTTPS 藏不住你访问的域名。ClientHello 里的 SNI 字段是明文的(因为服务器要靠它决定拿哪张证书出来),所以运营商依然能记录你访问了哪些站。ECH(Encrypted Client Hello)就是为补这个洞而生的新标准。
三种保障各自对应的技术手段值得对齐一遍:机密性靠对称加密(AES-128/256-GCM、ChaCha20-Poly1305),完整性靠 AEAD 里的认证标签,身份认证靠 X.509 证书和 CA 信任链。其中最容易出问题的恰恰是第三个——证书过期、中间证书链没配全、系统时间不对、根证书库过旧,这四个是生产环境 HTTPS 故障的绝对主力。
跨域与 CORS:为什么浏览器要挡你
跨域是前端最常撞墙的问题,而它的根源不是 HTTP 的限制,而是浏览器自己给自己加的一道锁:同源策略(Same-Origin Policy)。判断"同源"要求三件事全部相同:
同源(Same Origin,协议、主机、端口三样全都一模一样)这个判断标准,好比寄快递时核对门牌:"人民路 88 号 12 层 1203 室"和"人民路 88 号 12 层 1205 室"是两家人,哪怕只差最后一个数字,也绝不能把包裹放错。而"哪一层楼的哪个房间"这个细节之后的东西(也就是网址里的路径)反而不算——你是把包裹放门口还是玄关,不影响它是不是同一家。所以 www.example.com 和 api.example.com 虽然看着像亲戚,在浏览器眼里就是两户人家,这一点最反直觉,也最容易踩坑。
基准 URL: https://www.example.com:443/app/index.html
https://www.example.com/other.html ✔ 同源(路径不算)
https://www.example.com:443/x ✔ 同源(443 是 https 默认端口)
http://www.example.com/ ✘ 协议不同
https://api.example.com/ ✘ 主机不同(子域也算不同!)
https://www.example.com:8443/ ✘ 端口不同
# 判断标准:协议 + 主机 + 端口,三者必须完全一致
同源策略存在的理由值得想清楚。假设没有它:你在浏览器里登录了网上银行(Cookie 已存),然后打开一个恶意页面——那个页面里的 JS 就能直接 fetch('https://bank.com/api/balance'),浏览器会自动带上你的银行 Cookie,然后把余额、交易记录全都读走发到攻击者服务器。同源策略的本质是"防止 A 网站的脚本用你的身份去读 B 网站的数据"。
那正常的跨域需求怎么办?答案是 CORS(Cross-Origin Resource Sharing)——由被访问的那一方用响应头明确授权。注意这个方向:不是前端"绕过"限制,而是后端"点头同意"。CORS 把请求分成两类:
预检请求(Preflight,浏览器在正式请求之前先发一个 OPTIONS 去问一句"我能这么干吗")这个机制,好比你要去某栋写字楼办事,先在前台问一句"我能上 12 楼吗?能带这个包吗?"——前台点头你才刷卡上电梯。那个 Access-Control-Max-Age: 86400,翻译成人话就是"这个答复一天内有效,你今天再来不用重复问"——相当于前台给你办了张临时通行证,省掉一天里后续每一次的询问。
还有一件事必须提前掰清楚,因为它是所有 CORS 报错里最误导人的一点:浏览器拦下的是"读取响应"这一步,不是"发出请求"这一步。打个比方:你在邮局寄了一封信,收件方老老实实按信里的要求把钱转走了,只是回信在邮局门口被保安扣下没交到你手上。你以为"信没寄出去",其实事情早就办完了。这就是为什么 CORS 绝不能当权限校验用——一个不幂等的 POST 即使响应被拦,服务端该扣的款已经扣了。
【简单请求】满足全部条件时,浏览器直接发,不预检:
· 方法是 GET / HEAD / POST 之一
· Content-Type 只能是 text/plain、
application/x-www-form-urlencoded、multipart/form-data
· 没有自定义头部
GET /api/data HTTP/1.1
Origin: https://app.example.com ← 浏览器自动加,改不掉
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com ← 后端授权
Access-Control-Allow-Credentials: true ← 允许带 Cookie
↑ 若这两个头缺失或不匹配,浏览器会把响应扣下,
控制台报 CORS 错误 —— 注意:请求其实已经发出去、
服务器也已经处理了!被拦的只是"读取响应"这一步。
【预检请求】用了 PUT/DELETE、application/json、
或自定义头(如 Authorization)时,
浏览器会先自动发一个 OPTIONS 去问:
OPTIONS /api/data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400 ← 这个授权结果可缓存一天,
省掉后续每次的预检开销
三个高频坑要提前知道。第一,Allow-Origin: * 和带 Cookie 不能共存——想让浏览器带凭证,就必须把 Origin 写成具体值,规范明确禁止通配符配凭证。第二,CORS 错误经常被误判为"接口挂了",其实请求已经成功执行了(这也意味着 CORS 不能替代权限校验,一个不幂等的 POST 即使被 CORS 拦下响应,服务端的副作用也已经发生)。第三,前端能读到的响应头默认只有六个基础头,想让 JS 读到自定义头必须用 Access-Control-Expose-Headers 显式暴露。
队头阻塞:HTTP/1.1 最大的病根
要理解 HTTP/2 和 HTTP/3 为什么必须出现,就得先把"队头阻塞"(Head-of-Line Blocking)这件事讲透。它其实有两个不同层次的版本,很多人会混为一谈。
队头阻塞(Head-of-Line Blocking,最前面那一个卡住了,后面全部跟着卡)这个词听着玄,其实就是你在超市排队结账时最恨的那一幕:你手上只有一瓶水,可前面那位大爷正为一袋标价不对的白菜跟收银员理论三分钟——你和后面十个人只能干等,明明你们的东西一秒就能扫完。
第一代 · HTTP/1.1 的做法:一条队伍、一个收银员、严格按顺序结账。前面那位争执三分钟,后面全体陪站。浏览器的土办法是同时去六条不同的队伍排——听着聪明,实际上每开一条队都要重新走一遍"打招呼 + 验身份"的流程,而且六条队各自都得从慢慢起步开始。要是你要买八十样东西,还是得来回排十四轮。前端圈甚至发明过更邪的招:把商品分散到六家不同的超市去买(域名分片),骗浏览器多开几条队。
第二代 · HTTP/2 的做法:还是一条队伍,但改成把每个人的商品拆成一件一件轮着扫——你的水扫一下、大爷的白菜扫一下、后面那位的牛奶扫一下,交错进行,谁也不用等谁扫完。八十样东西可以在一条队伍上同时推进。这就是"多路复用"。
但第二代还留了一个病根:这条队伍用的是同一条传送带(TCP 字节流),传送带要求货物必须严格按放上去的顺序取下来。一旦第五个包裹在半路掉了,第六到第九个包裹明明已经到了,也得全部扣在传送带尽头不许取——哪怕它们是完全不相干的另一位客人的东西。这就是"TCP 层的队头阻塞",HTTP/2 在应用层怎么折腾都绕不过去。
第三代 · HTTP/3 的做法:干脆把整条传送带拆成很多条互不干扰的小传送带(QUIC 的多个流)。你那条带上掉了东西,只影响你自己;旁边那条带上的货照常往下走。两层队头阻塞到这里才算真正治好。
【第一层·HTTP 层的队头阻塞】HTTP/1.1 的问题
一条 TCP 连接上,HTTP/1.1 要求"请求必须按发出顺序应答":
时间 →
连接1: [请求A 发出]────等待────[响应A 3秒] [请求B]──[响应B]
↑
B 早就准备好了,但必须等 A 应答完才能发
这是协议规定,不是网络慢
浏览器的土办法:同一个域名开 6 条并行连接
→ 6 条 TCP 各自握手(每条 1 RTT + TLS 2 RTT)
→ 6 条各自做慢启动,都跑不满带宽
→ 网页有 80 个资源时,仍然要排 14 轮
→ 前端界还发明了"域名分片"这种邪招:
把资源分散到 img1.cdn.com、img2.cdn.com...
骗浏览器开更多连接
【HTTP/2 的解法】二进制分帧 + 多路复用
一条 TCP 连接上跑多个"流"(Stream),每个流独立编号:
连接1: [A的帧][B的帧][C的帧][B的帧][A的帧][C的帧]...
↑ 交错发送,互不阻塞,接收方按流 ID 重组
80 个资源可以在一条连接上同时进行
【第二层·TCP 层的队头阻塞】HTTP/2 没解决的
TCP 保证严格的字节流顺序。如果第 5 个包丢了:
已到达: [1][2][3][4][ 丢 ][6][7][8][9]
↑
TCP 把 6~9 全部扣在内核缓冲区里不交给上层,
直到 5 被重传补上 —— 即使 6~9 属于完全无关的
另一张图片!
HTTP/2 在应用层再怎么多路复用,也逃不过
底层这一个 TCP 字节流的约束。
丢包率越高,HTTP/2 的优势越小,
某些弱网场景甚至不如 HTTP/1.1 的多连接。
【HTTP/3 的解法】换掉 TCP,用 QUIC
QUIC 在 UDP 上自己实现可靠传输,但按"流"独立保序:
流1 丢包 → 只阻塞流1,流2 流3 照常交付上层
这才是真正彻底解决了两层队头阻塞
这段历史里有一个耐人寻味的细节:HTTP/1.1 其实曾经尝试过解决队头阻塞,方案叫"管线化"(Pipelining)——允许不等响应就连续发多个请求。但它最终彻底失败并被所有浏览器默认关闭,原因是响应仍然必须按序返回,一个慢响应照样堵死后面全部;再加上大量代理服务器实现有 bug,导致响应错配。管线化的失败证明了:在一条严格保序的字节流上,无论怎么在应用层耍花招,都无法真正实现并行。
HTTP/2 和 HTTP/3 · 更快的新一代
- HTTP/2(2015)一个连接同时传多个文件(以前要排队)、头部压缩、服务器主动推送——网页加载快 30%。
- HTTP/3(2022)基于 UDP 的 QUIC 协议——更快、更稳,尤其在弱网(地铁、电梯)下。Google、Cloudflare 已全面支持。
把三个版本的关键差异并排看,能看清每一代到底改了什么:
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| 传输层 | TCP | TCP | QUIC / UDP |
| 报文格式 | 纯文本,人可读 | 二进制分帧,必须用工具解析 | 二进制分帧 |
| 并发方式 | 开多条 TCP 连接(通常 6 条) | 一条连接多个流 | 一条连接多个流 |
| HTTP 层队头阻塞 | 有 | 无 | 无 |
| TCP 层队头阻塞 | 有 | 仍然有 | 无 |
| 头部压缩 | 无(每次都发全量) | HPACK(RFC 7541) | QPACK(RFC 9204) |
| 加密 | 可选 | 浏览器实际强制要求 TLS | 协议内建,无法关闭 |
| 建连开销 | TCP 1 RTT + TLS 1~2 RTT | 同 1.1 | 1 RTT,复用时 0 RTT |
| 切换网络(WiFi→5G) | 连接断开重建 | 连接断开重建 | 连接 ID 不变,无感迁移 |
| 服务器推送 | 无 | 有,但已被主流浏览器废弃 | 用 103 Early Hints 替代 |
头部压缩带来的收益比想象中大。一个典型的 HTTP 请求头有 500~800 字节,其中 User-Agent、Accept、Cookie 这些字段在同一个页面的几十个请求里几乎一字不差地重复了几十遍。HPACK 用一张动态索引表让第二次之后只发一个索引号,实测能把头部压到原来的十分之一以下。对于移动端上行带宽有限的场景,这个优化的效果甚至超过多路复用。
头部压缩(HPACK / QPACK,把重复出现的头部字段换成一个短编号)这套办法,好比餐厅点菜时的一个小习惯:第一次你得完整说"一份牛肉面,加香菜不加葱,中辣",第二碗你只要说"再来一碗一样的"就行了。头部字段就是那句啰嗦的完整描述——同一个网页要拿几十个资源,这句话原本要一字不差地重复几十遍。改成"跟刚才那个一样"之后,能压到原来的十分之一以下。
再把"500~800 字节的头部"这个数字翻译成人话:那大概是三百到五百个汉字——差不多一条完整的朋友圈长度。一个稍微复杂的网页要发八十个请求,等于你为了拿八十样东西,先抄了八十遍同一条朋友圈发过去。这就是为什么在手机上行带宽紧张的场景里,光是压掉这份重复,效果就能超过多路复用本身。
HTTP/2 服务器推送(Server Push)的失败是个值得记住的教训。它的设想很美好:服务器返回 HTML 时,顺便把页面需要的 CSS/JS 一起推过去,省掉一个来回。但实践中发现了致命问题——服务器不知道客户端缓存里已经有什么,于是大量推送是纯浪费带宽;而且推送优先级会挤占真正紧急的资源。Chrome 在 2022 年默认关闭了它,取而代之的是 103 Early Hints:服务器先回一个 103 告诉浏览器"你可以开始预加载这几个 URL 了",让浏览器自己决定要不要拿。这个转变体现了一条原则:与其让服务器猜,不如给客户端信息让它自己判断。
服务器推送为什么失败,换成大白话就是一件很好懂的事。想象一下你去餐厅只点了一碗面,服务员却自作主张给你端来一碟小菜、一杯饮料和一份甜点,理由是"大部分人都要"。问题是他不知道你包里已经带了饮料、也不知道你甜食过敏——这些菜端上来只是占了桌子、耽误了后厨做你真正想吃的那碗面。103 Early Hints(服务器先回一句"后面这几样你可能要用,先去备着")改成了另一种方式:服务员先递给你一份菜单说"这几样通常配着好吃",要不要由你自己定。这就是那条原则——与其让人替你猜,不如把信息给你,让你自己判断。
怎么知道一个网站在用哪个版本?几种办法:浏览器开发者工具的 Network 面板右键勾选 Protocol 列(会显示 h2 / h3 / http/1.1);或用命令行:
# 看协议版本和 Alt-Svc 头(服务器用它宣告支持 h3)
$ curl -I --http2 https://www.cloudflare.com
HTTP/2 200
alt-svc: h3=":443"; ma=86400
↑ 这一行在告诉浏览器"下次可以试试用 HTTP/3 连我"
# 直接强制用 HTTP/3 测试(需要 curl 编译时带 h3 支持)
$ curl -I --http3 https://www.cloudflare.com
HTTP/3 200
# 看完整的 TLS 协商结果(ALPN 决定了用哪个 HTTP 版本)
$ openssl s_client -connect example.com:443 -alpn h2,http/1.1 < /dev/null \
| grep ALPN
ALPN protocol: h2
↑ HTTP 版本的协商其实发生在 TLS 握手里的 ALPN 扩展中
最后那条命令揭示了一个容易被忽略的机制:HTTP/2 和 HTTP/1.1 的选择不是靠 HTTP 自己协商的,而是在 TLS 握手阶段通过 ALPN(Application-Layer Protocol Negotiation)扩展决定的。这意味着如果你的服务器 TLS 配置里没有开启 ALPN,浏览器就永远只会用 HTTP/1.1——这是"明明装了新版 Nginx 但还在跑 1.1"这类问题的常见原因。
你每天都在用的 HTTP 方法
| 方法 | 干什么 | 你什么时候触发 |
|---|---|---|
| GET | 获取资源 | 打开网页、刷抖音、看图片 |
| POST | 提交数据 | 登录、发朋友圈、填表单 |
| PUT | 更新资源 | 改头像、编辑资料 |
| DELETE | 删除资源 | 删微博、清空购物车 |
| HEAD | 只获取头部 | 检查文件是否存在、大小 |
HTTP 是"浏览器和服务器对话的格式"——请求/响应、状态码、Cookie、HTTPS 加密,四件事搞定。
Cookie 是 HTTP 的"记忆补丁",让无状态的协议能"记住"你。理解 Cookie,你就理解了"为什么登录一次,之后不用反复登"。