计算机网络 从入门到精通的交互式讲解

全景:网络是怎么通的

在钻进任何协议细节之前,先建立一张完整的地图:网络到底在解决什么难题、为什么要分层、一个 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 头与载荷端到端不变

层是概念模型,不是实现边界:内核里 TCP/IP 一体实现,TLS 横跨会话/表示/应用哪层都说不清,NAT 身在网络层却公然翻看传输层端口。拿分层当理解工具,别拿它当代码结构的判据。
分层最实用的打开方式是排障口诀,自下而上逐层二分: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 时容易陷进状态机,学路由时容易迷失在协议细节。每当那样,回到这趟旅程问一句:「我现在学的机制在哪一站?没有它,这趟旅程会坏在哪里?」——网络的所有设计都是为了让这趟旅程更快、更稳、更安全。
第二次访问快得多,不是网络变快了:DNS 有缓存、TCP/TLS 连接被复用、资源命中 HTTP 缓存——①②③甚至④整段被跳过。测性能必须区分冷启动与热路径:按着刷新键测出来的,从来不是首次访问的体验。
这趟旅程的四段耗时可以逐段量出来(怎么量见 01 章),量出来的比例往往出人意料——一次冷启动里,DNS 与 TLS 握手常常比传输本身还久。

上手:五条命令把一次访问拆开

网络对新手最大的障碍是「看不见摸不着」。这一章不讲协议,只做一件事:用五条零门槛命令,把上一章那趟旅程的每一站变成屏幕上的数字。之后每一章讲的机制,你都已经先见过它的读数。

「网页慢」是个没法直接回答的问题——先问清慢在哪一段、连接此刻是什么状态。三条命令就够: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 被封导致的。
<三个实验都只说明「这一层是通的/不通的」,别拿一层的结论给另一层定罪:ping 不通不代表 TCP 连不上(ICMP 常被单独封),curl 慢不代表带宽不够(可能全花在 DNS 上)。另外 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。分层的本质就是各层认各自的地址。

最费时间的弯路是按 OSI 七层「逐层学透再动手」——会话层、表示层在真实协议栈里没有独立实现,越抠越虚。现实是 TCP/IP 四层加亲手抓包。
迷路时回到上手章的 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(下一张卡)。「默认无状态、按需加状态」比反过来容易得多——这是协议设计送给分布式系统的一课。
状态行里的「OK」只是给人看的注释,机器只认数字——HTTP/2 干脆取消了原因短语,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 里,服务端无状态可随意扩容,但签发后没法主动作废)——没有免费的方案,只有不同的代价。
「自动携带」是把双刃剑:恶意网站诱导你的浏览器向银行站点发请求,Cookie 照带不误——这就是 CSRF 攻击。SameSite 属性(现代浏览器默认 Lax)+ CSRF token 就是补这个洞的。理解了「Cookie 跟域名走、不跟页面走」,这类漏洞就不神秘了。
用 curl 手动重演一次协商缓存:第一次 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.0HTTP/1.1HTTP/2HTTP/3
底层TCPTCPTCPQUIC over UDP
连接复用否(一请求一连接)(keep-alive)
并发请求只能多开连接排队(浏览器开 6 条)一条连上多流交错一条连上多流交错
应用层队头阻塞
传输层队头阻塞仍有(TCP 有序承诺)(流级独立可靠)
头部文本文本二进制 + HPACK 压缩二进制 + QPACK
首次连接 RTTTCP 1 + TLS 2TCP 1 + TLS 1~2TCP 1 + TLS 1合并为 1(重连 0)
  • 竖着看这张表,主线一眼可见:每一代都在削减「等待」——先复用连接,再复用请求,最后连握手都合并掉
误区「升级 HTTP/2 要改代码」——h2 只换传输格式,方法/状态码/头部的语义一字未动,应用代码基本无感;版本是在 TLS 握手的 ALPN 扩展里协商的(curl -v 可见 ALPN: server accepted h2)。同一站点:明文 http:// 走的还是 1.1,浏览器根本不支持不加密的 h2。
DevTools Network 面板加上 Protocol 列,能看到每个请求用的 h2/h3;编译启用了 HTTP/3 的 curl 可用 --http3 直接体验(不少发行版自带的构建未启用,会直接报不支持)。今天你刷的视频、逛的大站多半已经跑在 UDP 上的 h3 了——「UDP 不可靠所以没人拿它干正事」是过时十年的印象。

明文 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,并把所有已知不安全的算法组合清出了菜单。

深入理解:没有身份认证,加密一文不值

  • 中间人可以分别和你、和服务器各建一条「完美加密」的连接,两头转发——你和服务器都觉得加密了,他全程明文看戏。加密必须配上「这把公钥确实属于这个域名」的证明,这就是证书的使命(下一张卡)。
  • 由此理解浏览器为什么对自签名证书如临大敌:点「继续访问」等于亲手关掉身份认证,把中间人请进来。
