TCP协议
tcp工作在不可靠的IP层之上,它之所以可靠是因为依靠 序列号、ACK、超时重传、快速重传、校验和、连接管理。
TCP的可靠
TCP 有个反直觉的地方值得先说清楚:它并不保证数据不丢。它保证的是「丢了能被发现,并且补上」。所以可靠性不是靠某个黑科技,而是四组机
传输过程
传输过程中每个字节都有自己的编号:
发送方:字节 1-1000 是第 1 段,1001-2000 是第 2 段
接收方:收到后按序号重组,乱序也能还原
这样重复的段会按照序号丢弃,乱序到达之后会按照序号重排,确认应答后告诉对方收到了哪些。接收方收到数据之后会回复一个ACK:
发送方 → [SEQ=1, 1000字节] → 接收方
发送方 ← [ACK=1001] ← 接收方
ACK=1001 意思是"1000 及之前的字节我都收到了,下一个期望 1001。然后这样累计确认即可。发送发发完数据之后会启动定时器,接着等待ACK,如果没有就会重传SEQ=1, 超时时间(RTO)动态计算:根据 RTT(往返时间)动态调整。快速重传不需要等待超时,收到3个重复ACK就重传。
发送 [1][2][3][4][5]
接收方收到 [1][3][4][5],缺 [2]
接收方回 ACK=2(重复 3 次)
发送方收到 3 个 ACK=2 → 立即重传 [2]
每个TCP都有校验和,用于检测数据是否损坏。连接管理让双方完成三次握手建立连接、四次挥手关闭,能够确保双方都准备好,确保数据都传输完成。
重传达到上限(Linux 默认约 15 次)后 TCP 会放弃并报错——「可靠」不等于「一定会送达」,只等于「一定会告诉你成没成」。而且 它不保证时延,TCP 为了可靠可以无限排队,所以时间敏感的场景宁可丢掉过期数据(这就是 WebRTC 用 UDP 的原因)。
顺便讲解udp,udp是故意不做可靠性保证的,他把控制器交给了应用层,udp也有校验和,但是没有序列号、ack、重传、排序、去重、流量控制等等行为。
tcp窗口
窗口是tcp的流量控制机制,接收方可以提醒发送方自己还能接受多少。接受窗口rwnd是接收方的剩余缓冲区大小:
接收方缓冲区 64KB
已用 16KB
剩余 48KB → 告诉发送方"窗口 = 48KB"
然后发送方就可以根据窗口大小决定能发多少,这是为了防止接收方被大量的流量淹没,有窗口才能让双方能够按照节奏来发送。
窗口为0的话,发送方就会停止发送,并启动零窗口探测定时器,询问接收方窗口是否开了,当接收方处理完之后窗口才会>0,然后通知发送方继续,如果接收方窗口开了但通知丢了,发送方靠探测恢复。
并且有指标可以计算: 吞吐量 = 窗口大小 / RTT
窗口 64KB,RTT 100ms
吞吐量 = 64KB / 0.1s = 640KB/s ≈ 5Mbps
窗口 64KB,RTT 10ms
吞吐量 = 64KB / 0.01s = 6.4MB/s ≈ 51Mbps
RTT越大,窗口越重要,RTT越大的话,我们自然需要窗口越大,不然吞吐量上不去。
TCP头部窗口字段只有16位,也就是最大64KB,如果还不够,可以使用窗口缩放选项RFC 1323,能够把窗口扩大到8MB,现代TCP窗口都会启用窗口缩放。
tcp窗口分几个层次:内核自动管理、系统参数调整、应用层通过socket选项来间接影响。应用层看不到也改不了 TCP 窗口字段本身。窗口是内核根据接收缓冲区剩余空间自动计算的。
linux可以通过命令查看:
# 看系统级参数
sysctl net.ipv4.tcp_rmem
sysctl net.ipv4.tcp_wmem
# 输出示例:
# net.ipv4.tcp_rmem = 4096 131072 6291456
# min default max
tcp握手和挥手
为什么必须是三次,两次不行? 因为服务端在收到 ① 之后无法确认自己的 ISN 是否被客户端收到。如果只有两次,服务端发完 SYN+ACK 就认为连接建立,那还有一种糟糕情况:客户端很久之前发出的一个重复 SYN(网络里迷路的旧报文)突然抵达,服务端兴冲冲建好连接、分配资源,而客户端根本不知道有这条连接——资源就这么白占了,实际成了一条「幽灵连接」。第三次的 ACK 就是让服务端确认「这次握手是当下发生的一次真实往返」。三次还能顺带解决序号同步:只有客户端回了 ack = y + 1,服务端才知道自己的序号 y 已被对方接受。
挥手的核心在于:FIN 不是「我要关了」,而是「我这个方向没数据要发了」。所以它天然是一个半关闭(half-close)操作,四次挥手就是两个方向各关一次:
- 被动方收到 FIN 后,内核立刻回 ACK(协议栈自动完成,不等应用),此时连接变成「我可以收、你也可以继续发」——这就是
CLOSE_WAIT。它应用层的代码可能还在跑、还要回数据,所以不能顺手把自己的 FIN 也塞进这个 ACK。 - 等它真的处理完、调了
close(),才发出自己的 ③ FIN。主动方回 ④ ACK 后,不能马上关闭,要进TIME_WAIT。
WS协议
WS 借用了 HTTP/1.1 的 Upgrade 机制,把同一条已经建好的 HTTP 连接上的应用层协议换掉。没有新建连接、没有新端口、TCP 和 TLS 的握手一个都没重来——这也是 WS 能拿到极低延迟的根本原因。
补一个容易踩的细节:Upgrade 这个头只在 HTTP/1.1 里存在。所以如果要走 HTTP/2(比如通过 h2 的 CDN 回源),就不能用这套升级舞步,得改用 RFC 8441 的「扩展 CONNECT」——用 :protocol 伪头来协商 WS,机制完全不同。这也侧面说明:WS 的「升级」是绑在 HTTP/1.1 语义上的一个历史设计。
接下来是握手本身。使用的是 SHA-1 + magic GUID , RFC 6455 里的原例算了一遍对账:
实际的报文长这样(Key 用的是 RFC 6455 里的示例值,Accept 是我上面实测算出来的):
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
那个 GUID 是写死的常量,258EAFA5-E914-47DA-95CA-C5AB0DC85B11,全世界所有 WS 连接都一样,出现在 RFC 6455 §1.3。所以整个变换过程没有任何保密性——任何人都能复算。 客户端必须自己复算一遍并比对 Accept,不符就直接断开,不进入帧阶段。
握手还有个实用细节:Sec-WebSocket-Protocol 用来协商子协议(比如 graphql-ws、mqtt),Sec-WebSocket-Extensions 用来协商扩展(permessage-deflate 就是在这里开启的压缩)。服务端选的子协议如果不合法,客户端应当断开——协议没规定要严格,但规范建议如此。
升完级之后,字节流上跑的就是帧了:
TCP 只提供「有序的字节流」,它没有消息边界——你发了两个消息,对端可能一次 read 就收到粘在一起的两个,也可能被拆成三块。WS 的帧就是来补这个缺口的:FIN 标记消息结束,opcode 标记这条消息是什么类型,于是「消息」这个概念第一次在协议层被定义出来。这是 WS 相对裸 TCP 最核心的增值(其次是它能穿过 80/443 的代理和防火墙)。
它的报文形式:WS 的消息是二进制帧
ws客户端的文本帧字节可能长这样:
这个和 SSE 的差别一眼就能看出来:SSE 是一行行文本,WS 是一串字节。帧头就两个字节,拆开看全是位域。
继续更新。。。