3、TCP协议核心:TCP首部结构、三次握手与四次挥手、状态转换图
聊完了IP层的尽力而为,我们终于要碰TCP了。说实话,TCP才是粘包拆包问题的真正源头。IP层只管把包扔过去,至于顺序对不对、丢没丢、会不会把数据挤在一起——它一概不管。这些烂摊子,全是TCP来收拾的。
我个人习惯把TCP看作一个有洁癖的快递员。它不仅要保证包裹送到,还要保证包裹的顺序、完整性,甚至帮你把大包裹拆成小份、把小份拼回去。嗯,粘包拆包的本质,就藏在这个“拆”和“拼”的过程里。
3.1 TCP首部结构:20字节的硬核信息
先看TCP的头部。它最少20字节,最多60字节(因为有选项字段)。这20字节里,每个位都有用。我当年刚学的时候,觉得这玩意儿就是天书,后来在项目里抓包调了三天三夜,才真正看懂它有多精妙。
| 字段 | 长度(位) | 说明 |
|---|---|---|
| 源端口 | 16 | 发送方端口号 |
| 目的端口 | 16 | 接收方端口号 |
| 序列号(SEQ) | 32 | 本报文段数据的第一个字节序号 |
| 确认号(ACK) | 32 | 期望收到的下一个字节序号 |
| 数据偏移 | 4 | 首部长度,单位是4字节 |
| 标志位 | 9 | URG/ACK/PSH/RST/SYN/FIN等 |
| 窗口大小 | 16 | 接收窗口,用于流量控制 |
| 校验和 | 16 | 覆盖首部和数据的校验 |
| 紧急指针 | 16 | URG标志置1时有效 |
这里我重点说三个字段,因为它们跟粘包拆包直接相关。
序列号(SEQ):TCP不是按“包”来编号的,而是按“字节”。每个字节都有一个序号。发送方告诉接收方:“我这个包的第一个字节是序号X”。这是TCP能拼回正确顺序的根本。
确认号(ACK):接收方回复:“我已经收到了序号Y之前的所有字节,下一个请发Y”。这保证了不丢包。
标志位:SYN用于建立连接,FIN用于关闭连接,ACK用于确认,RST用于重置。三次握手和四次挥手,全靠这几个标志位撑场面。
核心理解:TCP是面向字节流的协议。它不关心你应用层发的是什么“消息”,它只看到一串连续的字节。你发两次write,它可能一次发出去(粘包);你发一次write,它可能分两次发(拆包)。这就是粘包拆包的根源。
3.2 三次握手:建立连接的双向确认
为什么是三次?不是两次,也不是四次?我刚开始做网络编程时也觉得奇怪,直到有一次我在项目中写了一个UDP模拟TCP的玩具,才发现两次握手根本不可靠。
三次握手的本质是:双方都要确认对方的收发能力正常。
- 第一次握手(SYN):客户端发送SYN包,序列号设为x。客户端进入SYN_SENT状态。这相当于说:“喂,我想跟你说话,我的起始序号是x。”
- 第二次握手(SYN+ACK):服务端收到后,回复SYN+ACK包。序列号设为y,确认号设为x+1。服务端进入SYN_RCVD状态。这相当于说:“收到你的请求了,我的起始序号是y,我确认你的x。”
- 第三次握手(ACK):客户端收到后,发送ACK包。序列号设为x+1,确认号设为y+1。客户端进入ESTABLISHED状态。服务端收到后也进入ESTABLISHED。这相当于说:“好的,我也确认你的y,咱们开始聊吧。”
你想想看,如果只有两次握手,服务端怎么知道客户端有没有收到自己的SYN+ACK?万一丢了,服务端就傻傻地等着,浪费资源。三次握手解决了这个“半开连接”的问题。
避坑指南:我曾经在写一个高并发服务器时,忽略了SYN Flood攻击。攻击者只发SYN,不发第三次ACK,服务端资源被耗尽。后来加了SYN Cookie才解决。三次握手虽然优雅,但也给了攻击者可乘之机。
3.3 四次挥手:优雅关闭的双向通道
关闭连接比建立连接更复杂。因为TCP连接是全双工的,双方都可以同时发送和接收。关闭时,每一方向都要单独关闭。
- 第一次挥手(FIN):主动关闭方(假设是客户端)发送FIN包,序列号为u。客户端进入FIN_WAIT_1状态。意思是:“我说完了,不再发数据了。”
- 第二次挥手(ACK):被动关闭方(服务端)回复ACK包,确认号为u+1。服务端进入CLOSE_WAIT状态。客户端收到后进入FIN_WAIT_2状态。意思是:“知道了,但我可能还有话要说。”
- 第三次挥手(FIN):服务端发完剩余数据后,发送FIN包,序列号为v。服务端进入LAST_ACK状态。意思是:“我也说完了,可以关了。”
- 第四次挥手(ACK):客户端回复ACK包,确认号为v+1。客户端进入TIME_WAIT状态,等待2MSL后关闭。服务端收到后直接关闭。意思是:“好的,确认收到,拜拜。”
为什么是四次?因为服务端收到FIN后,可能还有数据没发完,不能立刻关闭。所以先回ACK,等数据发完再发FIN。这中间可能有一个时间差。
注意:TIME_WAIT状态持续2MSL(最大报文段生存时间,通常30秒到2分钟)。这个状态是为了保证最后一个ACK能到达对方。如果ACK丢了,对方会重发FIN,客户端还能再回ACK。我曾经在生产环境遇到过大量TIME_WAIT连接导致端口耗尽的问题,后来调整了系统参数才解决。
3.4 状态转换图:TCP的完整生命周期
下面这张图是我自己画的TCP状态转换图。它把三次握手和四次挥手串在了一起。你盯着看五分钟,就能理解TCP的整个生命周期。
这张图里,左侧是客户端的状态机,右侧是服务端的状态机。虚线代表网络上的报文交互。你从上往下看,就是一次完整的TCP连接生命周期:从CLOSED开始,经过三次握手到ESTABLISHED,再经过四次挥手回到CLOSED。
有几个状态我特别想提醒你注意:
- LISTEN:服务端在等客户端来连。这是被动打开。
- SYN_SENT:客户端发了SYN,在等回复。这是主动打开。
- SYN_RCVD:服务端收到了SYN,发了SYN+ACK,在等第三次握手。
- ESTABLISHED:连接建立成功,可以传数据了。这是唯一能传数据的状态。
- TIME_WAIT:主动关闭方在等2MSL。这个状态很关键,但也容易出问题。
核心理解:粘包拆包只发生在ESTABLISHED状态。三次握手和四次挥手本身不涉及数据收发,但它们决定了连接什么时候可用、什么时候不可用。如果你在连接还没建立时就发数据,或者连接已经关闭了还在发,那就会出各种奇怪的问题。
3.5 状态转换与粘包拆包的关系
你可能会问:状态转换跟粘包拆包有什么关系?关系大了。
举个例子。我曾经在项目中遇到一个bug:客户端发完数据后立刻调用close(),服务端还没读完数据,连接就断了。结果服务端只收到了半个包,剩下的数据丢了。这就是典型的半关闭问题。
再比如,TIME_WAIT状态如果太多,会导致端口被占用,新连接无法建立。这时候如果客户端重试,可能会把数据发到旧的连接上,造成数据错乱。嗯,这虽然不是典型的粘包,但本质上是同一个问题:TCP的字节流边界不清晰。
所以,理解TCP的状态转换,是理解粘包拆包的前提。你只有知道连接什么时候是稳定的、什么时候在关闭、什么时候在等待,才能正确地设计应用层的协议。
个人经验:我建议你在写网络程序时,一定要处理好连接的状态变化。比如,在收到FIN后,不要立刻关闭socket,而是先读完缓冲区里的数据。我曾经因为没处理好这个,导致线上服务丢了一堆用户请求,被老板骂了一顿。