「上了 HTTPS 运营商就什么都看不见」想多了:目标 IP 永远明文,ClientHello 里的 SNI(域名)默认也是明文,DNS 查询多半还是明文——你访问了哪个网站依然公开,加密的是站内路径、参数与内容。ECH(加密 ClientHello)加上加密 DNS 正在补这个洞,但要浏览器、解析器、网站三方配合才生效。
读懂 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 就防不了谁」。
锁图标误区:证书只证明「你连上的确实是地址栏里这个域名」,不证明这个域名善良——钓鱼网站也能给自己的域名申请到完全合法的免费 DV 证书。「有 HTTPS = 网站可信」是危险等式:锁验的是管道,不是管道对面的人。
亲眼看一次:浏览器点地址栏锁图标能看到完整证书链;命令行 openssl s_client -connect example.com:443 会把链和握手细节全部打出来。看的时候对照上面的结构图指认三处:链有几级、每一级的签发者是谁、叶子证书的域名和有效期。

全球几亿个域名、每天万亿次查询,却没有任何中心数据库。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 的标准前戏。
应用解析域名不是只问 DNS:Linux 上会先过 /etc/hosts 和 NSS 配置(getent hosts 走的就是应用的真实路径),而 dig/nslookup 绕过这一切直连 DNS 服务器。「dig 结果正常但应用解析不对」时先查 hosts 文件——两个工具看到的可以不是同一个世界。
用 dig 亲手做一次:dig www.example.com 看答案区和 TTL;dig +trace www.example.com 完整重演「根→TLD→权威」的指路全程。读输出时留意:前几段只回 NS 记录(指路,这就是迭代),最后一段才回 A 记录(答案)——递归与迭代的分界线就在眼前。

传统 DNS 是明文 UDP、且不验证应答者身份——谁能看见你的查询、谁能抢先塞回一个应答,谁就能决定「你以为的网站在哪」。

攻击的三种形态

  • 运营商劫持:把不存在域名的 NXDOMAIN 应答改成自家广告页;或强行「加速」热门域名到自己的缓存。你输错网址却看到搜索推荐页,多半是它。
  • 抢答污染:UDP 无连接、先到先得——在查询路径上监听并抢先注入伪造应答即可让域名「解析」到任意地址。这是大范围网络审查在 DNS 层的技术实现之一。
  • 缓存投毒:骗过递归解析器让它缓存假记录,之后所有用户被打包带偏。2008 年 Kaminsky 攻击演示了这在当时有多容易,迫使全网紧急升级随机化防御(随机源端口 + 随机查询 ID,把猜中的难度提高几十亿倍)。

防御的演进

  • DNSSEC:给记录加数字签名——能验真伪,但不加密(看得见你查什么)、部署率长期低迷。
  • DoT/DoH:把 DNS 塞进 TLS/HTTPS。DoH 混在 443 端口的普通 HTTPS 流量里,劫持和区分都变难——浏览器和主流操作系统都已内置开关。代价是运营商/企业失去 DNS 层的可见性,这场拉锯还在进行。
误区「换成 8.8.8.8 就躲开了劫持」:明文 53 端口的查询在整条路径上仍然可见、可抢答——换解析器只是换了「问谁」,没换「怎么问」,路径上的注入者照样比真答案先到。真正封住抢答的是 DoT/DoH 把问答装进加密信道。
排障常识:能 ping 通 IP 但域名打不开 → 先怀疑解析。dig/nslookup 看结果对不对,再换 8.8.8.8 或 1.1.1.1 对比运营商解析器——「是不是 DNS 的锅」是网络排障的第一个二分点。运维圈的老梗「It's always 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:真双向。只有「客户端也要高频上行」(协作编辑、游戏、聊天输入状态)才真正需要它。
WebSocket 绕开了 HTTP 的成熟基建,成本在协议之外:负载均衡要支持长连接、断线重连后的消息补发要自己设计(心跳 + 序号 + 服务端缓存)、无状态扩容的优势没了。「连上了」只是起点,可靠的实时系统难在连接断了以后。
亲眼看升级握手:DevTools Network 面板筛选 WS,点开连接,Headers 里是 101 响应、Messages 标签里双向帧实时滚动。容易误解的一点: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 那张「电话簿」被拿来做了全球流量调度器。
「接上 CDN 网站就快了」不一定:CDN 只对可缓存的响应有效,动态接口、带 Set-CookieCache-Control: private/no-store 的响应通常直接回源。大站响应头里常见 cf-cache-status/x-cache 标着 HIT 还是 MISS——优化 CDN 先从命中率查起,而不是先换厂商。
面向延迟的架构直觉一句话:动态请求靠近计算(多机房 + 就近接入),静态内容靠近用户(CDN),实在避不开的远距离就把 RTT 个数砍到底(连接复用 + TLS1.3 + QUIC 0-RTT)。

