从第一次写网络程序到现在,我印象最深的一次调试,是在一个数据同步模块里遇到了“消息收不全”的问题。客户端明明把一个状态包发出去了,服务端收到的却是从中间断开的半截内容。当时我把路由、防火墙、对端代码都怀疑了一遍,最后才发现问题出在我自己对TCP的理解上——我默认“send一次,对面就应该recv到同样多的字节”,但TCP根本不这么工作。这篇文章想把TCP通信的核心流程和接口使用讲透:从三次握手到滑动窗口,从四次挥手中的TIME_WAIT到socket接口里每个函数背后解决的具体问题,再到实际项目中经常踩的坑。适合刚开始接触网络编程的读者,也适合写过一些代码、但一直没有系统梳理过TCP机制的工程师。
1. 先摆脱一个致命误解:TCP到底是协议还是管道
1.1 字节流模型:发一次不等于收一次
很多人第一次接触TCP,都会把“连接”理解成一端发数据、另一端收数据的通道。这个理解没错,但容易忽略一个关键属性:TCP提供的是字节流,不是消息流。
什么是字节流?把它想象成一根水管:你往里倒水,对端从另一头喝水。你倒进去的“水”是连续的、没有边界的,对端每次喝到多少水,取决于他喝水时有多大胃口,而不是取决于你倒进去时用了多少个杯子。你往TCP连接里写入一组字节,对端read()读到的可能是其中一部分、全部、甚至会连上你下一次写入的字节。
这就是为什么我的同步模块出现了半包问题:我调用send()发送了一条完整消息,服务端用一次recv()去读,结果只读到了前半部分,后半部分还在网络缓冲区里没有到达,或者早就到达但被内核放在了接收缓冲区里等待下一次recv()取出。
我在和很多开发者交流时发现,这个误解非常普遍。大家习惯了HTTP、gRPC这种现成的协议,很少直接接触裸TCP,等到自己用send/recv实现私有协议时,就会一脸疑惑:为什么我发的是1000字节,对面第一下只收到200字节?答案很简单——TCP不承诺应用层消息的完整性,它只承诺字节的可靠有序传输。
1.2 可靠性、有序性、流量控制:TCP替你扛的三件事
TCP之所以叫传输控制协议,核心在于它解决了三个问题。
第一是可靠性。数据在网络上传输不会百分之百稳定,报文可能丢失、损坏。TCP通过序号、确认ACK、超时重传等机制,把“可能丢数据”的IP网络包装成了“基本不丢数据”的可靠通道。
第二是有序性。IP层把数据包按路由各自转发,一条TCP连接里的报文可能走不同路径,到达顺序会发生颠倒。TCP在接收端根据序号把乱序的报文重新排好,再交给应用层。
第三是流量控制。如果发送方拼命发数据,接收方处理不过来,内核缓冲区满了就会丢数据。TCP用滑动窗口机制,让接收方把自己还有多少剩余缓冲区大小告诉发送方,发送方据此控制发送速度。
在实际工作中我第一次意识到这些机制的价值,是在排查一个文件传输问题的时候。20MB的文件通过网络传到对端,服务端直接按顺序写出了完整内容,没有缺页、没有乱序。光靠IP层是做不到这件事的,全依赖TCP在传输层默默兜底。
1.3 用TCP还是UDP,一句话判断标准
很多初学者会问:什么时候用TCP,什么时候用UDP?
我的判断标准很简单——**如果数据不完整会让业务出错,优先用TCP;如果数据迟到比数据丢失更不可接受,考虑UDP。**文件传输、数据库同步、订单消息、几乎一切需要准确落地的业务,都用TCP。音视频通话、游戏状态同步、实时监控画面,这类场景可以容忍偶尔丢几帧,但受不了因为重传导致的延迟增加,所以常用UDP,再在应用层做一定的补偿。
不过这个标准也不是绝对的。很多直播系统和实时通信最终也跑在TCP上,因为流媒体协议本身的缓冲设计可以吸收一部分延迟。你需要的不是背结论,而是理解TCP在每一条数据上投入了多少东西。
2. 三次握手:从SYN到ACK,内核在连接建立时的完整状态迁移
2.1 三次握手每一步在确认什么
TCP连接建立的过程,是一个叫“三次握手”的状态迁移。客户端的初始状态是CLOSED,服务端则是LISTEN,然后双方经过三个报文进入ESTABLISHED。
第一步,客户端发送一个SYN报文,携带自己的初始序列号x。这一步的作用是告诉服务端:我想建立连接,我的数据从这个序号开始。
第二步,服务端回复SYN+ACK,携带自己的初始序列号y,同时确认收到了客户端的x,确认号为x+1。这一步的作用是告诉客户端:我收到你的连接请求了,我也准备好发送数据了,我的数据从序号y开始。
第三步,客户端回复一个ACK,确认号是y+1。这一步的作用是告诉服务端:我收到了你的同步信息,连接正式建立。
为什么初始序列号不是从0开始、而是随机初始化?因为如果序列号固定可预测,历史连接中的延迟报文可能会被误认为是当前连接的数据,引发数据错乱,也会带来安全隐患。随机化的初始序列号在很大程度上解决了这个问题。
2.2 为什么必须是三次:两次握手会造成什么混乱
这是面试中几乎必考的问题。很多人只知道答案是“三次确定双方收发能力都正常”,但我想补充一个更根本的角度:TCP连接是全双工的,有两条独立的数据通道,每一条通道都需要单独确认对方能收、自己能发。
第一次握手后,服务端确认了客户端的发送能力。第二次握手后,客户端确认了服务端的收发能力、同时也确认了自己的收发出路。但这个时候,服务端还没有确认“客户端能收到我发的数据”。如果只有两次握手,服务端发出SYN+ACK后就直接认为连接建立了,可万一客户端没收到这个报文,客户端就会认为连接不存在;服务端却已经在等待数据了,这就形成了一个半开连接。
用打电话来类比会更直观。两个人通话,A问:“你能听到我说话吗?”B回答:“我能听到你,你能听到我说话吗?”到这里,如果A直接挂了电话,B根本不知道A是否听到了自己的声音。必须等A再回一句“我也能听到你”,双方才都确认链路畅通。三次握手的本质,就是完成这句话。
2.3 半连接队列与accept队列:backlog的真相
握手这件事不是一次函数调用那么简单,它背后涉及内核中的两条队列。
第一条是半连接队列,也叫SYN队列。服务端收到SYN后,会把这个连接放入SYN队列,状态记为SYN_RCVD,等待客户端回ACK。第二条是accept队列,也叫全连接队列。第三次握手的ACK到达后,连接从SYN队列移到accept队列,状态变成ESTABLISHED,等待应用调用accept()取走。
很多人写服务端代码时把listen(fd, backlog)里的backlog当成“最大并发连接数”,其实不对。backlog主要限制的是accept队列的长度,也就是“内核已经完成握手、但应用还没取走”的连接数量。Linux内核还会把backlog和net.core.somaxconn做一次取小操作,你设置一个很大的值也未必生效。
我遇到过一种情况:服务端应用accept线程处理得很慢,结果accept队列被占满,新完成的连接被内核直接丢弃,客户端表现为连接建立后请求超时。当时第一反应是调大backlog,但其实治标不治本——backlog只是让队列更不容易满,真正的问题在于应用层取连接的速度跟不上。正确做法是优化accept之后的处理逻辑,或者把accept操作分派到更多线程/进程,让队列保持流动。
2.4 观察握手最直接的方式:tcpdump一眼看清
理解三次握手最好的方式,是抓一次包看看。在服务端或客户端任意一侧执行:
tcpdump -i any -nn -S tcp port 8080发起一次连接后,你基本会看到三行关键报文:
1. IP client.50000 > server.8080: Flags [S], seq 1234 2. IP server.8080 > client.50000: Flags [S.], seq 5678, ack 1235 3. IP client.50000 > server.8080: Flags [.], ack 5679第一行的S是SYN,第二行的S.是SYN+ACK,第三行的.是纯ACK。注意观察第二行的ack 1235,它等于客户端初始序列号1234+1,说明服务端明确知道了客户端的数据起点。看到这三行,你就知道握手真实发生了,后面的业务数据全部是Flags [P.]或.P这种带数据的报文。
3. 数据收发阶段:滑动窗口、Nagle与粘包的工程博弈
3.1 从send返回值到对方recv之间发生了什么
连接建好之后,程序员面对最多的就是send和recv。但这两个函数远没有看上去那么直接。
send()做的事情只是把应用层数据拷贝到内核的发送缓冲区,然后返回“成功拷贝的字节数”。这个返回值只代表内核接收了多少数据,不代表对端应用接收了多少,更不代表对端处理成功。如果设了TCP_NODELAY,数据会尽量快地发出;如果没设,内核可能会合并一些数据,等待更优的网络报文。
数据从发送缓冲区出去后,被拆成一个个TCP分片,经过IP层路由、链路层传输,到达对端内核的接收缓冲区。recv()做的事是从接收缓冲区里拷贝一段数据到应用层缓冲区,返回实际拷贝的字节数。
滑动窗口在这里扮演的角色是“发送速度调节器”。接收方会在TCP报文头部携带window字段,告诉对端“我的接收缓冲区还剩多少空间”。发送方看到窗口变大会加快发送,看到窗口变小会减速甚至停止。如果这个机制失效,接收方缓冲区就会被塞满,新到的数据只能被丢弃,大量重传会把网络拖垮。
3.2 延迟ACK与Nagle算法:省流量和省时间要取舍
发送阶段有两个算法会让你在实际项目里感受到明显的延迟,一个是Nagle算法,一个是延迟确认。
Nagle算法的目的是减少网络中的小报文数量。它的逻辑很简单:一个连接上最多只能有一个未被确认的小分片,其余小数据要在缓冲区里等着,等前一个小分片被ACK后,再合并发送。这样能显著提升网络利用率,但也会牺牲延迟。
延迟确认则是接收方的行为:收到数据后不立刻回ACK,而是最多等40毫秒,看看能不能顺便把ACK挂在别的数据报文上。
问题在于,当这两个算法同时生效时,可能出现“发送方在等ACK才发下一批数据,接收方在等更多数据才回ACK”的互相等待,最终形成一个明显的延迟尖刺。我实际测过的小消息场景里,这种组合可以带来几十毫秒的额外延迟。对于IM、游戏同步这类高频小消息业务,解决办法很直接:开启TCP_NODELAY,关掉Nagle算法,牺牲一定的网络利用率,换取低延迟。
3.3 粘包与半包:字节流落到应用层的第一大坑
这是所有用TCP做私有协议的人都会遇到的两个兄弟问题。
粘包是指多条应用消息被合在一起读取。比如客户端发送A消息和B消息,服务端一次recv()可能把A和B一起读走,业务解码时如果不知道边界,就会把两条消息当成一条解析。
半包是指一条消息被拆开读取。比如1000字节的消息,第一次recv()只返回了300字节,剩下的数据还在接收缓冲区或者网络上游荡。
这两个问题的根因是同一个:TCP是字节流,没有消息边界。应用层必须自己规定边界,这就是“协议设计”的起点。
三种常见方案在工程里的取舍比较如下:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 固定长度 | 实现最简单,解析成本低 | 变长消息浪费带宽,长度受限 |
| 分隔符 | 文本协议直观,像HTTP头部 \r\n | 正文不能出现分隔符,需要转义或扫描 |
| 长度前缀 | 最通用,性能好 | 需要正确处理缓冲区累积和字节序 |
3.4 用长度前缀解包:一个可直接套用的读取模型
我自己的项目里基本都用长度前缀方案:消息格式为4字节长度 + 消息内容。这里有一个很关键的细节——长度字段一定要用网络序(大端)。我见过不止一个人在本机小端环境下直接解出几十亿的错误长度,导致程序立刻报错。
下面是一个可以直接用的读取模型,核心思路是先把长度字段读全,再根据长度把消息体读全:
import socket def recv_exact(sock, n): buf = b'' while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError("peer closed") buf += chunk return buf def read_message(sock): head = recv_exact(sock, 4) length = int.from_bytes(head, "big") return recv_exact(sock, length)但如果接收端把一次read的数据缓存起来,再逐步解析,效率会更高。我用的是类似的“缓冲队列”模式:
class BufferReader: def __init__(self, sock): self.sock = sock self.buf = b"" def read(self): while True: if len(self.buf) < 4: self.buf += self.sock.recv(4096) continue length = int.from_bytes(self.buf[:4], "big") if len(self.buf) >= 4 + length: msg = self.buf[4:4 + length] self.buf = self.buf[4 + length:] return msg self.buf += self.sock.recv(4096)这个模型规避了“一次recv不可能读完消息”“一次recv可能带来多条消息”两类问题。剩余数据保留在缓冲区里,下次继续解析,天然支持多条消息连续到达。
4. 四次挥手与状态迁移:TIME_WAIT和CLOSE_WAIT的坑我都替你踩过
4.1 四次挥手的本质:TCP半关闭设计
关闭一条TCP连接需要四次报文交换,原因在于TCP允许半关闭——发送方向和接收方向可以独立关闭。
主动关闭方调用close()后,会发送FIN,表示“我不会再向这个方向发数据了”。被动关闭方收到FIN后回复ACK,此时它还可以继续发数据,因为它的发送方向还没有关闭。等它把剩余数据发完,也调用close()发出自己的FIN,主动关闭方再回复最后一个ACK。
四次挥手中的状态迁移如下:
| 挥手阶段 | 主动关闭方状态 | 被动关闭方状态 | 说明 |
|---|---|---|---|
| 第一次挥手后 | FIN_WAIT_1 | CLOSE_WAIT | 主动方发FIN |
| 第二次挥手后 | FIN_WAIT_2 | CLOSE_WAIT | 被动方ACK,仍可发数据 |
| 第三次挥手后 | TIME_WAIT | LAST_ACK | 被动方发FIN |
| 第四次挥手后 | 等待2MSL后CLOSED | CLOSED | 主动方ACK |
这里还要区分close和shutdown。close()在fd引用计数不为0时不真正关闭连接,而且一旦关闭就是双向关闭。shutdown()可以只关写方向或只关读方向,是实现半关闭的正式接口。在应用层需要“我还想读数据,但不再发送数据”的场景里,应该考虑用shutdown(fd, SHUT_WR)。
4.2 TIME_WAIT为什么是2MSL,被高估的“端口耗尽”恐慌
四次挥手之后,主动关闭方会进入TIME_WAIT状态,持续2MSL时间。MSL是报文在网络里的最大生存时间,Linux里通常对应60秒左右,所以TIME_WAIT通常会持续一小会儿。
为什么需要这个等待?原因有两个。第一,最后一次ACK可能丢失,被动关闭方会重发FIN,主动关闭方需要留在原地响应。第二,要保证这一条连接里的所有报文都从网络中自然消失,否则一个时序混乱的迟到报文可能会被误认为是新连接里的数据。
很多人在服务端和客户端大量短连接的场景里看到一堆TIME_WAIT就紧张,其实它是正常现象。TIME_WAIT真正让人头疼的是另一种情况:客户端在短时间内发起海量短连接,每个连接消耗一个本地端口,TIME_WAIT里积压的端口又无法立刻复用,最终导致“端口耗尽”,表现为无法发起新连接。缓解方向是:把短连接改成持久连接、扩大本地端口范围、或认真评估能否使用SO_REUSEADDR。
SO_REUSEADDR这个选项经常被误解。它的主要作用是让服务端在TIME_WAIT状态下也能重新绑定同一个端口,避免服务重启失败。它并不是让TIME_WAIT消失的魔法开关。
4.3 服务端CLOSE_WAIT堆积:最常见的内存泄漏隐形元凶
比TIME_WAIT更需要警惕的是CLOSE_WAIT堆积。我在排查一个老服务的内存问题时,发现进程内存不断上涨,但代码review一直没找到明确的泄漏点。后来用ss查看连接状态,发现积压了几千个CLOSE_WAIT连接。
CLOSE_WAIT的本质是:被动关闭方收到了对方的FIN,回复了ACK,但自己一直没有调用close()关闭连接。此时连接处于“对端已经关闭,我也无事可做,但内核资源没有释放”的半吊子状态。
常见原因有三种:业务代码忘记调用close;对端关闭连接而本端还在等待写入,写失败后没有处理;或者业务线程被卡死,根本没有走到释放连接的逻辑。
快速定位CLOSE_WAIT的方法很简单,一条命令统计连接状态分布:
ss -tan | awk '{print $1}' | sort | uniq -c看到大量CLOSE_WAIT后,优先检查所有recv返回0的分支——recv返回0表示对端已经关闭,这里必须触发连接清理逻辑,把fd关闭、把业务对象从内存中移除。这个问题在定位上不难,但很容易被漏掉,因为很多人的代码里recv返回0的分支写得很草率。
5. socket接口全链路:从socket到recv,每个函数都在解决哪个具体问题
5.1 服务端骨架:socket、bind、listen、accept的职责边界
服务端程序有两个“文件描述符”要分清。第一个是监听用的监听fd,第二个是每一次accept返回的新连接fd。
一个最基础的服务端骨架:
import socket server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 8080)) server.listen(128) while True: conn, addr = server.accept() # 每次 accept 返回一个新连接bind()解决的问题是:把这台机器上的某个IP和端口绑定到这个fd上。绑定0.0.0.0表示监听本机所有网卡,绑定127.0.0.1表示只允许本机访问,绑定具体内网IP则意味着外部网络无法访问。我见过有人把服务绑定到了公司内网地址,部署到云上后外网怎么都连不通,排查半天才发现绑错了IP。
listen()的作用是让内核开始接受这条连接上的握手请求,backlog控制accept队列长度。accept()本身不做握手,它只是从内核的accept队列里取走一个已经完成握手的连接。
还有一点值得注意:bind()之前不一定有网络行为,真正让握手开始的是connect()到listen()的配合。理解这一点,是为了理解服务端“先listen再accept”的顺序,以及为什么服务端必须在启动阶段就做好监听,而不是等到有请求才临时创建。
5.2 客户端connect:阻塞与非阻塞,超时怎么控制
客户端的流程比服务端简单得多:socket()后直接connect()。
connect()默认是阻塞的,它会一直等到握手完成或者错误发生。在网络上不可达时,这个等待时间可能非常久,所以很多项目需要对连接超时做显式控制。
常见的做法是把socket设置为非阻塞,然后用poll或select等待可写事件,同时设置一个超时时间。基本流程是这样的:
1. fd = socket(AF_INET, SOCK_STREAM, 0) 2. fcntl(fd, F_SETFL, O_NONBLOCK) 3. ret = connect(fd, server_addr) 4. 如果 ret == -1 且 errno == EINPROGRESS,说明连接已开始但没完成 5. 用 poll(fd, POLLOUT, timeout_ms) 等待 6. 等待返回后,通过 getsockopt(fd, SOL_SOCKET, SO_ERROR, &err) 检查是否成功 7. err == 0 表示连接成功这里的坑在于:poll返回可写事件并不代表连接一定成功,也可能是连接失败被通知了。必须以SO_ERROR的值为准判断。有段时间我偷懒只做了第5步,结果连接失败也当成功处理,后续读写全部异常,直到加上第6步才稳定。
常见的connect错误码也值得记住:
| errno | 含义 | 常见场景 |
|---|---|---|
| ECONNREFUSED | 对端端口未监听,直接拒绝 | 服务没启动 |
| ETIMEDOUT | 网络不可达,超时 | 防火墙丢包、链路故障 |
| ENETUNREACH | 目标网络不可达 | 路由配置错误 |
5.3 send和recv的返回值、errno与缓冲区的真正含义
send和recv是收发数据的主力,但它们的返回值语义经常被忽略。
recv()返回0,代表对端关闭了写方向,也就是收到了FIN,此时本端不应该再继续读。返回-1时,必须看errno:
EAGAIN或EWOULDBLOCK:非阻塞模式下当前没有数据可读,不代表异常。EINTR:系统调用被信号打断。是否要重试取决于你注册信号处理时有没有加SA_RESTART标志。如果没有,建议在业务代码里遇到EINTR直接继续读。ECONNRESET:对端发来RST,连接被强制重置,通常发生在对端未正常关闭、或收到了根本不属于这个连接的数据时。
send()的返回值表示“内核发送缓冲区接受了多少字节”。如果返回比你要发送的数据少,不能直接忽略,需要循环把剩余部分继续写入。我自己封装过一个简单的发送函数:
def send_all(sock, data): total = 0 while total < len(data): sent = sock.send(data[total:]) if sent == 0: raise ConnectionError("send failed") total += sent return total这个函数看起来基础,但很多生产事故的直接原因就出在“一次send没发完,后面不管了”。
5.4 不得不调的setsockopt参数:从SO_REUSEADDR到TCP_NODELAY
在写服务端的过程中,有四个setsockopt选项我几乎每次都要用到。
SO_REUSEADDR是服务端重启的救星。没有它,服务刚停、端口还处于TIME_WAIT时,重新监听会被拒绝。
TCP_NODELAY用来关闭Nagle算法。需要低延迟的场景,比如IM、即时推送,几乎必开。代价是网络上的小报文会变多,内网环境基本无感,公网高延迟环境需要评估。
SO_KEEPALIVE可以在连接空闲时向对端发送探测包。但Linux默认的探测间隔大约是2小时,很多时候不满足业务需求,所以实际项目中更可靠的方式是应用层自己做心跳。
SO_LINGER是一个需要谨慎对待的选项。设置l_onoff=1, l_linger=0后,调用close()会直接发送RST放弃连接,缓冲区里未发送的数据会被丢弃。这在某些需要立即终止连接的场景下有用,但代价是数据可能悄悄丢失。
注意:
SO_LINGER设置为0的RST式关闭,会让对端收到ECONNRESET而不是正常的EOF,对端代码如果依赖recv()==0来触发正常关闭,会被打乱。除非明确知道自己要“强杀”这条连接,否则不要用。
6. 线上问题复盘:三个真实场景与我的排查三板斧
6.1 半包导致解码失败:一个“消息被切断”的定位过程
我有一个模拟项目,客户端发送带长度前缀的二进制消息,服务端解析后回执。上线后我发现,小消息一直正常,消息体超过4KB后,服务端偶尔报“长度不合法”。
排查时第一反应是怀疑字节序。我检查了长度字段,确实已经用大端写入。接着怀疑对端发送逻辑有问题,用tcpdump抓包发现,线上报文完整到达,长度字段也没有错。最后回到服务端代码,发现问题出在一次recv()上:代码里声明了一个固定长度缓冲区,假设一次读到的就是一个完整消息,直接拿去解析。消息超过一定长度后,TCP分片会拆开传输,一次recv()可能只拿到消息的一部分,解析自然失败。
修复思路就是把“一次读取”改成“按需读取”:先读4字节长度字段,再根据长度继续读消息体,不足部分循环等待。这个问题很经典,但也确实容易犯,尤其是从HTTP开发切过来的人,习惯了框架帮你解析好完整请求,到了裸TCP才发现一切要自己来。
6.2 连接异常断开后发送端毫无知觉:心跳机制怎么设计
另一个经常踩的坑是:对端进程崩溃、机器断电、网线断开时,本端连接可能长期处于看似正常的ESTABLISHED状态,直到你往这条连接上发送数据,内核才发现对端已经不可达,然后返回错误。
也就是说,如果业务代码长时间不发数据,这类“半开连接”会一直占着文件描述符和内存。解决半开连接的标准手段是心跳。
心跳设计我一般关注三个参数:心跳间隔、判定失败的超时次数、以及心跳和普通业务消息的区分。常见的做法是每10秒发送一个Ping请求,如果连续3个Ping没有收到Pong响应,就判定连接失效并触发重连。
要注意的是,心跳消息必须和业务消息使用统一的消息格式,通过消息类型字段区分,不能单独开一条链接。否则心跳正常而业务通道已经阻塞的问题会隐蔽地存在,依然是排查盲区。
6.3 断线重连的三件套:退避、标识与幂等
一旦判定连接失效,客户端就进入重连逻辑。我实践下来的重连设计有三件事必须做。
第一是指数退避。第一次失败等1秒,第二次2秒,第三次4秒,最长不超过一个上限,比如30秒。避免打满服务端和网络的故障恢复窗口。
第二是连接标识。每次重连成功后,客户端需要把连接唯一ID、业务上下文同步过去,避免旧连接的消息和新连接的消息混在一起,造成状态错乱。
第三是幂等。重连后客户端往往会把之前没发成功的消息重新发送,服务端必须有能力判断这条消息是否已经处理过,不能在重复发送时产生重复订单、重复扣款之类的问题。最简单的方式是每条消息带全局唯一ID,服务端做一次查重。
6.4 排查三板斧:tcpdump、ss、应用层打点
遇到任何TCP相关的问题,我的排查顺序基本是固定的。
先用tcpdump确认网络报文层面发生了什么:握手有没有完成、FIN有没有发出、RST是不是从对端回来的。这一步能快速把问题定位到“网络问题”还是“应用问题”。
然后看ss -tan,重点观察每个连接的状态、Recv-Q和Send-Q。Recv-Q一直堆积,说明应用读取不及时;Send-Q很大,说明对端接收窗口被占满,可能是消费速度跟不上。这两个数字是判断瓶颈在哪一侧的利器。
最后一步是应用层打点。我给收发函数封装过统一的统计接口,记录每个连接的累计发送量、接收量和当前缓冲区长度。线上出问题后,直接对比这些数字,就能知道数据是在哪个环节停滞的。
我自己的体会是,排查这类问题不要急着改代码,先把状态机和字节流两个概念刻在脑子里:任何一个连接都有生命周期,任何一次传输都是流式数据。只要沿着这两个方向查,大多数TCP疑难问题都能定位到具体的那一行代码。