编程久了会发现一个很有意思的现象:好多网络协议的设计,本质上都是在解决人和人打交道时的那点事。TCP三次握手与四次挥手,说白了就是两个网络程序在数据正式开聊之前,先互相确认“你在吗?我能跟你说话吗?”以及聊完之后“我得走了,你确定你也走了?”的过程。这俩过程被不少人比喻成“社恐”的破冰和告别,我觉得挺贴切,但仔细想想,又不是单纯地打两次招呼那么简单。
这篇文章我不打算给你堆砌那种“客户端发送SYN,服务器回复SYN+ACK”的教科书式图解。我打算从一个实际排查过无数连接问题的老开发者的视角,把这俩过程里最容易被忽略、最容易理解错的细节掰开揉碎了讲清楚。包括为什么是三次而不是两次,为什么挥手非要四次而不是三次,以及那些你在Wireshark里看到的奇奇怪怪的包到底是怎么回事。如果你是刚接触网络编程、或者写了好几年代码但一遇到连接问题就头皮发麻的开发者,这篇文章应该能给你一些代码之外的底气。
1. 内容整体设计与思路拆解
1.1 为什么非得有“握手”和“挥手”这些仪式
我们先把TCP想象成一部对讲机,而且是那种必须双方都按下才能通话的型号。你拿起对讲机,第一步肯定是喊一嗓子“喂,在吗?”——这就相当于发送一个SYN报文。对方听到了,回一句“在呢,你说!”——这相当于SYN+ACK报文。然后你为了保证通信可靠,再说一句“好嘞,那我开始说了”——这就是ACK报文。这个过程结束,才算建立起一个双方都认可的、可持续对话的连接。
那么问题来了,为什么叫“三次握手”?两次不行吗?四次行不行?这里有一个非常重要的基石理论,叫做防重复与防失效。假设网络里有一个迟到的旧报文,是上一次连接时客户端发出的SYN。如果只有两次握手,服务器收到这个迟到的SYN,会以为客户端想建立新连接,于是立刻分配资源并回复ACK,然后傻等客户端发数据。但实际上客户端根本没想连,这个连接就成了服务器上一个白白挂着、占用内存和端口的“僵尸连接”。
三次握手的存在,就是为了让发起方(客户端)能确认“我这个SYN是不是新鲜的”。如果服务器端回应了SYN+ACK,客户端发现这个ACK对应的序号是自己这次新发的,就会继续完成握手。如果旧SYN到达服务器后,服务器回了确认,但客户端一看这个确认的序号不是自己期望的,就默默丢掉,不会回复ACK。服务器迟迟等不到最终的ACK,就知道这个连接不该建立,于是释放资源。所以三次握手本质上是双方各退一步,用最小的通信成本,换取消灭历史混淆的确定性。
1.2 四次挥手:不是“复杂”,而是“半关闭”的优雅
握手是建立“双工”通道,双方都可以同时收发。而挥手,是拆除这个通道。为什么拆比建还多一次?原因在于TCP支持半关闭状态,也就是说,我可以单方面宣布“我不再发送数据了”,但我仍然可以继续接收你发送的数据,直到你也宣布不再发为止。
所以四次挥手的过程是这样的:一端说“我说完了,我要关闭发送方向”(发FIN),另一端收到后回应“我收到了你的关闭请求”(发ACK)。但此时,收到FIN的这一端可能还有一些数据没发完,所以它不能立刻也发FIN,只能等自己的数据都发送完毕,再发一个FIN表示“我也说完了”。这个等待的过程,就是中间那个“额外”的ACK和FIN之间的间隔。
你可以把它想象成挂电话:一个人说“我挂了啊”——这是FIN;另一个人说“行,知道了”——这是ACK。但挂电话的人听到“知道了”之后,并没有立刻把听筒放下,因为他知道对方可能还有一句“明天见”没说呢。直到对方也说出“我也没什么说的了,我挂了”并传来忙音,这通电话才算真正结束。这也就是四次挥手存在的合理性。如果像握手那样三次,就意味着收到FIN的一方必须立刻同时回ACK和FIN,显然不现实,因为对方必须把手里没发完的数据发干净。
1.3 类比公式:用“社恐”视角理解整个过程
如果用“社恐”场景来映射,那这个过程就更有意思了。一个社恐要跟另一个社恐建立对话,他不敢直接说一大堆话,而是先小声问一句“你在吗?”(SYN)。另一个社恐虽然也内向,但听到问话,还是鼓足勇气回了一句“在的,你呢?”(SYN+ACK)。第一个社恐一听,对方回应了,并且明确知道对方是在回应自己刚才那句问话,于是放心地补了一句“我也在的,那我们现在可以聊聊了”(ACK)。整个过程,俩人都只说了半句话,但确认了彼此的存在和意愿,而且排除了对方是不是在跟别人说话的干扰。
告辞的时候也一样。一个社恐想结束这尴尬的对话,但他不敢突然挂断,于是先说“那个……我这边有点事先挂了哈”(FIN)。另一个社恐接收到这个消息,心里松了口气,但嘴上还得客气一下“嗯嗯,好的,我知道了”(ACK),然后他心里想的是“既然你要走,我得赶紧把最后一句话说完,不然以后没机会了”。于是他又补了一句“那我也先挂了哈”(FIN)。第一个社恐收到这个回执,明白对方也没有要说的了,彻底挂断(ACK)。你看,这就是为什么握手三次、挥手四次。这个比喻不算牵强,因为“社恐”本质上是小心翼翼、害怕误判、避免尴尬,这恰恰是TCP协议栈设计的核心目标——避免连接建立时的歧义,避免连接关闭时的数据丢失。
2. 核心细节解析与实操要点
2.1 序号(Seq)和确认号(Ack):握手的灵魂所在
很多初学者容易忽略一个最关键的点:三次握手交换的SYN和ACK,并不是单纯的控制信号,它们身上背着一个特别重要的数值——序号(Sequence Number)。
第一次握手,客户端随机生成一个初始序号,我们叫它ISN_c,比如1000。这个序号不是固定的,是随机生成的。为什么要随机?为了防止被猜测。如果序号是固定的,攻击者就能伪造一个看起来合法的连接,把数据塞进来,这就是所谓的序号预测攻击。第二次握手,服务器端返回SYN+ACK时,会带两个数字:一个是对客户端序号的确认,值是ISN_c + 1,意思是“我收到了你的第1000号数据,我期待你下一次发给我1001号”;另一个是服务器自己的初始序号ISN_s,比如3000。第三次握手,客户端必须回复一个ACK,确认号是ISN_s + 1,告诉服务器“我收到了你的3000号,我期待你接下来发3001号”。
这个确认机制往后贯穿了整个TCP数据传输过程。每一次发送数据,都会携带当前数据段的起始序号;每一次接收数据,都会回复一个确认号来表示“我下次想要哪个序号”。这就好比你在跟朋友逐字核对一份文件:“我念到第3行第5个字了,你确认一下,然后我继续下面一个。”序号和确认号配合,才使得TCP可以在不可靠的网络中,实现可靠、有序、不重复的字节流传输。
实操中需要特别注意:Linux内核默认的TCP初始序号是随机生成的,但如果你用tcpdump抓包,会发现它们通常不是从0开始,而是类似1102919228这种大数字。这是内核通过时间+随机数混合计算出来的,目的就是为了防止序号被外界猜中。如果你在做网络抓包分析,看到序号很大,不要惊讶,这是正常的。
2.2 状态机:从CLOSED到ESTABLISHED再到TIME_WAIT
TCP其实是一个非常讲究“名分”的协议。连接建立和释放的过程,在系统层面通过一组状态迁移来管理。你在netstat里看到的每一行,背后都是一个状态机的实例。握手阶段的状态迁移是这样的:
客户端进程打开连接时,状态从CLOSED变成SYN_SENT,然后发送SYN。收到服务器的SYN+ACK后,状态变为ESTABLISHED,然后回复ACK。服务器端呢,从LISTEN状态收到SYN,进入SYN_RCVD状态,回复SYN+ACK,收到客户端的ACK后,状态才变成ESTABLISHED。
这里有一个非常实用的排查技巧:如果你发现服务器上大量连接停滞在SYN_RCVD状态,说明客户端拿到SYN+ACK之后没有回复ACK。最常见的原因有两个,一是客户端那边把回复的ACK包丢了,或者被防火墙拦截了;二是客户端收到SYN+ACK后发现这个连接不对,直接悄悄丢弃,服务器就卡在SYN_RCVD里干等。
挥手阶段的状态迁移更有意思。主动关闭的一方,发送FIN后状态变为FIN_WAIT_1,收到对端的ACK后变为FIN_WAIT_2,等待对方的FIN。当收到对方的FIN并回复ACK后,进入TIME_WAIT状态,这个状态会持续2MSL(报文最大生存时间,Linux默认60秒)后才会变为CLOSED。被动关闭的一方,收到FIN后从ESTABLISHED进入CLOSE_WAIT状态,这个时候它要赶紧把还没发完的数据发完,然后发送FIN并进入LAST_ACK状态,收到对方最后一个ACK后,变为CLOSED。
这里最霸道也是最容易被忽略的就是TIME_WAIT。很多人不知道为什么主动关闭的一方要等2MSL那么久,白占着端口不放。其实就两个原因:第一,确保最后一个ACK能到达对方。如果这个ACK丢了,对方会因为迟迟收不到ACK而重发FIN,如果这时候主动方已经关掉连接了,那就收不到这个重发的FIN,自然也无法回应,对方只能一直卡在LAST_ACK里。第二,确保网络上属于本次连接的所有报文都已经消失,避免它们干扰后续复用同一个端口的连接。这个在我们写高并发服务的时候尤其痛苦,大量的TIME_WAIT会把端口耗尽,后面我会专门讲这个。
2.3 MSS与Window:决定三次握手能不能“一次到位”
除了SYN、ACK、序号这老三样,三次握手还有一个容易被忽略但非常关键的参数协商过程——MSS(Maximum Segment Size,最大报文段长度)和窗口大小(Window Size)。
在第一次和第二次握手时,双方都会在TCP头部声明自己的MSS。比如客户端说“我能接收的最大单段数据是1460字节”,服务器说“我的是1400字节”。这个值是按照双方网卡的MTU(最大传输单元)再减去IP头和TCP头算出来的。常见的以太网MTU是1500字节,IPv4头一般是20字节,TCP头一般是20字节,所以MSS通常是1500 - 20 - 20 = 1460。
这个MSS协商结果直接决定你后续一个包能塞多少真正有用的数据。如果你不关注这个,可能一个write调用写了几千字节,TCP栈会自动帮你拆成多个段发送,每个段大小以MSS为上限。在排查“为什么我的网络吞吐这么低”的时候,MSS是一个特别容易中招的位置。比如你用了TCP隧道或PPPoE拨号,MTU变化导致MSS变小,如果两端的MSS没有协商好,就可能出现“能建立连接,但一传大文件就卡死”的诡异现象。
窗口大小则是动态调整的,它告诉对方“你最多能一次性发给我多少数据,不用等我确认”。这个数值在此后整个连接的生命周期里会不断变化,是流量控制和拥塞控制的基础。三次握手时交换的是一个初始窗口大小,比如65535,但如果双方启用了窗口缩放(Window Scale),这个数值可以扩展到更大。这也是为什么有些网络优化教程会建议调整内核参数来放大窗口,从而提升高延迟链路下的吞吐量。
3. 实操过程与核心环节实现
3.1 用一次纯手动抓包,看清三次握手全过程
纸上谈兵没用,我来带你实际操作一次。假设我们在本地Linux机器上用Python写一个极简的TCP服务端,端口监听在8888。然后用tcpdump抓包,再配一个客户端去连接它,看看Wireshark或者tcpdump里到底能看到什么。
先写一个最简单的服务端(注意,这里只是演示,不需要多线程):
import socket server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.bind(('0.0.0.0', 8888)) server_socket.listen(5) print("server listening on 8888...") while True: conn, addr = server_socket.accept() print(f"accepted connection from {addr}") conn.send(b"hello, tcp") conn.close()然后在另一个终端开启抓包:
sudo tcpdump -i lo -nn -XX -c 3 port 8888这里-i lo是抓本地回环接口,-nn不做域名和端口解析,-XX显示十六进制和ASCII。然后我们运行客户端:
python3 -c "import socket; s = socket.socket(); s.connect(('127.0.0.1', 8888)); print(s.recv(1024))"你会看到按下回车后,抓包终端立刻刷出三条记录:
第一次抓到的包是IP 127.0.0.1.x > 127.0.0.1.8888: Flags [S], seq 4235874485,这个就是SYN包,客户端发起连接,告诉服务器自己的初始序号。第二次抓到的包是Flags [S.], seq 1312916491, ack 4235874486,这个S.表示SYN+ACK,服务器确认了客户端的序号,同时带上了自己的序号。第三次抓到的包是Flags [.], ack 1312916492,这个.表示纯ACK,客户端告诉服务器“我收到你的序号了,下次你从1312916492开始发”。
这三条记录对应关系非常明确,但很多人抓包时会被一堆别的包干扰,所以这里建议你只抓3个包,就刚好覆盖一次完美的握手。有个细节值得注意,第一条SYN包的序号是4235874485,第三条ACK的确认号是1312916492,恰好是第二条包里服务器序号加1。这样你才能真正理解为什么说TCP是全双工可靠协议——每一个发送的包都携带序号,每一个确认都指向对方下一个期待发送的序号。
3.2 模拟一次挥手,以及在TIME_WAIT里看到的真相
接着我们看关闭过程。还是上面的例子,conn.close()在服务器端先执行,所以服务器主动关闭。你抓包的话会看到四个包:第一个是Flags [F.],服务器发FIN;第二个是Flags [.],客户端回ACK;第三个是Flags [F.],客户端也发FIN;第四个是Flags [.],服务器回最后ACK。
这里值得注意是,在正常情况下,如果客户端紧跟着也调用了close(),那么第二次和第三次之间的间隔会非常短,短到你甚至怀疑它们是不是应该合并成一个包。但协议要求必须是两个独立的包,因为客户端回完ACK后,还需要把底层缓冲区里剩余的数据都发出去,这需要时间。所以如果你抓到了一个FIN和一个ACK混在一起带F.标志且同时包含确认号,那说明这个包里既带有FIN标志,也带有对之前数据的确认,但这不是同时发起FIN,而是TCP的延迟确认和捎带确认机制发挥了作用。
在客户端主动关闭的场景下,你会在netstat里看到大量的TIME_WAIT状态条目。我踩过一个很深的坑:一个Java服务刚从BIO模型迁移到Netty模型,依然主动short-lived连接,结果线上频繁出现Cannot assign requested address。一查ss -tan,发现几千个TIME_WAIT把临时端口池全部占满了。
解决办法有两个层次,第一层是治标,内核参数调小TIME_WAIT回收时间,或者开net.ipv4.tcp_tw_reuse。但这里要特别提醒,在TCP时间戳开启的前提下,tcp_tw_reuse才安全。这个参数允许内核把处于TIME_WAIT状态的连接重新分配给新的连接,前提是新的连接的时间戳必须大于旧的连接最后记录的时间戳。如果你的业务方和服务器之间有时钟不同步的问题,这里就可能出现数据串包。第二层是治本,既然是短连接导致的问题,那就排查为什么短连接这么多。在HTTP/1.1场景,尽量用连接池和keep-alive,让连接复用起来。这才是最终解药。
3.3 用Python构造一个伪握手的实验(了解原理即可)
如果你想更深入地看三次握手的报文构成,可以不用真实连接,直接用原始套接字自己构造一个SYN包发出去。不过这需要root权限,而且代码相对绕。我提这个不是让你在生产环境去搞,而是帮你理解网络协议栈到底在你眼皮底下做了什么。
一个很简单粗暴的方式是用Python的scapy库:
from scapy.all import IP, TCP, send ip = IP(dst="127.0.0.1") syn = TCP(dport=8888, flags="S", seq=1000) send(ip / syn)这个包发出去之后,如果你服务端在监听,它就会回一个SYN+ACK。但因为你没有继续回ACK,连接建立不起来。这时候你去服务端执行netstat,大概率能看到一条SYN_RCVD状态的连接,挂着几秒钟然后消失。这个实验能让你直接感受到“三次握手如果不完成,服务端到底会不会一直占用资源”——答案是,它不会永远占用,系统有超时回收机制,但会积压短暂失效的半连接,数量大了一样能把你的服务拖垮。这也是SYN Flood攻击的基本原理,虽然我们不讨论攻击,但理解它的机制防患于未然是很有价值的。
4. 常见问题与排查技巧实录
4.1 握手阶段“卡住”怎么办:SYN_SENT和SYN_RCVD的奥妙
服务端能监听,但客户端就是连不上,这种问题几乎每个后端开发都遇到过。按照我踩坑的经验,先不用急着看业务代码,直接看状态。
如果你在服务器上执行netstat -apt看到大量SYN_RCVD,那就说明服务器收到了SYN,也回复了SYN+ACK,但迟迟得不到客户端的ACK。这时候优先排查下防火墙。很多云服务器的安全组策略、iptables规则,会把出方向的SYN+ACK给拦了。这一条我印象很深:有一次调试一台生产服务器的端口,明明nc -vz显示成功,但客户端老是超时。后来一查,是防火墙配置了iptables -A OUTPUT -p tcp --tcp-flags ALL SYN,ACK -j DROP,也就是只放行了SYN,禁止了SYN+ACK。这种配置多半是安全策略有瑕疵,把合法回包也拦了。
如果你在客户端上看到的是SYN_SENT一直没有变成ESTABLISHED,那问题就更靠前了,可能是SYN包根本没出去。有一个极端的案例:客户端的默认路由配错了,导致SYN出了网卡但不知道往哪走;还有的情况是客户端本地有其他程序占用了某个端口,你也抓不到包。
这里我给一个推荐排查顺序:第一步,ip route看路由通不通;第二步,iptables或云安全组看防火墙拦没拦;第三步,ss -ant看状态堆积在哪一侧;第四步,tcpdump -i eth0 port 你要测的端口看包里究竟有没有握手包,以及握手包从哪里开始丢。很多时候,看包比看代码快得多。
4.2 挥手阶段兄弟问题:CLOSE_WAIT堆积如山
相对于TIME_WAIT,服务器上更怕的是CLOSE_WAIT堆积。CLOSE_WAIT是被动关闭方收到FIN、回完ACK后进入的状态。按照设计,这个时候你应该赶紧把资源释放掉,发FIN进入LAST_ACK。但如果你的代码里忘记调用close(),或者连接对象没被垃圾回收,连接就会永远停在CLOSE_WAIT,文件描述符被占用,最终把进程的文件描述符耗尽,报“Too many open files”错误。
我记得有一个金融类接口,对接第三方支付回调时,业务线程一直在等待一个永远不返回的响应,导致连接迟迟不关闭,CLOSE_WAIT堆积到几千个。排查时关键看状态持续时间,如果一个CLOSE_WAIT持续了好几分钟还不变,那基本可以断定是业务逻辑漏了关闭动作。另外一个常被忽视的点是,如果你在做反代(比如Nginx),上游服务器主动关闭了连接,反代服务器没有及时关闭与客户端的连接,也会造成大量CLOSE_WAIT。这种情况常见于反代配置了proxy_read_timeout太短,上游响应超时,但反代没有立刻把连接断干净。
4.3 异常的重传:乱序、丢包与DUP ACK的真相
偶尔你会抓包发现大量TCP Dup ACK或者TCP Retransmission,这些都不是握手挥手的错,而是传输阶段的问题。Dup ACK的意思是接收方收到一个乱序数据包,它发现自己期待的序号还没到,但又不能干等着,于是只能重复确认上一次已经确认过的东西,提醒发送方“我还在等你那个序号”。当发送方连续收到三次Dup ACK,就会立刻重传那个可能丢失的包,不用等超时计时器。这就是快速重传机制。
这个机制在正常网络状况下可能无所谓,但如果拥塞严重,会发生“假重传”和“重传风暴”。比如你用无线网络在局域网里传文件,信号不稳定,哪怕有一小会儿丢包,就能触发快速重传,数据包凭空多出一倍。如果你在抓包分析性能问题,看到大面积的Dup ACK,先别急着骂TCP协议,检查一下是不是开了巨型帧(Jumbo Frame),两边MTU不一致,导致大量分片丢失。这种问题在云服务器上尤其阴险——明明在同一内网,但宿主机配置不一样,就可能出现MSS不匹配。这时你可以在服务端执行ping -M do -s 1472 目标IP来测试MTU,1472是1500 - 20(IP) - 8(ICMP)算出来的。如果这条命令返回“Frag needed”,那就说明路径上有更小的MTU,需要考虑调整MSS钳制。
4.4 关于状态和参数的一个速查表
我整理一张放在工程里可以直接用的速查表,涵盖连接生命周期常见状态的含义和对应动作。这张表不是纸上谈兵,每一行都是从实际事故里提炼出来的。
| 状态 | 含义 | 可能原因 | 推荐动作 |
|---|---|---|---|
| SYN_SENT(客户端) | 客户端已发SYN,等待SYN+ACK | 路由不通、防火墙丢弃出包、对方未监听 | 检查路由、防火墙、端口监听 |
| SYN_RCVD(服务端) | 已收SYN,回SYN+ACK,等ACK | 客户端收到包但丢弃、防火墙拦ACK、半连接队列满 | 看防火墙、看半连接队列大小 |
| ESTABLISHED | 连接已建立 | 正常 | 无 |
| FIN_WAIT_1 | 已发FIN,等ACK | 正常关机路径 | 观察是否快速进入FIN_WAIT_2 |
| FIN_WAIT_2 | 已收到ACK,等对方FIN | 对方不关闭,可能是半关闭 | 如果长时间存在,检查对端程序 |
| CLOSE_WAIT | 收到FIN,正在准备关闭 | 程序未调用close或连接泄漏 | 检查业务代码,修复连接泄漏 |
| LAST_ACK | 已发FIN,等最后ACK | 正常被动关闭中 | 检查是否卡住 |
| TIME_WAIT | 主动关闭后等待2MSL | 正常,但数量过多会占端口 | 调整tw_reuse、tw_recycle或复用连接 |
| CLOSED | 无连接 | 正常 | 无 |
针对需要调内核参数的朋友,我补充几句。Linux下/proc/sys/net/ipv4/下有几个关键文件能直接影响握手挥手行为:tcp_syn_retries控制SYN重发次数,默认是6,即1秒、2秒、4秒、8秒、16秒后总共重发6次,最后放弃。tcp_max_syn_backlog控制半连接队列长度,如果SYN洪水超过这个值,新来的SYN会被直接丢弃。tcp_fin_timeout和TIME_WAIT时长相关,但在新版内核不是你简单改个数字就完事。调参前建议看一眼当前内核文档或者使用sysctl -w临场实验,避免一次参数变动把线上搞崩。
5. 一些非常规但实用的工具与调试心得
5.1 用tcpdump和Wireshark做一次规范的分析流程
前面已经提到过tcpdump,但这里我要强调一个完整的分析流程,而不是只敲一个命令。我在实际工作中,分析TCP问题时几乎必做下面这几步。
第一步,在客户端和服务端同时抓包。如果问题发生在跨地域的网络上,那你必须在两端弄到包,单独抓一端的包很容易误判。服务端执行sudo tcpdump -i eth0 -s 0 -w server.pcap port 8888,客户端执行同样的命令但写到client.pcap。注意加-s 0,意思是抓全包,不要截断,否则后面分析MTU分片问题时就会因为缺少数据而抓瞎。
第二步,用Wireshark打开pcap文件,右键任意一条TCP流,选择“Follow TCP Stream”。这一步能完美展示这条连接从握手到挥手所有的报文,按时间顺序列出Seq、Ack、Flags。大多数问题会在这一眼看出端倪。比如你会发现连接只进行到了三次握手的第二步,或者挥手时只看到两个FIN而没有最后一个ACK。
第三步,过滤并用统计视图。Wireshark里有个菜单叫“Statistics -> TCP Stream Graph -> Time-Sequence Graph”,它能画出序列号随时间变化的曲线。如果曲线出现一段水平的“平台”,说明数据在某个阶段停滞了,接收窗口可能满了,或者发送方在等待确认。配合I/O Graph看吞吐量,几乎可以锁定问题出在哪一秒。
5.2 如何模拟网络故障来验证你的理解
这个方法我强烈建议测试团队用起来。不要等到生产环境出了网络问题再排查,而是用工具主动制造故障,观察TCP状态机怎么应对。最常用的工具是tc,在Linux上可以模拟延迟、丢包和乱序。
比如你想看看丢包对三次握手的影响:
sudo tc qdisc add dev eth0 root netem loss 30%这个命令让eth0口上的出方向包丢失30%。然后你再跑一遍上面的Python客户端和服务端代码,你会发现连接大概率连不上,抓包能看到大量的TCP SYN重传。为什么?因为第一条SYN有30%概率丢失,客户端收不到SYN+ACK,只能在超时后重新发SYN。原来SYN重试间隔是1秒、2秒、4秒、8秒、16秒,所以你感知到的是明显的卡顿,而不是立即失败。模拟这个场景之后,你对“重传超时”的感受会比看一百篇文章都深刻。
再比如你想验证快速重传机制,可以用tc netem dup 10%来模拟重复包,或者用delay 100ms制造高延迟。高延迟环境下你会发现TCP会试图用更大的窗口填充管道,吞吐量反而可能上升,但如果加上丢包,性能会急剧下降,因为每次丢包都要等RTO(重传超时)才能恢复。这些实验让你直观明白为什么卫星链路或者跨洋链路的TCP优化那么难做。
5.3 从性能角度看待三次握手:连接复用的本质价值
最后一个话题,也是很多做高并发服务的人最关心的话题之一:三次握手的开销到底有多大?值不值得花这么大力气去搞连接池?
我们来算笔账。一次标准的三次握手,在无丢包的情况下,至少需要一个RTT(往返时间)才能完成。假设客户端和服务器在同一城市的机房里,RTT约为0.5毫秒,那么握手的网络延迟就是0.5毫秒,但这只是开始。如果每次业务请求都重新建连,你还要承受每一次建立连接时的内核资源分配、协议栈开销、以及连接关闭时TIME_WAIT的煎熬。在跨地域或者跨国场景下,RTT可能到100毫秒以上,那么每次请求光花在握手上的时间就接近100毫秒——这还只是建立连接的时间,没算上数据传输。
所以工程上有一个铁律:能用长连接就不要用短连接。HTTP/1.1的Keep-Alive、HTTP/2的多路复用、数据库连接池、Redis连接池,本质上都是避开反复握手的开销。但是长连接也有自己的问题:保活。TCP的Keep-Alive默认2小时探测一次,这对很多需要快速感知对端掉线的场景来说太慢了。应用层需要自己做心跳机制,判断对端是否还活着。
我个人做长连接服务时,通常会在协议里设计一个Ping/Pong消息,客户端每30秒发一次Ping,服务端收到后回Pong,如果连续3次Ping没收到Pong,就判定连接已死,主动关闭。这样的设计能把“连接死亡”的发现时间从2小时压缩到90秒左右,代价只是每30秒几个字节的消息。理解三次握手的开销后,你会天然地认可这种设计,因为你不再把“建立TCP连接”看作一次廉价操作。
有一点要提示的是,在云原生环境里,服务实例频繁扩缩容,长连接的生命周期管理会变得更加复杂。当上游Pod被销毁时,原来到它的连接要优雅关闭,不然客户端会不停地重连、超时,造成雪崩。很多高可用方案会在服务关闭前先摘流量,然后等待存量请求跑完,再优雅退出。这背后依然是TCP连接状态机的约束在起作用——你有足够的时间来正常走完四次挥手,而不是让连接被操作系统强杀。
最后分享两个小观察
我调试TCP问题这么些年,最深的两个体会,一是永远不要靠猜,任何时候都要先看一眼抓包结果再下结论。二是在浏览器里看到网页转圈,往往不是网页本身的问题,而是那个不起眼的握手过程卡在了某个中间环节。有一次一个用户反馈“我们网站加载越来越慢”,排查到最后居然是一个CDN节点把SYN包给限速了,导致每次访问都要重传好几次SYN才能建立连接。
从学习途径来看,我一直觉得读书固然重要,但如果你真想彻底搞懂三次握手四次挥手,最好的办法是一边读RFC 793,一边自己动手抓包,再用tc去破坏网络,观察状态变化。经过这么一轮折腾,你对TCP就不再是学知识点,而是拥有了直觉。