传输层: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 是刻意的例外:允许多进程共同监听、由内核做负载均衡——高性能服务器的标准姿势)。
「一台服务器最多 65535 条连接」是流传最广的误算:65535 是端口空间不是连接空间,连接靠四元组整体区分,服务器单个端口的并发上限来自文件描述符和内存。真正撞端口墙的是客户端:对同一个目的 IP:端口 发起连接时每条要占一个本地临时端口,默认约 2.8 万条封顶——单机压测工具打不出更高并发,十有八九是撞了这堵墙而不是服务器扛不住。
亲眼看临时端口从哪来: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 三者对照

TCPUDPQUIC
头部开销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 服务时「响应不得远大于请求」已是安全共识。
「UDP 比 TCP 快」是被压缩过头的结论:同一条路径上包的传播时延一模一样,UDP 省掉的是握手 RTT、重传等待和保序阻塞,不是「传得更快」。而且省掉的每一项都是欠账——应用若需要可靠有序就得自己补回来,补完开销也回来了:QUIC 在 UDP 上重建可靠传输后,单包 CPU 开销反而高于内核 TCP。
亲手钉死「无连接」三个字:向一个根本没人监听的端口 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。
  • 窗口开多大?受两道闸门约束:接收方的处理能力(流量控制)和网络的承载能力(拥塞控制)——后面各有一张卡。
「面向字节流」的实战含义:send() 三次可能被对面一次 recv() 全收走,也可能一次 send 分两次到达——TCP 只保证字节顺序,不保留消息边界。所谓「粘包问题」不是 TCP 的 bug,是把字节流当消息流用的误解;解法永远是应用层自定边界(长度前缀 / 分隔符 / 固定长度)。
这套机制全程可观测: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),解码验证、重建状态。「把状态塞进对方必然回显的字段里」——无状态防御的经典手筋,值得记进工程武器库。
连接失败有两张完全不同的脸,别混:「Connection refused」= 收到了 RST,端口上没人监听,内核立刻回绝(loopback 一试便知);「timed out」= SYN 石沉大海——防火墙静默丢包,或 accept 队列满时 Linux 默认直接丢弃新 SYN 让客户端重试。把超时当「网络慢」一律重试,会把队列打得更满。两者的时间差极其悬殊:连 127.0.0.1:9(无人监听)0 ms 就返回 Could not connect——因为内核立刻回了 RST;连一个不可达地址 10.255.255.1:81干等 4020 ms 才超时。「立刻失败」和「等很久才失败」是两种完全不同的病:前者说明包到了、对面明确拒绝(查端口/进程),后者说明包石沉大海(查防火墙/路由/安全组)。看错方向能白查一小时。
用 strace 亲眼看握手归属: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」,意思是对面在一条你以为还活着的连接上掀了桌——去查对面进程和中间设备,而不是自己的代码。
网上常见「调小 tcp_fin_timeout 缩短 TIME_WAIT」——张冠李戴:该参数管的是孤儿 FIN_WAIT_2(对面迟迟不发 FIN 时本方等多久),跟 TIME_WAIT 无关;后者的 60 秒在 Linux 是编译期常量,sysctl 调不动。两个状态都在「挥手的后半场」,名字又都带 timeout 味,被搞混二十年了。
亲眼看罚站计时:关掉一条连接后 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 章再用)
网上老文章常推荐 tcp_tw_recycle「优化 TIME_WAIT」——它是著名毒药:NAT 环境下多用户共享出口 IP,时间戳校验错乱导致连接随机失败,Linux 4.12 已把该参数整个删除。教训通用:内核参数先懂 why 再动手,搜到的调优秘籍先查它针对的年代。
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 字节探测包逼对方重报窗口——协议设计的通用戒律:任何「等对方通知」的环节,都要考虑通知丢了怎么办
课本说「窗口字段 16 位、最大 64KB」——那是 1981 年的尺寸。现代 TCP 在握手时协商窗口缩放因子(RFC 7323),实际窗口 = 字段值 × 2^scale:ss -ti 里的 wscale:9,9 表示双方都放大 512 倍。读抓包时看到小小的 win= 数字就断言「窗口才几 KB」,忘了乘因子,是分析 TCP 流量最常见的误判。
流控 vs 拥塞控制一句话分清:流控保护接收方(端的问题,接收方指挥),拥塞控制保护网络(路的问题,发送方自律)。实际发送窗口 = min(rwnd, cwnd)——两道闸门,谁小听谁的。

网络路径是所有人共享的。没人管的话:拥塞→丢包→重传→更拥塞——1986 年互联网真的这样崩过(LBL 到伯克利的吞吐跌了近千倍)。拥塞控制是每个 TCP 端点自愿遵守的交通法,由 Van Jacobson 在事故后写进 TCP。

从零推导整套算法

  • 难题:网络能承载多少(瓶颈带宽)没人告诉你,而且随时在变(别的流加入退出)。唯一可观测的信号:丢包(路由器队列溢出)≈「路堵了」。
  • ① 起步不知深浅 → 从小窗口开始试探,每个 RTT 翻倍——这就是慢启动。名字叫慢,实为指数增长;「慢」是相对当年「一上来灌满整个接收窗口」的莽撞而言。
  • ② 逼近上次出事的水位(ssthresh)→ 收起倍增,改为每 RTT +1 小心逼近——拥塞避免
  • ③ 真出事了 → 砍半退让。两种严重程度两种反应:超时(连 ACK 都收不到,网络可能瘫了)→ cwnd 归 1 重新慢启动;3 个重复 ACK(还有包在到达,只是丢了个别)→ ssthresh=cwnd/2,从砍半处直接进拥塞避免(快恢复)。

