聊一聊TCP:三次握手我背了100遍,TIME_WAIT还是把我问住了
一、为什么今天还要聊TCP?
说实话,TCP这东西,我面试前背过无数遍。三次握手、四次挥手、流量控制、拥塞控制……背得滚瓜烂熟。但真到线上出问题时才发现:背过不代表懂过。
比如有一次,我们服务突然出现大量端口分配失败,查了半天发现是TIME_WAIT状态太多了。还有一次,客户端说“请求超时”,抓包一看,超时重传或RTO重传一直在发生。
所以今天我决定把这些年踩过的TCP坑,和自己重新理解过的特性,好好捋一遍。不写得太教科书,尽量讲清楚“为什么”。
二、先看一眼TCP长什么样
TCP的报文头其实不算复杂,但有几个字段你最好记住,因为后面大部分特性都靠它们:
序列号(Seq):解决乱序和重复的问题。每个字节都有自己的编号。
确认号(Ack):告诉对方“我收到几号之前的所有数据了”。
窗口大小(Window):告诉对方“我还能收多少,你慢点”。
标志位:SYN用来建立连接,ACK用来确认,FIN用来关闭连接,RST用来强行重置。
你可以把TCP想象成一个带签收和编号的快递系统——IP层只管扔包裹,TCP负责拼顺序、补丢件。
三、三次握手:不只是背个“SYN、SYN+ACK、ACK”
握手的目的很简单:让双方都确认彼此的收发能力是正常的。
过程我就不重复默写了,但我想聊聊两个被问烂的问题:
为什么是三次,不是两次?
如果只有两次握手,服务端发了SYN+ACK就认为连接建立了,但万一这个SYN是很久之前延迟到达的旧包呢?客户端早就忘记它了。这时候服务端会白白等一个不存在的连接。
三次握手可以保证:客户端确认了自己的ACK能被服务端收到,历史连接不会意外建立。这是防止历史连接初始化的核心目的。
SYN洪水攻击是怎么回事?
攻击者只发SYN,不回复ACK。服务端每收到一个SYN就分配资源等待,很快把半连接队列塞满。
常规解法是SYN Cookie——不提前分配资源,而是根据这个SYN算出一个cookie,放在SYN+ACK里,只有收到正确的ACK才真正建立连接。
四、四次挥手:TIME_WAIT是个好人
挥手比握手复杂一点,因为TCP是全双工的:双方都要单独关闭自己的方向。
客户端发FIN,表示“我不再发数据了”,但还可以收。
服务端回ACK,然后发自己的FIN。
客户端最后回ACK。
这里有一个经常被低估的状态:TIME_WAIT。
TIME_WAIT为什么要等2MSL?
MSL是报文最大生存时间。
两个原因:
保证最后一个ACK能被服务端收到。如果ACK丢了,服务端会重发FIN,客户端还能再回应。
让旧连接残留的包在网络上消失,不会干扰新连接。
五、可靠传输:丢包了怎么办?
TCP的可靠性不是靠玄学,是靠确认 + 重传。
累积确认
发送方不需要等每一个包的回复,可以连续发一个窗口。接收方回复Ack=100,就表示“99号及之前全收到了”。这叫累积确认。
重传策略
超时重传(RTO):发出去一个包,计时器到了还没收到ACK,就重传。但RTO不能固定,因为网络延迟在变。TCP会动态测量RTT,然后计算RTO。
这个机制的问题:如果网络只是轻微丢包,你要等一个RTO(通常几百毫秒),太慢了。
于是有了快速重传:当发送方连续收到3个相同的Ack(比如三个Ack=100),就说明100号包丢了,立即重传,不等超时。
再后来,又有了SACK,它解决了“我不知道到底丢了哪几个”的问题——可以在ACK里明确告诉对方“我缺了100到200之间的某些段”。
六、流量控制:你慢点,我快接不住了
流量控制解决的是接收方能力不足的问题。
接收方把自己的剩余缓冲区大小放在Window字段里告诉发送方。发送方严格遵守这个窗口,不能多发。
如果窗口变成0呢?发送方会定期发零窗口探测去探测窗口有没有打开,防止死锁。
还有一个经典问题:糊涂窗口综合征。如果接收方每次只打开很小的窗口,发送方就只发很少的数据,效率极低。常见的解法是Nagle算法——把小包攒一下再发,但注意在实时性要求高的场景(比如游戏)可能需要TCP_NODELAY。
七、拥塞控制:大家都慢点,前面真的堵了
流量控制是对端的问题,拥塞控制是整个网络的问题。
TCP假设:丢包 = 网络拥塞。
核心是四个算法:
慢启动:刚开始不知道网络容量,从一个小窗口开始,
指数增长,直到遇到丢包或达到慢启动阈值。拥塞避免:进入这个阶段后,
线性增长,小心试探。快速重传 + 快速恢复:发生快速重传后,不回到慢启动,而是把窗口减半,继续拥塞避免。
这个模型在当年是合理的,但在高带宽低延迟的网络里,等丢包再降速其实已经晚了。
所以谷歌提出了BBR算法,不再是“丢包驱动”,而是测量实际带宽和RTT来主动调速。现在很多内核已经默认支持了。
八、几个你一定会遇到的坑(实战向)
1. CLOSE_WAIT 堆积
CLOSE_WAIT出现在被动关闭方。如果它收到FIN后不回FIN,就会卡在这个状态。绝大多数情况是代码忘了关socket。
2. 粘包问题
TCP是流式协议,没有边界。你发了两个独立的包,接收方可能一次读完。
解法不靠TCP,靠应用层:固定长度、特殊分隔符、或者TLV或类型-长度-值格式。
3. 如何快速看连接状态
netstat -an | grep TIME_WAIT | wc -lss -state time-wait
后者更快。
九、总结一句人话
TCP的核心哲学很简单:牺牲一点实时性,换来极高的可靠性。
靠连接的建立与关闭、确认与重传、流量控制、拥塞控制这四个轮子,跑起了整个互联网的可靠传输。
当然它也有缺点——比如队头阻塞问题,所以现在像QUIC这样的协议(基于UDP实现TCP的可靠性)正在变得流行。
但对每一个后端开发者来说,TCP依然是绕不开的一课。懂它,不是为了背面试题,而是为了在线上出问题时,抓包能看出门道。
