IP 只承诺”尽力而为”:包可能丢、可能重复、可能后发先至。可你用 scp 拷一个 10 GB 的模型权重,最后的校验和一个比特都不会错。这份可靠性是两端的操作系统在不可靠的网络之上一点点搭出来的。这篇从”给每个包编号”这个最朴素的念头出发,看它怎么演变成 TCP 的滑动窗口、重传和拥塞控制,再往上看 HTTP 和 TLS 如何借用这条可靠的字节流。
前置:网络:从链路到 IP、文件系统与 IO(文件描述符)
端口与 socket:从”送到机器”到”送到进程”
IP 把包送到了机器,但机器上同时跑着浏览器、ssh、数据库,包该给谁?传输层在头部加了 16 位的端口号。一条连接由五元组唯一确定:协议、源 IP、源端口、目的 IP、目的端口。服务器的端口通常是约定好的(HTTPS 443、SSH 22、DNS 53),客户端的端口由操作系统临时分配。
程序通过 socket 使用传输层。在 Unix 里 socket 就是一个文件描述符,send/recv 跟读写文件几乎一样。服务器端的典型流程是 socket → bind → listen → accept,accept 每接到一个连接就返回一个新的描述符;客户端是 socket → connect。
传输层有两个主流选择。UDP 几乎什么都不加:端口、长度、校验和,一共 8 字节头,发出去就不管了。TCP 提供的是可靠、有序的字节流。“字节流”这个词有具体后果,下面这段代码在本机回环地址上实际运行:
import socket, threading, time
srv = socket.socket(); srv.bind(("127.0.0.1", 0)); srv.listen()
port = srv.getsockname()[1]
def server():
conn, addr = srv.accept()
time.sleep(0.2) # 让客户端的两次 send 都先到达内核缓冲区
print("server recv:", conn.recv(1024))
conn.sendall(b"ok"); conn.close()
t = threading.Thread(target=server); t.start()
c = socket.create_connection(("127.0.0.1", port))
c.send(b"hello "); c.send(b"world")
print("client got:", c.recv(1024)); c.close(); t.join()
u1 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); u1.bind(("127.0.0.1", 0))
u2 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
u2.sendto(b"hello ", u1.getsockname()); u2.sendto(b"world", u1.getsockname())
time.sleep(0.1)
print("udp recv:", u1.recvfrom(1024)[0], u1.recvfrom(1024)[0])server recv: b'hello world'
client got: b'ok'
udp recv: b'hello ' b'world'TCP 这边客户端 send 了两次,服务器一次 recv 就拿到了拼在一起的 hello world。TCP 不保留消息边界,它只保证字节的顺序。UDP 则相反,每次 sendto 是一个独立的数据报,接收方一次 recvfrom 拿一个,边界完整,但丢了就是丢了。
所以任何基于 TCP 的应用协议都必须自己定义”一条消息在哪里结束”:HTTP/1.1 用空行分隔头部、用 Content-Length 或分块编码说明正文长度;很多 RPC 框架在每条消息前加一个固定长度的长度字段。新手写 socket 程序最常见的 bug 就是假设一次 recv 恰好对应对方的一次 send。
什么时候选 UDP?当”晚到的数据不如不到”时,比如实时语音、游戏状态同步;或者一问一答、丢了重问就行,比如 DNS。还有一类是在 UDP 之上自己实现可靠性,QUIC 就是这样做的,后文会讲。
客户端用 TCP 依次 send(b"A"*100) 和 send(b"B"*100),服务器循环调用 recv(1024)。下列哪种情况不可能发生?
TCP 是有序字节流:边界可以任意合并或切开,但字节顺序一定和发送顺序一致,所以 B 不可能出现在 A 之前。
合并(第一项)和切开(第二项)都是正常现象,正好对应"TCP 不保留消息边界"。第四项恰好与 send 对齐,也是可能的,只是不能依赖它。
可靠传输:编号、确认、重传
在一个会丢包的信道上,怎么保证对方收到?最朴素的办法是停等:发一个包,等对方回确认(ACK),超时没收到就重发。为了区分”新包”和”重发的旧包”,包要带编号。这个协议是正确的,但慢得要命:从北京到旧金山 RTT 约 150 ms,每个 RTT 只发 1460 字节,吞吐不到 10 KB/s,而链路可能是 1 Gbit/s。
既然等待是浪费,自然的修正是不等确认,连续发出多个包。允许”已发出但未确认”的数据最多有 W 字节,这就是滑动窗口。吞吐上限变成 W / RTT。要把一条 1 Gbit/s、RTT 100 ms 的链路跑满,W 至少要等于带宽乘以时延(BDP):10^9 / 8 × 0.1 ≈ 12.5 MB。这个数字说明了为什么 TCP 头里 16 位的窗口字段(上限 64 KB)不够用,后来加了窗口缩放选项。
TCP 的具体做法:
- 序号按字节编。每个段的
seq是它第一个字节在流中的位置。初始序号(ISN)随机选,防止旧连接的残留包被误认,也增加伪造难度。 - 累积确认。
ack = n的意思是”n 之前的字节我都收到了,下一个要第 n 个”。如果中间缺了一段,接收方会不断重复发同一个 ack,后面先到的乱序数据先存在缓冲区里。 - 超时重传。发送方根据测得的 RTT 动态估计超时时间 RTO(平滑均值加上若干倍的偏差),超时就重发最早未确认的段,并且把 RTO 翻倍,避免网络已经拥塞时火上浇油。
- 快速重传。等超时太慢。如果连续收到 3 个重复 ack,说明后面的包都到了,唯独这一个丢了,立即重传,不等 RTO。
- 流量控制。接收方在每个 ACK 里通告自己缓冲区还剩多少空间(接收窗口 rwnd),发送方的在途数据不能超过它,免得快的发送方淹没慢的接收方。
一个例子:发送方连续发出 seq = 1000、2000、3000、4000 四个 1000 字节的段,2000 那个丢了。接收方收到 1000 后回 ack=2000;收到 3000、4000 时,仍然缺 2000,于是两次都回 ack=2000。发送方看到 ack=2000 重复了三次,立刻重传 2000。接收方补齐后,直接回 ack=5000,一个 ACK 确认了缓存里的全部数据。
一条链路带宽 100 Mbit/s、RTT 200 ms,没有丢包,TCP 的接收窗口固定为 64 KB。吞吐大约是多少?
每个 RTT 最多发出一个窗口的数据:64 KB / 0.2 s ≈ 320 KB/s ≈ 2.6 Mbit/s。要跑满链路,窗口需要达到 BDP,即 100 Mbit/s × 0.2 s = 2.5 MB。
没有丢包不等于能跑满,窗口才是这里的上限。"一半时间在等"没有依据。停等每个 RTT 只发一个段(约 1460 字节),比 64 KB 窗口慢得多。
建立与关闭连接
可靠传输需要两端对初始序号达成共识,还要确认对方确实在线,这就是三次握手:
客户端 → SYN, seq=x "我想连你,我的序号从 x 开始"
服务器 → SYN+ACK, seq=y, ack=x+1 "收到,我的序号从 y 开始"
客户端 → ACK, ack=y+1 "收到你的了"为什么不是两次?因为服务器需要知道客户端确实收到了它的 y。如果只有两次,网络里一个滞留很久的旧 SYN 到达服务器,服务器回一个 SYN+ACK 就以为连接建立了,开始分配资源等待永远不会来的数据。第三次握手让服务器确认对方是”现在”的、真实的。SYN 和 FIN 各自占用一个序号,所以 ack 是 x+1。
握手的代价是建立连接前要先花一个 RTT。服务器在收到 SYN 后、收到第三个 ACK 前,会把半开的连接放进一个队列;攻击者伪造大量源地址发 SYN 不回 ACK,就能把这个队列塞满,这是 SYN flood。常见的防御 SYN cookie 把连接状态编码进服务器选的序号 y 里,不再提前分配队列项。
关闭时,TCP 的两个方向是独立的,各自要关一次,于是是四次挥手:A 发 FIN 表示”我不再发了”,B 回 ACK;B 可能还有数据要发,发完后再发自己的 FIN,A 回 ACK。中间那段 A 已关、B 未关的状态叫半关闭。
主动关闭的一方在发出最后一个 ACK 后进入 TIME_WAIT,等 2 倍的最大报文生存时间(2MSL,Linux 上固定为 60 秒)才真正释放。原因有二:最后那个 ACK 可能丢了,对方会重发 FIN,这时要有人回应;同时要让这条连接的旧包在网络里彻底消失,免得被下一个使用相同五元组的新连接收到。高并发的短连接服务器上常看到大量 TIME_WAIT,它们占着端口号,这也是 HTTP 推崇长连接复用的原因之一。
关于握手与挥手,下列哪些说法正确?
握手的核心是双方各自的序号都要被对方确认。TCP 是全双工的,一方 FIN 只表示自己不再发送,另一方仍可以继续发,所以各关一次。
TIME_WAIT 在主动关闭方,作用是能重发丢失的最后一个 ACK、并等旧包过期,不是等对方数据。初始序号是随机选的,相对偏移靠 seq - ISN 计算,从 0 开始反而会让旧连接的包容易混进来。
拥塞控制:别把网络挤爆
流量控制保护的是接收方。但发送方和接收方都很快,中间的路由器队列却满了呢?每个发送方都在丢包后拼命重传,丢包更严重,有效吞吐反而趋近于零。1986 年互联网真的发生过这种拥塞崩溃,某段链路吞吐跌到原来的千分之一。Van Jacobson 随后给 TCP 加上了拥塞控制。
困难在于发送方看不到网络内部,只能从丢包和延迟推测拥堵。TCP 为此维护一个拥塞窗口 cwnd,实际在途数据不超过 min(cwnd, rwnd)。cwnd 怎么调?经典的 Reno 算法分三段:
- 慢启动:从 1 个(现代 Linux 是 10 个)段开始,每收到一个 ACK 加 1,效果是每个 RTT 翻倍。名字叫”慢”,其实是指数增长,只是起点低。
- 拥塞避免:cwnd 达到阈值 ssthresh 后改为每个 RTT 只加 1,小心地试探。
- 遇到丢包:如果是 3 个重复 ACK(说明网络还能传包,只是轻度拥堵),ssthresh 和 cwnd 都设为当前的一半,继续线性增长,这是快速恢复;如果是超时(说明可能严重拥堵),cwnd 直接回到初始值重新慢启动。
线性增加、乘性减小合称 AIMD。用几行代码模拟 20 个 RTT,第 9 个 RTT 发生一次 3-dup-ACK 丢包:
cwnd, ssthresh, out = 1, 32, []
for rtt in range(1, 21):
out.append(cwnd)
if rtt == 9: # 这一轮丢了包(3 个重复 ACK)
ssthresh = max(cwnd // 2, 2); cwnd = ssthresh
elif cwnd < ssthresh: cwnd = min(cwnd * 2, ssthresh) # 慢启动:指数增长
else: cwnd += 1 # 拥塞避免:线性增长
print(out)[1, 2, 4, 8, 16, 32, 33, 34, 35, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27]画出来就是经典的锯齿。为什么是乘性减而不是线性减?因为可以证明,多条共享瓶颈的 AIMD 流会收敛到公平分配带宽:每次减半时窗口大的流减得多,线性增长时大家加得一样多,差距逐轮缩小。
Reno 以丢包为信号,在长肥管道(高带宽、高 RTT)上恢复太慢,一次丢包后要几千个 RTT 才能涨回来。Linux 默认的 CUBIC 让窗口按时间的三次函数增长,离上次丢包时的窗口远时涨得快、接近时放慢。Google 的 BBR 换了思路:不等丢包,而是持续测量瓶颈带宽和最小 RTT,把在途数据维持在 BDP 附近,避免把路由器缓冲区填满造成排队延迟(bufferbloat)。
TCP Reno 收到 3 个重复 ACK 与发生超时,处理方式为什么不同?
重复 ACK 本身就是"后面的包到了"的证据,说明管道还通,所以乘性减小后继续拥塞避免。超时意味着连 ACK 都回不来,保守做法是从头开始探测。
接收方缓冲区满由接收窗口 rwnd 表达,不靠重复 ACK。两种信号携带的网络状态信息不同,处理不同并非为了省计算。丢包时增大窗口正是导致拥塞崩溃的行为。
HTTP:一问一答的文本协议
有了可靠的字节流,应用层协议就简单了。HTTP/1.1 是纯文本:请求行、若干头部、一个空行、可选的正文。直接用 socket 手写一个请求:
import socket
s = socket.create_connection(("example.com", 80), timeout=10)
s.sendall(b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")
d = b""
while (b := s.recv(4096)): d += b # 字节流没有边界,读到对方关闭为止
h, _, body = d.partition(b"\r\n\r\n")
print(h.decode().split("\r\n")[0]); print(len(body), "bytes body"); print(body[:60])HTTP/1.1 200 OK
589 bytes body
b'241\r\n<!doctype html><html lang=en><head><meta charset=utf-8>'正文开头的 241\r\n 不是网页内容,而是分块编码(chunked)的块长度,十六进制 0x241 = 577 字节,最后还有一个长度为 0 的块表示结束。服务器边生成边发、事先不知道总长度时就这样分帧。这正是上一节”TCP 不保留边界,应用自己分帧”的实例。
HTTP 是无状态的,每个请求独立;登录状态靠 Cookie 头在每次请求时带上。状态码第一位表示类别:2xx 成功、3xx 重定向、4xx 客户端错误(404 找不到、403 无权限)、5xx 服务器错误。方法上,GET 应当没有副作用,POST 提交数据,PUT 和 DELETE 是幂等的,重复执行效果相同。
HTTP 的演进几乎全是在和 TCP 的延迟较劲:
| 版本 | 传输 | 改进 | 遗留问题 |
|---|---|---|---|
| HTTP/1.0 | TCP | 每个请求一条连接 | 每次都要握手 |
| HTTP/1.1 | TCP | 长连接复用 | 同一连接上请求排队,前一个慢后面全等 |
| HTTP/2 | TCP + TLS | 二进制分帧,多个流在一条连接上交错 | TCP 层丢一个包,所有流一起等重传(队头阻塞) |
| HTTP/3 | QUIC(UDP) | 每个流独立重传,握手和 TLS 合并 | 部分网络限制 UDP |
HTTP/2 在应用层解决了排队,却撞上了 TCP 本身的有序性:一个包丢了,后面所有字节都不能交给应用,哪怕它们属于别的流。QUIC 的回应是绕过 TCP,在 UDP 上重新实现可靠传输和拥塞控制,让每个流各自有序。它跑在用户态,升级不用等操作系统内核。
HTTP/2 已经在一条连接上多路复用了很多请求,为什么在丢包较多的移动网络上表现可能还不如开多条 HTTP/1.1 连接?
TCP 只交付有序字节流,丢一个段会阻塞它之后所有已到达的数据,不管属于哪个流。多条独立连接里只有丢包的那条受影响。这正是 QUIC 要解决的问题。
解析开销不是网络层面的瓶颈。HTTP/2 正是靠连接复用避免了重复握手。HTTP/2 完全支持 Cookie 等全部 HTTP 语义,变的只是帧格式。
TLS:在明文的网络上建一条私密通道
到目前为止所有东西都是明文:路径上的任何路由器、同一 Wi-Fi 下的任何人都能读甚至改你的 HTTP 内容,上一篇也看到 DNS 可以被随意劫持。TLS 夹在 TCP 和 HTTP 之间,要同时解决三件事:别人看不到(加密)、别人改不了(完整性)、对面确实是 example.com 而不是冒充者(认证)。
难点在于双方事先没有共享的密钥。TLS 1.3 的握手大致是:
- 客户端发 ClientHello:支持的加密套件、一个临时的 Diffie-Hellman 公钥、要访问的域名(SNI)。
- 服务器回 ServerHello 和自己的临时 DH 公钥。双方各自用对方的公钥和自己的私钥算出同一个共享密钥,窃听者只看到两个公钥算不出它。之后的消息都已加密。
- 服务器发来证书(包含它的长期公钥,由证书机构 CA 签名)和一个用长期私钥对握手内容的签名。客户端用系统内置的根 CA 验证证书链,确认域名匹配,再验证签名,证明对方确实持有证书对应的私钥。
- 双方用共享密钥派生出对称密钥(如 AES-GCM、ChaCha20-Poly1305),后续数据都用快速的对称加密传输。
整个握手只要 1 个 RTT(TLS 1.2 要 2 个)。临时 DH 密钥用完即弃,这带来前向保密:哪怕服务器的长期私钥日后泄露,之前录下的流量也无法解密。
curl -v 能看到这些步骤的结果(本机经过了代理,所以连接地址是上一篇提到的 198.18.x.x 假地址,但 TLS 是端到端的,证书仍来自真正的服务器):
* Connected to example.com (198.18.0.208) port 443
* ALPN: curl offers h2,http/1.1
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
* ALPN: server accepted h2
* Server certificate:
* subject: CN=example.com
* subjectAltName: host "example.com" matched cert's "example.com"
* issuer: C=US; O=SSL Corporation; CN=Cloudflare TLS Issuing ECC CA 3
> GET / HTTP/2
> Host: example.com
< HTTP/2 200ALPN 是在握手里顺便协商上层协议,这里谈成了 HTTP/2。证书的 subjectAltName 匹配上了域名,签发者是一个中间 CA,它本身又由根 CA 签名。这也是为什么中间的代理无法冒充 example.com:它拿不到受信任 CA 为这个域名签的证书,除非你在系统里手动信任了它的根证书。
把两篇串起来,在浏览器输入 https://example.com 之后:DNS 解析出 IP,TCP 三次握手(1 RTT),TLS 握手(1 RTT),发出 HTTP 请求并收到响应(1 RTT)。冷启动至少 3 个 RTT 才能看到第一个字节,跨洋时将近半秒。CDN 把服务器放到离你近的地方缩短 RTT,HTTP/3 把 TCP 和 TLS 握手合成 1 个 RTT,恢复会话时甚至 0 RTT,本质都是在削减这些往返。
关于 HTTPS,下列哪些说法正确?
认证靠证书链,加密数据靠握手派生的对称密钥,这是公钥与对称加密的分工。
观察者仍能看到目的 IP,以及通常是明文的 SNI 域名和 DNS 查询,所以"访问了哪个网站"并不保密,保密的是内容。TLS 1.3 只使用临时 DH 交换,长期私钥只用于签名,泄露也不能解密过去的流量,这就是前向保密。
记住这三件事
- TCP 是有序字节流,不保留消息边界;可靠性来自按字节编号、累积确认、超时和快速重传,吞吐上限是窗口除以 RTT。
- 拥塞控制让发送方根据丢包或延迟自我节制,AIMD 的锯齿既避免崩溃又让多条流趋于公平。
- HTTP 的演进和 TLS 1.3、QUIC 的设计,核心都是削减往返次数和绕开 TCP 的队头阻塞。
延伸阅读
- Stanford CS144 讲义与 Minnow 实验说明(cs144.github.io),亲手实现一个 TCP
- Kurose & Ross, Computer Networking: A Top-Down Approach,第 2–3 章
- RFC 9293(TCP)、RFC 5681(TCP 拥塞控制)、RFC 9110(HTTP 语义)、RFC 8446(TLS 1.3)
- Van Jacobson, Congestion Avoidance and Control(SIGCOMM 1988)
- Ilya Grigorik, High Performance Browser Networking(hpbn.co,免费在线)