AIMD 为什么偏偏是「加性增、乘性减」

  • 数学结论:多条流共享瓶颈时,「+1 / ×0.5」的组合会收敛到公平分享——窗口大的流丢包时让出得多(乘性减对大者狠),窗口小的流相对获益,反复几轮大家趋同。增得温和保证稳定,减得凶狠保证快速让路——AIMD 是收敛性证明选出来的,不是拍脑袋。

深入理解:全靠自觉的脆弱平衡

  • 拥塞控制不是网络强制的,是端上代码的自觉。一个不退让的发送者能挤走同链路所有守规矩的流——这是 UDP 大流量应用必须自带拥塞控制的伦理与现实原因。互联网的稳定,建立在几十亿台设备恰好都跑着守规矩的代码之上——细想相当惊人,也解释了为什么新拥塞算法的「对旧算法是否公平」是学术界严肃审查的问题。
「慢启动从 1 个报文开始」已经过时:Linux 依 RFC 6928 把初始窗口提到 10 个 MSS,新建连接 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 持续修正——拥塞控制至今是活跃的研究前线,不是盖棺的教科书章节。
「本机改成 BBR,下载立刻变快」——方向搞反了:拥塞算法是发送方的属性,只作用于本机发出的数据。你下载时数据的发送方是服务器,快慢由它的算法决定,本机 sysctl 只影响上传方向。而且 BBR 的优势场景是有随机丢包或深缓冲的长路径,在低丢包的内网里与 CUBIC 差别很小——换算法之前先想清楚瓶颈在哪个方向、哪一段。
自己测 bufferbloat:满速下载的同时 ping 网关,延迟从 2ms 涨到 200ms 就是它。治法:路由器开 SQM/fq_codel(AQM 主动队列管理——队列过长就提前丢包/打标,让 TCP 早收到信号)。Linux 上查看与切换算法:sysctl net.ipv4.tcp_congestion_control,改成 bbr 只要一行。

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 发个版」
「QUIC 跑在 UDP 上,所以是不可靠传输」——恰恰相反:QUIC 在 UDP 之上重建了序号、确认、重传和完整拥塞控制(RFC 9000),可靠性语义与 TCP 等强。选 UDP 不是要它的「不可靠」,而是因为它是防火墙和 NAT 肯放行的最薄通道,让新传输层不必改内核、不必求中间设备就能部署——UDP 在这里是运载火箭,不是设计目标。
亲眼看协议协商:浏览器 DevTools 的 Network 面板右键表头勾出 Protocol 列,访问大站点常能看到首次请求走 h2、收到 Alt-Svc 头后续请求升级成 h3(即 HTTP/3 over QUIC)——「带 TCP 回退、择机升级」的现场直播。命令行用 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 地址每一跳都换——下一章会看到这正是两层地址分工的直接证据。
「每包独立寻路」别理解成「包总在走不同的路」:路由表稳定时,同一目的地的包几乎都走同一条路——独立决策是能力,不是常态;有多条等价路径的设备(ECMP)还会刻意按「流」做哈希,把同一条 TCP 连接钉在同一路径上防乱序。「IP 不保证顺序」≠「顺序总是乱的」。
「尽力而为」的现实标定:健康的有线网络丢包率低于 0.1%,WiFi/弱网 1~5%。TCP 把这些抹得让你无感——但延迟敏感的应用(游戏、语音)感知得到每一次丢包,这是它们偏爱 UDP 自己处理的原因。

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/8
172.16.0.0/12
192.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::/10IPv6 链路本地启用了 v6 但可能没有 v6 连通性
2000::/3IPv6 全球单播真的接入了 IPv6 互联网
两个高频算错:一个 /24 有 256 个地址但可配主机只有 254——全 0 是网络号、全 1 是广播地址;私有段 172.16/12 的范围是 172.16.0.0~172.31.255.255,172.32.x.x 已经是公网地址——「172 开头就是私网」是错的。手算拿不准就让 python3 的 ipaddress 模块验算:ip_network("192.168.1.1/24") 会直接报错「has host bits set」,提醒你把主机地址错当成了网段。
ip addr 看自己的地址和前缀,ip route 看路由表——「default via 192.168.1.1」那条就是「非本网段一律交给网关」。子网计算交给 ipcalc 命令,人负责看懂输出。

前面一直默认「主机有 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)。
DHCP 靠广播工作,而广播出不了本网段——所以每个网段/VLAN 都需要自己的 DHCP 来源:要么各放一台服务器,要么在网关上配 DHCP 中继(relay,把广播转成单播代问远端服务器)。「全公司一台 DHCP 服务器天然覆盖所有网段」是初学者常见的想当然。
想让某台设备 IP 固定,别在设备上手写静态地址(容易和 DHCP 地址池撞车、换网络还要改回来),去路由器上做「DHCP 保留/静态租约」——按 MAC 绑定固定 IP,既固定又不打架,家庭和小型网络里最省心的做法。

