1. 为什么每个搞网络编程的人,都得把TCP掰开揉碎看一遍
稍微接触过网络编程的人都绕不开TCP。不管是写Web服务、做即时通讯、处理流媒体,还是研究容器网络、Service Mesh,底层都是TCP在扛着。但很多人的问题在于:知道三次握手、四次挥手的名字,能背出SYN、ACK、FIN几个标志位,可真到了线上排查问题的时候,还是抓瞎。
比如你写一个客户端连服务端,突然超时了,到底是服务端没监听?防火墙把包丢了?还是半连接队列满了?又比如你压测的时候发现一大堆TIME_WAIT连接,导致端口不够用,应该调哪些参数?再比如服务端莫名其妙出现一堆CLOSE_WAIT,你会不会第一时间怀疑是代码里忘记调close了?
这些问题,如果只停留在"三次握手交换三个包"的层面,是答不上来的。真正有用的理解方式,是把TCP的状态机搞明白,把每个状态怎么进入、怎么退出、对应在Linux下是哪个函数触发的、在网络上表现为哪个包,全部串起来。这也是我这篇文章想做的事情:从协议原理到Linux网络编程的实际落地,把TCP的关键机制用大白话讲透。
这篇文章适合几类人:刚学完计算机网络、想在Linux下写点网络服务的初学者;写业务代码但偶尔被网络问题坑、想建立完整排查思路的后端开发;以及准备面试、想对TCP有更深理解的同学。不管你是哪一类,我保证你看完能对TCP有一个"立体"的认识,而不是停留在背流程图的阶段。
2. TCP的核心设计:在不可靠的网络上"假装"可靠
2.1 TCP到底解决什么问题
首先要搞清楚TCP出现的背景。IP协议只负责把数据包从一台机器送到另一台机器,它不关心包有没有丢、有没有乱序、有没有重复。这种"尽力而为"的传输方式在早期的互联网环境下是够用的,但一旦要传文件、传网页、传数据库数据,丢一个包就全乱了。
TCP的存在,就是在IP之上加了一层"可靠性机制"。它通过序列号、确认号、重传机制、流量控制和拥塞控制,让应用层感觉自己在用一条"专线"通信,虽然底层实际上是一堆可能在物理链路上丢失、乱序的IP包。
打个比方,IP协议像是快递公司的基础物流服务——包裹扔进网络就不管了。TCP则像是加了签收确认、退货重发、限流控制的增值服务。应用层把数据交给TCP,TCP负责保证这些数据完整、有序、不重复地到达对端。
2.2 TCP头部里藏着所有秘密
要理解TCP,头部字段是绕不过去的。相比IP头部,TCP头部信息量更大,但真正核心的就那几个字段。
源端口和目标端口各占16位,这个不用多说。关键是32位的序列号(Sequence Number)和32位的确认号(Acknowledgment Number),这两个字段是整个TCP可靠性的基石。序列号表示这个TCP段中第一个数据字节在整个数据流中的位置,确认号表示"我期望收到的下一个字节的序列号"。
举个例子,客户端发送一个序列号为1000、长度为500字节的报文段,那么这500字节占据序列号1000到1499。如果服务端完整收到,它回复的确认号就是1500,表示"我已经收到1499及之前的所有字节,下一个请从1500开始发"。
标志位里面,我们平时打交道最多的是SYN、ACK、FIN、RST这四个,另外还有PSH(推送数据给应用层)和URG(紧急指针)。后面讲三次握手和四次挥手时,实际上就是在看这几个标志位怎么配合。
还有一个经常被忽略但很重要的字段是窗口大小(Window Size)。它用于流量控制,告诉对端"我的接收缓冲区还能收多少字节"。窗口大小是动态变化的,这在分析TCP传输效率时很关键。
2.3 状态机:描述TCP生命周期的最佳工具
TCP不是一个"发完就结束"的协议,它是一个有状态的协议。从连接建立到数据传输再到连接关闭,连接会经历多个状态。在Linux下用netstat或ss查看连接状态时,大家见到的LISTEN、SYN_SENT、ESTABLISHED、FIN_WAIT_1、CLOSE_WAIT、TIME_WAIT等等,全是从这个状态机里来的。
理解状态机的关键,是把每个状态和发生的网络事件、本机调用的API函数对应起来。举个例子,服务端调用listen()之后进入LISTEN状态,客户端调用connect()发出SYN后进入SYN_SENT状态。这中间如果出了什么问题卡住了,到底卡在哪个状态,看输出就能大概定位。
我在实际排查问题的时候,第一件事永远是ss -tan看状态分布。宁可先花两分钟看一眼状态,也不要猜。很多奇奇怪怪的问题,比如连接建立慢、请求超时、连接被重置,看一眼状态分布就能缩小排查范围。
3. TCP三次握手:原理、细节与抓包验证
3.1 握手过程的全景拆解
三次握手(Three-Way Handshake)是TCP建立连接的过程,目的是让通信双方确认彼此的收发能力,并同步初始序列号。整个过程涉及三个报文段。
第一步,客户端发送一个SYN报文段,序列号设为x,然后进入SYN_SENT状态。这个报文段不携带应用层数据,但会消耗一个序列号。
第二步,服务端收到SYN后,如果同意建立连接,就回复一个SYN+ACK报文段。这个报文段里,SYN标志位被置位,序列号设为y,同时ACK标志位也被置位,确认号为x+1,表示"我收到了你的SYN,序列号x已经收下,下一个请发x+1"。服务端此时进入SYN_RCVD(也叫SYN_RECEIVED)状态。
第三步,客户端收到服务端的SYN+ACK后,回复一个ACK报文段,确认号为y+1。此时客户端进入ESTABLISHED状态。服务端收到这个ACK后,也进入ESTABLISHED状态。
这里有个细节很多人容易忽略:前两次握手各消耗一个序列号,第三次握手携带的ACK不消耗序列号(因为它不承载数据)。但如果第三次握手报文里捎带了应用层数据,那它就要消耗序列号了,这是TCP的捎带确认(Piggyback)机制。
3.2 为什么是三次,不是两次也不是四次
这个问题面试问烂了,但真要讲清楚得从两个角度来回答。
从"确认双方收发能力"的角度看:客户端发SYN,服务端收到,服务端知道了客户端的发送能力没问题、自己的接收能力没问题。服务端回SYN+ACK,客户端收到,客户端知道了自己的发送能力没问题、服务端的接收能力没问题,同时知道了服务端的发送能力没问题、自己的接收能力没问题。但此时服务端还不知道"自己的发送能力是OK的"——它不知道那个SYN+ACK有没有被客户端收到。所以必须有第三次ACK,服务端收到后,双方才互相确认了各自的发送和接收能力都正常。两次握手只能单向确认,做不到双向确认。
从"防止历史重复连接干扰"的角度看,这个原因更重要。考虑一种场景:客户端发送了一个SYN,因为网络拥堵导致这个SYN在网络上滞留了很久,客户端因为超时重传了另一个新的SYN。如果只有两次握手,服务端收到第一个迟到的SYN后就会建立连接,分配资源,但客户端其实已经用新连接在通信了,老连接白白浪费资源,甚至可能导致数据错乱。
有了第三次握手,客户端在收到服务端的SYN+ACK时,会检查这个确认号是否匹配自己当前连接的序列号。如果不匹配,客户端会发送一个RST报文段来终止这个历史连接。所以三次握手的第三个ACK,实际上是给客户端一个机会去"掐断"历史遗留连接。这也是RFC 793里明确提到的设计目的之一。
至于为什么不是四次,因为三次已经足够完成双向确认和历史连接清理了。四次握手本质上就是"服务端把SYN和ACK拆成两个包分别发",增加一次往返延迟不说,还完全没有带来额外的好处。所以TCP选择用三次握手达到性能与可靠性的平衡。
3.3 抓包实战:亲眼看看三次握手长什么样
理论说完了,还是得实操验证一下。在Linux上随手起一个服务端和客户端,用tcpdump抓包,就能看到三次握手完完整整的过程。
先用nc起一个监听端口,再在另一台机器上连接它,同时用tcpdump抓包:
# 终端1:抓取所有发往80端口的包 sudo tcpdump -i any port 80 -nn -S # 终端2:启动一个简易服务端 nc -l 80 # 终端3:发起连接 nc 192.168.1.100 80抓包结果里会出现三条关键记录。第一条是客户端IP.端口 > 服务端IP.80,标志位是[S],序列号是一个随机值(比如1543223421)。第二条是服务端回应的[S.],注意这个包同时带SYN和ACK标志位,抓包工具显示为[S.],序列号是另一个随机值,确认号是客户端的序列号加1。第三条是客户端的[.]标志包,只有ACK,确认号是服务端的序列号加1。
从这三次交互可以直观看到,每一次握手都同步了信息,而且序列号确实是随机初始化的。为什么初始序列号要随机?如果不随机,攻击者可以预测序列号,伪造TCP包注入数据,这就是TCP劫持攻击的基本原理。所以现代操作系统都采用随机初始序列号。
3.4 关于握手的一些深入细节
第一次握手发出的SYN如果丢了怎么办?TCP有超时重传机制。发送SYN后会启动一个定时器,默认情况下第一次超时重传间隔大概是1秒,然后每次翻倍:1秒、2秒、4秒、8秒,一直到达到系统上限(通常是127秒左右)。在Linux上,这个重传次数由net.ipv4.tcp_syn_retries控制,默认是6次。如果你遇到connect特别慢才报错,大概率就是在等SYN重传超时。
服务端收到SYN后,会把这个半连接放入半连接队列(也叫SYN队列),同时回复SYN+ACK。如果服务端短时间内收到大量SYN但一直不回ACK,可能是SYN Flood攻击,它会占满半连接队列,导致正常连接无法建立。Linux提供了net.ipv4.tcp_syncookies参数来应对,开启后服务端在队列满时不对SYN入队,而是通过计算一个cookie值放进SYN+ACK的序列号里,等客户端回ACK时再验证,这样就避免了资源耗尽。
另外,accept()函数和三次握手的关系也是一个常见误区。很多人以为调用accept()时内核才完成握手,实际上不是。内核在收到第三次ACK的时候就完成了握手,连接进入ESTABLISHED状态并放入全连接队列(accept队列)。accept()只是从全连接队列里取一个已经建立好的连接给应用层。所以如果全连接队列满了,即使三次握手完成了,应用层也来不及处理,内核可能会丢弃ACK,甚至发送RST,具体行为取决于tcp_abort_on_overflow这个参数。
4. TCP四次挥手:优雅关闭背后的复杂真相
4.1 挥手过程与状态迁移
TCP连接是全双工的,数据在两个方向上独立传输。这意味着关闭连接时,两个方向的传输都需要单独关闭,所以需要四次交互。
假设客户端主动发起关闭,整个过程如下。
第一次挥手:客户端发送FIN报文段,序列号设为u,然后进入FIN_WAIT_1状态。FIN表示"我的数据发送完了,我不会再发数据了"。
第二次挥手:服务端收到FIN后,回复ACK报文段,确认号为u+1。此时服务端进入CLOSE_WAIT状态,客户端收到这个ACK后进入FIN_WAIT_2状态。注意,服务端虽然已经回复ACK,但它还可以继续发送数据,因为服务端可能还有数据没发完。
第三次挥手:服务端把剩余数据发送完毕后,发送FIN报文段,序列号设为w,然后进入LAST_ACK状态。
第四次挥手:客户端收到服务端的FIN后,回复ACK报文段,确认号为w+1,然后进入TIME_WAIT状态。服务端收到这个ACK后直接进入CLOSED状态。
4.2 为什么必须四次,以及"半关闭"概念
如果说三次握手是TCP为了效率做的优化,那四次挥手则是因为全双工特性下的必然选择。关键区别在于:SYN和ACK可以合并,因为服务端在收到SYN时不需要额外准备时间,本身就可以立即回复。但FIN和ACK在被动关闭方那里往往不能合并——服务端收到客户端的FIN,只是表示客户端不发了,服务端自己的数据可能还没发完,它得先把剩余数据推送给应用层,等应用层完成收尾工作,才能发送FIN。
这正是"半关闭"(Half-Close)概念的体现。通过shutdown()函数,TCP允许只关闭一个方向的数据传输,但另一个方向仍然可以继续传输。比如客户端调用shutdown(sock_fd, SHUT_WR)后,虽然不能再发数据,但依然可以接收服务端的数据,直到收到服务端的FIN。
这里可以类比打电话的结尾。一个人说"我说完了"(FIN),对方说"听到了"(ACK),然后对方接着说"我也说完了"(FIN),这个人回一句"知道了"(ACK)。每一句话都代表一个方向的结束,所以最少需要四句话才能干净利落地挂断。
4.3 深入理解TIME_WAIT:为什么关闭后还要等2MSL
四次挥手后,主动关闭方会进入TIME_WAIT状态,等待2MSL(Maximum Segment Lifetime,报文最大生存时间)后才真正关闭。这是整个TCP协议里最容易被低估、也最影响实际运维的状态,值得仔细解释。
TIME_WAIT存在的原因主要有两个。
第一个原因是保证最后的ACK能到达对端。第四次挥手的ACK在网络上传输时可能丢失。如果服务端没收到这个ACK,它会因为超时重传FIN。如果客户端在发送ACK后立即关闭连接,就收不到重传的FIN,也就无法重发ACK,服务端会永远卡在LAST_ACK状态无法关闭。而TIME_WAIT状态下,客户端会一直保留足够的上下文信息,当收到服务端重传的FIN时,可以重新发送ACK。
第二个原因是让本次连接的报文全部在网络中消逝,避免影响后续连接。如果客户端发完ACK后立刻重用相同的IP和端口建立新连接,那旧的、仍在网络中游荡的报文段可能会被新连接误认为是自己的数据,造成数据混乱。2MSL时间可以保证连接中所有迟到的报文段都在网络中消失。
那么MSL是多少?在不同系统上不一样,Linux里的默认值一般是30秒左右,所以2MSL就是60秒。这也就是为什么你杀掉一个服务的进程后,立刻重启时偶尔会遇到Address already in use,多半就是有连接还处在TIME_WAIT状态还没释放。
值得注意的是,被动关闭方(收到FIN的那一方)是不经历TIME_WAIT的。它在发出最后一个ACK后就直接进入CLOSED。TIME_WAIT永远发生在一端,由谁主动关闭,谁承担TIME_WAIT的成本。
4.4 异常关闭:RST是怎么出现的
除了正常的四次挥手,TCP还有一条"暴力"关闭路径:RST报文段。当一方发现连接状态异常、无法继续处理时,会发送RST直接中止连接,双方都立刻进入CLOSED状态,不经过任何正常的挥手阶段。
常见的触发场景包括:往一个没有监听的端口发送数据,对端会回RST来拒绝;连接双方中一方崩溃重启后收到旧连接的数据,因为内核里的连接信息已经丢失,会回RST;服务器检测到业务异常(比如超时)主动通过设置SO_LINGER为0来发送RST关闭连接。
RST在业务上通常表现为"Connection reset by peer"。它在排查问题里有很重要的意义:如果一个连接突然收到RST,通常意味着对端程序已经不再持有这个连接,最常见的情况就是对方进程崩溃或者代码里异常路径没有正常关闭socket。看到RST先别慌,分析一下是哪个方向发的、发生在什么时候,基本就能锁定问题范围。
5. Linux网络编程视角:从socket API看TCP
5.1 服务端编程模型:从socket到accept
在Linux下写TCP服务端,流程非常固定:socket()创建套接字,bind()绑定地址和端口,listen()进入监听状态,然后循环accept()接受连接,read()/write()收发数据,最后close()关闭连接。
int listen_fd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(8080); bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)); listen(listen_fd, 128); while (1) { int conn_fd = accept(listen_fd, NULL, NULL); // 处理 conn_fd ... }这段代码背后的TCP状态变化是:socket()在内核创建一个套接字文件描述符;bind()只是绑定地址端口,还不涉及协议状态;listen()让套接字进入LISTEN状态;accept()从全连接队列取出一个ESTABLISHED连接。
listen()的第二个参数backlog很值得注意。它决定了全连接队列的长度。在Linux 2.6以上的内核中,这个参数表示全连接队列最大长度(内核还会自己调整一些上限)。如果这个值太小,并发连接多的时候,即使三次握手完成,连接也没人取用,客户端会一直卡着。
这里有一个实战经验:很多人写服务端时,backlog直接填5,生产环境一压测就出现大量连接超时。建议在确认操作系统限制允许的前提下,把backlog设大一些,比如1024或者更高。同时配合net.ipv4.tcp_max_syn_backlog调整半连接队列大小,以及net.core.somaxconn调整进程能接受的积压连接上限。
5.2 客户端编程模型:connect的超时重试
客户端这边流程更简单:socket()创建套接字,connect()发起连接,成功后read()/write()通信,最后close()。
int sock_fd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in server_addr; server_addr.sin_family = AF_INET; server_addr.sin_port = htons(8080); inet_pton(AF_INET, "192.168.1.100", &server_addr.sin_addr); if (connect(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) { perror("connect"); } // 通信... close(sock_fd);connect()的行为和tcp_syn_retries密切相关。如果不设置超时时间,connect()会阻塞很久,默认可能超过一分钟。实际项目中通常建议把socket设为非阻塞模式,配合select()/poll()/epoll()做超时控制。
非阻塞connect的完整逻辑大概是:先fcntl()设置O_NONBLOCK,然后调用connect()。如果返回EINPROGRESS,说明连接正在建立中,此时用select()或epoll()监听这个fd的可写事件,同时设定超时时间。一旦可写,再用getsockopt()取SO_ERROR判断是否连接成功。这是很多高性能客户端库的底层做法。
5.3 close和shutdown:到底有什么区别
close(fd)做的事情是:减少文件描述符的引用计数,同时发送FIN开始四次挥手。如果这个fd被多个地方引用(比如fork出来的子进程共享了父进程的fd),只有最后一个引用被释放时才会真正发送FIN。
shutdown(fd, SHUT_WR)则是直接关闭写方向,发送FIN,但读方向仍然可以工作。shutdown(fd, SHUT_RD)关闭读方向,shutdown(fd, SHUT_RDWR)则两个方向都关闭,但不释放文件描述符本身。
这个区别在实践中有很重要的作用。比如实现HTTP/1.1的keep-alive,服务端发完响应后不该调用close(),因为它还想继续读客户端的下一个请求。这时应该用shutdown(SHUT_WR)通知客户端"响应结束了"——不对,HTTP/1.1是用Content-Length或者chunked来标记响应结束的,不能随便shutdown写方向。真正典型的半关闭场景是类似FTP的协议:客户端发完命令后shutdown(SHUT_WR)告诉服务端"我没有命令要发了",但还要继续读服务端返回的数据。
5.4 高并发场景下的连接管理
简单的一对一accept模型只能用于学习。实际项目里,服务端需要同时管理成千上万个连接,这时候核心就变成I/O多路复用。
select和poll都是比较原始的方案,前者有FD_SETSIZE的限制,后者遍历所有fd判断事件,复杂度是O(n),连接一多效率就不行。Linux下主流方案是epoll:通过epoll_create()创建实例,epoll_ctl()注册fd和感兴趣的事件,epoll_wait()等待事件就绪。
epoll有几个常常被忽略的细节。第一,用水平触发(LT)还是边缘触发(ET)。ET模式下,事件只会通知一次,你必须一次性把数据读完,否则下次不会再次通知,容易漏数据。LT则只要缓冲区里还有数据就会一直通知。初学者建议先用LT,等熟练了再挑战ET。
第二,监听的fd最好也加入epoll,统一处理accept事件,而不是单独在另一个线程里死循环accept。这样做的目的是避免多线程同时调用accept导致惊群效应。Linux 2.6.18之后提供了EPOLLEXCLUSIVE标志,专门解决accept惊群问题。
第三,epoll_wait返回的事件数组要全部遍历处理,但处理某个fd的数据时如果遇到错误或者对方关闭,记得先从epoll里移除再关闭fd,否则可能触发一些难以排查的诡异行为。
6. 实战排查:TCP状态异常与经典故障处理
6.1 连接建立失败:超时与拒绝
先看一类最常见的故障场景:客户端connect一直超时,或者直接被拒绝。
connect超时(表现为卡住不动、最终报Operation timed out)通常意味着SYN包发出去后没有收到SYN+ACK。可能的原因按概率排:网络不通、防火墙丢弃了SYN包、服务端半连接队列满了导致内核直接丢包。排查时先在服务端抓包确认是否真的收到了SYN。如果收到了SYN但没回SYN+ACK,检查服务端是否处于LISTEN状态、半连接队列是否溢出(通过netstat -s看SYNs to LISTEN sockets dropped统计)、有没有防火墙规则把SYN+ACK挡掉了。
connect被拒绝(报Connection refused)则通常是服务端根本没有在对应端口监听。可能是进程挂了、端口没绑定,或者服务端主动回了RST(比如IP和端口不匹配)。还有一种坑是IPv4和IPv6混淆,服务端监听在IPv6地址上的双栈socket,客户端用IPv4去连也可能遇到问题。
6.2 大量TIME_WAIT与实际对策
压测或者高并发短连接场景下,你会经常看到ss -tan输出一大片TIME_WAIT。这本身不是错误,但它会占用本地端口资源。主动关闭方会占用一个四元组(本地IP、本地端口、远端IP、远端端口),如果端口耗尽,新连接就建立不起来了,报错通常是"Cannot assign requested address"。
我曾经调过一个问题:某服务每天高峰期都会报端口不够,ss -tan显示TIME_WAIT堆积了两三万个。当时排查发现,服务端每处理一个请求就主动关闭连接,导致每次请求都会产生一个TIME_WAIT。当时选的方案是改用连接池,让连接复用起来,TIME_WAIT数量立刻降了下来。对于大多数业务,这是最推荐的做法:能用长连接就用长连接。
如果连接池不太合适,还可以调整内核参数来缓解。net.ipv4.tcp_tw_reuse是一个常见选项,但注意它只对主动发起连接的一方有效,且需要同时开启net.ipv4.tcp_timestamps。它允许内核复用处于TIME_WAIT状态的连接来发起出站连接。但4.12之后的内核对tcp_tw_reuse的行为有一些变化,指望它彻底解决问题不如在应用层做连接复用靠谱。
还有一个狠一点的手段:通过SO_LINGER把关闭方式改为发RST。设置linger.l_onoff = 1且linger.l_linger = 0后,close()会直接发送RST终止连接,不进入TIME_WAIT。这个方式适合某些明确知道没有残留数据的场景,但副作用是可能丢失未发送的数据,而且对端会看到连接被异常重置,不适合所有业务,要谨慎使用。
6.3 大量CLOSE_WAIT:先查自己的代码
服务端出现大量CLOSE_WAIT几乎可以肯定是应用层代码的问题。CLOSE_WAIT的含义是:对端发送了FIN,内核回复了ACK,然后把这个状态通知给应用层。但应用层一直没有close()这个socket,导致四次挥手停在第二步。
为什么会这样?最常见的场景是:服务端读到了EOF(对端关闭),但代码里忘了在读到EOF后关闭连接。比如写了一个处理逻辑,循环调用read(),返回值是0时表示对端关闭,此时应该跳出循环并close(fd)。但如果代码逻辑写成了"继续读",或者抛了异常但没走关闭流程,连接就会一直卡在CLOSE_WAIT。
排查CLOSE_WAIT时,可以结合lsof -p 进程PID看这个进程打开了哪些连接,对比业务日志看是哪条请求路径没有关闭连接。另外要注意,close(fd)要在所有子线程处理完后调用,否则close了但线程还在用这个fd,也会引发奇怪的问题。
6.4 连接被重置:RST包的罪魁祸首
RST连接的情况五花八门,这里说几个排查过的典型案例。
第一种,服务端进程被kill掉。进程退出后内核会关闭它打开的所有socket。如果是正常关闭会走四次挥手,如果是非正常退出(比如segfault、kill -9),那些没有来得及close的连接会收到RST。
第二种,服务端缓冲区数据未读完就close了。当内核的接收缓冲区里还有未读取的数据,但进程调用close()时,内核会发送RST而不是FIN,表示"我有数据没处理完,你现在让我关,我丢弃了它们"。
第三种,配置了SO_LINGER为0的主动RST关闭。这类行为一般是业务代码故意为之,用于快速断开连接。
如果在抓包里看到RST,先看是谁发的、什么时间、在什么状态下发的。如果是服务端在ESTABLISHED状态下收到数据后立刻发RST,多半是协议解析失败或者应用层判断异常。如果是对端长时间空闲后突然RST,可能是中间的NAT设备超时把连接表项清了,对端不知道,还在往旧连接上发数据,结果收到RST。
6.5 常用诊断工具速查
目前在Linux下排查网络连接问题,我的标准组合是ss+tcpdump。netstat信息不够新,很多发行版上已经用ss替代它了。
# 查看监听端口和连接状态分布 ss -tan # 统计各种状态的连接数量 ss -tan | awk '{print $1}' | sort | uniq -c # 查看指定端口的详细状态 ss -tan sport = :8080 # 抓包分析三次握手、四次挥手、重传 tcpdump -i eth0 host 192.168.1.100 and tcp port 8080 -nn -S -w /tmp/cap.pcap我更习惯用tcpdump -w把包存成pcap文件,然后用Wireshark进行可视化分析。Wireshark的时间轴视图、TCP流重组、统计图表对于定位"哪个包丢了""哪次重传触发了"非常直观。直接看命令行输出也能定位大部分问题,但涉及复杂交互时我还是建议存文件再分析。
另外,nc -l和nc -vz非常适合做快速连通性测试。用nc -zv 目标IP 端口可以快速判断TCP端口是否可达,nc -l 端口可以拿来做一个最小化的服务端验证环境。还有strace -e trace=network可以跟踪某个进程的网络系统调用,对定位"程序到底有没有调用close"这类问题极有帮助。
7. 一些实操心得:我踩过几次坑之后总结下来的建议
文章写到最后,我分享几个自己的体会,不算什么深刻理论,都是实打实的工作教训。
内核参数不要太早调。很多人一看到TIME_WAIT多就急着开tcp_tw_reuse,看到重传多就急着调tcp_retries2。我的建议是先抓包、先看状态机,搞清楚问题到底是协议层面还是应用层面,再做调整。绝大多数连接异常问题,根源是应用层代码没有正确管理socket生命周期,调内核算屡屡治标不治本。
socket的关闭时机要当成一等公民来设计。什么时候主动关闭、谁来主动关闭、close与shutdown怎么配合,这些应该在代码设计阶段就想清楚,而不是等线上出问题了再打补丁。我在代码评审的时候,看到对close()的处理含糊不清,一定会追问:对端不按预期关闭怎么办?读超时了要做什么?这条路径出异常时连接会不会泄漏?
日志里永远要带连接标识。服务端高并发处理连接时,如果在日志里只打IP和端口,排查起来非常痛苦。建议每个连接分配一个全局唯一的连接ID,从accept后就打一条日志记录四元组,然后所有后续操作都带上这个连接ID。遇到问题时,拿连接ID去日志里倒查完整生命周期,一般很快就能锁定是在哪个阶段出的问题。
多利用内核提供的统计信息。很多时候不用抓包,netstat -s和ss -s里面的计数器已经能告诉你问题在哪。比如SYN to LISTEN sockets dropped这个计数说明半连接队列丢包,Segments retransmited很高说明网络丢包触发了重传,connection closed by RST很高说明某个节点在大量异常断开。学会读这些计数器,能节省大量定位时间。
TCP这个东西,理论是一层,实战是另外一层。拿着状态机图觉得自己懂了,和真正在线上被一个诡异的连接问题折磨一晚上之后对TCP的理解,完全是两个境界。希望这篇文章能帮你把那些概念真正落到Linux服务器上,下次面对各种状态、各种抓包结果时,心里能更有底气。