发送请求与服务器响应
前面三节全是准备工作:找到地址、接上线、验明身份加上锁。这一节终于开口说正事了——浏览器把要求说出去,服务器那头经过好几道手把东西找出来,再把结果送回。这是整段旅程里唯一真正"传输内容"的一步,也是服务器唯一有机会出错的一步。
号码查到了、电话拨通了、身份也互相验明了。现在你说:
"我要取 8848 号那件货。我是老客户,卡号在这儿。麻烦给我压缩包装,我拿不了太大的箱子。另外如果这货和上次那批一样,就不用重新给我了。"
柜员听完,转身去后面办:先看你卡号有效不有效(网关鉴权)→ 到货架区找 8848(应用处理)→ 发现要查库存记录(数据库查询)→ 按你要求打压缩包(压缩响应)→ 递给你并说一句"这是货,另外提醒你三天后有活动"(响应头)。
注意你那句话里的信息密度:一个动作(取)、一个目标(8848)、三个附加条件(我是谁、怎么包装、什么情况下不用给)。HTTP 请求的结构和这句话一模一样——一个方法、一个目标、一堆头部字段。
HTTP 的报文格式在 § 3.1 已经详细讲过。这一节要看的是:它在一次真实访问里怎么发、发出去之后服务器那头到底经过了几道手、以及回来的响应头怎么反过来影响你下一次访问。
上一步刚完成了什么,这一步要干什么
接力棒交到这一节时,手里有的是一条已验明身份的加密通道(§ 4.4 的产物),再加上 § 4.1 解析出来的那几样东西:方法(GET)、路径(/item/8848)、主机名。
这一节要干的事:把这些信息组装成一份规范的请求报文发出去,然后接住服务器的回复。听着简单,但这一步是整段旅程里唯一"服务器有机会犯错"的一步——前面四步全是通信层面的,出错只有"连不上"这一种;这一步开始涉及业务逻辑,出错方式一下丰富起来(404、500、403、超时、返回了错的内容)。
干完之后交给下一节的东西是:
- 一个状态码三位数字,说明这次办得怎么样(200 成功、404 找不到、500 服务器炸了)
- 一堆响应头告诉浏览器"这是什么类型、多大、能缓存多久、要不要设 Cookie"
- 响应体真正的内容——通常是一份 HTML 文本,这就是 § 4.6 渲染的原料
这一节的时间成本有两部分,性质完全不同:一个来回的网络时间(由距离决定,你改不了)+ 服务器处理时间(由服务器性能与业务复杂度决定)。把这两部分分开看,是排查"网站慢"的第一步。
先把这一节的术语翻译成人话
HTTP 术语在 § 3.1 已经过了一遍,这里补的是"实战流程视角"才会碰到的那些:
| 术语 | 换成大白话 | 生活里对应的东西 |
|---|---|---|
| 请求行(Request Line) | 说白了就是"你开口那句话的主干:干什么 + 对什么" | 在窗口说的"我要取 8848 号" |
| 请求头(Request Headers) | 说白了就是"一堆附加说明,一行一条" | 快递面单上那些栏目:易碎、代收、指定时间 |
| TTFB(Time To First Byte,首字节时间) | 说白了就是"从你说完话到对方吐出第一个字的间隔" | 你问完话,对方"嗯……"了一下才开始答的那段沉默 |
| 反向代理(Reverse Proxy) | 说白了就是"你以为在跟店家说话,其实先经过了一个前台" | 医院的分诊台:所有人先到它这儿,它再分到各科室 |
| 网关(Gateway / API Gateway) | 说白了就是"进门第一道关卡,管查票和限流" | 景区大门的安检与验票口 |
| 负载均衡(Load Balancer) | 说白了就是"把客人分到几个窗口去,别都挤一个" | 银行大堂经理引导排队的人去不同窗口 |
| 状态码(Status Code) | 说白了就是"这次办得怎么样的一个三位数编号" | 挂号机吐出的小票:成功、已满、该科室不存在 |
| 重定向(Redirect,3xx) | 说白了就是"这事不在我这儿办,你去那个窗口" | "社保业务不在 3 号窗,请到 7 号窗" |
| 协商缓存(304 Not Modified) | 说白了就是"你手上那份还能用,我不重新给你了" | "你上次拿的那份表格没改版,接着用" |
| 内容编码(Content-Encoding,压缩) | 说白了就是"我把东西压扁了寄,你收到自己撑开" | 洗衣店的真空压缩袋:体积小一大半,用时拆开就恢复 |
| 分块传输(Chunked Transfer) | 说白了就是"我边做边给你,不等全做完才一起端上来" | 餐厅先上凉菜,热菜陆续端,而不是全做好一起上 |
| Cookie | 说白了就是"人家发给你的一个小凭据,下次来要带着" | 寄存柜给的便签牌号——服务方不记你是谁,靠你把牌子带来 |
| 幂等(Idempotent) | 说白了就是"这动作做一次和做十次结果一样" | 按电梯上行键:按十次也还是上一趟 |
这张表里最值得先记住的是 TTFB,因为它是排查"网站慢"最关键的一个指标。TTFB 把"网络慢"和"服务器慢"分开了:TTFB 长而下载快 → 服务器处理慢;TTFB 短而下载慢 → 带宽或内容太大。这两种病的治法完全不同。
打个比方,把整个请求响应过程放到银行窗口上,每一部分都有精确的对应:
你的那句话 = 请求报文。
· "我要取" = 方法(GET / POST / PUT / DELETE,动作类型)
· "8848 号那件" = 请求目标(路径)
· "我是老客户,卡号在这儿" = Cookie 头(身份凭据)
· "给我压缩包装" = Accept-Encoding 头(我能接受压缩过的内容)
· "如果和上次那批一样就不用给了" = If-None-Match 头(协商缓存)
柜员转身去办的那段时间 = 服务器处理时间。这段时间里你什么都看不到,只能干等——这就是 TTFB 那段沉默。
柜员回来递给你的东西 = 响应报文。
· "给你"(或"该货已下架") = 状态码(200 或 404)
· "这是压缩包装的,一共 3 公斤" = Content-Encoding 与 Content-Length 头
· "这批货的批次号是 ABC,下次报这个号我就知道你要哪批" = ETag 头
· "这货一周内不会变,你先放着用" = Cache-Control 头
· 货本身 = 响应体(那份 HTML)
整个 HTTP 的设计,本质上就是把"人到窗口办一件事"这个过程写成了机器能严格执行的格式。而它最聪明的地方在于:这些附加条件(头部)是可选的、可扩展的——今天多加一个"请给我英文版"的说明,明天多加一个"我支持新的压缩格式",老服务器看不懂就当没说,照样能办事。相当于面单上多填了一栏备注,快递员不认识那栏就跳过,不影响整件事成立。
浏览器实际发出去的那份请求
现在看真东西。你在手机上点开那个商品页,浏览器通过加密通道发出的报文,解密之后大致是这样(为了看清结构,这里按 HTTP/1.1 的文本格式呈现;HTTP/2 与 HTTP/3 会把它压成二进制,但字段的含义完全一样):
GET /item/8848 HTTP/1.1
Host: shop.example.com
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ...
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Accept-Encoding: gzip, deflate, br
Referer: https://m.weixin.example/chat
Cookie: session_id=a1b2c3d4e5; uid=88192
If-None-Match: "v3-8848-20260806"
Connection: keep-alive
← 空行,头部到此结束
← GET 请求通常没有请求体
这份报文的语法规则(请求行、头部、空行、请求体这四段结构,以及为什么换行必须是 CRLF)在 § 3.1 已经详细讲过。这里我们换个角度看:每一行都在替你争取一件事。逐行拆一遍:
| 这一行 | 它在替你争取什么 | 不写会怎样 |
|---|---|---|
GET /item/8848 | 说清动作和目标——我要"取",取的是这个 | 没有这行就不成立,这是请求的主干 |
Host | 告诉服务器"我找的是哪个网站"(一个 IP 上常有几十个站) | HTTP/1.1 里不写是违规的,服务器会返回 400 |
User-Agent | 报出你的设备与浏览器,服务器可能据此给移动版页面 | 可能收到桌面版页面,在手机上难看 |
Accept | "我能接受这些格式的内容",还带优先级(q 值) | 服务器只能猜,可能给你一个你处理不了的格式 |
Accept-Language | "我优先要中文"——多语言网站靠它决定给哪种语言 | 可能收到英文页面 |
Accept-Encoding | "我能解开压缩的内容" | 服务器不敢压缩,传输量可能大好几倍 |
Referer | "我是从哪个页面点过来的"(这个词的拼写是历史上的笔误,规范里就这么写) | 网站统计不到来源;某些防盗链功能会失效 |
Cookie | "这是我的凭据"——服务器靠它认出你是谁 | 会被当成陌生人,登录状态丢失 |
If-None-Match | "我手上有版本 v3 那份,如果没变就别重发了" | 服务器只能把内容完整重发一遍,浪费流量与时间 |
Connection: keep-alive | "这条线别断,我等下还有请求" | 连接被关,下一个资源要重新握手(§ 4.3 讲过这笔开销) |
看完这张表,你应该能体会到一件事:HTTP 请求头是一场"预先谈判"。浏览器在开口的同一句话里,就把"我是谁、我能接受什么、我已经有什么、我希望怎么处理"全说清了,好让服务器一次就给出最合适的回复。
换成大白话:这相当于一个会办事的人到窗口的说话方式。不会办事的人只说"我要取货",然后被反复追问"卡号呢""要什么包装""你上次那份还在不在",来回问三遍。会办事的人一口气把该说的全说完,柜员一次就办妥了。而每一次"追问"在网络上就是一个来回——所以把信息一次说全,本身就是性能优化。
Host 头:一个字段撑起了整个虚拟主机时代
上面那些头部里,Host 最不起眼但历史意义最大。它是 HTTP/1.1 相对 1.0 最重要的强制新增项。
为什么必须有它?因为 IP 地址稀缺,而域名不稀缺(§ 1.3 讲过 IPv4 地址枯竭的问题)。如果一个 IP 只能放一个网站,那全世界能有的网站数量就被 IP 数量卡死了。
没有 Host 头时(HTTP/1.0 早期):
请求只写:GET /item/8848 HTTP/1.0
服务器收到后一脸茫然:我这台机器上跑着 30 个网站,
你这个 /item/8848 是要哪个网站的?
有了 Host 头之后:
GET /item/8848 HTTP/1.1
Host: shop.example.com ← 这一行解决了全部问题
于是一台服务器、一个 IP,可以承载成千上万个网站。
这种做法叫「虚拟主机」(Virtual Host)。
换成大白话:这相当于一栋写字楼共用一个门牌号。快递员送到楼下,光有门牌号不够,他还得知道"送几层几室"。Host 头就是那个"几层几室"——它让一个物理地址能容纳无数个逻辑地址。
你可能注意到这和 § 4.4 讲的 SNI 干的是同一件事。没错,它们的作用完全平行,只是发生在不同层、解决不同阶段的同一个问题:
| SNI | Host 头 | |
|---|---|---|
| 发生在什么时候 | TLS 握手时(§ 4.4) | 发 HTTP 请求时(本节) |
| 解决什么问题 | 服务器该出示哪份证书 | 服务器该返回哪个网站的内容 |
| 是否加密 | 明文 | 在 HTTPS 下是加密的 |
| 为什么不能只留一个 | 证书要在加密建立之前发,所以必须有一个握手期的字段 | 请求是加密之后才发的,用它更安全也更规范 |
相当于你进写字楼大门时跟保安说了一次"找 20 楼某公司"(SNI,大门口,公开听得见),进电梯上去后又跟那家公司前台说了一次"我找技术部老李"(Host,屋里说的,外面听不见)。两次都必须说,因为听的人不同、目的不同。
请求发出去之后:服务器那头的好几道手
请求离开你的设备后,通常不会直接抵达那个"跑业务代码的程序"。现代网站的架构里,一个请求往往要过好几道手。这就是 TTFB 那段沉默里发生的事。
你的请求到达机房,典型路径:
① CDN 边缘节点(如果网站用了 CDN)
· 这份内容我缓存里有吗?有 → 直接返回,压根不去源站 ★
· 没有 → 往后转发
② 负载均衡器
· 后面有 20 台服务器,这个请求分给谁?
· 顺便检查各台是否健康,挂了的不分给它
③ 反向代理 / 网关
· 解 TLS(很多架构在这一层解密,后面走内网明文)
· 查鉴权:这个 Cookie 有效吗?
· 限流:这个 IP 一分钟内请求太多了吗?
· 路由:/item/... 这类路径该交给哪个后端服务?
④ 应用服务器(真正跑业务代码的地方)
· 解析路径,取出商品 ID 8848
· 查缓存(如 Redis):这个商品的数据我内存里有吗?
· 没有 → 查数据库
⑤ 数据库
· 执行查询,返回商品信息
· ★ 这一层往往是整条链路最慢的一环
⑥ 应用服务器把数据套进模板,生成 HTML
⑦ 一路返回:应用 → 网关 → 负载均衡 → CDN → 你
★ 你在开发者工具里看到的那个 TTFB 数字,
是上面①到⑦全部时间 + 一个网络来回的总和。
这张图解释了一个很常被问的问题:为什么有的网站快、有的慢,明明我的网速一样?因为TTFB 里"服务器处理"那一部分跟你的网络毫无关系,它取决于对方的架构、缓存命中情况、数据库有多忙。
相当于你在两家餐厅点同一道菜。一家后厨提前备好了料、五分钟上菜;另一家现杀现洗、四十分钟。而你从家到两家餐厅的路程是一样的。路程是网络,备料是服务器架构——抱怨路太远是解决不了后厨慢的。
注意第 ① 步那个 ★ 号。CDN 命中的话,你的请求压根到不了真正的服务器——它在离你几十公里的边缘节点就被应答了。这就是为什么 CDN 能同时降低两笔成本:缩短了网络距离,还省掉了服务器处理时间。它是本章讲的所有优化手段里效果最直接的一个。
服务器返回的那份响应
处理完了,服务器把结果发回来。响应报文的结构和请求是对称的:状态行 + 响应头 + 空行 + 响应体。真实的样子大致如下:
HTTP/1.1 200 OK
Date: Sat, 08 Aug 2026 06:12:44 GMT
Content-Type: text/html; charset=UTF-8
Content-Encoding: br
Content-Length: 14203
Cache-Control: max-age=300, public
ETag: "v3-8848-20260808"
Vary: Accept-Encoding, Accept-Language
Set-Cookie: last_view=8848; Path=/; HttpOnly; Secure; SameSite=Lax
Strict-Transport-Security: max-age=31536000; includeSubDomains
Server: nginx
← 空行
<!DOCTYPE html>
<html lang="zh-CN">
<head><title>某款商品 - 商城</title>
<link rel="stylesheet" href="/css/main.css">
...
← 这一大段就是 § 4.6 渲染的原料
逐条看这些响应头分别在干什么——它们的共同特点是:每一条都在指挥浏览器接下来该怎么做。
| 响应头 | 它在告诉浏览器什么 | 换成大白话 | 不给会怎样 |
|---|---|---|---|
Content-Type | 这份内容是什么类型、什么字符编码 | "这是一份 HTML,用 UTF-8 编码读" | 可能出现乱码,或者浏览器把网页当纯文本显示 |
Content-Encoding | 内容被哪种方式压缩过 | "我压扁了寄,你收到自己撑开" | 浏览器不知道要解压,看到一堆乱码 |
Content-Length | 正文一共多少字节 | "这箱货净重 14203 克" | 浏览器不知道什么时候读完(§ 3.1 讲过这个字段写错的严重后果) |
Cache-Control | 这份内容能缓存多久、谁能缓存 | "五分钟内你直接用,别再来问我" | 浏览器只能保守处理,下次可能又白跑一趟 |
ETag | 这份内容的"版本指纹" | "这批货的批次号是 v3" | 没法做协商缓存,只能整份重传 |
Vary | 缓存时要区分哪些请求头 | "中文版和英文版是两批货,别混着存" | 可能给中文用户返回缓存里的英文页面 |
Set-Cookie | 给你一个凭据,下次来带着 | "这是寄存牌,下次凭它认你" | 没法维持登录状态 |
Strict-Transport-Security | 以后只准用 HTTPS 找我(§ 4.1 讲的 HSTS 就是这条头设的) | "以后只走监控走廊" | 留下明文请求的缝隙 |
其中 Vary 最容易被忽略,但它造成的 bug 最诡异,值得单独说。它的意思是"这份缓存的有效性依赖于请求里的某几个头"。
打个比方:一家店按顾客口味做了两种版本的货——微辣版和特辣版。如果仓库不区分地把它们堆在同一个格子里,那下一个说"我要微辣"的人可能拿到特辣。Vary: Accept-Language 就是在告诉所有缓存:"按语言分格存放,别混。"
真实世界里,忘写 Vary 导致的经典故障是:某个用户访问了英文版,页面被 CDN 缓存下来;后面所有中文用户都收到了那份英文页面。这类 bug 极难排查,因为它只在特定顺序下出现,而且开发者本地永远复现不出来。
状态码:三位数字里的分诊逻辑
状态码的完整清单在 § 3.1 讲过(那里也有一个可点击查询的组件)。这一节只讲一件事:在真实的访问流程里,不同状态码会把你导向完全不同的后续路径。
| 状态码 | 浏览器接下来会怎么做 | 对整段旅程的影响 |
|---|---|---|
| 200 OK | 接收内容,交给渲染引擎 | 正常路径,直接进 § 4.6 |
| 301 / 308 永久重定向 | 换个地址重新走一遍,并且记住这个跳转 | 多花一个完整来回;下次访问会直接去新地址 |
| 302 / 307 临时重定向 | 换个地址重新走一遍,但不记住 | 每次访问都要多花一个来回 |
| 304 Not Modified | 用本地缓存那份 | 省掉了传内容,但那个来回还是花了 |
| 403 Forbidden | 显示错误页 | 你身份不够——凭据可能过期了 |
| 404 Not Found | 显示错误页 | 路径写错了,或者内容已删(回到 § 4.1 的路径排查) |
| 500 服务器内部错误 | 显示错误页 | 服务器代码炸了——跟你的网络毫无关系 |
| 502 / 504 网关错误 | 显示错误页 | 前面那道网关连不上后端、或等后端超时了 |
| 503 服务不可用 | 显示错误页 | 服务器过载或在维护 |
这里有一个非常实用的区分,几乎没人一开始就搞清:4xx 和 5xx 的锅在不同的人身上。
- 4xx 是"你的问题"——请求本身有毛病:地址错了、没权限、格式不对。相当于你在窗口递错了表格或者证件不全。
- 5xx 是"服务器的问题"——你的请求完全合规,是对方办不了。相当于你的材料齐全,但柜员后台系统崩了。
这个区分的实际价值:看到 4xx 你该检查自己的操作,看到 5xx 你重试或联系对方就行,检查自己纯属白费功夫。
还要特别说一下 502 和 504 这一对,因为它们透露了服务器的内部结构:
502 Bad Gateway ← 网关说:"我联系不上后面那台服务器"
504 Gateway Timeout ← 网关说:"我联系上了,但它半天不回话,我等不下去了"
★ 这两个状态码本身就证明了「服务器不是一台机器」。
能报出"网关"错误,说明前面确实站着一个分诊台,
而真正干活的在它后面。
相当于医院分诊台告诉你:
502 = "那个科室的电话打不通"(可能整个科室没人了)
504 = "电话通了但一直没人接完这通"(人在但忙得顾不上)
这也解释了为什么你偶尔会看到"网站挂了但错误页很漂亮"——因为报错的是网关(它还活得很好),挂掉的是它后面的应用服务器。说白了,前台好得很,是后厨没人了。
重定向:那个你察觉不到的额外来回
重定向(3xx)是本节最值得从"流程视角"重新理解的一项。因为每一次重定向,都意味着整段旅程要从某一步重新走一遍。
你输入:example.com
实际可能发生的连锁:
第 1 趟 http://example.com
→ 301 跳到 https://example.com
(多花:一个完整来回)
第 2 趟 https://example.com
→ 301 跳到 https://www.example.com
(多花:一次 DNS 查询 + 建连接 + TLS 握手 + 一个来回)
★ 因为域名变了,前面几节全部要重做
第 3 趟 https://www.example.com
→ 200 终于拿到内容
一共三趟。而用户的感受只是"这网站有点慢"。
换成大白话:这相当于你去办事,第一个窗口说"这业务搬到二楼了",二楼窗口说"哦我们又搬到隔壁楼了"。三趟跑下来事情办成了,但时间花了三倍。而如果第一个窗口一开始就贴张纸写清"本业务在隔壁楼三层",你只需要跑一趟。
这就是网站优化里"减少重定向链"这一条的全部道理。而 § 4.1 讲的 HSTS 预加载正是为了消掉上面那第 1 趟——让浏览器不必先去问一次就知道该用 HTTPS。
顺便区分 301 和 302,这一对在实践中差别很大:
| 301 永久重定向 | 302 临时重定向 | |
|---|---|---|
| 含义 | "这地址永久换了,以后直接去新的" | "这次先去那边,但下次还来问我" |
| 浏览器会不会记住 | 会(可能长期缓存这条跳转) | 不会(每次都要问一遍) |
| 性能 | 好——只有第一次多花一个来回 | 差——每次都多花一个来回 |
| 风险 | 配错了很难挽回——用户浏览器已经记住了,改回来他也不去问了 | 配错了影响小,改了立刻生效 |
| 生活里对应 | 门上贴"本店已永久迁至新址"——熟客从此直接去新店 | 门上贴"今日装修,临时在隔壁营业"——明天还得来看一眼 |
最后那一行的风险值得记住:301 是会被浏览器长期记住的,所以它是一个"不太容易反悔"的动作。相当于你在通讯录里把某人的号码永久改了,之后就算原号又启用了,你也不会再去打原号。说白了:不确定要不要永久搬家时,先用 302。
压缩与分块传输:让内容更快到眼前的两招
响应体真正开始传的时候,有两个机制在悄悄提速。它们的思路完全不同,但都很朴素。
第一招:压缩。HTML、CSS、JS 都是纯文本,而文本的压缩率极高——大量重复的标签名、属性名、空格,压完常常只剩几分之一。
它是怎么谈成的?就是靠前面那对头部:
你说: Accept-Encoding: gzip, deflate, br
("我能解开这三种压缩格式")
它回: Content-Encoding: br
("那我用 br 这种压缩,你自己解开")
★ 这是一次典型的"能力协商":
客户端报出自己支持什么,服务器从中挑一个。
老客户端不支持新格式?那它就不写在清单里,服务器自然不用。
—— 新旧共存,谁都不用被迫升级。
相当于洗衣店的真空压缩袋:同样一床被子,压完体积只剩三分之一,运起来轻松得多,你收到自己一撑就恢复。代价是两头都要花一点力气(服务器压、你解),但省下的传输时间远远超过这点计算成本。
不过有一类内容不该再压:图片(JPEG、PNG、WebP)、视频、已经打包好的压缩文件。因为它们内部已经压过了,再压一遍不但省不下几个字节,还白费两头的算力。说白了,真空袋对已经抽过真空的东西没用。
第二招:分块传输(Chunked Transfer Encoding)。它解决的是另一个问题:如果服务器要生成的内容很大、或者要边算边给,它压根不知道 Content-Length 该填多少。
普通方式:
服务器必须先把整份内容生成完,数清字节数,
填好 Content-Length,然后才开始发。
→ 用户在这段时间里什么都看不到
分块方式:
服务器每生成一块就发一块,每块前面标上"这块多少字节",
最后发一个长度为 0 的块表示"完了"。
→ 用户能很早看到内容的开头部分
相当于餐厅上菜:
普通方式 = 所有菜做完一起端上来(干等)
分块方式 = 凉菜先上,热菜陆续端(边等边吃)
这个机制对用户感知的速度影响极大,而且值得注意的是:它并没有让总时间变短,只是让"开始看到东西"这个时刻提前了。
本质上就是:同一顿饭吃完的时间可能一样,但"从坐下到第一口"这段等待,决定了你觉得这家店快还是慢。网页也一样——用户感知的快慢,主要取决于第一屏什么时候出现,而不是最后一个字节什么时候到。这一点在 § 4.6 讲渲染时会进一步展开。
Cookie:无状态协议怎么记住你是谁
HTTP 有个根本特性叫无状态(Stateless,§ 3.1 与 § 1.2 都讲过)——服务器默认不记得你上次来过。每一个请求在它眼里都是全新的陌生人。
那登录状态是怎么维持的?靠 Cookie:服务器不记,让你自己记,每次来的时候带着。
第一次访问(登录成功):
服务器 → 你:Set-Cookie: session_id=a1b2c3d4e5; HttpOnly; Secure
之后每一次请求:
你 → 服务器:Cookie: session_id=a1b2c3d4e5
服务器一看这个编号,去自己的记录里查:"哦,这是 88192 号用户"
★ 关键点:服务器不需要"记住你",
它只需要能"认出你手上那个编号"。
相当于超市门口的寄存柜。柜台不会记住你长什么样、存了什么,它只给你一个便签牌号;你回来把牌子递上去,它按号开柜。记忆的负担从服务方转移到了客户手上——这正是 HTTP 能在云上无限扩容的根本原因。
为什么这个转移这么重要?因为如果服务器必须"记住每个用户",那同一个用户的每次请求都必须落到同一台服务器上(否则另一台不认识他)。而把记忆交给客户端之后,任何一台服务器都能服务任何一个用户——加机器就能扛更多流量。
换成大白话:如果每个柜员只认自己接待过的客人,那客人就得永远找同一个柜员,柜员一休假业务就断。而凭牌号办事的话,任何柜员都能接任何客人,人多了就多开窗口。
那几个 Cookie 属性各自在干什么,值得记一下——它们全是安全属性:
| 属性 | 作用 | 换成大白话 |
|---|---|---|
HttpOnly | 禁止网页脚本读取这个 Cookie | "这个牌号只能交给柜台,不许给店里其他人看"——防止脚本偷走凭据 |
Secure | 只在 HTTPS 连接上发送 | "只在加密通道里出示,明文场合一律不掏出来" |
SameSite | 限制跨站请求时是否携带 | "别人家的网页替我发的请求,别把我的牌号带上"——防跨站伪造 |
Path / Domain | 限定这个 Cookie 在哪些路径与域名下有效 | "这张牌只在这一层楼、这几个柜台有效" |
Max-Age / Expires | 多久后失效 | "这张牌当天有效,过期作废" |
顺便提醒一件实际后果:Cookie 会随每一个请求发出去,包括那些图片、CSS、JS 请求。所以 Cookie 存太多东西会让每个请求都变胖。这也是为什么很多网站把静态资源放在一个单独的域名下——那个域名下没有 Cookie,请求头能小一大截。相当于你去取一瓶水也要把整个证件夹掏出来,不如把"要出示证件的柜台"和"随便拿的货架"分开设置。
整节小结:一次请求响应的完整时间线
把本节内容按真实顺序串起来,接在 § 4.4 的第 ⑦ 步之后:
接过一条已验明身份的加密通道
│
├─① 组装请求报文:请求行 + 十来条头部
│ (每一条头都在替你争取一件事:身份、语言、压缩、缓存)
│
├─② 通过加密通道发出去(体积通常只有几百到几千字节)
│
├─③ ═══ 等待期 · 这就是 TTFB 那段沉默 ═══
│ 服务器侧依次经过:
│ CDN 边缘节点 → 命中就直接返回 ★
│ 负载均衡器 → 分给哪台
│ 网关 → 解 TLS、鉴权、限流、路由
│ 应用服务器 → 跑业务代码、查缓存
│ 数据库 → 往往是最慢的一环
│ 套模板生成 HTML → 压缩 → 返回
│
├─④ 收到状态行:看这三位数字决定下一步
│ 200 → 继续收内容
│ 3xx → 换地址重走一遍(可能要从 § 4.2 重新开始)
│ 304 → 用本地缓存那份
│ 4xx → 你的请求有问题
│ 5xx → 对方办不了,跟你的网络无关
│
├─⑤ 收响应头:这些头会指挥浏览器接下来的行为
│ Content-Type → 按什么类型和编码解读
│ Content-Encoding → 要不要解压
│ Cache-Control / ETag → 这份要不要存、存多久
│ Set-Cookie → 记下这个凭据
│ Vary → 缓存时要按哪些维度分格
│
├─⑥ 收响应体(可能是分块陆续到达的)
│
└─⑦ 交出一份 HTML 文本 → 进入渲染 → 这就是 § 4.6 的起点
耗时量级:
网络往返 由距离决定(同城十毫秒级,跨洲百毫秒级)
服务器处理 从毫秒级到秒级,完全取决于对方架构与负载
下载内容 由体积与带宽决定
(TTFB = 网络往返 + 服务器处理;把这两者分开是排查的第一步)
这张图里最实用的一条是第 ④ 步那个状态码分支。看清状态码,你就知道该找谁:4xx 找自己、5xx 找对方、3xx 是白跑了一趟、304 说明缓存机制在起作用。
一个页面不止一个请求:几十个请求的调度
前面讲的都是"那一个请求"——拿 HTML 的那个。但现实里,一个页面的加载是几十甚至上百个请求的合奏。这一点常被忽略,却是理解网页性能的关键。
请求 1:GET /item/8848 → 拿到 HTML
↓ 浏览器开始解析这份 HTML,边解析边发现引用了别的东西
请求 2:GET /css/main.css ← 从 <link> 标签发现的
请求 3:GET /js/app.js ← 从 <script> 标签发现的
请求 4:GET /img/8848-1.jpg ← 从 <img> 标签发现的
请求 5~40:更多图片、字体、图标、统计脚本……
★ 关键:这些请求不是等第一个完全处理完才发的。
浏览器一边读 HTML 一边就把发现的资源排进队列,尽量并行去取。
这个"边读边预先取"的机制叫预加载扫描(Preload Scanner)。
相当于装修工头拿到图纸的做法:他不会看完整本图纸才开始订料,而是翻到哪一页就把那页要的材料先订出去——瓷砖、水泥、电线各家供货商同时在备货。如果非要一样一样顺着来,工期要长好几倍。
但这里有个很重要的约束:不是所有资源都能并行取,因为有些有依赖关系,还有些会挡住后面的活儿。
| 资源类型 | 会不会挡住渲染 | 为什么 |
|---|---|---|
| CSS 样式表 | 会 | 样式没到位就画,用户会看到"没穿衣服"的页面,所以浏览器宁愿等(§ 4.6 详讲) |
| 普通的同步脚本 | 会,而且挡得最狠 | 脚本可能修改页面结构,所以解析必须停下来等它下载并执行完 |
标了 async / defer 的脚本 | 不会 | 明确告诉浏览器"我不急,你先干别的" |
| 图片 | 不会 | 图没到就先留个空位,来了再填 |
| 字体 | 可能造成文字闪烁 | 字体没到时用备用字体,到了再换,于是文字会跳一下 |
这张表解释了一个非常常见的现象:为什么有些网页会先白屏一两秒,然后突然整页出来。因为它顶部有一个挡住渲染的脚本或样式表,浏览器在排队等它,什么都不敢画。而如果把这些不急的东西标成异步,页面就能先把能画的画出来,用户的感受会好得多。
换成大白话:一支流水线上,某个工位不管三七二十一非要等某个零件到齐才肯往下传,后面所有工位全部干等。而其实那个零件只影响最后一道装饰工序——它压根不该卡住整条线。
HTTP/1.1、2、3:同一句话的三种说法
前面展示的请求报文是 HTTP/1.1 的文本格式。真实世界里你的浏览器可能在用 2 或 3。要强调的是:换版本换的是"怎么把这句话装进网络包",不是"这句话说什么"。方法、路径、头部字段的含义完全一样。
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| 报文形式 | 纯文本,人能直接读 | 二进制帧 | 二进制帧 |
| 底层用什么 | TCP | TCP | QUIC(跑在 UDP 上) |
| 一条连接上能同时跑几个请求 | 一个(要排队) | 很多个(多路复用) | 很多个 |
| 头部压缩 | 没有——每个请求都把那十几行头重发一遍 | 有 | 有 |
| 一个包丢了会怎样 | 那条连接上后面的都得等 | 仍会等(因为底层 TCP 要求按序交付) | 只影响那一个请求 |
| 生活里对应 | 一条队伍,前面办完才轮到后面 | 一个窗口能同时受理多人,但如果某人的材料掉了,所有人一起等 | 各人的材料互不影响 |
第五行那个"仍会等"是 HTTP/2 最著名的遗留问题,业内叫队头阻塞(Head-of-Line Blocking)。HTTP/2 在应用层解决了排队问题,但它底下的 TCP 仍然要求"按顺序交付"——所以某个包在路上丢了,TCP 就把后面所有已经到达的数据全扣着不给上层,直到那个包补上。
换成大白话:这相当于一条传送带。HTTP/1.1 是"一件一件放",HTTP/2 改成了"多件并排放",效率大增。但传送带的规矩是"必须按放上去的顺序取下来"——所以中间某一件掉了,后面那些明明已经运到的,也只能停在带子上等。HTTP/3 换掉 TCP 的根本理由就是这个:它要摆脱"必须按序"这条规矩。
这里能看出一个很值得记住的模式:当你在某一层做足了优化,瓶颈就会掉到下一层去。HTTP/2 把应用层榨干了,问题就露在传输层;于是 HTTP/3 干脆连传输层一起换。说白了,优化就是不断把瓶颈往下推,直到推到物理定律那儿推不动为止——而那个推不动的东西,就是 § 1.6 讲的光速带来的传播时延。
请求方法:为什么必须区分 GET 和 POST
最后补一块本节流程视角下很实用的内容。请求行里那个动词(方法)不只是形式,它决定了浏览器和路上所有缓存怎么对待这个请求。
| 方法 | 意思 | 安全吗(不改变数据) | 幂等吗(做多次结果一样) | 能被缓存吗 |
|---|---|---|---|---|
| GET | 取一份东西 | 是 | 是 | 能 |
| HEAD | 只要头部,不要内容 | 是 | 是 | 能 |
| POST | 提交一份东西 | 不是 | 不是 | 通常不能 |
| PUT | 把某个东西整体替换成这份 | 不是 | 是(替换十次和一次结果相同) | 不能 |
| DELETE | 删掉某个东西 | 不是 | 是(删十次和一次结果相同) | 不能 |
"安全"和"幂等"这两个词值得单独说清,因为它们很容易被误解。
- 安全(Safe)不是指"加密"或"没有漏洞",而是指"这个动作不改变服务器上的数据"。相当于去图书馆查一本书在不在架上——查一百次,馆藏一本没少。
- 幂等(Idempotent)指"做一次和做多次结果相同"。相当于按电梯上行键,按十下也还是上一趟;而"从账户取五百块"不幂等,做十次就少了五千。
这两个性质有非常实际的后果,而且都跟"重试"有关:浏览器和中间设备可以放心重试幂等请求,绝不敢随便重试非幂等请求。
这就解释了你一定遇到过的一个现象:刷新一个提交过表单的页面时,浏览器会弹窗问"确认要重新提交吗"。而刷新一个普通页面它从不问。
换成大白话:重新看一遍某份材料,没人需要征求你同意;但"要不要再交一遍申请表",柜员一定会先问你一句——因为交两遍可能真的办出两笔业务来。说白了:能重来的动作可以悄悄重来,不能重来的动作必须先问。
这也回头解释了 § 4.4 讲的 0-RTT 只能用于幂等请求那条限制:因为 0-RTT 的数据可能被重放,而"重放"和"重试"在服务器看来是同一件事——所以只有那些"多做几次也无害"的请求才敢用它。
最后提一个实践中的常见错误:把"改数据"的操作写成 GET。比如 GET /delete?id=8848。这看着能用,但危险得很——因为整个网络世界都假定 GET 是安全的:浏览器会预取它、代理会缓存它、爬虫会随手访问它。相当于你把"销户"这个业务放在了"查询"窗口,还写着"随便看"——结果谁路过瞄一眼,账户就被销了。
顺便说清另一个常见混淆:"GET 不能带请求体""POST 更安全"这两句话都不严谨。准确的说法是——GET 在规范上不禁止带请求体,但实践中带了往往没人处理,所以不要带;至于安不安全,POST 只是把数据从地址栏移到了请求体里,明文传输时它一样被路上的人看得见——真正提供保密的是 § 4.4 的 TLS,不是方法名。
换成大白话:把密码从写在信封外面改成写在信纸上,确实不会被随手瞄到,也不会被抄进快递记录(这是 POST 相对 GET 的真实好处);但只要信封不封口,拆开一看照样清清楚楚。封口这件事只有加密能做。
这一节是整段旅程里唯一真正传输内容的一步,也是唯一"对方有机会犯错"的一步——前四步出错只有"连不上"一种,这一步开始有 404、500、403、超时好几种。
报文格式见 § 3.1(四段结构、CRLF、Content-Length 写错的后果、状态码全表)。本节换成流程视角看三件事:
一 · 请求头是一场"预先谈判"。浏览器在开口的同一句话里就说清了"我是谁(Cookie)、我能接受什么(Accept 系列)、我已经有什么(If-None-Match)、这条线别断(Connection)"。把信息一次说全本身就是性能优化——因为每一次追问就是一个来回。其中 Host 头撑起了整个虚拟主机时代,它和 § 4.4 的 SNI 作用平行但发生在不同层。
二 · 服务器不是一台机器。一个请求要过 CDN → 负载均衡 → 网关 → 应用 → 数据库好几道手,TTFB 就是这几道手加一个网络来回的总和。TTFB 长而下载快说明服务器慢,TTFB 短而下载慢说明内容太大或带宽不足——这两种病的治法完全不同。而 CDN 命中的话请求压根到不了源站,它同时省掉了距离与处理时间。
三 · 响应头在指挥浏览器接下来做什么。Content-Type 决定怎么解读、Content-Encoding 决定要不要解压、Cache-Control 与 ETag 决定下次还要不要来问、Set-Cookie 给你凭据、Vary 决定缓存怎么分格(漏掉它会导致中文用户拿到缓存里的英文页)。
三条最实用的判断:① 4xx 找自己、5xx 找对方;② 每次重定向都是白跑一个来回,301 会被浏览器长期记住所以不易反悔,不确定就先用 302;③ Cookie 的意义是把记忆负担从服务器转移给客户端,这正是 HTTP 能无限加机器扩容的根本原因。
下一步交出什么:一份 HTML 文本。但它现在还只是一堆字符,屏幕上什么都没有。把这堆字符变成你看得见的画面,是最后一节的事——而那一节的性质跟前五节完全不同:它不再等来回,它开始拼命地算。