路由器的工作拆成两件事:转发(数据面:查表把包发往下一跳,纳秒级)和路由(控制面:跑协议算出这张表,秒级收敛)。两者分离,是理解从家用路由器到 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 全球范围掉线——互联网的规模问题最终都会砸到某台具体设备的内存上
别把路由表当防火墙规则读:iptables 是从上到下第一条命中即停,路由表与条目顺序无关——所有条目一起参与比较,前缀最长者赢,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:用密码学签名声明「谁有权通告哪个前缀」,通告不合法就丢弃。部署率逐年上升但远未全覆盖——互联网骨干至今运行在半信任之上,这是「网络安全是系统工程」最宏观的例证。
「BGP 也在算最短路,只是单位换成 AS 跳数」——只对一半:AS 路径长度在选路顺序里排在本地策略(local preference)之后,商业关系随时推翻它。所以「物理距离近 ≈ 网络路径短」不成立,同城跨运营商的流量绕行外地交换中心很常见——mtr 看到绕路别急着当故障报,那可能是合同使然。
一句话记三家:RIP 是「口口相传的距离」(距离向量),OSPF 是「先同步地图、再各自导航」(链路状态),BGP 是「合同优先于地图」(路径向量+策略)。想亲眼看全球路由:bgp.tools 或 RIPEstat 在浏览器里就能查任意前缀此刻被哪个 AS 通告、AS 路径是什么——不用装任何东西。

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」也是私网地址——地址枯竭的临时补丁,成了互联网的永久建筑。
「在 NAT 后面 = 有防火墙」是危险等式:「外面进不来」只是转换表查不到条目的副作用,不是安全策略——UPnP 能让内网程序自动开洞,打洞技术专门绕它,IPv6 普及后这层「保护」干脆不存在。安全要靠显式的防火墙规则,别把副作用当保障。
判断自己在几层 NAT 后:本机 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 大跳变的那一跳通常是跨洋/跨运营商出口。
两个高频误读:①ICMP 直接封装在 IP 里(协议号 1),没有端口——「ping 用哪个端口」本身就是个错误的问题,防火墙放行它靠 type/code;②traceroute 中间某跳延迟高 ≠ 那里拥塞——路由器用慢速的控制面 CPU 回 ICMP 且普遍限速,转发正式流量走的是硬件快路径,只有「从某跳起延迟持续抬高直到终点」才指向真实问题。
mtr = traceroute + ping 的连续统计版,哪一跳开始丢包一目了然。报网络故障时附一张 mtr 截图,比「我这里网卡了」有用一百倍——它直接指认了锅在谁家。

每种链路的单帧有上限(以太网 1500 字节 = MTU)。包太大怎么办?IP 的原始答案是分片——但现代网络的共识是尽量别让它发生:分片是一整类「诡异故障」的温床。

分片为什么讨厌

  • 丢一片 = 全包重传:重组只在终点做,等不齐就整包作废——丢包代价被放大了 N 倍。
  • 安全设备处理不了:只有第一片带传输层头(端口在里面),后续分片无法按端口过滤——防火墙对分片「宁可错杀」直接丢弃是常见配置。
  • 所以 IPv6 干脆禁止路由器分片,只允许源端分——把问题逼回源头解决。

路径 MTU 发现(PMTUD)与它的经典坑

  • 源端把包标上 DF(禁止分片)发出;路径上 MTU 不够的路由器丢包并回 ICMP「包太大,我最大只能过 X」;源端收到就改小重发——逐步探出全路径最小 MTU。
  • 坑在哪:很多防火墙一律封禁 ICMP → 「太大了」的通知永远到不了源端 → 大包默默消失。经典症状:能 ping 通、小请求正常、大文件上传或某些页面永远卡死。PPPoE 拨号(MTU 1492)、VPN/隧道(再减几十字节)是高发场景。
「TCP 不用管 MTU」是对的,但别顺手推广到 UDP:TCP 按 MSS 自动切段,应用一次 write 多大都行;UDP 一个 sendto 就是一个 IP 包,超过路径 MTU 就得靠分片碰运气——DNS、QUIC 都刻意把 UDP 载荷压在 1200 字节上下,就是为了躲分片。带 DF 位发超过接口 MTU 的包,本机直接报 sendmsg: Message too long,根本出不了门。
手工定位:ping -M do -s 1472 目标(1472+28 头 = 1500),不通就逐步减小,找出真实路径 MTU。工程兜底叫 MSS(最大报文段长度,TCP 单段能携带的最大数据字节数)钳制:设备在 TCP 握手时改写 MSS,让两端自觉发小段——家用路由器 PPPoE(以太网上的点对点协议,常见宽带拨号方式)模式默认开着,所以你多年没撞上这个坑,不代表它不存在。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 才改用随机接口标识)。
你可能已经在用 v6:手机蜂窝网大概率 v6 优先,test-ipv6.com 一测便知。开发者注意:把地址当 4 字节、只监听 0.0.0.0、用正则解析 IP 的老代码在 v6 环境全会出错——「地址可能是 16 字节」应当进入每个网络程序的默认假设。

