全景:网络是怎么通的
在钻进任何协议细节之前,先建立一张完整的地图:网络到底在解决什么难题、为什么要分层、一个 URL 从敲下回车到页面出现经历了什么。后面每一章都是把这张地图的某一段放大。
网络的全部使命一句话:把一段字节从一台机器送到任意另一台机器。难点不在「送」,在「任意」二字——任意距离、任意厂商的设备、任意糟糕的线路。
三个根本困难
- 异构:光纤、WiFi、海底电缆、卫星,不同介质不同年代的设备必须能互相说话;
- 规模:几十亿台设备不可能两两拉线,必须共享链路、层层中转;
- 不可靠:线路会断、设备会满、包会丢——可靠性只能在不可靠之上造出来。
核心答案:分组交换
- 被否掉的是电路交换(打电话那套):通信前先建一条独占线路——你看网页时 99% 的时间在阅读、线路空着却被独占,而数据流量恰恰是突发的;
- 分组交换把数据切成小包,每个包自带地址、各走各的路、在中转设备排队——代价是延迟抖动、乱序和丢包。
ping -c 20 连续测同一目标,末行 min/avg/max/mdev 里 min 接近这条路径的物理地板,max 与 min 的差主要就是路上排队的波动;丢包率在上一行——一条命令看见分组交换三个代价里的两个。分层不是官僚主义,而是把「任意两台机器通信」拆成五个可以独立解决、独立演化的小问题;而分层在字节上的样子就是套信封——发送时每层加一个头部,接收时逐层拆开。
五层,五个问题
- 应用层:这段字节是什么意思?传输层:送到那台机器的哪个进程,丢了怎么办?网络层:怎么跨越千山万水找到那台机器?链路层:怎么送到线路另一头的下一台设备?物理层:比特怎么变成信号?
- IP 是沙漏的细腰:上面应用协议无限多、下面物理介质无限多,中间只有一个 IP——任何新应用只要说 IP 就能跑遍全球。代价是它很难改,IPv4 到 IPv6 迁移三十年未竟。
一个 HTTP 请求的真实包装
[以太网头 14B [IP 头 20B [TCP 头 20B [ GET / HTTP/1.1 ... ]]]] [FCS 4B]
这个机械过程是读懂一切抓包输出的钥匙。但三层信封的命运完全不同:以太网头每一跳都拆掉重写(换成下一程的 MAC),IP 头一路只递减 TTL,而 TCP 头与载荷端到端不变。
ping 得通吗(网络层)→ 端口连得上吗(nc 主机 端口,传输层)→ 协议对不对(应用层)。用 Wireshark 抓一个包点开,就是这个套娃的解剖图。在地址栏敲下 https://example.com 回车,到页面出现,网络世界发生的全部事情。之后每一章都是把这趟旅程的某一站放大。
五步主线
① DNS 解析 example.com → 93.184.x.x // 第02章:先查电话簿(IP 以你实际解析到的为准) ② TCP 三次握手 和服务器约好序号、确认双向可达 // 第03章 ③ TLS 握手 协商密钥、验证证书(防中间人) // 第02章 ④ HTTP 请求 GET / → 服务器回 HTML // 第02章 ⑤ 渲染与后续 浏览器解析 HTML,继续请求 CSS/JS/图片(回到①~④,多数可复用连接)
而每一个包的物理旅程是
你的电脑 →(WiFi,第05章)→ 家用路由器(NAT,第04章) → 小区/运营商网络 → 骨干网(BGP 选路,第04章) → 服务器机房 → 目标服务器 中途经过十几跳路由器,每一跳独立决定下一步往哪送
- 几个数量级值得先有感觉:同城 RTT(往返时间)约 5ms,跨国 150~300ms。一次冷启动的 HTTPS 首包 = DNS(1 RTT) + TCP(1 RTT) + TLS(1 RTT) + HTTP(1 RTT)——慢主要慢在 RTT 的个数 × 单个 RTT 的长度。之后你会看到,几乎所有现代优化(CDN 内容分发网络、连接复用、TLS1.3、QUIC 0-RTT)都在砍这两个因子之一。
为什么这条主线值得反复回看
- 学 TCP 时容易陷进状态机,学路由时容易迷失在协议细节。每当那样,回到这趟旅程问一句:「我现在学的机制在哪一站?没有它,这趟旅程会坏在哪里?」——网络的所有设计都是为了让这趟旅程更快、更稳、更安全。
上手:五条命令把一次访问拆开
网络对新手最大的障碍是「看不见摸不着」。这一章不讲协议,只做一件事:用五条零门槛命令,把上一章那趟旅程的每一站变成屏幕上的数字。之后每一章讲的机制,你都已经先见过它的读数。
「网页慢」是个没法直接回答的问题——先问清慢在哪一段、连接此刻是什么状态。三条命令就够:curl -w 切时间,ss 读 TCP 的实时变量,ping 把「距离」变成毫秒。
curl -w:一次访问切成四段
$ curl -o /dev/null -s -w 'DNS %{time_namelookup} TCP %{time_connect} TLS %{time_appconnect} 首字节 %{time_starttransfer} 总 %{time_total}
' https://example.com
DNS 0.0088s TCP 0.0118s TLS 0.4134s 首字节 0.5438s 总 0.5438s
↑ 握手占了全程约四分之三- 四个数是累计值不是各段耗时,算 TLS 那一段要做减法(0.4134 − 0.0118 ≈ 0.40 s);
- 本机这条链路的结论很清楚:时间几乎全花在建连和握手上,真正传数据不到 1 毫秒——所以优化方向是砍 RTT 个数(连接复用、0-RTT),不是压缩那几 KB。
ss 与 ping:连接的实时读数与延迟的地板
$ ss -tin
ESTAB 172.28.134.71:40574 198.18.3.151:443
cubic wscale:0,9 rto:204 rtt:0.454/0.223 mss:1448 pmtu:1500
cwnd:10 bytes_sent:1571 bytes_acked:1572 segs_out:4 segs_in:3
$ ping -c 20 目标 # 末行 min/avg/max/mdev 四个数
家里网关 约 1~2 ms / 国内大站 约 20~40 ms / 跨洋站点 150 ms 以上- 一行
ss里能指认出 03 章几乎每个概念:cubic 是当前拥塞算法、rtt 是内核实时估算的往返时延、cwnd 是拥塞窗口——课本变量,内核在为每条连接实时计算; ping的四个数各有含义:min 接近这条路径的物理地板(光速加设备转发),max 与 min 的差主要是排队波动,而 mdev(抖动)对实时应用比平均值更要命。
ping 不通断定「网络不通」:大量服务器和云厂商默认丢弃 ICMP,能 curl 通却 ping 不通是常态。同理 ss 看到的 rtt 是内核的平滑估算,不等于你刚才那一次请求的真实往返。ss -tlnp 看谁在监听哪个端口(撞上「Address already in use」时就用它找占用者);curl -w 的格式串可以存进文件用 -w @文件 复用,省得每次手敲。三个实验各只要一条命令,但每个都戳穿一个常见误解。数字取自本机,你的会不同、方向一定相同。
① 连接失败有两张完全不同的脸
连 127.0.0.1:9(无人监听) → 0 ms 就返回 Could not connect 连 10.255.255.1:81(不可达) → 干等 4020 ms 才超时
- 「立刻失败」是收到了 RST(TCP 的复位包)——包到了、对面明确拒绝,去查端口和进程;「等很久才失败」是包石沉大海——防火墙静默丢包或路由不通,去查防火墙、路由、安全组。看错方向能白查一小时,而这两者的区别一眼就能看出来。
② HTTP 真的是「人能直接读」的文本
$ printf 'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n' | nc example.com 80 → HTTP/1.1 200 OK // 三行文本换回一整页 HTML $ printf 'GET / HTTP/1.1\r\n\r\n' | nc example.com 80 → HTTP/1.1 400 Bad Request // 少一行 Host 就被拒
- 亲手打一次比读十遍协议格式有用。顺带验证了 HTTP/1.1 为什么规定 Host 必带:一台服务器上挂着几百个网站,没有它服务器不知道你要哪一个——虚拟主机能成立全靠这一行。
③ 用二分法量出路径 MTU(一帧最多能装多少字节)
$ ping -M do -s 1472 目标 # 1472 + 8(ICMP) + 20(IP) = 1500 → 通 $ ping -M do -s 1500 目标 # 总长超过 MTU 且禁止分片 → 不通
-M do是「置 DF 位、禁止分片」。从 1472 往下二分,第一个通的值 + 28 就是路径 MTU。这条命令能定位第 04 章那类「能 ping 通、小请求正常、大文件上传永远卡死」的诡异故障——它们几乎都是 MTU 加上 ICMP 被封导致的。
dig 和应用看到的解析结果可以不一样,原因见 02 章 DNS 那张卡。ss 连起来用,就是一套分层排障流程:DNS?→ 连通?→ 端口?→ TLS?——每一步都在验证「下层服务正常」这个分层承诺。网络这门课有两种讲法:自上而下从你每天用的 HTTP 讲起,一层层往下问「它靠什么实现」;自下而上从电信号讲到应用层,逻辑严密但前几章看不出用处。本页走自上而下,因为它让每一层都带着一个你已经遇到过的问题去读。
顺着往下走的四段
- 02 应用层:HTTP、TLS 与 DNS——你每天都在用的那一层,也正是上手章五条命令量到的东西;
- 03 传输层:TCP 的艺术——可靠传输怎么在不可靠的网络上做出来。这一章是全页的核心,握手、重传、拥塞控制的每个机制都能回到上手章那几个数字上;
- 04 网络层:IP、路由与 NAT → 05 数据链路层:以太网、ARP 与 WiFi → 06 物理层:信号与极限——包到底怎么找到对面那台机器,越往下越接近电与光;
- 07 实战与精通 → 08 远程界面 → 09 微项目:把协议写出来 → 10 路线图——把前面的知识变成动手能力。
迷路时的两个锚
一是回到上手章的 curl -w 那张卡:一次访问被切成 DNS、握手、TLS、首字节四段,你现在读的这一章负责哪一段?二是问「这一层看到的地址是什么」——应用层看域名、传输层看端口、网络层看 IP、链路层看 MAC。分层的本质就是各层认各自的地址。
curl -w:一次访问被切成 DNS、握手、TLS、首字节四段,你现在读的这一章负责哪一段?应用层:HTTP、TLS 与 DNS
地图铺好,就从栈顶往下走——第一站是离你最近的应用层,它定义「这段字节是什么意思」。这一章深挖你每天打交道最多的三样东西:HTTP 如何组织 Web、TLS 如何在监听者眼皮下建立秘密、DNS 如何在毫秒内查遍全球——以及它们各自的阴暗面与演化。
HTTP 的设计哲学是人能直接读:一行意图、一堆头部、一个空行、然后是正文。先看一次真实对话,所有概念都长在它上面。
一次完整的请求与响应
GET /index.html HTTP/1.1 // 方法 + 路径 + 版本 Host: example.com // 必带:一台服务器上挂着几百个网站 User-Agent: curl/8.5 // 空行 = 头部结束 HTTP/1.1 200 OK // 版本 + 状态码 Content-Type: text/html // 正文是什么格式 Content-Length: 1256 // 正文多长(字节流里定边界) <html>... // 正文
方法与状态码:约定的语义
- 方法表达意图:GET 读取(可缓存)、POST 提交、PUT 整体替换、DELETE 删除。「幂等」(重复执行结果不变)不是纸面概念:请求超时后客户端敢不敢自动重试,全看幂等——GET 重发无害,POST 重发可能重复下单,所以支付系统要额外设计幂等键。
- 状态码第一位是给机器的分类:2xx 成功、3xx 去别处、4xx 你错了、5xx 我错了。排障时的关键区分:401 没登录 / 403 登录了也不许 / 404 资源不存在 / 502 网关身后的服务挂了 / 503 服务过载 / 504 后端超时——半夜报警,这几个数字决定你先查哪台机器。
无状态:故意的设计
- 服务器处理完一个请求就忘掉它,不记得「上一个请求是谁发的」。看似缺陷,实为可扩展性的地基:任何一台服务器都能处理任何请求,负载均衡随便把请求撒到几千台机器上——有状态的协议做不到这么轻松地水平扩展。
- 需要状态时显式补:Cookie/Session/Token(下一张卡)。「默认无状态、按需加状态」比反过来容易得多——这是协议设计送给分布式系统的一课。
curl -v 下响应行只剩 HTTP/2 200;HTTP/2 还强制头部名小写(content-type)。拿「200 OK」字样或头部大小写做字符串匹配的脚本,一遇 h2 就碎。printf 'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n' | nc example.com 80——三行文本换回一页 HTML。两次对照,一个字的差别:printf 'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n' | nc example.com 80
→ HTTP/1.1 200 OK
printf 'GET / HTTP/1.1\r\n\r\n' | nc example.com 80
→ HTTP/1.1 400 Bad Request // 少一行 Host 就被拒HTTP/1.1 规定 Host 必带——因为一台服务器上挂着几百个网站,没有它服务器不知道你要哪一个(这也是虚拟主机能成立的全部机制)。缓存决定「要不要再问服务器」,Cookie 决定「服务器认不认识你」——两套机制都长在头部字段里,是前端性能与登录态的共同地基。
缓存的两级决策
Cache-Control: max-age=3600 // 强缓存:1 小时内根本不发请求 过期后,带着「指纹」条件性再问: If-None-Match: "abc123" // 我手里版本的 ETag 是这个 → 304 Not Modified // 没变,接着用(省掉整个响应体) → 200 + 新内容 // 变了,给你新的
- ETag 是内容指纹、Last-Modified 是修改时间,前者更精确(时间戳有秒级精度和分布式时钟问题)。
- 工程标准答案:HTML 不缓存或短缓存;JS/CSS 文件名带内容哈希 + 缓存一年——内容一改文件名就变,旧缓存自然失效。「缓存失效是计算机科学最难的两件事之一」,在 HTTP 里被文件名哈希优雅绕开——这就是构建工具产出 app.3f8a9c.js 的原因。
Cookie 与登录态
- 服务器用 Set-Cookie 发一张小纸条,浏览器之后对同域名的每个请求自动带上——无状态的 HTTP 于是有了「会话」。
- 登录态两种存法:Session id(状态存服务端,好踢人下线,但多机要共享存储)vs JWT(状态签名后放 token 里,服务端无状态可随意扩容,但签发后没法主动作废)——没有免费的方案,只有不同的代价。
curl -v 记下响应头里的 Last-Modified(或 ETag),第二次带上 -H 'If-Modified-Since: 刚才那个值'——返回 304、响应体一个字节都不发。DevTools 里勾上 Disable cache 再对比,缓存行为立刻可控可见。从 1.0 到 3,每次大版本升级咬着同一条主线:削减延迟、消灭队头阻塞(HOL)。理解这条主线,版本差异根本不用背。
三步演进,一步一个瓶颈
- 1.0 → 1.1:复用连接。1.0 每个请求新建一条 TCP——三次握手 + 慢启动(慢启动见第 03 章)的成本每次重付。1.1 默认持久连接(keep-alive),连接复用了,但一条连接上的请求必须排队:前一个响应慢,后面全部干等——应用层队头阻塞。浏览器只好并发开 6 条连接,开发者搞出雪碧图、文件合并这些「把请求数压到 6 以内」的反模式优化。
- 1.1 → 2:复用请求。二进制分帧 + 多路复用:一条连接上几百个流(stream)交错传输,谁的数据好了先发谁,应用层 HOL 消失;HPACK 头部压缩再省一笔(Cookie 每个请求都带,重复头部占比惊人)。
- 但 TCP 层的 HOL 还在:所有流跑在同一条 TCP 上,TCP 承诺「字节流严格有序」——丢一个包,即使后面包都到了、属于别的流,也必须一起等重传。弱网丢包环境下 HTTP/2 可能比 1.1 六条连接还慢(1.1 的六条里只有一条被丢包卡住)。
HTTP/3:干脆换掉 TCP
- 问题出在 TCP 的「全局有序」承诺上,而这个承诺改不了(TCP 在内核和中间设备里僵化了)——于是 QUIC 在 UDP 上重造传输层:每个流独立可靠,丢包只阻塞所在流。加密握手与传输握手合并,重连 0-RTT;WiFi 切 4G 连接不断。传输层细节在第 03 章 QUIC 卡展开。
四个版本一张表
| HTTP/1.0 | HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|---|
| 底层 | TCP | TCP | TCP | QUIC over UDP |
| 连接复用 | 否(一请求一连接) | 是(keep-alive) | 是 | 是 |
| 并发请求 | 只能多开连接 | 排队(浏览器开 6 条) | 一条连上多流交错 | 一条连上多流交错 |
| 应用层队头阻塞 | 有 | 有 | 无 | 无 |
| 传输层队头阻塞 | 有 | 有 | 仍有(TCP 有序承诺) | 无(流级独立可靠) |
| 头部 | 文本 | 文本 | 二进制 + HPACK 压缩 | 二进制 + QPACK |
| 首次连接 RTT | TCP 1 + TLS 2 | TCP 1 + TLS 1~2 | TCP 1 + TLS 1 | 合并为 1(重连 0) |
- 竖着看这张表,主线一眼可见:每一代都在削减「等待」——先复用连接,再复用请求,最后连握手都合并掉。
curl -v 可见 ALPN: server accepted h2)。同一站点:明文 http:// 走的还是 1.1,浏览器根本不支持不加密的 h2。明文 HTTP 时代,同一 WiFi 下任何人都能看到你的密码。TLS 在 HTTP 底下垫了一层,解决三件事:加密(读不懂)、完整性(改一个字节就露馅)、身份认证(对面真是银行,不是钓鱼者)。
核心难题:在监听者眼皮下约定秘密
- 对称加密(AES)快,但双方得先拿到同一把钥匙——钥匙怎么在被监听的信道上送过去?直接发出去等于没有加密。
- (EC)DHE 密钥交换的数学魔法:双方各自持有一个私密数,只交换公开值,然后各自用「自己的私密 + 对方的公开值」算出相同的共享密钥;监听者拿到全部公开值也算不出(离散对数难题)。
- 共享密钥每次会话临时生成、用完即弃 → 前向保密:哪怕服务器私钥将来泄露,攻击者录下的历史流量也永远解不开——这是 TLS 1.3 强制 ECDHE、淘汰 RSA 密钥交换的原因。
TLS 1.3 握手(1 RTT)
Client → ClientHello: 支持的算法列表 + 我的 DH 公开值
Server → ServerHello: 选定算法 + 我的 DH 公开值 + 证书 + 签名
// 此刻双方已各自算出会话密钥
Client → Finished
之后所有数据用对称加密(AES-GCM)传输- 非对称运算慢几个数量级,所以只用来「认证 + 协商」;大宗数据全走对称加密。1.3 比 1.2 砍掉一个完整 RTT,并把所有已知不安全的算法组合清出了菜单。
深入理解:没有身份认证,加密一文不值
- 中间人可以分别和你、和服务器各建一条「完美加密」的连接,两头转发——你和服务器都觉得加密了,他全程明文看戏。加密必须配上「这把公钥确实属于这个域名」的证明,这就是证书的使命(下一张卡)。
- 由此理解浏览器为什么对自签名证书如临大敌:点「继续访问」等于亲手关掉身份认证,把中间人请进来。
curl -v 的一行体检报告:SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384——斜杠后是对称套件(AES-256-GCM 加密 + SHA384 摘要)(新版 curl 还会再跟两段:密钥交换组与证书签名算法);1.3 的套件名里不再出现密钥交换算法——它被挪进扩展里单独协商,与加密套件解耦。想看握手逐条消息,换 openssl s_client -msg -connect 域名:443。本机上的完整一行:* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
/ X25519MLKEM768 / id-ecPublicKey
* ALPN: server accepted h2
* subject: CN=example.com
* issuer: C=US; O=SSL Corporation; CN=Cloudflare TLS Issuing ECC CA 3四段依次是协议版本 / 对称套件 / 密钥交换组 / 证书公钥算法。留意第三段 X25519MLKEM768——这是后量子混合密钥交换(经典 X25519 与格密码 ML-KEM 各算一份、拼起来当共享密钥),2024 年起在主流浏览器和 CDN 上已默认启用。它防的是「现在录下流量、等未来有量子计算机再解密」这种攻击,所以必须提前部署——加密算法的迁移窗口以十年计,等到威胁出现就来不及了。你从没见过这家网站,凭什么信它出示的公钥?答案:把信任外包给一小撮全球公认的机构(CA),再用签名链把信任传递下来。
信任链的结构
根 CA 证书(自签名,预装在操作系统/浏览器里 ≈ 信任的公理)
└─ 签发 → 中间 CA 证书
└─ 签发 → example.com 的证书(域名 + 公钥 + 有效期)
浏览器验证:逐级验签名 → 链到受信根即通过
同时检查:域名匹配?没过期?没被吊销?- 根 CA 私钥离线锁在保险库里,日常签发全由中间 CA 干——根一旦泄露整个体系崩塌,所以它只在极少数仪式性场合出场。
- CA 怎么确认「你拥有这个域名」:ACME 协议自动验证(往你服务器放指定文件、或加一条 DNS 记录)。Let's Encrypt 用它把证书变成免费 + 全自动续期,全球 HTTPS 占比从此陡升——一个协议改变了整个 Web 的安全水位。
体系的裂缝与修补
- 任何一家受信 CA 被攻破,就能给任意域名签发假证书——2011 年荷兰 DigiNotar 被入侵,伪造的 Gmail 证书被用于针对伊朗用户的中间人攻击,事后该 CA 被全球拉黑、直接破产。
- 修补是证书透明度(CT):所有证书必须写进公开的只增日志,浏览器拒收没有 CT 记录的证书——域名主可以监控「谁在偷偷给我的域名发证」。防不住发假证,但保证发了必留痕。
- 公司/校园网的「HTTPS 审计」原理:往你电脑装一张企业自己的根证书——技术上就是一台被授权的中间人设备。理解这一点,你就明白「设备是谁的,TLS 就防不了谁」。
全球几亿个域名、每天万亿次查询,却没有任何中心数据库。DNS 靠两招扛住:层次化授权(每个域只有自己的权威服务器说了算)+ 层层缓存(绝大多数查询根本出不了本地)。
一次完整解析(缓存全空的最坏情况)
你 → 递归解析器(运营商或 8.8.8.8): www.example.com 的 IP?
递归解析器 → 根服务器: ? ← 「去问 .com 的服务器」 // 只指路
递归解析器 → .com TLD: ? ← 「去问 example.com 的权威」
递归解析器 → 权威服务器: ? ← 「93.184.x.x,TTL 3600」
你 ← 93.184.x.x // 解析器缓存 1 小时,期间谁问都直接答(IP 以你实际解析到的为准)- 你只发一问、拿最终答案(递归查询——把跑腿外包给解析器);解析器逐级问路(迭代查询)。根和 TLD 只指路不代查——全球的查询最终都指向这十几组服务器,若它们亲自查到底早被压垮;只指路 + 结果可缓存,负载才撑得住。
- 层次 = 授权:example.com 的记录只有它的权威服务器能改,改动不需要通知任何中心——去中心化让 DNS 可以无限横向生长。
记录类型的实际用途
- A/AAAA:域名 → IPv4/IPv6。CNAME:别名——www 指向 CDN 分配的域名,是 CDN 调度的入口(第 9 卡)。MX:这个域的邮件投给谁。TXT:放验证信息(SPF 反伪造邮件、各种「证明你拥有此域名」)。NS:这个域授权给哪些权威服务器。
- TTL 是缓存寿命,也是变更的延迟:改记录前先把 TTL 调小,是运维换 IP 的标准前戏。
/etc/hosts 和 NSS 配置(getent hosts 走的就是应用的真实路径),而 dig/nslookup 绕过这一切直连 DNS 服务器。「dig 结果正常但应用解析不对」时先查 hosts 文件——两个工具看到的可以不是同一个世界。传统 DNS 是明文 UDP、且不验证应答者身份——谁能看见你的查询、谁能抢先塞回一个应答,谁就能决定「你以为的网站在哪」。
攻击的三种形态
- 运营商劫持:把不存在域名的 NXDOMAIN 应答改成自家广告页;或强行「加速」热门域名到自己的缓存。你输错网址却看到搜索推荐页,多半是它。
- 抢答污染:UDP 无连接、先到先得——在查询路径上监听并抢先注入伪造应答即可让域名「解析」到任意地址。这是大范围网络审查在 DNS 层的技术实现之一。
- 缓存投毒:骗过递归解析器让它缓存假记录,之后所有用户被打包带偏。2008 年 Kaminsky 攻击演示了这在当时有多容易,迫使全网紧急升级随机化防御(随机源端口 + 随机查询 ID,把猜中的难度提高几十亿倍)。
防御的演进
- DNSSEC:给记录加数字签名——能验真伪,但不加密(看得见你查什么)、部署率长期低迷。
- DoT/DoH:把 DNS 塞进 TLS/HTTPS。DoH 混在 443 端口的普通 HTTPS 流量里,劫持和区分都变难——浏览器和主流操作系统都已内置开关。代价是运营商/企业失去 DNS 层的可见性,这场拉锯还在进行。
HTTP 是「客户端问、服务器答」,服务器无法主动开口。聊天、行情、协作编辑需要反方向——WebSocket 把一条 HTTP 连接升级成全双工通道。
升级握手:从 HTTP 里破茧
GET /chat HTTP/1.1 Host: example.com Upgrade: websocket // 我想换协议 Connection: Upgrade Sec-WebSocket-Key: x3JJH... HTTP/1.1 101 Switching Protocols // 同意,切换 // 此后这条 TCP 连接不再说 HTTP,改说 WebSocket 帧: // 双向、任意时刻、每帧只有 2~14 字节头部开销
- 为什么要「借」HTTP 的壳:复用 80/443 端口、穿过现有防火墙和代理。一个全新的协议在真实互联网上寸步难行——中间设备只放行它们认识的流量。「骑在 HTTP 上出生」是新协议的生存策略,gRPC、QUIC 都有类似的故事。
实时方案全家福(按需选择,别一律上 WebSocket)
- 短轮询:定时问「有新消息吗」——延迟高、空转多,仅适合频率极低的场景。
- 长轮询:服务器憋到有数据才响应——延迟低了,但每条消息消耗一次请求。
- SSE:服务器沿着一条 HTTP 响应持续单向推流——纯 HTTP、自动重连、实现极简。AI 对话的逐字输出用的就是它。
- WebSocket:真双向。只有「客户端也要高频上行」(协作编辑、游戏、聊天输入状态)才真正需要它。
Sec-WebSocket-Accept 是把 Key 拼上固定 GUID 再 SHA-1 得来的,作用不是加密,而是证明对端真的懂 WebSocket——不是哪个缓存或误配的 HTTP 服务器顺嘴应了个 101。真实系统的应用层是三个场景三种答案:对外的开放 API、内部微服务的高频互调、静态内容的全球分发。
REST vs gRPC:边界用前者,内部用后者
- REST:HTTP 语义 + JSON。人可读、curl 可调、浏览器原生支持、生态无处不在——对外 API 的默认选择,兼容性就是生产力。
- gRPC:HTTP/2 + Protobuf 二进制 + 强类型接口定义(.proto 文件即文档即代码生成)+ 原生流式调用。内部微服务每秒百万次互调时,序列化的体积和 CPU 差出几倍就是真金白银的机器成本。
- 经验法则:系统边界重兼容(REST),系统内部重效率(gRPC);浏览器直连 gRPC 至今仍别扭(需 grpc-web 网关转换)。
CDN:把内容搬到用户隔壁
- 三步原理:全球铺边缘节点缓存内容;用 DNS 调度(CNAME 到 CDN 域名,按用户位置返回就近节点 IP)或 anycast(同一 IP 多地宣告、路由就近送达)把用户引到最近节点;未命中再回源站取。
- 效果是把跨国 200ms 的 RTT 变成同城 10ms——回忆第 00 章「慢 = RTT 个数 × 单个 RTT」,CDN 直接砍第二个因子。现代 CDN 顺手承包了 TLS 卸载、DDoS(分布式拒绝服务攻击)清洗、边缘计算。
- 这也解释了为什么 dig 大站域名会看到一串 CNAME 链到 CDN 厂商域名、且不同城市解析出不同 IP——DNS 那张「电话簿」被拿来做了全球流量调度器。
Set-Cookie 或 Cache-Control: private/no-store 的响应通常直接回源。大站响应头里常见 cf-cache-status/x-cache 标着 HIT 还是 MISS——优化 CDN 先从命中率查起,而不是先换厂商。传输层:TCP 的艺术
上一章的 HTTP、TLS 都默认「脚下有一条可靠管道,能把消息完整送到对面进程」——传输层就是造这条管道的地方,解决两件事:送进哪个进程、丢了怎么办。这一章从端口讲到 TCP 如何在丢包的网络上造出可靠字节流、连接怎样体面地建立与告别、以及拥塞控制如何让互联网没有堵死——传输层是整个协议栈里工程智慧最密集的地方。
IP 地址把包送到主机,端口把包送进进程。(源IP, 源端口, 目的IP, 目的端口) 这个四元组唯一标识一条连接——它是内核分发数据的钥匙,也是后面 NAT、负载均衡的基本词汇。
端口的约定
- 16 位,0~65535。0~1023 是知名端口(80 HTTP、443 HTTPS、22 SSH、53 DNS),Linux 上绑定它们需要特权——这是「网站默认跑 443」和「容器里常用 8080」的由来。
- 客户端端口是临时随机的(Linux 默认 32768~60999)。连接靠四元组整体区分,所以一台服务器的 443 端口能同时服务几十万条连接:目的端口都一样,但每条连接的源 IP:端口各不相同。
实践:用 ss 看见连接
$ ss -tlnp # 谁在监听哪些端口(t=TCP l=listen n=数字 p=进程) $ ss -tnp # 已建立连接的四元组和归属进程 $ ss -tlnp | grep :8080 # 「Address already in use」时找出占用者
三个常见误解
- 端口是主机的属性,不是网络的——两台机器各自的 8080 毫不相干。
- 防火墙「开放端口」= 允许目的端口为 X 的包进入,不是什么全局开关。
- 一个端口同时只能被一个进程 listen(SO_REUSEPORT 是刻意的例外:允许多进程共同监听、由内核做负载均衡——高性能服务器的标准姿势)。
cat /proc/sys/net/ipv4/ip_local_port_range(默认 32768~60999,约 2.8 万个);随便发起一条连接再 ss -tn,本端端口就落在这个区间里。判别口诀:看 ss 输出里哪端端口是「整齐的知名数字」哪端是「随机大数」,立刻知道谁是服务方谁是发起方。UDP 在 IP 之上只加 8 个字节:源端口、目的端口、长度、校验和。它不是「劣化版 TCP」,而是不替你做任何决定的诚实选项——把所有权衡留给应用。
不做什么,为什么反而是优点
- 不握手:第一个包就是数据。DNS 查询一来一回 2 个包搞定;换 TCP 光握手就要 3 个包再加挥手 4 个。
- 不保序、不重传:实时音视频宁可丢一帧也不要等重传——迟到的数据等于没用的数据,TCP 的「可靠」对实时流是负资产(一次重传等待让后续所有帧集体迟到,这就是 TCP 直播会「越卡越延迟」的原因)。
- 保留报文边界:发三次收三次,天然是「消息」而非字节流——很多简单协议因此选它。
谁在用
- 小问小答类:DNS、DHCP(开机自动领 IP)、NTP(网络时间协议)。实时容错类:游戏、语音、视频会议——自带前向纠错(FEC)、丢帧插值等应用层补偿。以及 QUIC:在 UDP 上把可靠传输整个重造一遍,图的就是「一切自定义」的自由(本章末卡)。
TCP / UDP / QUIC 三者对照
| TCP | UDP | QUIC | |
|---|---|---|---|
| 头部开销 | 20 字节起(带选项更多) | 8 字节 | 可变(多数字段加密) |
| 建连 | 三次握手 1 RTT | 不握手,第一个包就是数据 | 1 RTT,重连 0 RTT |
| 可靠 / 有序 | 都保证 | 都不保证 | 每个流独立可靠有序 |
| 消息边界 | 无(字节流,要自己切) | 保留(发三次收三次) | 有(流内是字节流) |
| 拥塞控制 | 内置 | 没有(要自己实现) | 内置且可插拔 |
| 实现位置 | 内核 | 内核(极薄) | 用户态——可随 App 发版演进 |
| 连接标识 | 四元组(换网即断) | 无连接 | Connection ID(换网不断) |
- UDP 那一列全是「不」——它不是劣化版 TCP,而是把所有权衡都留给你的诚实选项。QUIC 那一列说明了它的存在意义:把 TCP 的保证一条条重建在 UDP 上,只为了摆脱「实现在内核里」这一个约束。
UDP 的两个真实麻烦
- 没有拥塞控制 = 没有刹车。大流量 UDP 应用必须自带节制(QUIC 内置完整拥塞控制、WebRTC 有 GCC),否则会挤垮共享链路上守规矩的 TCP 流——这是运营商对 UDP 限速的原因之一。
- 无连接 = 可伪造源地址:向 NTP/DNS 服务器发一个源地址伪造成受害者的小查询,服务器把放大几十倍的响应砸向受害者——反射放大 DDoS 的原料。设计 UDP 服务时「响应不得远大于请求」已是安全共识。
sendto(),返回值照样是成功——UDP 的「发送成功」只意味着包离开了本机。对面回的 ICMP 端口不可达是异步到达的,只有 connect 过的 UDP socket 才会在下一次调用时报出 Connection refused(本机 loopback 一试便知)。UDP 把所有权衡都留给应用,TCP 走的是相反的一条路:把最麻烦的那部分一次做到底,做成人人可用的默认。下层的 IP 会丢包、乱序、重复,TCP 用三件套在其上造出「可靠有序字节流」的幻觉:编号、确认、重传。原理朴素,精髓全在细节的打磨里。
三件套
- 序号:给每个字节编号(不是每个包)。接收方按号重组——乱序自动归位,重复自动丢弃。
- 确认(ACK):接收方回「第 N 字节之前我全收到了」(累计确认)。
- 重传:发出后计时(RTO),超时没等到 ACK 就重发。
两个关键打磨
- RTO 怎么定:定短了误重传,定长了干等。答案是持续测量 RTT 的均值和抖动(SRTT + 4×RTTVAR),网络变慢自动放宽——「用测量自适应,不用固定参数」,这是网络算法的第一课,之后在拥塞控制里还会反复出现。
- 不等超时的快速重传:1000~1999 丢了,2000 之后的包到达时接收方只能重复喊「我还在等 1000」。发送方连收 3 个重复 ACK 就立刻重传,不苦等超时;SACK 选项进一步告知「我具体缺哪几段」——只补窟窿,不重发已到的。
滑动窗口:吞吐的来源
- 发一个等一个(停等协议)的吞吐 = 1 包/RTT,在 RTT 200ms 的跨国链路上惨不忍睹。解法:连发一窗口的数据再等确认,每收到确认窗口前滑一格——吞吐 ≈ 窗口大小 / RTT。
- 窗口开多大?受两道闸门约束:接收方的处理能力(流量控制)和网络的承载能力(拥塞控制)——后面各有一张卡。
ss -ti 对任一条连接打出 rto/rtt/rttvar、cwnd(retrans、sack 等在发生时才出现)——课本里的每个变量,内核都在实时计算给你看。本机上的一条真实连接(curl 访问 example.com 时抓的):ESTAB 172.28.134.71:40574 198.18.3.151:443 cubic wscale:0,9 rto:204 rtt:0.454/0.223 mss:1448 pmtu:1500 cwnd:10 bytes_sent:1571 bytes_acked:1572 segs_out:4 segs_in:3 send 255154185bps pacing_rate 510027512bps delivery_rate 76970096bps minrtt:0.295 snd_wnd:65535 rcv_wnd:64512一行里能指认出本章几乎每个概念:cubic 是当前拥塞算法、wscale 是窗口缩放因子、rto:204 印证「Linux 的 RTO 下限是 200 ms」(rtt 才 0.45 ms,rto 仍被钉在 200 出头——所以真走到超时重传最少干等 200 ms,这就是快速重传如此重要的原因)、cwnd:10 正是 RFC 6928 规定的初始窗口 10 个 MSS、mss:1448 = 1500 − 20(IP) − 32(带选项的 TCP 头)。课本里的每个变量,内核都在实时算给你看。
为什么恰好三次?不是规定,而是「在不可靠信道上互相确认收发能力 + 同步初始序号」的最小方案——可以推导,不必背诵。下面 SYN 指「请求建连」这个标志位,ACK 指「我收到了」这个标志位。
推导:为什么两次不够、四次多余
- 目标:双方都确认「我能发你能收、你能发我能收」,并交换各自的初始序号 ISN(后续编号、去重都从它数起)。
- 一次(A→B):B 知道「A 能发、我能收」,A 一无所知。
- 两次(A→B→A):A 确认了双向都通;但 B 发出的应答有没有到,B 自己不知道——B 的发送能力没被确认。若就此建连,B 可能对着一条根本没通的路发数据。
- 三次(A 再回一个 ACK):B 的疑问解除。理论上双方各发一次、各确认一次要 4 步,但中间两步(B 的 ACK 与 B 的 SYN)合并成一个包——4 步压成 3 步,且无法再少。三次是下限,不是习俗。
报文与状态
客户端 服务器 CLOSED LISTEN ── SYN, seq=x ─────────────────→ (进入 SYN_RCVD) (SYN_SENT) ←─ SYN+ACK, seq=y, ack=x+1 ───── ── ACK, ack=y+1 ───────────────→ ESTABLISHED ESTABLISHED
- ISN 为什么要随机:防上一条同四元组连接的迟到报文串台,也防攻击者预测序号伪造连接。
- 两次握手的具体灾难:滞留网络许久的旧 SYN 突然到达,服务器直接建连、分配资源,对着不存在的对话空耗——三次握手下客户端不会为旧 SYN 回第三个 ACK,假连接建不起来。
深入理解:SYN 洪泛与 SYN Cookie
- 半开连接(收到 SYN、没等到最后的 ACK)要占内核队列。攻击者伪造海量源地址狂发 SYN,队列一满正常用户就连不上——最古老也最长寿的 DDoS。
- SYN Cookie 的妙手:服务器干脆不存半开状态,把连接信息加密编码进自己回的 ISN 里;第三次握手的 ACK 回来时(ack = ISN+1),解码验证、重建状态。「把状态塞进对方必然回显的字段里」——无状态防御的经典手筋,值得记进工程武器库。
127.0.0.1:9(无人监听)0 ms 就返回 Could not connect——因为内核立刻回了 RST;连一个不可达地址 10.255.255.1:81 则干等 4020 ms 才超时。「立刻失败」和「等很久才失败」是两种完全不同的病:前者说明包到了、对面明确拒绝(查端口/进程),后者说明包石沉大海(查防火墙/路由/安全组)。看错方向能白查一小时。strace -e connect,accept ./client_server 里 connect() 一次调用返回即三次握手全部完成——它发生在内核里。更妙的证据:让服务器 listen 后故意不 accept,客户端连过去照样双方 ESTABLISHED(ss -tn 可见)——accept 根本不参与握手,只是从「已完成连接队列」里取货。建立连接三次,关闭却要四次,关完还要罚站 2MSL——每一步看似多余的仪式,都在给不可靠的网络兜底。
为什么是四次
- TCP 全双工,两个方向独立关闭:「我说完了」不等于「你说完了」。主动方发 FIN(我不再发数据),被动方先回 ACK(知道了)——但它自己可能还有数据要发,发完才发自己的 FIN。
- ACK 和 FIN 之间隔着「把话说完」的时间,所以拆成两步、共四次。当被动方无话可说时,ACK 和 FIN 可以合并——抓包里经常看到三个包的挥手,教科书的四步是一般情形而非铁律。
TIME_WAIT 为什么等 2MSL
- 主动关闭方发完最后一个 ACK 后不能立刻消失,两个理由都能推出来:
- ① 这个 ACK 可能丢。丢了对方会重发 FIN,必须留个人在场答复——否则对方收到的是 RST,报「连接被重置」的错。
- ② 让本连接的旧报文在网络中自然死亡。报文最大存活时间是 MSL,一来一回最多 2MSL;等完这段,同四元组的新连接绝不会收到「上辈子」的数据。
- 所以是主动关闭的一方进 TIME_WAIT——谁先说再见,谁负责站到最后。
深入理解:RST 为什么不需要这些仪式
- FIN 是「话说完了」的体面告别,RST 是单方面中止:连接不存在、进程崩了、防火墙清了会话,直接一个 RST 中止,不保证送达、不走状态机。收到「Connection reset by peer」,意思是对面在一条你以为还活着的连接上掀了桌——去查对面进程和中间设备,而不是自己的代码。
ss -tano,主动关闭侧挂着 timer:(timewait,59sec,0) 在倒数。Linux 的 TIME_WAIT 时长是写死在内核里的 60 秒(常量 TCP_TIMEWAIT_LEN),不是哪个 sysctl。口诀:谁先说再见,谁站六十秒。TCP 的 11 个状态不用背——真正值钱的是三种「状态堆积」模式,每种都指向一类明确的故障。这张卡是生产环境网络排障的核心武器。
先看全局分布
$ ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c
12 LISTEN
8043 ESTAB
2917 TIME-WAIT # ← 各状态的连接数,异常堆积一眼可见
412 CLOSE-WAIT三种堆积,三种病因
- 大量 CLOSE_WAIT:对面已发 FIN、内核已替你 ACK,但应用一直没调 close()。它不会自己消失,堆到文件描述符上限服务就挂。十有八九是代码 bug:连接泄漏(异常路径忘了关)、线程池耗尽处理不过来——看到它持续增长,直接查应用代码,不用怀疑网络。
- 大量 TIME_WAIT:本方是主动关闭方 + 短连接频繁(典型:反向代理对后端没开 keep-alive)。它是正常机制,2MSL 后自己消失;量大时的真实代价是客户端侧临时端口耗尽。正解是连接复用(长连接/连接池),而不是上来就改内核参数。
- 大量 SYN_RECV:半开连接堆积——正在被 SYN 洪泛,或应用 accept 太慢导致队列溢出。用 ss -lnt 看监听口的 Recv-Q/Send-Q(当前排队数/队列上限)确认。
三种堆积的速查表(生产排障直接对照)
| 看到 | 说明 | 会不会自己消失 | 该查哪里 |
|---|---|---|---|
| CLOSE_WAIT 持续增长 | 对面已发 FIN,你的应用没调 close() | 不会——堆到 fd 上限服务就挂 | 应用代码(连接泄漏、异常路径忘了关),不用怀疑网络 |
| TIME_WAIT 量大 | 本方是主动关闭方 + 短连接频繁 | 会(60 秒后) | 正常机制;真实代价是客户端临时端口耗尽。正解是连接复用,不是改内核参数 |
| SYN_RECV 堆积 | 半开连接积压 | 会(重试超时后) | SYN 洪泛,或 accept 太慢(看 ss -lnt 的 Recv-Q) |
| FIN_WAIT_2 量大 | 你发了 FIN,对面迟迟不发它的 FIN | 会(受 tcp_fin_timeout 约束) | 对端应用没关连接——查对面 |
顺手的深挖命令
$ ss -tnp state close-wait # 哪个进程攒的 CLOSE_WAIT(直接定位泄漏者) $ ss -ti dst 10.0.0.5 # 单条连接的 cwnd/rtt/重传数(第 07 章再用)
ss -tano 的 timer 列是被低估的信息源:timer:(timewait,59sec,0) 是 TIME_WAIT 倒计时,timer:(on,…,N) 是重传计时器且 N 是已重传次数,timer:(keepalive,…) 是保活探测。State 列告诉你连接在哪,timer 列告诉你它在等什么——排障时后者往往更快指向病因。前面三卡解决的是「丢了怎么补」,接下来两卡回答另一个问题:一次到底该发多少。先看接收方这一侧。发送方可能是万兆服务器,接收方可能是块手表。流量控制让接收方随时告诉发送方「我还能吃多少」,防止接收缓冲被灌爆。
机制:ACK 里捎带的窗口通告
- 每个 ACK 都带着 rwnd = 接收缓冲的剩余空间;发送方保证在途未确认数据 ≤ rwnd。
- 链条自动传导:应用读得慢 → 缓冲堆积 → rwnd 缩小 → 发送方减速 → 对面应用的 send() 开始阻塞。这就是「背压」(backpressure)的最底层实现——消费者的压力一路顶回生产者,中间没有任何一环需要显式协调。
- 由此得到一个反直觉的真相:本地程序卡住会拖慢远端的发送方。下载限速器的一种实现就是故意慢读 socket,让 TCP 流控替你把源头压慢。
零窗口死锁与探测
- rwnd=0 时发送方停发。等接收方腾出空间发「窗口更新」——但这个更新包如果丢了,双方互相干等成死锁:一个等窗口、一个等数据。
- 所以发送方设持续计时器,定期发 1 字节探测包逼对方重报窗口——协议设计的通用戒律:任何「等对方通知」的环节,都要考虑通知丢了怎么办。
ss -ti 里的 wscale:9,9 表示双方都放大 512 倍。读抓包时看到小小的 win= 数字就断言「窗口才几 KB」,忘了乘因子,是分析 TCP 流量最常见的误判。网络路径是所有人共享的。没人管的话:拥塞→丢包→重传→更拥塞——1986 年互联网真的这样崩过(LBL 到伯克利的吞吐跌了近千倍)。拥塞控制是每个 TCP 端点自愿遵守的交通法,由 Van Jacobson 在事故后写进 TCP。
从零推导整套算法
- 难题:网络能承载多少(瓶颈带宽)没人告诉你,而且随时在变(别的流加入退出)。唯一可观测的信号:丢包(路由器队列溢出)≈「路堵了」。
- ① 起步不知深浅 → 从小窗口开始试探,每个 RTT 翻倍——这就是慢启动。名字叫慢,实为指数增长;「慢」是相对当年「一上来灌满整个接收窗口」的莽撞而言。
- ② 逼近上次出事的水位(ssthresh)→ 收起倍增,改为每 RTT +1 小心逼近——拥塞避免。
- ③ 真出事了 → 砍半退让。两种严重程度两种反应:超时(连 ACK 都收不到,网络可能瘫了)→ cwnd 归 1 重新慢启动;3 个重复 ACK(还有包在到达,只是丢了个别)→ ssthresh=cwnd/2,从砍半处直接进拥塞避免(快恢复)。
AIMD 为什么偏偏是「加性增、乘性减」
- 数学结论:多条流共享瓶颈时,「+1 / ×0.5」的组合会收敛到公平分享——窗口大的流丢包时让出得多(乘性减对大者狠),窗口小的流相对获益,反复几轮大家趋同。增得温和保证稳定,减得凶狠保证快速让路——AIMD 是收敛性证明选出来的,不是拍脑袋。
深入理解:全靠自觉的脆弱平衡
- 拥塞控制不是网络强制的,是端上代码的自觉。一个不退让的发送者能挤走同链路所有守规矩的流——这是 UDP 大流量应用必须自带拥塞控制的伦理与现实原因。互联网的稳定,建立在几十亿台设备恰好都跑着守规矩的代码之上——细想相当惊人,也解释了为什么新拥塞算法的「对旧算法是否公平」是学术界严肃审查的问题。
ss -ti 直接显示 cwnd:10。另一个易错点:cwnd 的单位是 MSS 段数不是字节。课本的算法骨架没变,常数早已现代化——拿三十年前的数字答面试题会露馅。cat /proc/sys/net/ipv4/tcp_congestion_control(多数发行版是 cubic),ss -ti 里每条连接详情行的开头就写着算法名(可能排在 ts sack 等选项标志之后),后面跟着当前 cwnd。注意 cwnd 不在 TCP 头里——它是发送方的私有变量,两端各算各的、互不相知,这正是「拥塞控制全在端上」的直接证据(对比 rwnd:它是真实写进报文的字段)。经典算法把「丢包」当唯一拥塞信号。这个假设在大带宽、深缓冲的现代网络里制造出新病——排队延迟爆炸(bufferbloat)。现代算法的演进方向:换信号。
丢包信号的两宗罪
- 长肥管道爬得太慢:跨国 10Gbps 链路的在途容量巨大,AIMD 每 RTT 只 +1,砍半后爬回满载要几个小时。CUBIC(Linux 默认二十年)用三次曲线快速回到上次丢包的水位附近、再放平谨慎试探——缓解了这一条。
- bufferbloat:设备厂商给路由器堆大缓冲「减少丢包」,结果丢包信号迟迟不来——TCP 按设计会一直增窗,把整个缓冲填满才收到第一个丢包。带宽没多一分,数据却在队列里排队几百毫秒。家里有人满速下载、你打游戏就 460,通常不是带宽不够,是路由器缓冲被灌满了。
BBR:不等丢包,直接建模
- 思路反转:持续测量瓶颈带宽 BtlBw 和最小往返 RTprop,把在途数据量控制在 BDP = BtlBw × RTprop 附近——刚好填满管道、不去填缓冲。丢包不再是主要信号。
- 在有随机丢包的链路上(无线、长距离跨国),CUBIC 把每次丢包都当拥塞、疯狂砍半——误伤;BBR 按模型走,吞吐可以高出几倍到几十倍。Google 在 YouTube 和 GCP 全量部署。
- 没有免费午餐:BBR v1 与 CUBIC 共存时抢占过猛(不公平),v2/v3 持续修正——拥塞控制至今是活跃的研究前线,不是盖棺的教科书章节。
TCP 的几大顽疾(队头阻塞、握手 RTT、没法演化)根源相同:它实现在内核里、头部对中间设备明文可见——看得见就会被做假设,被做了假设就再也改不动。QUIC 的答案釜底抽薪:搬到用户态、跑在 UDP 上、除了必要字段全部加密。
四项核心改进
- 握手合流:传输握手与 TLS 1.3 握手合并——首次连接 1 RTT,重连 0-RTT(数据跟着第一个包就走;代价是 0-RTT 数据可被重放,只允许幂等请求)。对比 TCP+TLS 的 2~3 个 RTT。
- 流级可靠:一条连接承载多个互相独立的可靠流,丢包只阻塞所在流——第 02 章 HTTP/2 的 TCP 层队头阻塞,在这里被真正解决。
- 连接迁移:连接由 Connection ID 标识而非四元组——WiFi 切蜂窝、NAT 重新绑定端口,连接照旧。视频通话走出电梯不再重连。
- 可演化(治僵化):TCP 想加个新特性有多难——中间盒子看见不认识的选项就丢包,MPTCP、TFO 推广十年步履维艰(这现象有个名字:协议僵化 ossification)。QUIC 把头部加密,中间设备无从窥探也就无从干涉,新版本随应用发版即可上线。
代价与现状
- 用户态逐包加密的 CPU 开销高于内核 TCP(内核旁路优化在追赶);部分网络对 UDP 限速或封锁,所以 HTTP/3 永远带 TCP 回退(Alt-Svc 头协商升级)。
- QUIC 的意义超出 HTTP/3:DNS over QUIC、MASQUE 代理都在其上生长。它真正改变的是节奏——传输层创新从「等全世界内核和中间设备升级」变成「App 发个版」。
curl -v --http3 也能看(需 curl 编译了 HTTP/3 支持,不少发行版默认没带)。网络层:IP、路由与 NAT
上一章 TCP 管好了两个端点之间的可靠,却默认「对方主机总能找到」——真正负责跨越千山万水把包送到那台主机的,是网络层。核心只有一个激进简单的协议——IP:每个包独立寻路、每一跳独立决策、不承诺送达。这一章从这份简单讲起,看它如何支撑起全球路由,又如何被 NAT 这块补丁悄悄改写。
IP 的设计哲学激进地简单:每个包独立寻路,每台路由器只看目的地址做一次「下一跳给谁」的决策,不承诺送达、不保证顺序。这份简单正是它统一世界的原因。
为什么「不可靠」反而是优点
- 路由器无状态(不记任何连接):任何一台宕机,后续的包自动从别的路绕行,网络整体存活——这是 ARPANET 的生存性基因(设计目标就包括部分节点被摧毁后仍能通信)。
- 可靠性放到端上做(TCP)——端到端原则:中间只做所有应用都需要的事(转发),特殊需求(可靠、加密、顺序)留给两端。于是路由器可以专注到用硬件转发,每秒处理上亿个包;如果每台路由器都要维护连接状态,互联网长不到今天的规模。
IPv4 头部里真正值得懂的字段
- TTL:每过一跳减 1,减到 0 丢弃并回 ICMP 报错。它不是时间是跳数,目的是防路由环路里的包永生转圈——traceroute 整个建立在这个字段上(后面卡)。
- 协议号:6=TCP、17=UDP、1=ICMP——解封装时「往上交给谁」的路标。
- 源/目的 IP 全程不变(NAT 是唯一例外),而 MAC 地址每一跳都换——下一章会看到这正是两层地址分工的直接证据。
IP 地址的本质设计:前缀说「哪个网络」,后缀说「网络内哪台主机」。路由器只需按前缀转发——路由表从几十亿条压缩到几十万条,这是互联网能全球寻址的关键。
CIDR 怎么读、怎么用
192.168.1.0/24 // 前 24 位是网络号,后 8 位主机号(256 个地址) 10.0.0.0/8 // 前 8 位网络号(约 1678 万个地址) 掩码是「前缀多长」的另一种写法: /24 = 255.255.255.0 同网段判断: 两个 IP 分别与掩码按位与,结果相同 → 同网段 → 同网段: 直接二层送达(下一章 ARP) → 不同网段: 交给默认网关
- 划分子网 = 前缀加长(/24 一分为二成两个 /25),聚合 = 前缀缩短(把手上连续的网段并成一条路由对外通告)。思想是树状分配:注册机构给 ISP 大块,ISP 切小块给客户——外部路由表只需记大块。别陷进借位手算题,看懂前缀的层次含义才是这节的全部价值。
- 历史上的 A/B/C 类地址就是「只许 /8、/16、/24 三种切法」——太粗糙(一个 B 类 6.5 万个地址,多数组织用不完),90 年代被任意前缀长度的 CIDR 取代。类别地址如今只剩考古价值。
几段特殊地址的用途
- 私有段 10/8、172.16/12、192.168/16:公网不路由,配合 NAT 全世界无限复用——你家和邻居家都是 192.168.1.x,互不冲突因为它们从不见面。
- 127.0.0.1 回环(不出网卡,测本机服务);169.254/16 链路本地——DHCP 失败时的自救地址,看到它先查 DHCP/网线,是排障的高频信号。
特殊地址段速查
| 段 | 用途 | 公网可路由 | 看到它意味着 |
|---|---|---|---|
10.0.0.0/8172.16.0.0/12192.168.0.0/16 | 私有地址 | 否 | 你在 NAT 后面(注意 172.32.x.x 已是公网) |
127.0.0.0/8 | 回环 | 否(不出网卡) | 只在本机内通 |
169.254.0.0/16 | 链路本地(自救) | 否 | DHCP 拿不到地址——查 DHCP 服务与网线 |
100.64.0.0/10 | 运营商级 NAT(CGNAT) | 否 | 你在运营商的第二层 NAT 后,端口映射基本无望 |
fe80::/10 | IPv6 链路本地 | 否 | 启用了 v6 但可能没有 v6 连通性 |
2000::/3 | IPv6 全球单播 | 是 | 真的接入了 IPv6 互联网 |
ipaddress 模块验算:ip_network("192.168.1.1/24") 会直接报错「has host bits set」,提醒你把主机地址错当成了网段。前面一直默认「主机有 IP」,但 IP 是逻辑地址、随网络而变——刚插上网线的机器一无所有,连自己是谁都不知道。DHCP 让它广播喊一嗓子就领到全套配置:跑在 UDP 67/68 上(第 03 章「小问小答」名单里的那位),入网零配置的幕后功臣。
DORA 四步
① Discover 主机广播: 「有 DHCP 服务器吗?我还没有 IP」 // 源地址 0.0.0.0,只能广播 ② Offer 服务器: 「给你备了 192.168.1.23,要吗」 ③ Request 主机广播: 「我要 192.168.1.23 这个」 // 仍是广播——让多台服务器都知道花落谁家 ④ Ack 服务器: 「成交,租期 24 小时」
- 发来的不只是一个地址,是上网三件套:IP/掩码(我是谁、哪些是邻居)、默认网关(出网找谁)、DNS 服务器(第 02 章那本电话簿去哪查)——一台主机的全部网络人格,由这一次交换塑造。上一张卡的「同网段判断」、后面的路由,都建立在这几个值配对的前提上。
- 租约(lease):地址是租的不是送的——租期过半就悄悄续租,续不上到期收回。地址因此可以循环利用(咖啡馆的 /24 服务得了一天几百位客人),也解释了为什么设备隔天重连 IP 可能就变了。
排障呼应:169.254 的来历
- 上一张卡说「看到 169.254 先查 DHCP」,因果就在这里:Discover 喊了没人应,主机只好自己编一个链路本地地址自救——所以它不是病,是症状。此时该查的不是路由,而是 DHCP 服务本身和你到它之间的那段链路(网线、交换机口、VLAN)。
路由器的工作拆成两件事:转发(数据面:查表把包发往下一跳,纳秒级)和路由(控制面:跑协议算出这张表,秒级收敛)。两者分离,是理解从家用路由器到 SDN(软件定义网络)的一切网络设备的前提。
转发一个包的全部动作
路由表:
10.0.0.0/8 → 下一跳 A
10.1.0.0/16 → 下一跳 B
0.0.0.0/0 → 下一跳 C // 默认路由,谁都匹配
目的 10.1.2.3: 同时命中前两条 → 取前缀最长的 /16 → 发给 B- 最长前缀匹配 = 更具体的说了算。默认路由 /0 是「零位前缀」,任何地址都匹配它,所以永远垫底兜底——这套规则让「例外」可以随时插进「一般」之上。
- 它也是流量工程的杠杆:想把某段流量引到别处,通告一条更长前缀即可截胡——BGP 劫持滥用的正是这一点(下一张卡)。
深入理解
- 线速转发靠硬件查表(TCAM/多级 Trie),一次查找几纳秒——软件路由器和硬件路由器的性能差距主要在这。
- 「路由表爆炸」是真实的工程焦虑:全球 BGP 表已超 100 万条 IPv4 前缀。2014 年的「512K 事件」:前缀数越过大量老设备的 TCAM 默认容量上限,路由器批量异常,多家 ISP 全球范围掉线——互联网的规模问题最终都会砸到某台具体设备的内存上。
ip route 输出的排列只是显示习惯。两种「查表」语义完全不同,混淆了会读错别人的路由配置。ip route get 8.8.8.8 输出「via 网关」,换成同网段地址则只有「dev eth0」(直接二层送达)——你的 Linux 主机每发一个包都在做最长前缀匹配。顺带一看:/proc/sys/net/ipv4/ip_forward 默认是 0,收到不是发给自己的包直接丢;写成 1,这台主机就成了一台路由器。互联网不是一张网,是 7 万多个自治系统(AS——运营商、云厂商、大学)组成的联邦。域内路由(OSPF)解的是「最短路径」这道数学题;域间路由(BGP)解的是「商业关系」这道政治题。
OSPF:域内的最短路
- 做法:每台路由器把自己的链路状态泛洪给全域,于是人人手里都有全网地图,各自跑 Dijkstra 算最短路——先同步地图,再各自导航。
- 对比更老的 RIP(距离向量:只和邻居互报「我到 X 要几跳」,逐跳扩散):RIP 收敛慢、坏消息传得更慢(数到无穷问题)、以跳数为度量无视带宽差异。OSPF 收敛快、以链路开销做权重——大型网络的标准答案。
BGP:域间的政策
- AS 之间的关系是商业合同:客户付钱买运输(transit)、对等免费互换(peering)。BGP 选路不选最短,选最省钱:本地策略优先级高于路径长度——「客户路由 > peer 路由 > provider 路由」是每个 ISP 的默认配置,因为走客户的路有钱赚、走 provider 的路要付费。
- 所以两地之间的流量路径经常绕远(绕过没有商业关系的网络);全球 BGP 收敛以分钟计——互联网的「地图」像大陆漂移一样缓慢变化。
BGP 的信任危机:真实事故
- BGP 的原始设计是「你通告的前缀就当是你的」。2008 年巴基斯坦电信为在国内封锁 YouTube,通告了一条比 YouTube 官方更长的前缀,路由泄漏到全球——全世界的 YouTube 流量被吸进巴基斯坦黑洞两小时。2018 年攻击者劫持亚马逊 DNS 服务的前缀,配合假证书偷走以太坊钱包。
- 修补是 RPKI:用密码学签名声明「谁有权通告哪个前缀」,通告不合法就丢弃。部署率逐年上升但远未全覆盖——互联网骨干至今运行在半信任之上,这是「网络安全是系统工程」最宏观的例证。
NAT 的初衷只是省地址:IPv4 只有 43 亿个地址,全家几十台设备共用一个公网 IP。但它顺手打碎了「任意主机直接互连」的原始假设,重塑了此后所有协议的设计。
NAPT 机制:用端口扩容地址
内网 192.168.1.5:51000 ──网关改写──→ 公网 203.0.113.7:61234 ──→ 服务器
转换表: (192.168.1.5:51000) ↔ (203.0.113.7:61234)
服务器回包到 203.0.113.7:61234 ──查表改回──→ 192.168.1.5:51000- 一个公网 IP × 6 万多个端口,够一栋楼的设备复用。代价是连接必须由内网发起:外面主动进来的包在转换表里没有条目,直接丢弃。
- 「家里设备天然不被扫描」是 NAT 的意外安全红利;但同一件事换个立场就是 P2P 的噩梦——两个都躲在 NAT 后的设备怎么直连?
NAT 打洞:P2P 的求生术
- 视频通话不想走中转服务器(延迟、成本),于是有了打洞:STUN 服务器告诉你「你在公网上的映射是什么」;然后双方同时向对方的公网映射发包——各自的 NAT 被自己的出向包「打出洞」(建了表项),对方的包顺洞而入,直连建立。
- 打不动的情况:对称型 NAT 给每个目的地分配不同映射,预测不了 → 退回 TURN 中转。WebRTC 的 ICE 框架把「直连→打洞→中转」的尝试次序全自动化——你每次视频通话它都在背后跑一遍。
深入理解:NAT 改变了协议设计的基因
- 它违背端到端原则:IP 层不再人人可达,「服务器必须有公网 IP」从习惯固化成铁律,客户端-服务器架构被水泥浇筑——P2P 应用(早年 BT、电驴的辉煌)从主流退场,NAT 是推手之一。
- 在应用层载荷里写 IP 地址的协议(FTP、SIP)会被 NAT 弄坏——地址改了、载荷里的没改,得靠网关特判修补(ALG)。教训:上层协议不该携带下层的地址,现代协议设计的常识由此而来。
- 运营商级 CGNAT 更进一步:你家的「公网 IP」也是私网地址——地址枯竭的临时补丁,成了互联网的永久建筑。
ip addr 是私网段、「查公网 IP」的网站返回的却是另一个地址 → 中间必有 NAT;若家用路由器 WAN 口拿到的还是私网段或 100.64/10(CGNAT 专用段)→ 你在运营商的第二层 NAT 后面,想端口映射到公网基本无望。WSL2 的虚拟网络就是一层现成的 NAT——里面看到的地址是 Windows 分配的私网段。ICMP 是 IP 的「差错回执」协议:包死在半路时,路由器用它给源头捎个信。ping 和 traceroute 是把这套回执机制反过来当探测器用的巧思。
ping:最简单也最常被误读
- 发 ICMP Echo Request、对端回 Echo Reply,测出 RTT 与丢包率。读数门道:mdev(抖动)对实时应用比平均值更重要——均值 30ms 抖动 ±50ms 的线路打游戏比稳定 60ms 体验差得多。
- 两个方向的误读都要防:ping 不通 ≠ 主机死了(大量主机和防火墙静默丢 ICMP);ping 得通 ≠ 服务活着(进程崩了,IP 层照样回 Echo)。它测的仅仅是 IP 层连通性。
traceroute:用 TTL 骗出每一跳
原理: 发 TTL=1 的包 → 第 1 跳减到 0,丢弃并回 ICMP Time Exceeded
(这个报错的源地址暴露了第 1 跳是谁)→ TTL=2 探出第 2 跳 → …
$ traceroute example.com
1 192.168.1.1 1ms # 家用路由器
2 100.64.0.1 5ms # 100.64/10 = 运营商 CGNAT 段
3~5 10.x / 城域网 10ms
6 * * * # 这跳不肯回 ICMP(常见,不代表断)
7 93.184.x.x 152ms # 延迟跳变 → 出海了(目标 IP 以实际解析为准)- 读图法:某跳显示 * 只是它不回话;真正的故障是「从某跳起全是 * 且终点不可达」。RTT 大跳变的那一跳通常是跨洋/跨运营商出口。
每种链路的单帧有上限(以太网 1500 字节 = MTU)。包太大怎么办?IP 的原始答案是分片——但现代网络的共识是尽量别让它发生:分片是一整类「诡异故障」的温床。
分片为什么讨厌
- 丢一片 = 全包重传:重组只在终点做,等不齐就整包作废——丢包代价被放大了 N 倍。
- 安全设备处理不了:只有第一片带传输层头(端口在里面),后续分片无法按端口过滤——防火墙对分片「宁可错杀」直接丢弃是常见配置。
- 所以 IPv6 干脆禁止路由器分片,只允许源端分——把问题逼回源头解决。
路径 MTU 发现(PMTUD)与它的经典坑
- 源端把包标上 DF(禁止分片)发出;路径上 MTU 不够的路由器丢包并回 ICMP「包太大,我最大只能过 X」;源端收到就改小重发——逐步探出全路径最小 MTU。
- 坑在哪:很多防火墙一律封禁 ICMP → 「太大了」的通知永远到不了源端 → 大包默默消失。经典症状:能 ping 通、小请求正常、大文件上传或某些页面永远卡死。PPPoE 拨号(MTU 1492)、VPN/隧道(再减几十字节)是高发场景。
sendmsg: Message too long,根本出不了门。ping -M do -s 1472(1472 + 8 ICMP 头 + 20 IP 头 = 1500)通;-s 1500(总长 1528 > MTU 且 DF 置位)不通。二分法从 1472 往下试,第一个通的值 + 28 就是路径 MTU。ip link 也能直接看本机接口的 MTU(eth0 是 1500,loopback 是 65536——回环不走物理线路,所以 MTU 大得多)。IPv6 把地址扩到 128 位(够给地球上每粒沙子分几个),顺手清理了 IPv4 的历史包袱——协议 1998 年就定稿了,全球流量占比至今才逼近半数(部分大国已过半)。慢的原因比协议本身更值得学。
协议本身的改进
- 地址写法:冒号十六进制,连续零段用 :: 压缩一次——2001:db8::1。
- 不再需要 NAT:每台设备真公网地址,端到端原则理论上复活;无广播(改用组播)、SLAAC 设备自己组合出地址(免 DHCP 也能上网)、固定长度首部利于硬件快转、邻居发现 NDP 取代 ARP。
迁移为什么慢:这是经济学问题,不是技术问题
- 不兼容:v6-only 和 v4-only 的主机无法直接通信,过渡期必须双栈(两套都跑)或翻译(NAT64/DNS64)——成本翻倍,而收益是「方便别人连你」的正外部性。谁先升级谁吃亏,集体行动困境教科书案例。
- NAT 太成功:CGNAT 把「地址不够」麻醉成「勉强够用」——痛感消失,动力也消失。恰恰是 IPv4 的补丁延缓了 IPv4 的退役。
- 先行者都是被逼的:移动运营商新增几亿设备实在无 v4 可分(美国 T-Mobile 全网 v6-only + 464XLAT 兜底翻译),中国近年政策与规模双驱动推进——短缺才是最好的迁移布道者。
ip addr 里看到 fe80:: 开头的地址 ≠ 已接入 IPv6 互联网:fe80::/10 是链路本地地址,每个启用 v6 的接口都会自动生成一个(常按 EUI-64 从 MAC 推导,中间插着 ff:fe),只在本链路有效、不可路由,ping 它还得带接口名(ping fe80::1%eth0)。判断真有 v6 连通,要看是否有 2000::/3 的全球单播地址且默认路由可达。正是这个反例:ip -6 addr 只有一条 fe80::215:5dff:fe1e:4e2a/64 scope link,没有任何 scope global 的地址——接口启用了 IPv6、却完全没有 IPv6 互联网连通性。注意那个地址中间的 ff:fe:这是 EUI-64 把 48 位 MAC 撑成 64 位接口标识时插进去的固定标记,看到它就知道这个地址是从 MAC 推导来的(也正因如此它会泄露 MAC,隐私扩展 RFC 4941 才改用随机接口标识)。数据链路层:以太网、ARP 与 WiFi
上一章 IP 规划了从源到目的的全程路线,可每一跳的实际投递——把帧送到同一段链路上的下一台设备——得靠链路层落地。这一章解释为什么有了 IP 还需要 MAC 地址、交换机如何不用配置就能自学习、ARP 为何是内网攻击的万年入口、以及无线为什么天生比有线难。
已经有了全球唯一的 IP,为什么还要一套 48 位的 MAC 地址?因为 IP 说的是「终点在哪」,链路层要回答的是「下一步交给谁」——两个问题,两套地址。
快递类比与真实机制
- IP 地址 = 面单上的收货地址,全程不变;MAC = 每一程承运人之间的交接,每一跳都换。你的包发往网关时,帧的目的 MAC 是网关的;网关转发给下一跳时,拆掉旧帧、套上新帧、换新 MAC——IP 头是信,以太网帧是信封,每一程换个信封。
- MAC 没有任何位置含义(前 24 位厂商编号 + 后 24 位序列号,出厂烧录)——因为链路层只在一个局域网内寻址,规模小到可以「广播喊一嗓子 + 用表记住」(ARP、交换机自学习);而 IP 必须层次化(前缀聚合)才能支撑全球路由——第 04 章 CIDR 的全部意义。
深入理解:为什么两层缺一不可
- 只用 MAC 行不行?不行——MAC 无层次,全球路由表要记几百亿条平铺条目,没有硬件装得下。
- 只用 IP 行不行?也不行——链路层不只为 IP 服务(同一根线上还可能跑别的三层协议),而且 IP 是逻辑地址、换个网络就变,链路设备需要一个稳定的硬件身份来交接帧。分层解耦在这里又一次兑现:谁的问题谁的地址。
ip link set dev eth0 address ... 就能改,现代手机(iOS 14+/Android 10+)默认对每个 WiFi 网络随机化 MAC 防跟踪,macOS 等桌面系统也在跟进——「按 MAC 做白名单/计费/审计」既挡不住有心人,还可能把合法设备的随机地址当成陌生人。交换机没有配置、没有协议协商,插上就能用——靠一条朴素规则自己学出转发表:「从 1 号口收到 A 发的帧,那 A 一定在 1 号口后面」。
自学习三步
收到帧: 源 MAC=A, 入端口 1
① 学习: 记下 A → 端口1(顺手的副产品,零成本)
② 查表: 目的 MAC=B 有记录 → 只从对应端口转出
③ 兜底: 没记录或广播帧 → 除入口外全端口泛洪
// B 一回包,第①步就把它学到了——泛洪只发生在冷启动- 表项带老化时间(约 5 分钟),设备挪了位置自动重学。整个机制无协议、无中心、即插即用——以太网击败一众更「精致」的对手(令牌环、ATM),靠的正是这种不讲道理的简单。
深入理解:简单的两个软肋
- 泛洪依赖无环:拓扑有环时广播帧被无限复制,几秒打瘫全网(广播风暴)。生成树协议 STP 自动阻断冗余链路把图剪成树——代价是冗余带宽闲置;现代数据中心干脆放弃大二层,用三层路由的 leaf-spine 架构绕开整个问题。
- 转发表可被攻击:MAC flooding 用海量伪造源地址灌爆表,交换机退化成全泛洪的集线器——攻击者就能听到所有人的流量。交换机的端口安全(限制每口 MAC 数)是标配防御。
| 交换机的 MAC 表 | 主机的 ARP 表 | |
|---|---|---|
| 记的是 | 哪个 MAC 在哪个端口后面 | 哪个 IP 对应哪个 MAC |
| 怎么来的 | 自学习(看收到帧的源 MAC + 入端口) | 广播问、单播答(ARP 请求/应答) |
| 住在哪 | 交换机(二层设备) | 每台主机 |
| 怎么看 | 登录交换机 show mac address-table | ip neigh 或 arp -a |
| 老化时间 | 约 5 分钟 | 几十秒到几分钟 |
发包前的临门一脚:手里有目的 IP(或网关 IP),但封帧需要 MAC——ARP 用「广播问、单播答」解决。而它毫无防伪能力这一点,是内网攻击的万年入口。
机制与两个关键细节
- 广播「谁是 192.168.1.1?」→ 持有者单播回「我,MAC 是 xx」→ 存入 ARP 缓存(几十秒到几分钟老化)。
- 问谁由掩码决定:目的 IP 同网段 → ARP 问它本人;跨网段 → ARP 问网关(帧交给网关,IP 头原封不动)。第 04 章「同网段判断」的用武之地就在这一步——掩码配错的机器为什么「ping 得通邻居 ping 不通外网」,答案在此。
- 免费 ARP(gratuitous ARP):不问自答地广播自己的映射——用于上线宣告、IP 冲突检测、主备切换时刷新全网缓存。keepalived 的 VIP 漂移能秒级生效,靠的就是它。
ARP 欺骗:内网中间人
- ARP 应答没有任何认证,谁都能喊「我是网关」。攻击者持续广播伪造映射,全网流量先过他的网卡再转出去——加密之前的一切(HTTP 内容、DNS 查询、明文密码)尽收眼底。
- 这就是「公共 WiFi 危险」这句忠告的技术实体,也是 HTTPS 必须无处不在的理由之一——链路层的信任边界就是同一个局域网里的所有陌生人。
- 防御:交换机动态 ARP 检测(DAI,对照 DHCP 记录验证应答)、关键服务器静态绑定、以及最根本的——别让不可信设备进你的二层网络(下一张 VLAN)。
ip neigh 里的 STALE 不是故障:Linux 的邻居表项在 REACHABLE(刚确认过)→ STALE(过期待验证)之间流转是常态,下次通信会顺带重新确认,不用清理;真正的「问了没人应」是 INCOMPLETE → FAILED——ping 一个同网段不存在的地址,就能看到表项停在 FAILED。广播域太大是灾难(广播风暴殃及全楼、财务和访客同网段互通),物理分网又太贵——VLAN 用打标签的办法,在同一套交换机上切出多个互相隔离的逻辑网络。
机制
- 802.1Q 在以太帧里插 4 字节标签,12 位 VLAN ID(4094 张网)。Access 口接终端:进出时打上/剥掉固定标签,主机全程无感知;Trunk 口连交换机:帧带着标签跑,一条线承载所有 VLAN。
- VLAN 之间默认完全隔离,互通必须过三层(路由器/三层交换机)。这给了管理员一个干净的执法卡点:跨部门流量必然经过可控的三层设备——ACL(访问控制列表)、审计都加在那里。「隔离靠二层,放行靠三层」是园区网设计的骨架。
工程日常与思想延伸
- 典型划分:办公、服务器、访客、IoT 各一个 VLAN。家庭场景也值得做:智能摄像头和你的 NAS 不该在同一张网里——IoT 设备的固件安全性配不上你的信任。
- 云计算把这个思想推到极致:VXLAN 用 UDP 隧道把二层网络叠加在三层之上,24 位 ID 支持 1600 万个租户网络——「逻辑网络与物理网络解耦」从省钱技巧长成了 SDN 与云网络的地基。
ip link add link eth0 name eth0.10 type vlan id 10 就能建出 802.1Q 子接口——服务器插在 Trunk 口上,一张物理网卡同时接入多个 VLAN,虚拟化宿主机全靠这一招。口诀:Access 口一张网,Trunk 口全部网。有线以太网当年发帧时能边发边听,撞了立刻发现(CSMA/CD);无线做不到——自己发射的功率完全淹没接收(听不出撞没撞),而且 A、C 都听得见中间的 B 却互相听不见(隐藏终端)。无线的一切复杂性都源于「冲突不可检测」。
CSMA/CA:从「检测冲突」退到「回避冲突」
- 先听信道,忙则等;空闲也不立刻发——先随机退避一小段(否则所有等待者会在信道一空的瞬间同时开火,必撞)。
- 发完必须等对方 ACK:收不到 ACK 就当撞了重传——有线不需要这一步,因为撞没撞自己听得见;无线只能靠回执反推。
- 隐藏终端的补丁 RTS/CTS:先用小包向 AP 预约,AP 广播「都安静,让 A 说」——预约包也可能撞,但小包撞了损失小。
深入理解:WiFi 慢的真正原因
- 无线是半双工共享介质:同信道的所有设备——包括邻居家的——分享同一段「空口时间」。「WiFi 慢」多数时候不是带宽小,而是空口争用 + 重传在烧时间。
- 速率自适应:信号差就换更稳但更慢的调制(从 256-QAM 一路降到 QPSK,第 06 章讲为什么),同一台 AP 下远近设备速率能差 50 倍。而慢设备发同样的数据占用更久的空口——一台墙角的老设备能拖慢全家,airtime fairness 调度就是治这个的。
- WiFi 4/5/6/7 每一代的主线(MU-MIMO、OFDMA)都是同一句话:让多设备更高效地分享空口。
物理层:信号与极限
上一章的帧说到底是一串比特,而比特要真正越过一根铜线、一段光纤、一片空气,就落到了整摞协议的地面——物理层,它回答「比特怎么变成电、光、电磁波」。这一章刻意精简为两张卡:编码与调制的直觉,以及香农定理——它给一切通信画了上限,也解释了从拨号上网到 WiFi 7 的全部提速手段。
0 和 1 要变成导线上的电压、光纤里的光、空气中的电磁波,有两个关键动作:编码(比特怎么摆进信号)与调制(信号怎么载上波)。抓住各自的一个核心矛盾就够了。
编码的矛盾:接收方靠什么对齐节拍
- 朴素方案「高电平=1、低电平=0」有暗病:一长串连续的 0,接收方数不清到底几个——双方时钟没那么准,几百个比特后就漂移错位。
- 解法是把时钟藏进信号:曼彻斯特编码让每个比特中间必有跳变(自带节拍器),代价是占用双倍带宽——老以太网用它。现代标准(4B/5B、64B/66B)用少量冗余比特保证「跳变足够频繁」,把开销从 100% 压到 25%(4B/5B)乃至 3%(64B/66B)——工程演进的方向永远是同样保障、更少代价。
调制的矛盾:一个波形想装更多比特
- 改变载波的幅度/频率/相位来表达比特(ASK/FSK/PSK);QAM 同时调幅+调相,把「一个码元」变成星座图上的一个点:256-QAM 每码元装 8 比特——WiFi、5G、有线电视都靠它堆速率。
- 代价:星座点越密,噪声越容易让接收方认错点。信号质量决定敢用多密的调制——第 05 章 WiFi 的速率自适应,本质就是在星座密度之间换挡。
C = B · log2(1 + S/N):给定频宽 B 和信噪比 S/N,无差错传输的速率有一个数学硬上限。它不是考试公式,而是判断「什么值得做、什么是徒劳」的工程罗盘。
读懂它,提速只有三条路
- 更宽的频谱 B(线性收益):WiFi 从 20MHz 到 320MHz 信道、5G 上毫米波、WiFi 6E 开辟 6GHz——频谱是要天价竞拍的稀缺资源,「新频段」是每代无线标准的头条。
- 更高的信噪比 S/N(对数收益):S/N 翻倍只多约 1 bit/Hz——收益递减,所以没人靠无限堆功率提速;靠近 AP、屏蔽更好的线缆是性价比更高的方式。
- 绕开单信道的 log 诅咒:MIMO 多天线制造多条并行空间流——变相增加信道数量。这是 WiFi 和 5G 近十年提速的真正主力。
三种介质的本质差异(一张表的洞察版)
- 光纤:可用频宽以 THz 计、损耗极低(跨洋百公里一个中继)、天然免疫电磁干扰——骨干网与数据中心的唯一选择;波分复用让一根纤跑几百个波长,扩容只换两端设备不换纤。
- 铜缆双绞线:便宜、能顺带供电(PoE),但高频衰减快——速率与距离互相掐(10Gbps 的 Cat6 只保 55 米)。
- 无线:唯一支持移动,但频谱共享 + 衰减 + 干扰三座大山——同代无线速率总落后有线一个量级。这是物理,不是工程落后。
实战与精通
前面五章给了地图,这一章给车钥匙:亲手写 socket、亲眼读一次完整的加密会话、学会区分「延迟的病」和「带宽的病」。
所有网络框架的地基都是 socket API。十几行 Python 写一个 TCP echo 服务器——比再读十章书更能建立「连接」的手感,而且每一行都连着前面学过的机制。
最小可运行版本
import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 免受上次进程留下的 TIME_WAIT 困扰 s.bind(("0.0.0.0", 9000)) s.listen(128) # 128 = 全连接队列长度(第03章排障那条队列!) while True: conn, addr = s.accept() # 取出一条已完成三次握手的连接 data = conn.recv(4096) # 读的是字节流片段,不是"一条消息" conn.sendall(data) conn.close() # 触发四次挥手,本进程一侧进 TIME_WAIT
- 另开终端 nc 127.0.0.1 9000 敲字符当客户端;再开一个终端 ss -tan | grep 9000,看着自己进程的连接在 ESTAB → TIME-WAIT 之间流转——第 03 章的状态机在你眼前上演。
每一行都连着前面的章节
- listen 的参数就是 SYN 洪泛排障里的全连接队列;accept 之前握手早已由内核完成;recv 返回任意长度印证「字节流无边界」;close 之后你就是主动关闭方——TIME_WAIT 归你。
- 这个版本一次只能服务一个连接。下一层深度是 I/O 模型:epoll 多路复用如何让单线程扛十万连接——09 章第 ① 个微项目就是把这个 echo 服务器写成三种并发模型并压 2000 条连接对照,OS 页的 I/O 多路复用章则是它的机制解释。
recv 返回空字节串不是「暂时没数据」,是对端已经关闭——阻塞 socket 没数据时会一直等,「返回空」只有连接结束这一种含义,把它当空数据继续循环就是空转死循环;send 可能只发出一部分(返回值是实际发送的字节数),要么自己循环补发,要么直接用 sendall。Wireshark 是网络学习的显微镜:学过的每个头部字段都能在真实字节里指认。这张卡给一个固定练习流程——完整走一遍,全书的概念就全部落了地。
练习流程(15 分钟)
① 开始抓包,过滤器: host example.com ② 另开终端: curl -v https://example.com 然后停止抓包 ③ 按时间顺序指认: DNS UDP 53 的查询/应答——A 记录、TTL(第02章) TCP SYN → SYN+ACK → ACK——看 ISN、MSS、窗口缩放选项(第03章) TLS ClientHello(注意 SNI 里的明文域名!)、ServerHello、证书链(第02章) 数据 Application Data——长度可见、内容不可读 挥手 谁先发 FIN?谁进 TIME_WAIT?(第03章)
- 任选一个包点开:帧头 → IP 头 → TCP 头 → 载荷逐层展开——这就是第 00 章「套娃」的解剖图。菜单 Statistics → Flow Graph 能把整次会话画成时序图,握手挥手一目了然。
顺便学到的隐私课
- TLS 加密了内容,但 SNI(你访问的域名)、DNS 查询、IP、流量时序全是明文——「开了 HTTPS 就完全隐身」是误解。这正是公司网关「知道你访问了什么网站但看不到内容」的原因;ECH(加密 ClientHello)正在把 SNI 这个缺口也补上。
- 合规提醒:只抓自己的流量。这既是法律边界,也是这个行业的职业操守。
SSLKEYLOGFILE 环境变量(curl、Chrome、Firefox 都认)让客户端把 TLS 会话密钥写进文件,Wireshark 里指向它,你自己的加密流量就能明文展开:跑一次 curl 就能看到密钥文件生成。这不算破解——密钥由端点自愿导出,只解得开你自己的会话。「网络慢」是两种完全不同的病:延迟高(路远或排队)和带宽小(管道细)。诊断方法、优化手段完全不同——混为一谈是性能调优里最常见的错误。
两个量的本性
- 延迟的地板是光速:北京↔洛杉矶光纤单程约 50ms,物理定律面前充钱没用。能优化的只有两样:RTT 的个数(连接复用、TLS1.3、0-RTT、把内容用 CDN 搬近)和排队(bufferbloat,第 03 章)。
- 带宽可以花钱买,延迟不能。所以先判断负载类型:小请求(API 调用、网页交互)性能几乎全由 RTT 决定;大传输(备份、视频分发)才由带宽决定。给 API 加带宽、给下载优化 RTT,都是无用功。
BDP:把两个量拧在一起的公式
BDP = 带宽 × RTT = 链路上能同时「在途」的数据量
例: 100Mbps × 100ms = 1.25MB
若 TCP 窗口只有 64KB → 利用率 = 64KB/1.25MB ≈ 5% // 带宽再大也跑不满- 长肥管道吞吐上不去,第一嫌疑人永远是窗口不够——现代内核会自动调窗(tcp_rmem/tcp_wmem 的上限决定天花板),跨国大流量传输值得检查这几个参数。
- 单流吞吐 ≈ min(窗口/RTT, 瓶颈带宽, 拥塞算法在当前丢包率下的上限)。最后一项常被忽略:跨国链路丢 0.1% 的包,CUBIC 的吞吐就雪崩——这是跨境专线、多流并传工具(aria2 -x16)存在的商业理由。
-P 8 多流再测:「单流上限」和「链路缺容量」是两种病,处方完全不同。远程界面:X11 转发、VNC/RDP 与实时 UI
把界面和运行它的机器分开,是网络最古老也最日常的用途之一——SSH 里开图形程序、连远程桌面、以及今天所有「实时协作」的网页。这一章按传输的是什么把它们分成三类,并把延迟预算算清楚:远程界面好不好用,几乎全由 RTT 与往返次数的乘积决定。
远程界面只有三种做法,区别在于网络上跑的到底是什么。搞清楚这一点,带宽、延迟、体验的差异就都能推出来。
三类对照
| 传绘图命令(X11) | 传像素(VNC / RDP) | 传状态(Web / 自研协议) | |
|---|---|---|---|
| 网上跑什么 | 「在这画条线」「写这几个字」 | 压缩后的屏幕图像或差分块 | 业务数据(JSON 等),界面在本地渲染 |
| 带宽 | 低(但请求多) | 高,随分辨率与画面变化率上升 | 最低 |
| 对延迟的敏感度 | 极高,每个操作可能多次往返 | 中,输入一次往返即可看到反馈 | 低,本地可先响应再同步 |
| 断线重连 | 断了就没了 | 会话可留在服务端 | 可重连并补拉状态 |
| 典型场景 | SSH 里临时开一个 GUI 工具 | 远程桌面、无人值守机器 | 今天绝大多数应用 |
为什么 X11 转发在广域网上难用
- X11 的协议是请求-回复式的:应用要「查一下字体宽度」「问一下窗口属性」,每一次都是一个往返;
- 局域网 RTT 0.3 ms 时,几百次往返一共几十毫秒,无感;跨城 RTT 30 ms 时,同样的几百次往返就是十几秒——这就是「本地秒开、远程转圈」的成因;
- 这条规律可以推广:任何「每个动作一次往返」的协议,延迟成本 = RTT × 往返次数,而往返次数是协议设计决定的,不是带宽能补救的(这与 05 章 HTTP 的队头阻塞、以及 TCP 握手成本是同一个道理)。
选择的判据
- 只是偶尔开一个图形工具:
ssh -X最省事,局域网可用,广域网忍一忍; - 要长期使用一个远程桌面:RDP > VNC(RDP 传的是「绘图指令 + 位图缓存」的混合,带宽效率高得多,还支持剪贴板/音频/多显示器重定向);
- 是你自己在写的应用:别做前两种,传状态——界面跑在本地,网络只承载数据。这也是 Web 取代远程桌面成为主流的根本原因。
# 三种模型在命令行上的样子
# ① 传命令:远程程序连回你本地的显示服务器
$ ssh -X user@host # 需要服务端 X11Forwarding yes
$ xclock # 窗口出现在本地屏幕上
# 实际连的是本地:DISPLAY=localhost:10.0(SSH 建的隧道)
# ② 传像素:把整块屏幕编码后发过来
$ ssh -L 5901:localhost:5901 user@host # 先把 VNC 端口隧道到本地
$ vncviewer localhost:5901 # 再用本地客户端连隧道口
# ③ 传状态:只发数据,界面在浏览器里
$ curl https://api.example.com/orders # 几 KB JSON,界面本地渲染ssh -X 与 ssh -Y 的区别值得知道:-X 启用 X11 的安全限制(受信任程度低,某些程序会因此报错或功能受限),-Y 是信任模式,等于让远程程序拥有你本地 X 会话的完全权限——它能截你的屏、读你的按键。-X 用不了再考虑 -Y,并且只对自己完全信任的主机用。远程界面的体验可以量化。把一次「按键到看到反馈」的时间拆成几段,就知道该优化哪一段,也知道某个方案在你的网络条件下有没有可能好用。
一次输入反馈的时间构成
- ① 本地采集:键盘/鼠标事件到客户端程序,通常 1~5 ms;
- ② 上行传输:约 RTT/2;
- ③ 服务端处理与渲染:应用响应 + 抓帧 + 编码,10~30 ms 是常见量级;
- ④ 下行传输:约 RTT/2,画面数据大时还要加上串行化时间(数据量 ÷ 带宽);
- ⑤ 本地解码与显示:解码几毫秒,再加最多一帧的显示等待(60 Hz 下 16.7 ms)。
于是总延迟 ≈ RTT + 30~50 ms 的固定开销。同城 RTT 10 ms 时总计约 50 ms,勉强跟手;跨国 RTT 200 ms 时总计 250 ms 起,打字都会明显滞后。
人的感知阈值(通用参考)
- ~100 ms 以内:感觉「立即」;
- 100~300 ms:能察觉延迟但仍可操作;
- >300 ms:需要改变操作方式(先停下等反馈再动),拖拽与文字输入体验急剧劣化;
- 这三档解释了很多产品决策:为什么远程桌面要就近部署机房、为什么云游戏对网络挑剔、为什么本地优先(local-first)的应用体验总是更好。
三条能真正降低延迟的做法
- ① 减少往返次数:把「问一次答一次」改成批量提交(协议层面的改造,收益最大);
- ② 本地预测:先在本地画出反馈,再等服务端确认(游戏里叫客户端预测,编辑器里叫乐观更新)。这是把「延迟」变成「偶尔纠正」的经典交换;
- ③ 减少缓冲层数:每一层缓冲都以一帧为单位增加延迟。像素流方案里「画质优先」的预设通常也意味着更大的缓冲;
- 反过来,加带宽只对「数据量 ÷ 带宽」那一段有效——如果画面本来就压得很小,加带宽一点用都没有。
# 先量 RTT,再判断某个方案有没有可能好用
$ ping -c 5 host
rtt min/avg/max/mdev = 12.3/14.1/18.9/2.1 ms # 同城机房
# 估算:总延迟 ≈ RTT + 30~50ms 固定开销
# RTT 14 ms → 约 50~65 ms 跟手
# RTT 80 ms → 约 110~130 ms 能用,拖拽开始别扭
# RTT 200 ms → 约 230~250 ms 打字明显滞后,要靠本地预测救
# 抖动比平均值更影响体感(mdev 就是抖动)
# 平均 30 ms、抖动 2 ms → 稳定,可预期
# 平均 30 ms、抖动 40 ms → 一顿一顿,比稳定的 80 ms 还差「传状态」这条路上,浏览器有三种长连接选择。选错的代价通常不是不能用,而是在弱网、代理、断线场景下坏得很难查。
三选一
| WebSocket | SSE(Server-Sent Events) | WebRTC DataChannel | |
|---|---|---|---|
| 方向 | 双向 | 只有服务端 → 客户端 | 双向,可点对点 |
| 底层 | HTTP 升级后的独立协议 | 就是一个长的 HTTP 响应 | UDP(可选可靠/有序) |
| 自动重连 | 要自己写 | 浏览器内置(带 Last-Event-ID 续传) | 要自己写,且信令复杂 |
| 穿透代理 | 多数可以,个别企业代理会掐 | 最好(就是普通 HTTP) | 要 STUN/TURN,最麻烦 |
| 适合 | 协作编辑、聊天、双向控制 | 通知、进度、日志流、AI 流式输出 | 音视频、低延迟游戏、P2P |
长连接必须自己处理的四件事
- ① 心跳:中间的 NAT 与负载均衡器会在几十秒到几分钟无流量后悄悄断开连接。「连接看起来还在,发出去石沉大海」就是这么来的——定期发 ping,并在没收到 pong 时主动重连;
- ② 指数退避重连:服务端重启时如果所有客户端立刻重连,会形成惊群把服务再打挂一次。退避 + 随机抖动是标配;
- ③ 断线期间的状态补齐:重连后要能拿到「断线这段时间发生了什么」(序号 / 游标 / 版本号),否则界面会停留在过期状态且没人知道;
- ④ 背压:服务端推送快过客户端处理时,消息会在内存里堆积。要么丢弃中间态(只保留最新),要么让客户端确认后再发。
一条常被忽略的现实
- 大多数「实时」需求并不需要长连接:几秒一次的轮询实现简单、无状态、天然穿透一切代理,在几千并发以内往往更划算;
- 判据是更新频率与延迟要求:秒级要求且更新稀疏 → 轮询;亚秒级或更新密集 → 长连接;
- 先用轮询上线,扛不住再换,比一开始就上 WebSocket 再补心跳、重连、补齐、背压四件套要快得多。
// SSE:服务端只要按格式往一个长响应里写
Content-Type: text/event-stream
Cache-Control: no-cache
id: 42
event: progress
data: {"done": 30, "total": 100}
// 客户端三行,断线自动重连(并带上 Last-Event-ID)
const es = new EventSource("/api/stream");
es.addEventListener("progress", (e) => render(JSON.parse(e.data)));
// WebSocket 的心跳与退避重连(自己写,没有内置)
let delay = 1000;
function connect() {
const ws = new WebSocket(url);
ws.onopen = () => { delay = 1000; setInterval(() => ws.send('{"t":"ping"}'), 25000); };
ws.onclose = () => {
setTimeout(connect, delay + Math.random() * 500); // 抖动,避免惊群
delay = Math.min(delay * 2, 30000); // 指数退避
};
}readyState === OPEN 不代表对端还活着。TCP 连接在对端进程崩溃、机器断电、或中间设备静默丢弃后,本地仍然会显示连接正常,直到你发数据并等到超时(可能几分钟)。「界面不再更新但也不报错」的经典成因就是这个。唯一可靠的判据是应用层心跳:N 秒内没收到对端任何消息就当它死了,主动重连,别信 socket 的状态字段。微项目:把协议写出来
前面八章讲的机制,这一章逐个变成能编译、能被 curl 访问、能抓包验证的代码。六个微项目全部是单文件 C、零依赖:并发模型三连、手写 HTTP 服务器、消息边界、手写 DNS 查询、在 UDP 上重造可靠传输、端口转发。每个项目都附本机跑出来的数字,你的机器数值会有出入,但结论应当一致。
协议这门课有个特殊之处:它的每一条规则都能被你亲手违反一次,然后立刻看到后果。少发一个 Host 头就拿到 400,忘了长度前缀就分不清消息边界,超时设短一点重传就爆炸——这些在纸上是知识,在代码里是当场的报错。这一章把六件事写成能跑的程序。
六个项目
| 项目 | 回答的问题 | 对应章 | 验证方式 |
|---|---|---|---|
| ① echod | 一个进程怎么同时服务几千人 | 03 | 2000 条并发连接压一遍 |
| ② httpd | HTTP 报文到底长什么样 | 02 | curl 和浏览器直接访问 |
| ③ framing | 所谓「粘包」到底是什么 | 03 | 三次 write 一次 read 收全 |
| ④ dnsq | 域名怎么变成一个 UDP 包 | 02 | 直接问真实 DNS 服务器 |
| ⑤ rudp | TCP 的可靠性凭什么做到 | 03 | 人造丢包,量重传与耗时 |
| ⑥ tcpfwd | 两个方向的字节流怎么独立收尾 | 03 | 转发一次真实 HTTP 请求 |
为什么值得自己写一遍
- 协议的坑几乎全在「边界情况」上:连接半关了怎么办、消息只到一半怎么办、包丢了怎么办。用框架时这些都被替你处理了,自己写一次才会知道框架在替你挡什么。
- 六个项目彼此能串起来:② 的服务器可以用 ① 的 epoll 循环改造,⑥ 可以转发 ② 的流量,③ 的长度前缀是任何自定义协议的第一块砖。
- 每张卡的结构都是「目标 → 关键几十行 → → 现在你来」。中间那段代码是骨架不是答案。
三条通用纪律(六个项目都适用)
recv返回 0 只有一个含义:对端关闭了。不是「暂时没数据」——阻塞 socket 没数据时会一直等。把 0 当空数据继续循环,就是一个满速空转的死循环。send可能只发出去一部分(返回值是实际字节数)。要么自己循环补发,要么用sendall这类封装。这个 bug 在小数据量下永远不出现,一上生产就现形。- 先
signal(SIGPIPE, SIG_IGN)。往一条已被对端关闭的连接写数据,默认行为是整个进程被信号杀掉——排查这个「服务器无故消失」能耗掉一整天。
gcc -O2 -Wall -o x x.c 就能编,不装任何库。建议每个项目都开着 ss -tan 或 ss -tin 跑——本页 03 章讲的状态机和拥塞窗口变量,会在你自己的进程上实时变化,这种对照是任何教材给不了的。「服务器怎么同时服务很多人」这个问题,只有三种基本答案。最有说服力的学法是把同一个 echo 服务器写三遍,然后用 2000 条并发连接同时压它们——差别不是百分之几,是能不能用。
三种写法的核心差异只有几行
// ① 一次一个:最直白,也最没用 for (;;) { int c = accept(ln, 0, 0); serve(c); } // serve 里阻塞读写直到对端关 // ② 每连接一进程:能并发了,代价是每人一个进程 for (;;) { int c = accept(ln, 0, 0); if (fork() == 0) { close(ln); serve(c); _exit(0); } close(c); // 父进程必须关自己那份 fd } // ③ 单线程 epoll:一个循环照顾所有人 fcntl(ln, F_SETFL, O_NONBLOCK); int ep = epoll_create1(0); epoll_ctl(ep, EPOLL_CTL_ADD, ln, &ev); for (;;) { int n = epoll_wait(ep, evs, 64, -1); // 只把「有事的」fd 交回来 for (int i = 0; i < n; i++) { if (evs[i].data.fd == ln) { // 新连接:一次收干净 while ((c = accept4(ln, 0, 0, SOCK_NONBLOCK)) >= 0) epoll_ctl(ep, EPOLL_CTL_ADD, c, &...); } else { /* 老连接:读一次、回一次 */ } } }
2000 条并发连接压下去(本机 WSL2,4 核)
| 模型 | 结果 | 进程/线程 | 内存 |
|---|---|---|---|
| ① 一次一个 | 第 1 条连接就永远等不到回复 | 1 | — |
| ② 一连接一进程 | 2000 条全部回显,约 3.3 秒 | 一千多个进程 | RSS 合计约 870 MB |
| ③ 单线程 epoll | 2000 条全部回显,约 0.24 秒 | 1 个 | 约 1.9 MB |
- ① 的失败方式值得亲眼看一次:连接全都能建立(内核替你完成了握手、排在全连接队列里),握手成功不代表有人在服务你——这正是 03 章那条「accept 之前握手早已完成」的实践含义。第 0 条回显正常,第 1 条永远卡住,因为服务器还阻塞在第 0 条的
recv上。 - ② 和 ③ 差了十几倍时间、几百倍内存。注意 RSS 合计是有水分的:fork 出来的进程大量共享页面,这个数字重复计算了共享部分(07 章讲过 RSS 与 PSS 的区别),但「一千多个内核调度实体 vs 一个」这个差距是实打实的。
- ③ 的关键不是 epoll 这个 API,而是倒过来的控制流:不再是「我等这条连接」,而是「谁有事叫我」。这也是所有异步框架(Node、Netty、asyncio、Go 的 netpoller)底下的同一个循环。
现在你来
- 把 ③ 的
fcntl(ln, F_SETFL, O_NONBLOCK)删掉再跑——整个循环会卡死在 accept 上,所有已连上的客户端一起挨饿。(这是我写这份代码时踩的第一个坑,症状是「连接数到齐了但一条数据都不回」。) - 把 ③ 改成 边缘触发(EPOLLET):必须循环读到 EAGAIN 为止,否则剩下的数据再也不会通知你。这个差别值得亲手撞一次。
- 加上「每连接一线程」的第四种写法对比:比进程省一点内存,但和 ③ 依然不是一个量级。C10K 问题的答案从来不是「换更快的机器」。
EPOLL_CTL_DEL 再 close,并且不要在一轮循环里对同一个 fd 处理两次。这类 bug 的症状是「偶尔有条连接收到别人的数据」,极难复现。ulimit -n(本机默认 10240),不然你会以为是服务器崩了。HTTP 是一个文本协议,这是它统治世界的原因之一:不用任何库,你就能手写一个浏览器认得的服务器。目标是让 curl 拿到 200、404、400,并且复用同一条连接。
关键在「读到哪算读完了」
// 头部以空行结束——可能要读好几次才凑齐 while (!(end = memmem(buf, used, "\r\n\r\n", 4))) { ssize_t r = recv(c, buf + used, sizeof buf - used - 1, 0); if (r <= 0) return; // 0 = 对端关了 used += r; } sscanf(buf, "%15s %255s %15s", method, path, ver); // 请求行三段 int keep = !strstr(buf, "Connection: close"); // 1.1 默认长连接 // 应答:状态行 + 头部 + 空行 + 正文 snprintf(h, sizeof h, "HTTP/1.1 %d %s\r\n" "Content-Type: %s\r\n" "Content-Length: %zu\r\n" // 少了它,客户端只能等连接关闭才知道读完 "Connection: %s\r\n" "\r\n", code, msg, ct, len, keep ? "keep-alive" : "close"); send(c, h, n, MSG_MORE); // MSG_MORE:先别发,和正文拼一个包 send(c, body, len, 0); memmove(buf, buf + (end - buf + 4), used - hl); // 把下一个请求的残留挪到开头
- 最后那行 memmove 是长连接的全部秘密:一次 recv 可能已经把第二个请求的头几个字节捎带过来了(③ 那张卡讲的就是这件事)。丢掉这段残留,第二个请求就会莫名其妙解析失败。
Content-Length是接收方判断「正文读完了没有」的唯一依据(除非用 chunked)。它和 ③ 的长度前缀是同一个思想在应用层的化身。
用 curl 验收,四条一次跑完
$ curl -s -D- http://127.0.0.1:8080/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 25
Connection: keep-alive
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/nope
404
$ curl -s -o /dev/null -w 'connects=%{num_connects} ' \
http://127.0.0.1:8080/ http://127.0.0.1:8080/a.txt
connects=1 connects=0 ← 第二个请求没有新建连接,长连接生效了
$ printf 'GET / HTTP/1.1\r\n\r\n' | nc 127.0.0.1 8080
HTTP/1.1 400 Bad Request ← 没有 Host 头,HTTP/1.1 必须拒绝num_connects=0是长连接最干净的证据——第二个请求走的是同一条 TCP 连接,省掉了一次握手(本页 02 章讲连接复用价值时的具体数字来源)。- Host 头的强制要求是虚拟主机的地基:同一个 IP 上住着几百个网站,只有 Host 能区分。自己写服务器时才会意识到,HTTP/1.0 到 1.1 最重要的改动之一就是这个必填字段(02 章那一卡)。
现在你来
- 加 chunked 传输编码:不知道总长度时怎么办(每块前面写十六进制长度,最后一个 0 块结束)。写完你就明白流式响应、SSE、以及为什么
Content-Length和Transfer-Encoding不能同时来。 - 加 HEAD 与 If-Modified-Since:条件请求 + 304,这是缓存的入口。
- 把 ① 的 epoll 循环装进来,让它单线程扛住并发;再用浏览器打开,看 devtools 的 Network 面板认不认你的响应头。
GET /../../etc/passwd,必须显式拒绝含 .. 的路径,这是最古老也最常被复现的漏洞;② 行尾必须是 CRLF——curl 对只用 \n 的响应头照收不误(正因为对端宽容,这个错能一路潜伏到换客户端、过代理时才炸),而规范要求的就是 CRLF,别赌宽容;③ 正文长度要和 Content-Length 严格一致,多一个字节客户端会把它当成下一个响应的开头,少一个字节客户端会一直等——写代码时让正文和长度来自同一个表达式,别手写数字(我第一版就是手写的,中文正文的字节数当场算错)。curl -v 和 nc:nc 让你逐字节地手写请求,故意漏掉某个头、故意写错换行、故意只发一半,看服务器怎么反应。协议实现的健壮性就是这么练出来的。「粘包」是初学网络编程时最常听到、也最被误解的词。真相很简单:TCP 从来没有承诺过消息边界,它只承诺字节顺序。这不是 bug,是设计。二十行代码就能让你彻底不再困惑。
先亲眼看见它
发送方:write("AAA"); write("BBB"); write("CCC"); // 三次调用,共 9 字节
接收方:慢半拍再读一次
发送方 write 了三次(3+3+3=9 字节),接收方一次 read 拿到 9 字节:"AAABBBCCC"
第二次 read 拿到 3 字节:"DDD" ← 同样是 write 一次,这回没粘上- 同一段代码,两种结果——这才是问题的本质:粘不粘取决于时序、Nagle 算法、MTU、接收缓冲区,全部不由你控制。所以「怎么防止粘包」是个伪问题,正确的问题是「我的协议怎么划分消息」。
- 顺带反过来也成立:一次 write 的数据可能分成几次才读到(拆包)。凡是假设「write 一次 = read 一次」的代码,都只是还没被流量打到而已。
解法只有三种,第一种最常用
// 长度前缀:4 字节网络序长度 + 载荷 unsigned len = htonl(strlen(msg)); write(s, &len, 4); write(s, msg, strlen(msg)); // 接收方:先读满 4 字节,再按长度读满载荷 static void must_read(int fd, void *p, size_t n) { // 读满 n 字节才回来 size_t got = 0; while (got < n) { ssize_t r = read(fd, (char *)p + got, n - got); if (r <= 0) return; // 对端关了或出错 got += r; } } must_read(c, &len, 4); len = ntohl(len); must_read(c, buf, len);
- 三种划分办法:长度前缀(最通用,HTTP/2、gRPC、Redis 的批量回复都是)、分隔符(HTTP 头部的空行、Redis 命令的 CRLF——代价是载荷里出现分隔符要转义)、定长(简单但浪费)。
must_read这个「读满为止」的循环,是所有网络代码里复制粘贴次数最多的十行。任何语言的网络库里都有它的等价物(Go 的io.ReadFull、Python 的recv_into循环)。- 本机跑出来:三条消息 hi / hello world / x 被准确地一条条取回——边界是你自己造出来的,不是 TCP 给的。
现在你来
- 给长度前缀加上限检查:如果对方发来
len = 0xFFFFFFFF,你的malloc(len)会怎样?这是真实世界里最常见的一类拒绝服务漏洞,一行if (len > MAX)就能挡住。 - 换成分隔符方案实现一个迷你 Redis 协议解析器(RESP),体会转义的麻烦。
- 用
setsockopt(TCP_NODELAY)关掉 Nagle 再跑一次:小消息更不容易粘在一起了——但这只是改变了概率,没有改变契约,代码该有的must_read一行都不能省。
htonl/ntohl)。在同架构的两台机器之间不转换也「能跑」,等到对面换成别的架构或者用别的语言实现时才会炸——而那通常已经是上线之后。同理别直接把 struct 发出去:结构体的填充与对齐由编译器决定,两端编译器不同就对不上。DNS 是最适合手写的二进制协议:一个 UDP 包发出去,一个包回来,全部逻辑一百行以内。写完之后 02 章那张 DNS 卡上的每个字段,你都能在字节流里指出来。
请求包:12 字节头部 + 问题段
memset(q, 0, 12); q[0] = id >> 8; q[1] = id & 0xFF; // 事务 ID,应答必须原样带回 q[2] = 0x01; // RD=1:请你替我递归查 q[5] = 0x01; // QDCOUNT = 1,一个问题 n = 12 + encode_name(q + 12, host); // 域名编码 q[n++] = 0; q[n++] = 1; // QTYPE = A q[n++] = 0; q[n++] = 1; // QCLASS = IN // 域名编码:点不见了,换成长度字节 static int encode_name(unsigned char *out, const char *host) { while (*p) { const char *dot = strchr(p, '.'); int len = dot ? dot - p : (int)strlen(p); out[n++] = len; memcpy(out + n, p, len); n += len; p = dot ? dot + 1 : p + len; } out[n++] = 0; // 根标签,长度 0 结尾 }
$ ./dnsq example.com
查询包 29 字节,问题段编码:07 6578616d706c65 03 636f6d 00 0001 0001
↑7 e x a m p l e ↑3 c o m ↑根
应答 45 字节:ID=0x1234 QR=1 RCODE=0 问题 1 条,回答 1 条
记录1: TYPE=1 TTL=... RDLENGTH=4 名字用了压缩指针(0xC0) → A 一个 IPv4 地址解析应答时唯一的难点:压缩指针
- 应答里的记录要重复一遍域名,DNS 的做法是:如果某字节高两位是 11(即 0xC0),那它和下一字节合起来是一个「跳到报文某偏移处继续读」的指针。省下的字节在当年 512 字节包大小限制下至关重要。
- 所以跳过一个名字的代码必须处理两种形态:
while (m[off]) { if ((m[off] & 0xC0) == 0xC0) return off + 2; // 指针,两字节到底 off += m[off] + 1; // 普通标签,跳过长度+内容 } return off + 1;
- 这个设计也是一类经典漏洞的来源:指针可以指向任意偏移,包括指回自己——恶意应答能让朴素的解析器无限循环。真实解析器必须限制跳转次数或只允许向前跳。自己写一次,就永远记得加这个限制。
本机的意外收获:解析器在替你编答案
- 本机(WSL2 的 NAT 模式)跑出来的结果很奇怪:查
example.com返回198.18.1.99,而查一个根本不存在的域名,也返回 RCODE=0 加一个 198.18.x.x 地址——不是 NXDOMAIN。 - 用系统工具交叉验证
getent hosts example.com,得到的是同一个地址:说明程序没写错,是这台机器的解析器如此(WSL 的 DNS 代理把所有查询映射到自己的地址段)。 - 这恰好演示了 02 章 DNS 卡里那条最重要的警告:你拿到的答案取决于你问的是谁。运营商劫持 NXDOMAIN 塞广告页、企业网关做内部解析、公共 DNS 与本地 DNS 给出不同结果——把
./dnsq 域名 8.8.8.8和./dnsq 域名 你的本地DNS的输出并排一比,胜过一切论证。
现在你来
- 支持 CNAME / MX / AAAA:只是换 QTYPE 和解析 RDATA 的方式,但你会撞见「一个查询返回一串记录、前面是 CNAME 后面才是 A」的真实链条。
- 加超时重试与 TCP 回退:应答太大(TC 位置 1)时必须改用 TCP 重问一次,这是 DNS 唯一跨传输层的机制。
- 把它写成一个迷你 DNS 服务器:收到查询就回一个固定 IP。二十行,然后把
/etc/resolv.conf指向它——你就有了一个可以随意做实验的解析器。
dig +norecurse、dig @8.8.8.8 example.com 能把同样的字段打印成人话;但手写一遍的收益在于「字节位置」而不是「字段含义」——知道 TTL 在第几个字节,和知道 TTL 是什么,是两种不同的理解。这是全页最值得做的一个项目:UDP 只给你「尽力而为的数据报」,你要在上面造出「可靠有序的字节流」。做完之后 03 章的每一个设计决定,你都会有切肤的理解——因为每一条都是你自己被迫加上去的。
发送方:滑动窗口 + 超时整窗重传(Go-Back-N)
while (base < NPKT) { while (next < base + win && next < NPKT) { // 窗口没满就继续发 pkt_t p = { .seq = next }; sendto(s, &p, sizeof p, 0, ...); next++; } if (recv(s, &ack, 4, 0) == 4 && ack > base) { // 累积确认推着窗口往前滑 base = ack; timer = now(); } else if (now() - timer > RTO) { // 超时:整窗重发 for (unsigned i = base; i < next; i++) sendto(s, ...); timer = now(); } }
- 接收方更简单,只收按序的包,乱序一律丢弃,然后回一个「我期待的下一个序号」——这就是累积确认。一个 ACK 丢了不要紧,后面的 ACK 会把窗口一次推更远。
- 四个机制缺一不可:序号(认出重复和乱序)、确认(知道对方收到了)、超时(知道该重发了)、窗口(不必等一个确认再发下一个)。少任何一个,可靠性就不成立。
人造 20% 丢包,跑 64 个包(本机数字,趋势才是重点)
| 配置 | 实际发出 | 其中重传 | 耗时 |
|---|---|---|---|
| 窗口 1,丢包 0% | 64 | 0 | 5 ms |
| 窗口 1,丢包 20% | 97 | 33 | 约 1.6 秒 |
| 窗口 8,丢包 20% | 213 | 149 | 约 1.0 秒 |
| 窗口 16,丢包 20% | 304 | 240 | 约 0.8 秒 |
- 第一行到第二行是这张表最震撼的地方:丢 20% 的包,耗时涨了三百倍,不是涨 20%。 因为停等协议每丢一个包就要干等一个超时(本例 RTO=40 ms),而正常一轮往返只要零点几毫秒。这就是「丢包对吞吐的伤害远超丢包率本身」的机制解释,也是 03 章拥塞控制那一卡里 CUBIC 在跨国链路上雪崩的原因。
- 窗口从 1 加到 16,耗时腰斩,但重传次数涨了七倍多——Go-Back-N 一超时就把整窗重发,窗口越大浪费越狠。这正是选择性重传(SACK)存在的全部理由:只重发真丢的那个。TCP 从 1980 年代的 Go-Back-N 走到今天的 SACK+RACK,就是这张表在推动。
- 把 RTO 从 40 ms 改成 10 ms 再跑:快了,但会开始出现不必要的重传(包还在路上就当它丢了)。超时定多长是个没有完美答案的问题——所以真 TCP 要动态估计 RTT 并留出余量(RTO = SRTT + 4×RTTVAR),而不是写死一个数。
现在你来
- 实现选择性重传:接收方缓存乱序包并回报「我还缺哪些」,发送方只补缺口。跑同样的 20% 丢包,看重传次数掉到多少——这一步做完,你就真的理解 SACK 了。
- 加动态 RTO:按
SRTT = 7/8·SRTT + 1/8·sample估计往返时间,观察它在丢包时怎么被「重传歧义」搞乱(重传包的 ACK 该算给哪一次?答案是 Karn 算法:重传的样本不采纳)。 - 加拥塞控制:慢启动 + 拥塞避免 + 快速重传。窗口不再由你写死,而是随网络状况呼吸——到这一步,你手上就是一个小号 TCP 了。
tc qdisc add dev lo root netem loss 20% delay 10ms 能给回环接口注入丢包和延迟——把你的程序和真 TCP 放在同一条烂链路上对比,是这个项目最好的收尾。端口转发只有一件事要做:把 A 的字节搬给 B,把 B 的字节搬给 A。听上去二十行就能写完,但它是理解「一条 TCP 连接其实是两条独立方向的流」最好的道具——而绝大多数人的第一版都会在收尾时出错。
核心循环:两个方向各自收尾
struct pollfd p[2] = { { c, POLLIN, 0 }, { u, POLLIN, 0 } }; int open_dirs = 2; while (open_dirs > 0 && poll(p, 2, 10000) > 0) { for (int i = 0; i < 2; i++) { if (!(p[i].revents & (POLLIN | POLLHUP))) continue; int from = p[i].fd, to = p[!i].fd; ssize_t r = recv(from, buf, sizeof buf, 0); if (r > 0) { for (ssize_t off = 0; off < r; ) { // send 可能只发一部分 ssize_t w = send(to, buf + off, r - off, 0); if (w <= 0) { open_dirs = 0; break; } off += w; } } else { shutdown(to, SHUT_WR); // 关键:把 EOF 转达过去,而不是 close 整条连接 p[i].events = 0; // 这个方向不再关心 open_dirs--; } } }
- 那一行
shutdown(to, SHUT_WR)就是这个项目的全部价值。 客户端发完请求后关掉自己的写端,意思是「我说完了,但还在听」——nc -N就是这么做的(strace 里能看到它调shutdown(3, SHUT_WR);顺带一提curl不这么做,它连 POST 完 body 都不调 shutdown,靠 Content-Length 划边界)。转发器如果此时close整条上游连接,服务器就再也没机会把响应送回来——症状是「用了代理之后大文件下载被截断 / 某些请求收不到响应」。 - 正确的做法是让 EOF 单向传递:只关上游的写端,上游读到 EOF 后处理完请求、写回响应、再关它自己的写端;下行方向这才收尾。两个方向各自独立结束,连接才算干净关闭。
验收:转发一次真实 HTTP 请求
$ ./tcpfwd 8086 127.0.0.1 8085 # 8086 转发到本机的 httpd $ curl -s -D- http://127.0.0.1:8086/ HTTP/1.1 200 OK ... ← 一字不差地穿过去了 $ printf 'GET /a.txt HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n' \ | nc -N 127.0.0.1 8086 # -N:发完就 shutdown 写端 hello from a.txt ← 半关闭之后仍然收到了响应 [转发结束] 上行 51 字节,下行 116 字节
nc -N那一条是专门用来验半关闭的:去掉-N,nc 会一直握着写端不放,你就测不出这个 bug;把代码里的shutdown换成close,这条命令会拿不到响应——两个方向各改一次,这个机制就再也忘不掉了。- 这一百行还是很多实用工具的原型:SSH 的
-L本地转发、kubectl port-forward、各种代理的核心循环,本质上都是这个双向中继加上一层握手协商。
现在你来
- 加 SOCKS5 握手(几十行的协商:版本、认证方式、目标地址),你的转发器就变成了一个能给浏览器用的通用代理。
- 用
splice()替换 read/write:数据在内核里直接从一个 socket 搬到另一个,不经过用户态——零拷贝的最小实例,也是 OS 页 I/O 那一章的实战入口。 - 加超时与连接数上限:转发器最容易被打死的方式就是半开连接堆积(对方连上就不说话)。这也是所有网关都要有 idle timeout 的原因。
close 和 shutdown 的区别是这个项目的唯一考点,也是网络编程里被讲错最多的一对:close 释放整个文件描述符、两个方向一起完;shutdown(SHUT_WR) 只发一个 FIN,读方向照常。 另外 shutdown 影响的是底层连接,如果这个 fd 被 fork 或 dup 复制过,所有副本一起受影响——而 close 只是少一个引用,最后一个关掉时才真正发 FIN。从这里到精通:路线图
协议的地图铺完了,剩下的路要一个协议一个协议亲手走。最后这一章给出收尾路线:难度递进的动手项目、按阶段的读物,以及一条自测标准。
这一页给了你地图,精通只能来自把每个协议都亲手摸过。按顺序走,每一步都有明确的产出物——其中动手写代码那几步已经在 09 章展开成六个微项目。
动手项目(难度递进)
- ① 把抓包变成习惯:每学一个协议就抓一次真实流量(流程见上一章的抓包卡)。两周后你会发现自己开始用「包」而不是「玄学」思考网络问题。
- ② 写 socket 程序三部曲(09 章已把这三步写成可运行代码):TCP echo → epoll/select 多路复用聊天室 → 能被浏览器访问的简易 HTTP 服务器(解析请求行和 Host 头、返回文件)。浏览器里看到自己服务器吐出的页面,是这门课最大的正反馈时刻。
- ③ 搭自己的网络实验场:Linux 的 network namespace + veth 几条命令就能搭出多「主机」拓扑,跑路由、玩 NAT、故意制造 MTU 故障(tc 还能注入延迟丢包模拟弱网);或者给家里刷 OpenWrt 玩 VLAN 与 SQM——内核就是免费的无限实验室。
- ④ 终极练习(09 章第 ⑤ 个微项目给了骨架与丢包):在 UDP 上实现一个玩具可靠传输——序号、ACK、重传、滑动窗口全部自己造一遍。等于亲手重写 TCP 的核心,做完后第 03 章的每一个设计决定你都会有切肤的理解。
书与资料(按阶段)
- 主线教材:Kurose & Ross《计算机网络:自顶向下方法》——本页的章节弧线与它同构,读它等于把本页展开十倍。
- 深入 TCP/IP:《TCP/IP 详解 卷1》(W. Richard Stevens)——配着抓包读,三十年不过时;编程配 Beej's Guide(免费)与《UNIX 网络编程》。
- 一手资料:RFC 比传说中好读——从 RFC 9293(TCP 重整版)、RFC 9000(QUIC)读起,你会惊讶标准文档里全是设计理由。
- 保持更新:Cloudflare / Netflix / APNIC 的技术博客是真实工程案例的富矿——BGP 劫持复盘、QUIC 部署数据、拥塞算法之争都在那里直播。
ss -tin 的输出里认出一条连接卡在哪;背得出 CUBIC,不等于能判断眼前这次卡顿是丢包还是缓冲区堆积。上面每一条自测题都要求你先说出预期、再跑命令对答案——顺序反了就只是在给已知结果找解释。