
“从输入 URL 到页面显示,中间发生了什么?”是个经典面试题。刚开始觉得它就是一条流程:查 DNS,建连接,发请求,浏览器渲染。后来沿着每一步往下问,发现缓存、TCP、TLS、路由这些知识都能串起来。
这篇就按一次网页访问的顺序记录。主线用 HTTPS over TCP 讲,也就是 HTTP/1.1 或 HTTP/2;HTTP/3 走 QUIC,会在开头单独说明。遇到容易被追问的地方,我会把原因和回答一起写下来。
图 1:一次访问的主线。所有示意图都可以点击查看原图。
图里的顺序是一条便于理解的主线。浏览器可能提前解析 DNS、复用已有连接,也会边收 HTML 边解析、边请求图片和脚本。协商缓存需要发请求,因此要等建连之后才能拿到 304。ARP 和路由则参与每一次网络收发,不是等 HTTP 发完了才开始。
一、先把 URL 和 HTTP 版本说清楚
1.1 地址栏里的内容怎么变成 URL
输入 ttzz1.com 时,浏览器会判断这是网址还是搜索词,再按自己的策略补全协议和路径。现代浏览器经常优先尝试 HTTPS,但不能把所有输入都理解成“无条件补 https”;显式协议、HSTS、浏览器设置和访问失败后的处理都会影响结果。
以这个地址为例:
https://www.ttzz1.com:443/blog/post?id=1&tag=net#section-2
协议 主机 端口 路径 查询参数 片段
| 部分 | 例子 | 发请求时的作用 |
|---|---|---|
| scheme | https | 指定访问方案,影响默认端口和安全连接方式 |
| host | www.ttzz1.com | 用于解析地址,也参与证书校验和 HTTP 主机识别 |
| port | 443 | 指定客户端要连接的服务端端口 |
| path | /blog/post | 标识服务端的资源或路由 |
| query | id=1&tag=net | 携带查询参数 |
| fragment | section-2 | 由客户端处理,不包含在 HTTP 请求目标中 |
HTTP 的默认端口是 80,HTTPS 是 443。省略端口时,浏览器按 scheme 使用默认值,地址栏不一定把它显式显示出来。服务实际监听 8443,客户端就需要写出端口,或者由 443 上的入口转发过去。DNS 的 A/AAAA 记录只提供地址,不会自动把浏览器改到应用监听的端口。
URL 语法还允许 userinfo,例如 user:pass@host,但不适合把密码放进网页地址:它容易进入历史记录或日志,浏览器也会限制这类用法。
片段有个很好记的例子:/#/user/1。服务端收到的请求目标是 /,前端再读取 hash 决定展示什么。只修改片段通常不需要重新下载整份页面。
1.2 URL 和 URI 有什么区别
URI 是统一资源标识符,URL 是其中通过访问位置和方式定位资源的一类。HTTP 地址既是 URI,也是 URL;urn:isbn:978... 则是在用名字标识资源。
面试可以这样回答:
URI 强调标识资源,URL 进一步给出如何定位、访问资源。网页里的 https 地址属于 URL,因此也属于 URI。
Java 中 URI 适合解析、规范化和拼接地址。URL 的部分操作可能涉及网络,例如某些协议处理器的相等性比较可能触发 DNS;不能笼统地说“创建 URL 对象就会请求网络”。
1.3 HTTP 和 HTTPS 的区别
HTTP 定义请求、响应、方法、状态码等应用层语义。HTTPS 在 HTTP 通信中引入 TLS,提供传输机密性、完整性,以及通常的服务端身份认证。
HTTPS 能防止链路上的人直接读取或随意修改 HTTP 内容,但不会替应用解决所有安全问题。网站自身的 XSS、错误鉴权、数据库泄漏仍然要由应用处理。TLS 通常也不会隐藏目标 IP;没有 ECH 等保护时,SNI 还可能暴露域名。
HSTS 是服务端通过响应头告诉浏览器:在有效期内,这个主机应该用 HTTPS 访问。浏览器只接受安全连接上的 HSTS 策略。已经记录策略后,即使输入 HTTP 地址,也能在浏览器内部升级,而不必先发明文请求等 301。
Strict-Transport-Security: max-age=31536000; includeSubDomains
首次访问还没拿到策略时存在空窗。预加载列表能覆盖这个场景,但加一个 preload 字样本身并不等于已进入浏览器名单。
1.4 HTTP/1.0:文本报文,默认短连接
HTTP/1.0 用文本形式表达请求行和头部,例如 GET / HTTP/1.0。这里的“文本”指协议报文的表达方式,响应体当然可以是图片、音频等二进制数据。
它默认一次请求响应后关闭连接。页面需要 HTML、CSS、JS 和多张图片时,频繁建连会重复支付握手成本。部分实现支持 Connection: keep-alive 扩展,所以“1.0 绝对不能复用连接”也不准确。
1.0 已经有 Content-Length。知道长度时按长度读,不知道时常用连接关闭表示响应结束。长度不是连接关闭的替代名称,而是消息边界的一种表达。
1.0 没有强制要求 Host 头。一个 IP 因而不容易通过标准请求头区分多个域名站点,但也不能直接推导成“一 IP 永远只能放一个网站”,还可以使用不同端口等方式。
1.5 HTTP/1.1:连接复用、Host 和分块传输
HTTP/1.1 默认使用持久连接。一次响应完成后,同一条 TCP 连接可以继续承载后续请求,除非一方要求关闭或连接因超时等原因结束。
这里容易混淆:HTTP keep-alive 指连接复用;TCP keepalive 是空闲连接的探测机制。前者不负责检测对端是否还活着。
Host 是 1.1 的必需头,一台服务器可据此把 a.example.com 和 b.example.com 分发给不同站点。HTTPS 选证书还需要更早的 SNI,后面讲 TLS 时再接上。
如果服务端要边生成边发送,无法提前确定总长度,可以使用 chunked:
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/plain
5
hello
6
world
0
线上各行以 CRLF 分隔。每块先给出十六进制长度,再发送数据;零长度块表示数据块结束,之后还可以有 trailer,最后以空行结束整个分块消息。它解决的是消息定界,不等于多路复用,也不是 HTTP/2 的二进制帧。
1.1 还丰富了缓存控制,并支持 Range 请求,比如下载文件的一部分。服务端接受范围请求时可返回 206 Partial Content 和 Content-Range,也可能忽略 Range 返回完整的 200。
1.1 支持流水线:连续发送多个请求,不必等前一个响应结束才发送下一个。但同一连接的响应必须按请求顺序返回。排在前面的响应很慢,后面的响应就被挡住,这就是应用层队头阻塞。实际客户端经常通过多条连接并发,而没有广泛使用流水线。消息定界、持久连接和流水线规则见 RFC 9112。
1.6 HTTP/2:二进制帧、多路复用和 HPACK
HTTP/2 保留方法、状态码等 HTTP 语义,改变了它们在连接上的表达方式:把消息拆成二进制帧,按 stream 区分请求响应。
例如,一个大文件在 stream 1,小接口在 stream 3,CSS 在 stream 5。不同流的帧可以交错传输,接收端按流重新组装。小接口不必遵守 HTTP/1.1 那种整份响应的排队顺序。
这里不是“每个帧都有一个非零 stream ID”。帧头有 stream identifier 字段,但 SETTINGS 等连接级帧使用 0;承载具体请求响应的帧才关联相应流。
HPACK 用静态表、动态表和可选的哈夫曼编码压缩头部。方法、路径、Cookie、User-Agent 等字段往往重复,复用表项能减少传输开销。静态表由协议预定义,动态表随着连接中的编码更新;不是简单把所有头都 gzip 一遍。它压缩头部,响应体的 gzip、br 属于另一件事。
2 解决了 1.1 响应排队带来的阻塞,但底层仍是一条有序 TCP 字节流。如果前面的 TCP 段丢了,后续字节即使已经到达,也不能越过缺口交给 HTTP/2。同一连接上的多个流都可能等这次重传。
因此,“HTTP/2 已经解决全部队头阻塞”是错的。HTTP/2 的分帧和流机制见 RFC 9113。
1.7 HTTP/3:把传输层换成 QUIC
HTTP/3 把 HTTP 语义映射到 QUIC。QUIC 基于 UDP,却自己实现可靠传输、流控制、拥塞控制和加密。UDP 不可靠,不代表建立在它上面的 QUIC 不可靠。
QUIC 可以在每个流内维护有序可靠交付。一个流缺了数据,其他流的数据仍可交付,不再被同一条 TCP 字节流的缺口整体挡住。不过多个流还共享连接级拥塞预算,丢包仍可能降低整个连接的发送速度。它消除了跨流的传输层交付阻塞,没有消除一切相互影响。参见 RFC 9000。
QUIC v1 使用 TLS 1.3 进行握手与密钥协商,不是先建 TCP 再叠 TLS。正常情况下新连接可以 1-RTT 建立;恢复连接时,满足条件可发送 0-RTT 早期数据。遇到 Retry、参数不匹配等情况还可能增加往返。
0-RTT 有重放风险。不能看到 GET 就自动判断安全,更不能用“加了 TLS”证明支付请求不会重复执行;服务端需要决定哪些操作允许早期数据,并采取防重放措施。TLS 对早期数据的限制见 RFC 8446 §8。
QUIC 用 connection ID 帮助识别连接,在允许迁移且路径验证通过等条件下,能应对 NAT 重绑定或 Wi-Fi 切换蜂窝网络。不能把它背成“换网一定不断”。协议也常在用户态实现,更方便随软件更新,但这不代表 QUIC 被禁止在内核实现。
HTTP/3 使用 QPACK 压缩头部。它把动态表更新放在专门的流中,并配合确认和阻塞流上限控制依赖。如果某个头部块引用的动态表项还没到,它仍可能暂时阻塞;QPACK 是为 QUIC 的多流环境管理这种依赖,不是保证所有头部解码永不等待。参见 RFC 9204。
图 2:三个版本分别在哪一层等待。
1.8 放在一起比较,以及 TLS 的关系
| 版本 | 消息表达 | 连接与并发 | 头部压缩 | 还要注意什么 |
|---|---|---|---|---|
| HTTP/1.0 | 文本请求行、头部 | 默认短连接,部分实现可 keep-alive | 没有专用头部压缩 | 常用连接关闭定界未知长度响应 |
| HTTP/1.1 | 文本请求行、头部 | 默认持久连接,流水线响应按序 | 没有专用头部压缩 | 应用层响应队头阻塞 |
| HTTP/2 | 二进制帧 | 一条 TCP 连接上的多流并发 | HPACK | TCP 层缺口仍影响多个流 |
| HTTP/3 | HTTP 帧映射到 QUIC 流 | QUIC 多流并发,底层 UDP | QPACK | 仍有流内排序、共享拥塞控制和表项依赖 |
HTTP 版本与 TLS 版本不是一一对应的:
- HTTP/1.0、1.1 可以明文传输,也可以通过 HTTPS 使用 TLS。SSL、TLS 1.0/1.1 是历史版本,现代部署应使用 TLS 1.2 或 1.3。
- HTTP/2 协议有明文使用方式;浏览器网站访问通常使用 TLS。HTTP/2 over TLS 要求 TLS 1.2 或更高,并不限定只能用 1.2。
- HTTP/3 依赖 QUIC;QUIC v1 的握手使用 TLS 1.3。
不要按笔记中的简写,把“HTTP/1.1 → SSL/TLS,HTTP/2 → TLS 1.2”背成固定绑定。
TLS 握手中的 ALPN 用于协商应用协议,比如 h2 和 http/1.1。HTTP/3 也在 QUIC 的 TLS 握手中通过 ALPN 协商 h3;Alt-Svc、HTTPS DNS 记录承担的是发现 HTTP/3 服务入口的作用。HTTP/3 的协议映射见 RFC 9114。
二、缓存:能直接使用副本,就少走一次网络
2.1 强缓存和协商缓存
缓存有可用且新鲜的副本时,浏览器可以直接读取,不向服务端确认。开发者工具可能显示 200 (from memory cache) 或 from disk cache;这不表示刚从网络收到一份 200。
需要验证副本是否仍有效时,浏览器发条件请求:
| 缓存机制 | 服务端返回的头 | 下次请求携带的头 | 判断方式 |
|---|---|---|---|
| 新鲜度 | Cache-Control: max-age=3600、Expires | 通常不需条件头 | 根据缓存年龄和寿命判断能否直接用 |
| 修改时间验证 | Last-Modified | If-Modified-Since | 比较修改时间 |
| 实体标签验证 | ETag | If-None-Match | 比较资源版本标识 |
对普通 GET,验证通过且内容未变时返回 304,不带响应体,浏览器使用已有副本;变了则通常返回 200 和新内容。304 不是“这次从网络又下载了一份页面”。
同时收到 If-None-Match 和 If-Modified-Since 时,优先处理实体标签条件。Last-Modified 的秒级精度可能区分不了一秒内多次修改,时间变化也不一定代表内容变化。ETag 由服务端生成,未必是内容哈希;多节点应该让同一表示的标签规则保持一致。
缓存判断发生在请求前,但条件请求真正拿到结果仍需要网络。把“检查协商条件”和“已经收到 304”都写在 DNS 前面,时间顺序就反了。
2.2 几个容易背错的 Cache-Control
| 指令 | 含义 |
|---|---|
max-age=3600 | 缓存寿命为 3600 秒;年龄计算也会考虑 Date、Age 等信息 |
no-cache | 可以存储,但复用前需要成功验证 |
no-store | 不允许缓存存储这次请求或响应 |
private | 允许浏览器这类私有缓存存储,共享缓存不应存储 |
must-revalidate | 过期后必须验证,不能随意拿过期副本应付 |
immutable | 在新鲜期内声明内容不会改变,可避免不必要的再验证 |
immutable 不意味着“过期后仍永久有效”。也别把 no-cache 写成“不缓存”,它允许保留副本。新鲜度和验证的具体规则可对照 MDN HTTP caching。
2.3 为什么上线了,用户还在看旧界面
HTML 入口还引用旧 JS、浏览器有旧副本、CDN 返回旧 HTML、Service Worker 使用旧缓存,都可能造成这个现象。这些层不一定按一个固定顺序逐个执行,Service Worker 还可以直接拦截请求。
常见做法是入口经常验证,静态资源用内容哈希命名:
/index.html Cache-Control: no-cache
/assets/app.a3f9c2.js Cache-Control: public, max-age=31536000, immutable
改代码后 JS 文件名变化,最新 HTML 就引用新地址。部署时先上传新资源,再切换 HTML,并保留一段时间的旧资源,避免用户打开的旧页面继续请求旧 chunk 时遇到 404。
强制刷新不能直接清掉 CDN 的缓存;Service Worker 也有自己的生命周期。skipWaiting() 会改变激活时机,但不能单独保证新旧资源版本一致。排查时可以查看请求 URL、Cache-Control、Age、ETag,以及 CDN 的命中头,先确定旧内容来自哪层。
缓存能减少流量、延迟和源站压力,也会增加更新一致性和隐私管理的负担。个性化内容尤其要避免错误进入共享缓存。
三、DNS:域名要解析成哪个地址
3.1 先查缓存,再找递归解析器
浏览器可能已有 DNS 缓存;系统解析器也有缓存和 hosts 等本地规则。没有可用结果时,再向配置的递归 DNS 解析器查询,它可能来自路由器、运营商或公共 DNS 服务。
这是面试中常说的“浏览器、操作系统、本地 DNS”几个层次,但 hosts 不是第四层缓存,优先级也不能对所有浏览器和操作系统一概而论。浏览器使用 DoH 时,部分流程还会改变。修改 hosts 后遇到旧地址,需要结合浏览器缓存、已有连接和系统解析器检查,不能保证永远立即生效。
Windows 可用以下只读命令观察系统状态:
ipconfig /displaydns
Resolve-DnsName ttzz1.com
3.2 递归和迭代,区别在谁负责查到底
客户端向递归解析器提出的问题是:“帮我拿到这个域名的答案。”解析器没有缓存时,通常进行迭代查询:
客户端 → 递归解析器
├→ 根服务器:拿到 .com 的委派信息
├→ .com 服务器:拿到 example.com 的权威服务器信息
└→ 权威服务器:拿到记录,或继续处理别名
客户端 ← 返回解析结果,并在相应层按 TTL 缓存
迭代回应可能告诉查询方下一步去哪问,也可能直接给答案。已有中间委派缓存时,解析器不必每次都从根重新走。根服务器是 13 个逻辑标识对应的分布式部署,不能理解成全球只有 13 台机器。
这条主线依据 RFC 1034 的解析模型。
3.3 记录类型、TTL 与传输协议
| 记录 | 作用 |
|---|---|
| A / AAAA | IPv4 / IPv6 地址 |
| CNAME | 另一个域名的别名,需要继续解析目标 |
| NS | 权威服务器信息 |
| MX | 邮件接收服务器 |
| TXT | 文本信息,常用于验证和邮件策略 |
| HTTPS / SVCB | 描述服务绑定及相关参数,客户端可据此发现服务能力 |
CNAME 可能增加解析工作,但解析器也可能在同一响应中得到后续结果,不等于必然再付一次网络往返。传统 CNAME 不能与同名其他数据任意共存,因此域名顶点的 SOA/NS 会限制其使用;一些平台的 ALIAS 或 flattening 是另一种处理方式。
TTL 长会降低查询压力,也让旧记录存留更久。计划切换前提前降低 TTL,仍需等待原来的较长 TTL 消耗完,不能在切换瞬间调短就期待所有缓存立即失效。
传统 DNS 通常使用 UDP,也支持 TCP。没有 EDNS 时经典 UDP DNS 的报文上限是 512 字节;现代 EDNS 可以协商更大的 UDP 负载。响应被截断,客户端通常改走 TCP;区域传送也使用 TCP。不要说成“超过 512 字节就一定不能用 UDP”。
DoT 用 TLS,DoH 用 HTTPS 保护客户端与解析器之间的查询。它们不能单独保证解析器给的记录真实。HTTPDNS 是另一类应用自己通过 HTTP(S) 接口解析的方案;如果接口仍用不受保护的明文 HTTP,也不能凭名字保证防劫持。
DNS 调度可能参考递归解析器位置、EDNS Client Subnet 等信息,结果未必总是离用户最近的机器。
四、建立 TCP 和 TLS 连接
4.1 TCP 三次握手为什么不能只做两次
客户端 服务端
CLOSED LISTEN
── SYN,seq=x ─────────────────────────→
SYN_SENT SYN_RECV
←──────────── SYN+ACK,seq=y,ack=x+1 ───
── ACK,ack=y+1 ───────────────────────→
ESTABLISHED ESTABLISHED
客户端在发送最后 ACK 时进入 ESTABLISHED,服务端要等收到这个 ACK 才进入 ESTABLISHED,双方不是在同一个瞬间完成。
握手让双方同步初始序列号,确认对方收到了自己的序列号,并协商 MSS、窗口扩大、SACK 等选项。三次握手的最后一次确认让服务端知道:客户端确实收到了服务端的 SYN,而非只看到一个可能来自旧连接的请求。
如果只做两次,服务端对迟到的旧 SYN 回复后就进入已建立状态,即使客户端根本不打算建立这个连接,也会形成错误状态。第三次让客户端有机会接受当前连接,或者拒绝不符合当前状态的回应。握手也提供了双向通信可用的证据,但不是对未来收发永远正常的保证。
SYN 占一个序列号,所以确认号是 x+1。FIN 也占一个,纯 ACK 不占。初始序列号不是每次固定为零,避免历史连接数据混淆,也降低可预测序列号带来的风险。
4.2 三次握手丢包,分别怎么办
| 丢失的位置 | 典型后果与恢复 |
|---|---|
| 第一次 SYN | 客户端等不到回应,按定时器重传 SYN |
| 第二次 SYN+ACK | 服务端重传 SYN+ACK;客户端也可能因等不到回应重传 SYN |
| 第三次 ACK | 服务端仍停在 SYN_RECV,超时重传 SYN+ACK;客户端再次确认,或后续合法数据段携带的 ACK 完成确认 |
第三次纯 ACK 没有独立的“可靠消息重传定时器”,通常依靠对端 SYN+ACK 重传触发再次确认。服务端收到 SYN 后占用半连接资源,大量伪造或不完成握手的请求就可能造成 SYN flood。
SYN cookies 等机制可以在压力下减少半连接状态占用,但正常连接也要受队列上限、应用 accept 速度等因素约束。握手与状态转换详见 RFC 9293 §3.5。
4.3 TLS 1.2 握手,以 ECDHE 为例
TCP 建好后,HTTPS 还要协商加密。TLS 1.2 有不同密钥交换方式,我这里用带服务端证书的 ECDHE 完整握手讲,避免把老式 RSA 流程当作唯一答案。
| 阶段 | 消息 | 要完成的事 |
|---|---|---|
| 客户端发起 | ClientHello | 给出版本、加密套件、client_random、SNI、ALPN 等 |
| 服务端回应 | ServerHello、Certificate、ServerKeyExchange、ServerHelloDone | 选择参数,发证书和带签名的临时 ECDHE 参数 |
| 客户端验证与交换 | ClientKeyExchange、ChangeCipherSpec、Finished | 验证身份和签名,发送自己的临时公钥,导出密钥并验证握手 |
| 服务端完成 | ChangeCipherSpec、Finished | 切换加密状态并完成握手校验 |
双方用各自临时私钥和对方临时公钥计算相同的共享秘密,不会把会话密钥直接明文传过去。共享秘密、client_random、server_random 等进入密钥派生流程,得到后续记录加密所需的密钥。
证书里的长期公钥用于认证签名,并不必然用来加密 pre-master secret。用服务端 RSA 公钥加密 pre-master secret,是 TLS 1.2 旧式 RSA 密钥交换的另一条流程。ECDHE 的临时密钥用完销毁后,长期证书私钥将来泄漏,也不应让已记录的历史会话被直接解密,这就是前向安全性。
Finished 校验的是握手 transcript,并证明双方掌握对应密钥,不能只理解成一个“握手结束”的通知。TLS 1.2 的消息定义可查 RFC 5246 §7.4。
4.4 证书、SNI 和 TLS 1.3
证书校验要检查信任链、有效期、目标主机名、用途和签名等;吊销检查的策略取决于实现。证书由可信 CA 签发,但证书域名对不上当前站点,仍然不能通过。
HTTP Host 在加密连接建好后才发送,服务端选证书时还读不到它。SNI 把目标域名放在 ClientHello 扩展里,让共用 IP 的不同 HTTPS 站点能够选各自证书。普通 SNI 可被链路观察者看到,ECH 用于保护 ClientHello 中的相关信息。
TLS 1.3 通过更早发送 key share 等调整,把常规完整握手减少到 1-RTT;TLS 1.2 的上述完整握手通常需要 2-RTT。这里计算的是 TLS 自身,不包含之前的 TCP 建连,也不保证所有握手路径都恰好这么多。
1.3 移除了静态 RSA 密钥交换等旧机制。PSK 恢复和 0-RTT 是可选路径,且不能简单宣称“所有恢复连接都零往返、所有 PSK 模式都具有相同的前向安全性”。
五、发送 HTTP 请求,服务端返回响应
5.1 一次请求经过哪些处理
以 HTTP/1.1 为例:
GET /blog/post?id=1 HTTP/1.1
Host: www.ttzz1.com
Accept: text/html
Accept-Encoding: gzip, br
Cookie: session_id=abc123
If-None-Match: "a3f9c2"
请求行是方法、请求目标和版本,头部描述主机、内容协商、状态与条件等信息,空行结束头部。这个 GET 示例没有请求体。
Cookie 只有满足域、路径、Secure、SameSite 等条件时才会携带,不是任何请求都会带上所有 Cookie。HTTP/1.1 默认持久连接,不必额外写 Connection: keep-alive;HTTP/2、3 也不能照搬 1.1 的连接专用头。
服务端可能由 Web 服务器直接返回静态内容,也可能先过反向代理,再由应用路由分发到 handler,执行鉴权、参数验证和业务逻辑,访问数据库或其他服务,最后序列化响应。客户端的 TCP 对端可能是代理,不一定是执行业务的应用进程。
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 29
Cache-Control: no-cache
ETag: "a3f9c2"
<!DOCTYPE html><h1>Hello</h1>
这里的 29 字节按这段 ASCII 正文本身计算,不包含代码块展示时的末尾换行。实际服务要按真正发送的正文计算长度,不能把字符数直接当字节数,中文和压缩响应尤其如此。
5.2 常见状态码,以及重试的边界
| 状态码 | 含义与常见追问 |
|---|---|
| 200 / 201 / 204 | 成功、已创建、成功且没有响应内容 |
| 206 | 成功返回部分表示,常用于范围下载 |
| 301 / 302 | 永久或临时重定向;历史兼容行为可能把 POST 改为 GET |
| 304 | 条件 GET/HEAD 的缓存验证结果,不带消息内容 |
| 307 / 308 | 临时或永久重定向,保留方法 |
| 400 / 401 / 403 / 404 | 请求有问题、需要认证、拒绝访问、资源不存在 |
| 429 | 请求过多,可能给出 Retry-After |
| 500 / 502 / 503 / 504 | 内部错误、网关收到无效上游响应、服务不可用、网关等待上游超时 |
401 表示缺少有效认证凭证。403 表示服务端理解请求但拒绝执行,可能是权限问题,也可能来自其他策略,不能限定成“一定知道你是谁”。
502、504 都发生在网关一侧,但看到错误不代表业务没执行。比如支付已成功,网关却没等到响应。客户端能否重试,要结合幂等键、操作语义、重试预算和结果查询判断。
5.3 收到 200,是不是整份数据已经收完
收到状态行或响应头,只能证明这部分信息已经到达。响应体可能还在路上,甚至中途断掉。
| 协议或消息类型 | 怎么判断传输结束 |
|---|---|
| HTTP/1.1,Content-Length | 收到声明的正文长度 |
| HTTP/1.1,chunked | 收到完整分块终止结构及 trailer 结束 |
| HTTP/1.1,关闭定界 | 靠关闭判断;难以区分有序关闭导致的完整响应和被截短内容 |
| HTTP/2 | 根据流的 END_STREAM 等状态判断,声明长度时还需校验一致性 |
| HTTP/3 | 根据 QUIC 流的正常结束和 HTTP 消息规则判断,复位不等于正常完成 |
HEAD、204、304 等本来就没有响应内容,不能盯着 Content-Length 一条规则判断所有消息。拿到完整字节后,还可能存在解压、格式解析或业务校验错误。
面试回答可以落在这句:
状态码说明服务端给出的 HTTP 结果。是否收到完整响应,要看消息定界和流结束状态;是否完成业务,还要看应用协议和业务结果。
六、TCP:这条连接到底保证了什么
这一部分我想多写一点。能背出三次握手,只是开始;继续问“发送成功没有”“接收方读没读”“为什么连接关了还要等”,才会碰到 TCP 真正的边界。
6.1 send() 返回成功,对方就收到了吗
对普通 TCP socket,send() 成功通常表示本机协议栈接受了这次提交的若干字节。数据可能还在发送缓冲区里,受拥塞窗口、接收窗口和网络调度限制,甚至尚未发上链路。
把几个阶段拆开就清楚了:
应用调用 send()
→ 本机接受待发送字节
→ 数据发出并到达对端 TCP
→ 对端 TCP 确认,返回 ACK
→ 对端应用 read() 读取
→ 应用解析并执行
→ 事务提交 / 消息持久化
→ 返回应用层结果
| 观察到什么 | 能说明什么 | 还不能保证什么 |
|---|---|---|
| send 返回 n | 本次有 n 字节被本机接受 | 对端收到、应用读取、业务成功 |
| TCP ACK 前移 | 相应字节被对端 TCP 确认 | 应用已经消费或持久化 |
| 应用层确认 | 对端按应用协议确认到了某阶段 | 阶段定义以外的持久性或下游结果 |
| 收到完整业务成功响应 | 服务端声明业务成功 | 仍需按该业务约定理解“成功” |
ACK 丢失时,发送端也不能因“没看到 ACK”断言对端没收到。对端可能已经收到了,只是确认没回来,发送端随后重传,对端按序列号去重。
服务端 accept 后不 read,也不意味着所有数据都只留在客户端。对端内核仍可接收并 ACK,直到接收缓冲区逐渐填满,通告窗口缩小,发送端才会受到更明显的限制。
send() 还可能部分写。例如提交 100 KB,只接受了 16 KB,调用方必须处理剩余数据。阻塞 socket 也不应该无条件假设一次 send 全部完成。下面是 Python 普通阻塞 socket 的示例:
def send_all(sock, data: bytes) -> None:
remaining = memoryview(data)
while remaining:
written = sock.send(remaining)
if written == 0:
raise ConnectionError("socket stopped accepting data")
remaining = remaining[written:]
# 标准库已有对应封装:
# sock.sendall(data)
非阻塞 socket 遇到暂时不可写,还要处理 BlockingIOError 并等待可写事件,不要原地忙等。sendall 成功同样不代表业务执行成功;失败时还可能不知道此前究竟发送了多少。
这里不能照搬到所有语言 API。Java 阻塞 OutputStream.write(byte[]) 返回 void,不能写 offset += out.write(...);Java NIO 的 SocketChannel.write(ByteBuffer) 才有返回写入字节数的语义。
Linux 对 send 的说明明确不提供送达保证,见 send(2)。需要知道消息是否落库,就要设计“持久化后确认”的应用协议,而不能把 TCP ACK 当作数据库提交通知。
6.2 TCP 如何实现可靠传输
TCP 面向的是有序字节流。它不保留应用调用 send 的边界,应用要自己设计消息长度、分隔符或其他 framing。
假设发送端依次发送:
seq=1001,长度=1000 → 字节 [1001, 2001)
seq=2001,长度=1000 → 字节 [2001, 3001)
seq=3001,长度=1000 → 字节 [3001, 4001)
第二段丢失,第三段先到时,接收方还期待 2001,所以累计确认停在 ACK=2001。它可以缓存后面的乱序数据,但不能把缺口之后的字节直接按完整顺序交给应用。第二段补齐后,累计确认就可前移到 4001。
可靠传输依靠几种机制共同工作:
- 序列号按字节编号,接收方据此重组、排序和识别重复数据。
- 累计 ACK 告诉发送方,下一个期望收到的字节在哪里。
- 校验和帮助检测传输损坏,但不是加密完整性保护,不能替代 TLS。
- 发送端保留未确认数据,在检测到丢失时重传。
- 窗口限制在途数据,避免无限发送压垮接收方或网络。
“可靠”不等于网络永久断开后也能必然送达。无法恢复时连接会失败,应用需要处理错误。可靠字节流同样不提供跨连接的业务去重,更不保证 exactly-once 执行。
6.3 超时重传:RTO 为什么不能是固定值
RTT 是一次往返的时间,RTO 是判断等待确认过久的超时阈值。阈值太短,正常抖动也会被误判丢包;太长,真正丢包后的恢复又慢。
经典计算保留平滑 RTT 和波动估计:
首次样本 R:
SRTT = R
RTTVAR = R / 2
后续样本 R':
RTTVAR = (1 - β) × RTTVAR + β × |旧 SRTT - R'|
SRTT = (1 - α) × SRTT + α × R'
α = 1/8,β = 1/4
RTO = SRTT + max(G, 4 × RTTVAR)
G 是时钟粒度。先用旧 SRTT 更新 RTTVAR,再更新 SRTT。具体实现还会设置超时上下限,不能把公式当成实际等待时间的全部规则。
发送数据后,重传定时器跟踪未确认数据。确认前移就更新定时器;超时则重传最早未确认的段,并通常把 RTO 翻倍,称为指数退避。继续失败时等待越来越长,避免网络出问题后反而被重传流量淹没。
重传后的 ACK 到底确认的是原包还是重传包,可能分不清。Karn 算法要求这类歧义样本不用于 RTT 更新;TCP timestamp 能帮助消除歧义时有例外。算法依据 RFC 6298。
TCP 重传超时与 HTTP 请求超时不是同一个东西。客户端业务超时返回失败后,TCP 可能还在尝试送数据,服务端也可能继续执行。要取消业务任务,需要应用自己定义取消语义。
6.4 快速重传、SACK 和现代丢包恢复
不必所有丢包都等 RTO。经典快速重传观察重复 ACK:收到三个重复 ACK,发送方推测前面出现缺口,尽早重传对应段。
这里是“原先那次 ACK 之后又收到三个重复 ACK”,不要和“总共看见三次同一个确认号”混成一件事。重复 ACK 也可能由乱序造成,所以是丢包推断,不能视为绝对证据。
上面三个段的例子只有一个后续段,可能产生不了足够多重复 ACK。因此快速重传不能覆盖所有丢包,RTO 仍然是兜底。
SACK 在握手中协商支持后,让接收方报告累计确认之后哪些字节区间已到达。例如:
累计 ACK = 2001
SACK 报告已收到 [3001, 5001)
发送方据此优先补 [2001, 3001) 的缺口
它减少多处丢包时盲目重传,但累计 ACK 仍负责确认连续前缀,SACK 不会让应用越过缺口读到乱序字节。选择确认定义见 RFC 2018。
现代实现还可能使用 RACK、尾部丢包探测等恢复机制,见 RFC 8985。面试先讲清超时和重复 ACK,再说明这是一套经典模型,实际内核不只这一种判据。
6.5 滑动窗口:为什么不发一段就等一次 ACK
如果每发一个段就停下来等确认,网络往返很长时,大多数时间都在空等。滑动窗口允许发送方连续发送一批数据,确认前移后再释放新的发送空间。
图 3:先看字节处于哪个区域,再看窗口如何移动。
从发送端看,字节大致分成四段:
| 区域 | 状态 |
|---|---|
| 已确认 | 对端累计确认已经覆盖,可释放相应重传数据 |
| 已发送未确认 | 正在途中或 ACK 还没回来,要保留以备重传 |
| 窗口内未发送 | 在当前约束下可以继续发送 |
| 窗口外 | 需要等窗口变化才允许发送 |
接收窗口 rwnd 由接收端在 TCP 头中通告,反映它愿意接收的字节范围。发送端也有自己的拥塞窗口 cwnd。简化理解:
允许的在途量 ≈ min(rwnd, cwnd)
还能新增的量 ≈ max(0, min(rwnd, cwnd) - 已在途未确认量)
第二行是帮助理解的预算模型,真实 TCP 还会考虑序列号右边界、SACK 恢复、pacing 等规则;不能拿它代替所有内核发送逻辑。发送缓冲区则是本机存放待发送数据的地方,不等于网络已经允许全部发出去。
举个简单数字:rwnd=32 KB,cwnd=10 KB,已经有 8 KB 在途,那么可新增预算约 2 KB。即使本机 send 又接受了 100 KB,也不会立刻把这 100 KB 全发上链路。
TCP 头原始窗口字段是 16 位。窗口扩大选项在握手阶段协商缩放,使高带宽、高延迟链路能使用更大窗口。其规则见 RFC 7323。
6.6 接收窗口变成 0,怎么恢复
对端应用读得慢,接收缓冲区填满,就可能通告零窗口。发送端要暂停普通新数据发送,不能靠重传不停轰炸。
应用读走一些数据后,接收端发窗口更新。但这个更新也可能丢失:接收端以为已经告诉对方有空位,发送端却还在等零窗口解除。
因此 TCP 有零窗口探测,也称 persist 机制。发送端间隔探测,促使接收端再次报告当前窗口,避免双方永远等下去。它解决窗口更新丢失的死锁,和空闲连接 keepalive、丢包重传定时器是不同问题。
6.7 流量控制和拥塞控制到底差在哪
| 对比项 | 流量控制 | 拥塞控制 |
|---|---|---|
| 要保护谁 | 接收端,避免它处理不过来 | 网络路径,避免队列和链路被压垮 |
| 主要变量 | 接收窗口 rwnd | 拥塞窗口 cwnd,以及具体算法状态 |
| 主要信息来源 | 对端通告可接收的范围 | ACK、丢包、RTT、ECN 等反馈 |
| 典型现象 | 接收端不读,窗口越来越小 | 链路排队、时延变大、丢包导致退让 |
rwnd 很大,说明接收方有空间,不代表沿途网络有足够带宽。cwnd 很大,也不代表接收方此刻还能接收。
还有个常见估算:带宽时延积 BDP = 带宽 × RTT。例如 100 Mbit/s、RTT=100 ms,BDP 大约 1.25 MB。想充分利用这样的链路,需要容纳一定在途数据;窗口仅有 10 KB 时,很难跑满。但持续堆出远超路径能力的数据又会增加排队延迟。
6.8 拥塞控制具体怎么做:先讲经典 Reno
面试常问慢启动、拥塞避免、快速重传和快速恢复。我先用 Reno 的经典模型解释,不把它说成所有系统正在执行的唯一算法。
慢启动阶段,cwnd 随确认增长。在确认模式等条件合适时,每个 RTT 近似翻倍,例如 1、2、4、8 个 MSS。这个从 1 开始的数字是教学例子,实际初始窗口不必是 1 MSS;“慢”是相对于一上来把所有数据全发出去,并非线性增长。
达到慢启动阈值 ssthresh 后,进入拥塞避免,增长变缓,经典模型大约每个 RTT 增加 1 MSS。用较缓的增速继续探索容量,避免刚恢复就再次把网络冲满。
检测到丢失后,超时和三个重复 ACK 的反应不同:
| 反馈 | Reno 的经典处理 |
|---|---|
| RTO 超时 | 降低 ssthresh,cwnd 降至 1 MSS,重传并重新慢启动 |
| 三个重复 ACK | 快速重传缺失段,降低 ssthresh,进入快速恢复 |
| 快速恢复中的额外重复 ACK | 临时增加窗口预算,使已经离开网络的数据释放空间 |
| 确认恢复范围的新 ACK | 退出经典 Reno 快速恢复,把 cwnd 收到 ssthresh,继续拥塞避免 |
经典快速恢复中,可把 ssthresh 设为不大于 max(FlightSize/2, 2×MSS) 的值,cwnd 临时设为 ssthresh + 3×MSS。FlightSize 是实际在途未确认数据,不一定等于 cwnd,所以“丢包后一律把当前 cwnd 除二”只是粗略说法。
超时通常被视为更严重的拥塞或传输失效信号;重复 ACK 则表明后续一些数据仍能通过,因此快速恢复不用像超时那样退回最小窗口。遇到多个丢包,NewReno、SACK 恢复会比这个基本模型复杂。经典规则见 RFC 5681。
实际系统可能运行 CUBIC、BBR 等算法。CUBIC 以时间和上次拥塞事件的窗口等构造三次函数,适合高速长距离网络;BBR 根据带宽、RTT 等建立路径模型。它们不能用一张 Reno 的“锯齿图”完全解释。CUBIC 的标准描述见 RFC 9438。
6.9 四次挥手:关闭的是两个发送方向
TCP 是全双工。A 不想再发,不表示 B 也已经发完,因此两个方向分别结束:
A 主动关闭 B 被动关闭
ESTABLISHED ESTABLISHED
── FIN,seq=u ──────────────────────────→
FIN_WAIT_1 CLOSE_WAIT
←──────────────────────── ACK,ack=u+1 ──
FIN_WAIT_2 B 仍可继续发送剩余数据
←──────────────────────── FIN,seq=v ────
TIME_WAIT LAST_ACK
── ACK,ack=v+1 ────────────────────────→
等待 2MSL CLOSED
CLOSED
图是典型分开的四个报文,ACK 和 FIN 也可能合并成一个,因此线上不保证每次都恰好四个包。
收到 FIN 表示对方的发送方向结束,接收方把先前数据读完后,再读会得到 EOF。这时本地发送方向仍可以继续工作。API 要表达这种半关闭,通常使用 shutdown 的写方向关闭;直接 close 的具体行为还受实现和选项影响。
B 的 CLOSE_WAIT 等待的是本地应用关闭发送方向。如果代码忘了关闭 socket,就可能长时间停在这里。它和 A 侧由协议定时器管理的 TIME_WAIT,排查方向不同。
6.10 TIME_WAIT 为什么要等 2MSL
MSL 是 Maximum Segment Lifetime,报文段在网络中的最大生存时间假设。它不是 RTT,也不是 IP TTL:TTL 主要按路由跳数消耗,不能直接当秒数。
TIME_WAIT 有两个目的。
第一,保留旧连接状态,以便再次确认对端重传的 FIN。最后一个 ACK 如果丢了,B 还停在 LAST_ACK,会重传 FIN。A 不能立刻忘记这条连接,需要能识别重传并再发 ACK。收到重复 FIN 后,TIME_WAIT 的等待通常会重新计时。
第二,让旧连接残留的报文有时间退出网络,降低同一四元组很快重建连接后,把旧数据误认成新连接数据的风险。四元组是源 IP、源端口、目的 IP、目的端口。
2MSL 为旧报文及其可能引发的反向报文保留了一个双向时间尺度;“一个 MSL 让旧段消失,一个 MSL 留给反向回应”是帮助理解的模型。不要把它当成“FIN 重传 RTO 一定小于 MSL”或者“等 2MSL 就在任意恶劣网络中保证关闭成功”的数学证明。
图 4:如果最后一个 ACK 丢失,仍保留状态的一方可以再次确认 FIN。
可以直接这样回答:
主动正常关闭连接的一方通常进入 TIME_WAIT,等待 2MSL。一方面,如果最后的 ACK 丢失,它还能确认对端重传的 FIN;另一方面,让旧连接报文在网络中消退,避免同一四元组立即复用时发生混淆。MSL 是报文最大生存时间假设,实际系统的等待长度由实现决定。
同时关闭时,双方都可能进入 TIME_WAIT。协议状态和重传处理见 RFC 9293 §3.6。
为什么有人说两分钟,也有人说一分钟?因为协议解释与系统实现不能混在一起。Linux 常见实现的 TIME_WAIT 长度是 60 秒,可查看内核的 TCP_TIMEWAIT_LEN 定义。这不是所有操作系统的统一值。
6.11 为什么会有大量 TIME_WAIT
先看它在哪台机器、对应哪类连接。TIME_WAIT 通常集中在主动正常关闭方,可能是客户端,也可能是服务端或代理。
常见来源有:
- 请求量很高,但每次都新建短连接。
- HTTP 或数据库客户端没有连接池,或者没有正确归还、复用连接。
- 服务端主动关闭很多连接,例如 keep-alive 空闲超时、请求次数上限、连接寿命限制。
- 客户端与代理的超时设置不协调,不断关闭和重建。
- 故障后的高频重试放大建连与关闭量。
- 部署、实例下线或流量突增,引起一段时间的连接周转。
对反向代理还要分清两条链路:浏览器到代理是一条连接,代理到上游是另一条。代理作为上游客户端频繁建连,可能在这一侧产生很多 TIME_WAIT,并不证明浏览器那侧没有复用。
稳定条件下可粗估:
TIME_WAIT 数量 ≈ 每秒进入 TIME_WAIT 的连接数 × 状态驻留时间
假设每秒 2000 条连接进入此状态,驻留 60 秒,就可能看到约 12 万条。这个数字是估算示例,受连接复用、实现、清理和流量变化影响。
所以“TIME_WAIT 很多”首先说明连接周转很快,不自动等于攻击或系统故障。
6.12 大量 TIME_WAIT 会有什么后果
最值得关注的是出站连接的临时端口压力。某台客户端不断用同一个源 IP 连接同一个目的 IP 和端口,旧四元组尚不可复用时,新连接需要其他临时源端口;可用组合有限,就可能建连失败。
不能把它简单说成“全机器一共只有 65535 条 TCP 连接”。TCP 连接由四元组区分,不同目的地址可以在系统允许的条件下使用相同源端口;实际限制还取决于临时端口范围、绑定方式和复用规则。
| 影响 | 需要理解的边界 |
|---|---|
| 出站端口耗尽或建连失败 | 尤其是单一源地址高频连接固定上游时 |
| 内核状态占用 | TIME_WAIT 有状态表、内存和查找管理成本,但不等于完整活跃 socket |
| NAT / 防火墙状态压力 | 这些设备有自己的映射和超时,不能假定与主机同步释放 |
| 重建连接开销 | 重复 TCP、TLS 握手,可能还失去已有拥塞窗口带来的收益 |
应用 close 后,TIME_WAIT 一般不继续占用原应用的文件描述符,所以它通常不是“进程 FD 泄漏”的直接证据。同样,服务端很多客户端都访问 443,不会因为 TIME_WAIT 就把监听 443 端口“用完”。
6.13 如何排查与处理
先确认数量、增长速率、出现在哪个地址和端口,以及是否伴随建连错误、延迟升高、重试增多。只看一张 netstat 截图,缺少时间和业务上下文。
Linux 可做只读观察:
ss -s
ss -tan state time-wait
ss -tin
sysctl net.ipv4.ip_local_port_range
sysctl net.ipv4.tcp_tw_reuse
Windows 可按状态计数:
Get-NetTCPConnection -State TimeWait | Group-Object RemoteAddress, RemotePort
netsh int ipv4 show dynamicport tcp
处理优先回到连接生命周期:复用 HTTP 客户端、连接池、合理的 keep-alive,确认响应体被读取或正确关闭,使连接能返回池中;让客户端、代理、上游的超时协调,并限制失败重试。
确有容量问题时,再结合系统评估临时端口范围、源地址或实例扩容。内核参数要针对版本、拓扑和连接方向验证,不能照抄“优化三件套”。
特别记住几个坑:
tcp_fin_timeout管的是孤儿 FIN_WAIT_2 连接,不是调 TIME_WAIT 时长。tcp_tw_reuse是满足安全条件时的复用规则,取值及适用范围依实现,不等于删除全部 TIME_WAIT。SO_REUSEADDR主要解决相应地址绑定场景,不能当作客户端临时端口问题的通用解法。- 不要为了降低数字就强行缩小 TIME_WAIT 状态上限,也别通过 RST 式关闭绕过正常关闭语义,可能丢弃尚未交付的数据。
- 旧文章里的
tcp_tw_recycle已从现代 Linux 移除,尤其不能照搬到 NAT 环境。
前两项和 TIME_WAIT 数量上限的语义可核对 Linux IP Sysctl 文档。
6.14 TIME_WAIT 与 CLOSE_WAIT,面试怎么区分
| 状态 | 所处位置 | 主要在等什么 | 大量出现时先查什么 |
|---|---|---|---|
| TIME_WAIT | 通常在主动正常关闭方 | 协议等待期结束,期间可再确认 FIN | 连接周转、池复用、端口容量 |
| CLOSE_WAIT | 收到对方 FIN 的一方 | 本地应用结束发送方向 | 是否忘记 close、异常路径漏释放 |
| FIN_WAIT_2 | 本方 FIN 已确认后 | 对端最终结束发送方向 | 对端关闭行为、半关闭是否符合预期 |
| LAST_ACK | 被动关闭方已发 FIN | 对方确认自己的 FIN | 最后 ACK 是否丢失、路径是否异常 |
TIME_WAIT 由协议在正常条件下自动走完;CLOSE_WAIT 通常要应用采取关闭动作。把二者都回答成“调参数缩短等待”会错过真正的问题。
6.15 TCP 粘包、Nagle 与三个 keep-alive
所谓粘包和拆包,是应用期待“每次 send 对应一次 read”,但 TCP 只给有序字节流。两个短消息可能一起读到,一个大消息也可能分多次读到。解决办法在应用协议:长度前缀、分隔符或固定长度。HTTP 的消息定界就是这一类设计。
Nagle 会在有未确认数据时抑制部分小段发送,尝试合并数据;延迟 ACK 又可能让接收方稍等再确认,在某些小请求交互中叠加延迟。低延迟场景可能使用 TCP_NODELAY,但要结合发送模式测量,不能统一断言所有业务都应该关闭 Nagle。
最后把名字很像的几件事分开:
| 机制 | 用途 |
|---|---|
| HTTP keep-alive | 多次 HTTP 交换复用连接 |
| TCP keepalive | 在配置的空闲周期后探测连接是否仍可通信 |
| 应用心跳 | 判断对端应用是否按业务协议响应,也可帮助保活映射 |
对端 TCP 回探测包,不代表业务线程没有卡死;应用超时、任务状态和 TCP 连通性需要分别管理。
七、数据包怎么封装,一跳一跳走到对面
7.1 HTTPS 数据的逐层封装
这部分在每次发送时都发生。对于 HTTPS over TCP:
HTTP 消息
↓ TLS 编码成加密记录
TCP 字节流 / TCP 段:源端口、目的端口、序列号、ACK、窗口等
↓
IP 包:源 IP、目的 IP、TTL / Hop Limit 等
↓
链路帧:当前链路的源地址、目的地址和校验信息
↓
物理信号:网线、光纤、无线介质
TLS 记录、TCP 段、IP 包和应用消息之间不是一一对应关系。一个 HTTP 响应可以被拆进多个 TLS 记录和 TCP 段,一个 TCP 段也不必正好放一个完整 HTTP 消息。
HTTP/3 的这条链变成 HTTP/3 帧、QUIC、UDP、IP、链路帧,不经过 TCP。
| 设备的典型角色 | 主要依据 |
|---|---|
| 二层交换机 | 帧的 MAC 地址 |
| 路由器 | 目的 IP 与路由表 |
| 四层负载均衡 | IP、端口及连接状态等 |
| HTTP 反向代理 | 主机、路径、头部等应用信息 |
这是基础模型,现实中还有防火墙、NAT、隧道和组合设备。不能因为讲分层,就断言网络设备永远不会查看其他层的字段。
7.2 ARP 问的是最终服务器,还是网关
IPv4 在以太网这类链路上发包,需要知道下一跳的 MAC。系统先按路由表选出接口和下一跳,然后查邻居缓存;没有可用映射时,发 ARP 请求。
同一链路直连的目标,可以问目标自己的 IP:
本机:192.168.1.100/24
目标:192.168.1.5
广播:谁是 192.168.1.5?
回应:我是 192.168.1.5,我的 MAC 是 ...
普通 ARP 请求广播出去,目标通常单播回应,本机缓存映射。广播不会被路由器按普通 IP 路由转发到远端去查服务器。
目标如果是公网地址,路由表可能选择默认网关 192.168.1.1。此时问的是网关的 MAC:
IP 包目的地址:远端服务器 IP
这一跳帧的目的 MAC:网关 MAC
准确回答不是“一跨网段必然问默认网关”,而是问路由所选下一跳的 MAC;常见家庭网络里,这个下一跳就是默认网关。更具体路由存在时可能选择其他路由器。
ARP 的基本请求响应机制见 RFC 826。
7.3 ARP 缓存失效了怎么办
邻居缓存避免每发一个包都广播。条目老化后,系统会按邻居状态机重新确认或重新解析。stale 不一定立即删除,也不一定马上阻止第一次使用;具体老化时间依系统和状态配置而定,不能背成统一“缓存 20 分钟”。
解析期间数据可能暂时排队,多次尝试仍失败时通信会报错或超时。ARP 缺少强身份认证,伪造映射可造成中间人攻击。网络侧可通过动态 ARP 检测等措施缓解;HTTPS 的证书验证也有助于避免把链路劫持直接变成明文业务泄露。
IPv6 不用 ARP,而是通过 ICMPv6 的邻居发现机制处理相应问题。
7.4 路由:目标 IP 一样,下一跳为什么一直变
路由器依据目的 IP 做最长前缀匹配:
目的:203.0.113.5
0.0.0.0/0 → 默认出口
203.0.113.0/24 → 下一跳 A
203.0.113.0/25 → 下一跳 B
该目的同时匹配时,/25 比 /24 更具体,优先选择。
每经过一个路由器,IPv4 TTL 减少,耗尽后丢弃并通常返回 ICMP Time Exceeded。traceroute 逐渐增大 TTL,利用沿途回应推测路径;部分节点不回应、路径变化时,结果也会不完整。
在无 NAT 等改写的普通路由中,端到端源、目的 IP 通常保持,而每一跳的链路封装重新建立。以太网链路上的源、目的 MAC 随下一跳变化。隧道、NAT 等场景则需要另外讨论。
7.5 NAT:内网地址如何和公网通信
内网常用私有 IPv4 地址,如 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16,这些地址不能作为普通公网路由目标直接访问。
出口设备可做地址转换,家庭网络常见的是带端口转换的 NAPT/PAT:
内网连接:192.168.1.100:54321 → 198.51.100.20:443
出口转换:203.0.113.7:40001 → 198.51.100.20:443
响应回来:依据映射状态,把目标还原为 192.168.1.100:54321
多个内网连接因此能共用公网地址。Basic NAT 主要转地址,NAPT 才通过地址加端口复用;不能把所有 NAT 都等同为改端口。
映射过期后,已有通信也可能受影响。外网主动连接没有映射的内网服务,需要端口转发、反向连接或其他穿透方案,实际还要考虑防火墙策略。
服务端看到的地址可能是出口设备或代理地址,不能用一个 IP 精确代表一个用户。
八、浏览器收到 HTML 后,怎样把它显示出来
8.1 解析、布局、绘制和合成
浏览器会边接收边解析 HTML。解析时发现 CSS、JS、图片等资源,再安排更多请求,这些请求又回到缓存、解析地址和连接复用的流程。
HTML 字节 → 解码、解析 → DOM
CSS 内容 → 解析 → CSSOM
DOM + CSSOM → 样式计算与渲染 / 布局相关树
→ 布局:算位置和尺寸
→ 绘制:生成绘制记录并栅格化
→ 合成:组合相应图层,显示到屏幕
“DOM 和 CSSOM 合成渲染树”是适合面试的概括,真实浏览器内部有更细的树和阶段。
渲染树也不应描述成“只包含肉眼可见元素”:visibility: hidden 的元素仍占据布局空间;display: none 会让元素不生成相应盒子,其子树也不参与通常布局。伪元素可以参与渲染,却没有对应普通 DOM 元素。
渲染流程的入门说明可看 MDN:How browsers work。
8.2 CSS 与 JS 分别阻塞什么
| 资源 | 典型行为 |
|---|---|
| 需要用于当前显示的 CSS | 可能阻塞渲染;不直接阻塞 HTML 解析,但也可能让依赖它的脚本等待 |
| 普通外部经典 script | 解析器通常等待下载和执行,期间暂停继续解析 |
| defer 经典 script | 并行下载,解析完成后按文档顺序执行,DOMContentLoaded 等它们 |
| async script | 并行下载,可用后执行,次序不保证;执行期间仍可能占住主线程 |
| module script | 默认有类似 defer 的行为,还涉及模块依赖加载 |
所以“async 不阻塞解析”只能用来概括下载阶段,执行时仍可能打断解析。把所有 JS 都放在底部,也不意味着资源能尽早下载;defer 往往能兼顾发现时机和执行顺序。
8.3 重排、重绘与合成
改变几何属性,例如尺寸、位置、字体,通常需要重新布局;只改变颜色、背景等通常无需改几何,但要重绘;某些 transform、opacity 动画在合适的图层条件下,可以主要通过合成更新。
这是一组典型规律,不是浏览器对所有 CSS 操作的硬保证。transform 也可能受图层分配等影响,过多提升图层还会占内存。
一个常见性能问题是布局抖动:刚写样式,又读 offsetWidth 等几何信息,迫使浏览器立即计算布局,再继续写。可以把读和写分批,减少反复强制布局;动画优先选择适合合成的属性,再用性能工具验证。
8.4 页面显示、DOMContentLoaded、load 不是同一时刻
DOMContentLoaded 发生在文档解析完成、需要等待的 defer 与模块脚本执行之后。它不直接等所有图片,但相关脚本对样式表的等待可能间接推迟它,见 MDN DOMContentLoaded。
load 要等文档及会阻塞此事件的依赖资源,例如普通图片和样式表;懒加载图片等不在“所有资源都必须结束”的简单定义里。页面可以在这两个事件前显示一部分内容,也可以在 load 后仍等待业务接口才可用。
LCP 观察最大内容元素的呈现,INP 观察交互到下一次绘制的延迟,CLS 观察布局偏移。它们分别反映加载、响应和稳定性,也不能只凭某个事件就证明用户体验良好,指标定义见 Web Vitals。
可以采用的优化包括:尽早发现关键 CSS,合理使用 preload、preconnect 和 dns-prefetch,减少主线程长任务,为图片预留尺寸,用 font-display 控制字体加载显示,以及延迟处理首屏外内容。preconnect 节省的是提前完成的连接准备时间,不保证固定“省一个 RTT”,也不应该对所有域名滥用。
九、面试时怎样把整条链路讲顺
我会先说主线,再根据追问展开 TCP、TLS 或渲染,不在第一句话就把所有名词堆上去:
浏览器先识别并解析 URL,确定协议、主机、端口和请求目标,片段部分留在客户端处理。它检查可用缓存,能直接使用新鲜副本就不用发网络请求;需要验证或没有缓存时,才继续网络访问。
浏览器通过自身和系统解析能力拿到目标地址,没有缓存时由递归解析器查询相应 DNS 服务器。已有合适连接可以复用;新建 HTTPS over TCP 连接时,先三次握手同步序列号,再进行 TLS 握手、验证证书并协商密钥与应用协议。如果使用 HTTP/3,这部分换成 QUIC。
请求在应用层构造,经过 TLS、传输层、IP 和链路层封装。主机和沿途路由器依据路由选下一跳,IPv4 以太网链路上可通过 ARP 获取下一跳 MAC,出口还可能做 NAT。服务端或代理收到请求后完成解析、路由和处理,返回 HTTP 响应。
浏览器判断响应状态与缓存规则,边接收边解析 HTML,再加载 CSS、JS 和其他资源,构建 DOM、计算样式,经过布局、绘制和合成显示页面。收到 200 不等于正文已经收完;页面能显示也不等于业务已经可交互。响应结束后连接可能继续复用,真正关闭时再走 TCP 关闭流程。
下面这些追问可以用来检查自己是不是只背了流程:
| 追问 | 回答落点 |
|---|---|
| HTTP/2 为什么还会阻塞? | HTTP 流多路复用,但共用有序 TCP 字节流 |
| HTTP/3 基于 UDP,为什么还可靠? | QUIC 自己实现确认、恢复、流控制和拥塞控制 |
| send 成功说明什么? | 本机接受字节;送达、消费、事务结果是更晚的阶段 |
| ACK 丢了会重复执行业务吗? | 同连接 TCP 按序列号去重;应用重试仍需业务幂等 |
| TIME_WAIT 为何 2MSL? | 再确认重传 FIN,并隔离旧连接残留报文 |
| TIME_WAIT 多该先改参数吗? | 先查主动关闭方、连接周转、复用和实际资源压力 |
| 流量控制与拥塞控制? | rwnd 保护接收方,cwnd 等机制约束网络负载 |
| ARP 查询谁? | 路由选择的下一跳;直连目标或常见默认网关 |
| 304 出现在 DNS 前吗? | 缓存检查可以,真正条件请求与 304 需要网络 |
| 网关超时能直接重试支付吗? | 结果可能未知,需幂等键或结果查询 |
写到最后,我更愿意把这道题当成一条检查线索。沿着它问“当前是谁在负责”“确认到哪一步”“失败后谁来恢复”,很多原本分散的概念就接起来了。
FIELD NOTES / DISCUSS
文章讨论
读完后,欢迎留下你的补充、疑问或不同看法。
正在读取评论…