物理层:信号与极限

上一章的帧说到底是一串比特,而比特要真正越过一根铜线、一段光纤、一片空气,就落到了整摞协议的地面——物理层,它回答「比特怎么变成电、光、电磁波」。这一章刻意精简为两张卡:编码与调制的直觉,以及香农定理——它给一切通信画了上限,也解释了从拨号上网到 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 的速率自适应,本质就是在星座密度之间换挡。
千兆以太网的线上并没有每秒 10 亿次的方波翻转:1000BASE-T 用 4 对线并行、每对只有 125M 波特的五电平调制(PAM-5)拼出 1Gbps——比特率 = 码元率 × 每码元比特数 × 并行路数。把 bps 直接当成信号翻转频率,是读物理层规格书时最常见的错位;也因此「数字信号」指的是离散判决,不是波形长得像方波。
波特率 vs 比特率一句话:比特率 = 码元速率 × 每码元比特数。1200 波特的老调制解调器和每码元 10 比特的 1024-QAM,是同一根轴上的两端——「怎么在一个码元里塞更多比特」是通信工程五十年的主旋律。

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 米)。
  • 无线:唯一支持移动,但频谱共享 + 衰减 + 干扰三座大山——同代无线速率总落后有线一个量级。这是物理,不是工程落后
别把香农定理和奈奎斯特准则混为一谈:奈奎斯特(2B·log2V)说的是无噪声信道下的码元率上限,理论上靠增大电平数 V 可以无限提速;香农才是加上噪声后的真实天花板——V 堆得再多,星座点也会被噪声糊成一团无法分辨。「为什么 WiFi 不用百万级 QAM」的答案就藏在两个公式的落差里,这也是通信课和面试最爱设坑的地方。
实用推论:运营商 300M 宽带、WiFi 测速只有 100M——瓶颈几乎总在无线那一跳(空口争用 + 信号衰减)。插网线测一次,立刻知道该怪运营商还是怪路由器摆放位置。

实战与精通

前面五章给了地图,这一章给车钥匙:亲手写 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
C 语言路线的经典免费教材是 Beej's Guide to Network Programming——薄、直接、全是能跑的代码。写完 echo 再挑战:给它加上「长度前缀」的消息边界,你会亲手理解为什么「粘包」根本不是 TCP 的错。

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 这个缺口也补上。
  • 合规提醒:只抓自己的流量。这既是法律边界,也是这个行业的职业操守。
「HTTPS 抓下来全是乱码,所以 Wireshark 调不了自己的接口」——设 SSLKEYLOGFILE 环境变量(curl、Chrome、Firefox 都认)让客户端把 TLS 会话密钥写进文件,Wireshark 里指向它,你自己的加密流量就能明文展开:跑一次 curl 就能看到密钥文件生成。这不算破解——密钥由端点自愿导出,只解得开你自己的会话。
服务器上没有图形界面时用命令行搭档:tcpdump -i any host example.com -w cap.pcap 抓下来,拖回本地用 Wireshark 分析——「现场 tcpdump、回家 Wireshark」是排障的标准工作流。

「网络慢」是两种完全不同的病:延迟高(路远或排队)和带宽小(管道细)。诊断方法、优化手段完全不同——混为一谈是性能调优里最常见的错误。

两个量的本性

  • 延迟的地板是光速:北京↔洛杉矶光纤单程约 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)存在的商业理由。
「测速网站跑满 500Mbps,为什么我的下载只有几十兆」并不矛盾:测速工具用多条并发流 + 就近服务器量的是链路容量,你的单条远端 TCP 流被 min(窗口/RTT,丢包下的拥塞窗口) 卡着。反过来 iperf3 单流测不满也先别报障——加 -P 8 多流再测:「单流上限」和「链路缺容量」是两种病,处方完全不同。
三件套定位 90% 的「网络慢」:iperf3 测纯带宽(排除应用嫌疑)、mtr 看延迟丢包出在哪一跳、ss -ti 看单条连接的 cwnd/rtt/retrans(窗口卡住还是重传拖累,一眼分明)。先测量,再优化——和所有性能工程一样。

远程界面: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,界面本地渲染
「带宽够就不卡」是远程界面最常见的误判。1 Gbps 的链路配 60 ms 的 RTT,跑 X11 转发照样难用得想砸键盘——因为瓶颈是往返次数 × RTT,与带宽无关。判据很简单:把窗口拖动一下,如果是「跟手但模糊」,那是带宽问题(像素流在降质);如果是「清晰但延迟一拍」,那是 RTT 问题。前者可以调压缩质量,后者只能换协议或换更近的服务器。
ssh -Xssh -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 还差
用「平均延迟」评估交互体验会严重失真。人对抖动的敏感度远高于对绝对延迟:稳定的 80 ms 可以适应,30±40 ms 的抖动会让人一直误判——手已经动了,画面还停在上一拍。所以监控远程界面要看 p95/p99 与抖动,而不是平均 RTT(这与 COA 页讲帧时间时的结论一致:均值掩盖尖峰,而人只记得尖峰)。
判断「值不值得就近部署」有个很朴素的算法:光在光纤里大约每毫秒走 200 公里,往返就是 100 公里/ms。北京到上海约 1200 公里,理论最小 RTT 就是 12 ms 左右,加上设备转发实际二三十毫秒——这是物理下限,任何优化都突破不了。所以跨洲的交互式远程桌面必然难用,只能靠本地预测或改成异步流程。

「传状态」这条路上,浏览器有三种长连接选择。选错的代价通常不是不能用,而是在弱网、代理、断线场景下坏得很难查。

三选一

WebSocketSSE(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);                     // 指数退避
  };
}
WebSocket 的 readyState === OPEN 不代表对端还活着。TCP 连接在对端进程崩溃、机器断电、或中间设备静默丢弃后,本地仍然会显示连接正常,直到你发数据并等到超时(可能几分钟)。「界面不再更新但也不报错」的经典成因就是这个。唯一可靠的判据是应用层心跳:N 秒内没收到对端任何消息就当它死了,主动重连,别信 socket 的状态字段。
心跳周期建议设在 25~30 秒:多数中间设备的空闲超时是 60 秒或 5 分钟,25 秒能安全落在最短的那一档之内,又不会太费流量。更重要的是「双向心跳」——客户端发 ping、服务端回 pong,两边各自超时判定。只有单向心跳时,「服务端已经挂了但 TCP 连接还没断」这种状态可以持续很久。

微项目:把协议写出来

前面八章讲的机制,这一章逐个变成能编译、能被 curl 访问、能抓包验证的代码。六个微项目全部是单文件 C、零依赖:并发模型三连、手写 HTTP 服务器、消息边界、手写 DNS 查询、在 UDP 上重造可靠传输、端口转发。每个项目都附本机跑出来的数字,你的机器数值会有出入,但结论应当一致。

协议这门课有个特殊之处:它的每一条规则都能被你亲手违反一次,然后立刻看到后果。少发一个 Host 头就拿到 400,忘了长度前缀就分不清消息边界,超时设短一点重传就爆炸——这些在纸上是知识,在代码里是当场的报错。这一章把六件事写成能跑的程序。

六个项目

项目回答的问题对应章验证方式
① echod一个进程怎么同时服务几千人032000 条并发连接压一遍
② httpdHTTP 报文到底长什么样02curl 和浏览器直接访问
③ framing所谓「粘包」到底是什么03三次 write 一次 read 收全
④ dnsq域名怎么变成一个 UDP 包02直接问真实 DNS 服务器
⑤ rudpTCP 的可靠性凭什么做到03人造丢包,量重传与耗时
⑥ tcpfwd两个方向的字节流怎么独立收尾03转发一次真实 HTTP 请求

为什么值得自己写一遍

  • 协议的坑几乎全在「边界情况」上:连接半关了怎么办、消息只到一半怎么办、包丢了怎么办。用框架时这些都被替你处理了,自己写一次才会知道框架在替你挡什么。
  • 六个项目彼此能串起来:② 的服务器可以用 ① 的 epoll 循环改造,⑥ 可以转发 ② 的流量,③ 的长度前缀是任何自定义协议的第一块砖。
  • 每张卡的结构都是「目标 → 关键几十行 → → 现在你来」。中间那段代码是骨架不是答案

三条通用纪律(六个项目都适用)

  • recv 返回 0 只有一个含义:对端关闭了。不是「暂时没数据」——阻塞 socket 没数据时会一直等。把 0 当空数据继续循环,就是一个满速空转的死循环。
  • send 可能只发出去一部分(返回值是实际字节数)。要么自己循环补发,要么用 sendall 这类封装。这个 bug 在小数据量下永远不出现,一上生产就现形。
  • signal(SIGPIPE, SIG_IGN)。往一条已被对端关闭的连接写数据,默认行为是整个进程被信号杀掉——排查这个「服务器无故消失」能耗掉一整天。
别一上来就追求「高性能」:这六个项目的目的是看清机制,不是跑分。真要做性能,瓶颈的位置会和你的直觉完全不同(内存拷贝、系统调用次数、锁竞争往往排在协议解析前面),而那需要先有 perf 数据再动手——没有测量的优化只是把代码写复杂。
全部六个项目用 gcc -O2 -Wall -o x x.c 就能编,不装任何库。建议每个项目都开着 ss -tanss -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
③ 单线程 epoll2000 条全部回显,约 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 的 fd 管理有个隐形陷阱:close 一个 fd 会自动把它从 epoll 集合里摘掉,但如果你在同一轮事件里还持有它的旧事件、或者 fd 号被立刻复用给了新连接,就会张冠李戴。稳妥做法是EPOLL_CTL_DELclose,并且不要在一轮循环里对同一个 fd 处理两次。这类 bug 的症状是「偶尔有条连接收到别人的数据」,极难复现。
压测客户端也要自己写一个(几十行):先把 N 条连接全部建立并保持不关,再逐条收发。用 ab / wrk 这类工具压是「串行开关连接」,量不出「同时在线」的成本——而同时在线正是 C10K 的题目本身。别忘了 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-LengthTransfer-Encoding 不能同时来。
  • HEAD 与 If-Modified-Since:条件请求 + 304,这是缓存的入口。
  • 把 ① 的 epoll 循环装进来,让它单线程扛住并发;再用浏览器打开,看 devtools 的 Network 面板认不认你的响应头。
三个必须一开始就防住的坑:① 路径穿越——GET /../../etc/passwd,必须显式拒绝含 .. 的路径,这是最古老也最常被复现的漏洞;② 行尾必须是 CRLF——curl 对只用 \n 的响应头照收不误(正因为对端宽容,这个错能一路潜伏到换客户端、过代理时才炸),而规范要求的就是 CRLF,别赌宽容;③ 正文长度要和 Content-Length 严格一致,多一个字节客户端会把它当成下一个响应的开头,少一个字节客户端会一直等——写代码时让正文和长度来自同一个表达式,别手写数字(我第一版就是手写的,中文正文的字节数当场算错)。
调试 HTTP 服务器最好的工具不是浏览器而是 curl -vncnc 让你逐字节地手写请求,故意漏掉某个头、故意写错换行、故意只发一半,看服务器怎么反应。协议实现的健壮性就是这么练出来的。

「粘包」是初学网络编程时最常听到、也最被误解的词。真相很简单: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 发出去:结构体的填充与对齐由编译器决定,两端编译器不同就对不上。
判断一个协议设计得好不好,先问一句:接收方读到第几个字节时,能确定这条消息还有多长? 长度前缀是「读完前 4 字节就知道」,HTTP 是「读到空行就知道」,而一个没有边界设计的自研协议往往是「猜」——那就是事故的起点。

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 指向它——你就有了一个可以随意做实验的解析器。
事务 ID 必须随机且要校验:本例为了演示写死成 0x1234,真实客户端如果这么干,攻击者只要抢先伪造一个 UDP 应答就能污染你的解析结果(这正是 DNS 缓存投毒的原理,也是源端口随机化和 DNSSEC 存在的理由)。另外别忘了校验应答的 ID 与问题段和你发出去的一致——UDP 无连接,任何人都能往你的端口上发包。
不想写代码时,dig +norecursedig @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%6405 ms
窗口 1,丢包 20%9733约 1.6 秒
窗口 8,丢包 20%213149约 1.0 秒
窗口 16,丢包 20%304240约 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 了。
最容易写错的一条:累积确认的语义是「我已经完整收到了 ack 之前的所有包」,不是「我收到了第 ack 号包」。写成后者,一旦 ACK 丢失窗口就再也滑不动。第二个坑是重传时定时器要重置,否则会在同一个超时点疯狂重发。第三个坑最隐蔽:接收方必须对重复包也回 ACK——重复包意味着对方的 ACK 丢了,你不吭声它就只能一直超时。
丢包要用可复现的伪随机(固定 seed),否则每次跑的数字都不一样,改进了还是改坏了根本看不出来。想测真实网络的效果,Linux 的 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 的原因。
closeshutdown 的区别是这个项目的唯一考点,也是网络编程里被讲错最多的一对:close 释放整个文件描述符、两个方向一起完;shutdown(SHUT_WR) 只发一个 FIN,读方向照常。 另外 shutdown 影响的是底层连接,如果这个 fd 被 fork 或 dup 复制过,所有副本一起受影响——而 close 只是少一个引用,最后一个关掉时才真正发 FIN。
写完这六个项目,回到 07 章的抓包卡再抓一次自己的程序:你会第一次在 Wireshark 里看到自己写的字节——SYN 是你 connect 触发的、FIN 是你 shutdown 触发的、载荷是你 send 的。「课本 → 代码 → 抓包」三者对上的那一刻,这一页就真正读完了。

从这里到精通:路线图

协议的地图铺完了,剩下的路要一个协议一个协议亲手走。最后这一章给出收尾路线:难度递进的动手项目、按阶段的读物,以及一条自测标准。

这一页给了你地图,精通只能来自把每个协议都亲手摸过。按顺序走,每一步都有明确的产出物——其中动手写代码那几步已经在 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,不等于能判断眼前这次卡顿是丢包还是缓冲区堆积。上面每一条自测题都要求你先说出预期、再跑命令对答案——顺序反了就只是在给已知结果找解释。
一个自测标准:给你一个「网页偶尔打不开」的模糊故障,你能否有序二分——DNS?(dig)连通?(mtr)端口?(nc)TLS?(openssl s_client)应用?(curl -v)。能把协议栈当成排障检查表逐层用,这门课就毕业了——剩下的精通,是工作里日积月累的案例库。